chore: sprint handoffs + Lsp4j runner live-proof
Add per-agent sprint handoff docs (Sonnet/Codex/opencode) mapping the 1-week sprint's two goals to concrete Vikunja tasks. Kept in repo root (docs/ is gitignored). Includes hanging Lsp4jDiagnosticsRunner change + its live-proof test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rdo9fe7SujNVeyZA8YkpkD
This commit is contained in:
@@ -0,0 +1,27 @@
|
|||||||
|
# Handoff — Codex
|
||||||
|
|
||||||
|
**Sprint goal focus:** Well-specified, self-contained bugs with tight file pointers. Low ambiguity — the spec is in each Vikunja ticket.
|
||||||
|
|
||||||
|
Read the full task body in Vikunja (project 4) before starting each — `get_task_details <id>`.
|
||||||
|
Mark each **Doing** on start, and **commit green work referencing the task #** when done.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### #266 — Retire `pm.repoRoot()`, unify session workspace-root to ONE source
|
||||||
|
The workspace root is plumbed through THREE sources that can diverge (boot-static `ProjectMemoryService.repoRoot()`, per-session `boundWorkspace?.workspaceRoot`, `sessionConfig.workspace?.workspaceRoot`). A stopgap already made `sessionWorkspaceRoot(sessionId)` read the bound value; this task RETIRES the vestigial boot root.
|
||||||
|
Collapse to canonical `boundWorkspace?.workspaceRoot` (invariant #9, replay-safe). ⚠️ The shared L3 namespace `project:<repoRoot>` is used by ProjectMemoryService AND ArchitectContradictionChecker AND the concept compiler — all must switch together or memory keys drift.
|
||||||
|
Pointers: `ServerModule.kt`, `memory/ProjectMemoryService.kt`, `BootWorkspace.kt`, `Main.kt`, `workspace/WorkspaceResolver.kt`, `git/GitRunBranchTransport.kt`.
|
||||||
|
**Do this before #189.**
|
||||||
|
|
||||||
|
### #189 — Server repo-map scan uses cwd, not session workspace_root
|
||||||
|
Symptom of the same #266 divergence (`repoRoot()` vs `bindWorkspace`). Read #266 first — its canonical-root fix may absorb this. **Confirm #189 still needs its own change after #266 lands**; if not, close it referencing #266.
|
||||||
|
|
||||||
|
### #191 — Resolve/validate generated manifest dependencies before plan lock or scaffold accept
|
||||||
|
A scaffolded manifest (e.g. package.json) needs its deps resolvable / a `setup` step (`npm ci`) before the build gate can pass — otherwise the gate fires into absent `node_modules`.
|
||||||
|
**Land this EARLY** — Sonnet's #263/#267 live run depends on it going green.
|
||||||
|
|
||||||
|
### #264 — Review loop can't converge
|
||||||
|
The review loop loops back `changes_requested` indefinitely with scope drift. Bound iterations + anchor the reviewer to the fixed DoD so it can't keep expanding scope. Kill the drift.
|
||||||
|
|
||||||
|
### #40 — Build-gate: toolchain-aware command resolution
|
||||||
|
A flat command alias can't serve two toolchains (e.g. npm vs gradle). Resolve build commands per detected toolchain. Related to #263 (the build gate) and #191.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Handoff — opencode (free inference)
|
||||||
|
|
||||||
|
**Sprint goal focus:** Additive, visible TUI work. Blast radius contained to the Go app (`apps/tui-go`) plus the WS messages it reads. Cheap inference is fine here — a human eyeballs the result.
|
||||||
|
|
||||||
|
Read the full task body in Vikunja (project 4) before starting each — `get_task_details <id>`.
|
||||||
|
Mark each **Doing** on start, and **commit green work referencing the task #** when done.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### #295 — TUI: token usage display for router/talkie (like narrator)
|
||||||
|
The narrator already shows token usage in the TUI. Mirror the same display for the router and talkie streams. Follow the existing narrator pattern — don't invent a new widget.
|
||||||
|
|
||||||
|
### #296 — TUI: execution plan viewer (freestyle sessions + general)
|
||||||
|
Add a view that renders the session's execution plan (stages/transitions) in the TUI. Useful for freestyle runs especially. Reuse whatever plan data already comes over the WS.
|
||||||
|
|
||||||
|
### #298 — TUI output view: show CoT/reasoning on artifact + tool-call turns
|
||||||
|
The reasoning/CoT stream is already captured (reasoningArtifactId on InferenceCompleted). Surface it in the output view on artifact and tool-call turns so the operator can see the model's reasoning.
|
||||||
|
|
||||||
|
### #265 — TUI clarification modal not dismissed when answered externally
|
||||||
|
When a clarification is resolved on the server (or answered outside the TUI), the modal stays open. Dismiss it on the server-resolve / external-answer signal. Contained bug — find the modal state and the resolve message.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
These are all `apps/tui-go` (Go / Bubble Tea, WS client). Don't touch the Kotlin core.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Handoff — Sonnet
|
||||||
|
|
||||||
|
**Sprint goal focus:** Freestyle runs survive & gates bite (Goal 1) + one hard resilience feature (Goal 2).
|
||||||
|
You get the router/orchestrator brain-surgery — cross-module, judgement-heavy, easy to get subtly wrong.
|
||||||
|
|
||||||
|
Read the full task body in Vikunja (project 4) before starting each — `mcp__vikunja__get_task_details <id>`.
|
||||||
|
Mark each **Doing** on start, and **commit green work referencing the task #** when done.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### #299 — Single provider death → unrecoverable session kill
|
||||||
|
A retryable provider connection drop collapses into a hard `NoEligibleProvider` abort that fails the WHOLE session.
|
||||||
|
On a retryable inference failure, tolerate a briefly-absent provider (bounded wait/backoff for the capability) before declaring terminal. Distinguish "provider temporarily down" from "capability never configured".
|
||||||
|
Files: `CapabilityAwareRoutingStrategy.kt:31`, `DefaultInferenceRouter.kt:54`, `ServerModule.runSession` (the catch that fails the session), SessionOrchestrator retry path.
|
||||||
|
|
||||||
|
### #300 — HealthMonitor detects provider loss ~18s too late
|
||||||
|
Same incident as #299. Health status must GATE routing/retry, not just log reactively. Mark a provider unhealthy immediately on the connection drop (event-driven), and have the retry consult health + wait for recovery.
|
||||||
|
**Do #299 first — #300 completes the health-gating half.**
|
||||||
|
|
||||||
|
### #297 — Analyst CoT indecision loop burns full budget, emits nothing
|
||||||
|
Analyst maxed 16384 reasoning tokens and emitted an empty artifact because the request maps to a PARENT epic, not a single task, and the DoD prompt says "the single task this run owns" → endless oscillation.
|
||||||
|
Primary fix: pre-resolve which task the run owns before the analyst, OR make the DoD prompt explicit ("define the DoD for the epic as a whole; do NOT pick a child"). Optional backstop (can be a separate task): reasoning-token soft cap.
|
||||||
|
|
||||||
|
### #263 + #267 — Auto build-gate never fires on real freestyle scaffold
|
||||||
|
The terminal build gate produced ZERO `StaticAnalysisCompleted` events across a 60-file run. Root cause traced in the ticket: writes-based terminal-stage selection can't pick a non-writing REVIEW terminal stage, and the last writing stage's build gate is shadowed by its own contract gate short-circuiting.
|
||||||
|
Fix direction: attach the auto build-gate to the last WRITING stage, or run it as a workflow-terminal check on the `done` transition independent of per-stage autoBuildGate. Reconcile code (per-stage writes filter) vs comment (terminal-only) — they disagree today.
|
||||||
|
**#267 is the settle-it-live half: one live run confirms the gate fires. Do them together.**
|
||||||
|
**Depends on #191 (Codex) landing** — the gate fires into missing `node_modules` without `setup=npm ci`. Coordinate so your live run goes green.
|
||||||
|
|
||||||
|
### #301 — Escalate repeated scope/manifest write-block to user approval (Goal 2)
|
||||||
|
After N same-path scope/manifest rejections (config `escalate_scope_after_n`, default 3), stop rejecting: reach back to the FIRST rejected invocation's pristine write args in the event log, present via the existing approval/pause flow, approve→widen scope + execute, reject→continue. No branching/replay-engine — plain forward event-log read.
|
||||||
|
Wiring pointers in the ticket: `SessionOrchestratorToolExec.kt` ~234-282 (BLOCK branch) and ~284-422 (existing approval flow + `OutsidePathAccessGrantedEvent` widen-and-execute template).
|
||||||
|
**This is the natural carry-over if the lane is over-full — it's a feature, not a run-killer.**
|
||||||
|
|
||||||
|
## Sequencing
|
||||||
|
299 → 300 (same owner). 263/267 needs 191 (Codex) landed first.
|
||||||
+49
-13
@@ -7,20 +7,22 @@ import com.correx.core.kernel.orchestration.LspDiagnosticsRunner
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.eclipse.lsp4j.ClientCapabilities
|
import org.eclipse.lsp4j.ClientCapabilities
|
||||||
|
import org.eclipse.lsp4j.Diagnostic
|
||||||
import org.eclipse.lsp4j.DidOpenTextDocumentParams
|
import org.eclipse.lsp4j.DidOpenTextDocumentParams
|
||||||
import org.eclipse.lsp4j.DocumentDiagnosticParams
|
|
||||||
import org.eclipse.lsp4j.InitializeParams
|
import org.eclipse.lsp4j.InitializeParams
|
||||||
import org.eclipse.lsp4j.InitializedParams
|
import org.eclipse.lsp4j.InitializedParams
|
||||||
import org.eclipse.lsp4j.MessageActionItem
|
import org.eclipse.lsp4j.MessageActionItem
|
||||||
import org.eclipse.lsp4j.MessageParams
|
import org.eclipse.lsp4j.MessageParams
|
||||||
|
import org.eclipse.lsp4j.PublishDiagnosticsCapabilities
|
||||||
import org.eclipse.lsp4j.PublishDiagnosticsParams
|
import org.eclipse.lsp4j.PublishDiagnosticsParams
|
||||||
import org.eclipse.lsp4j.ShowMessageRequestParams
|
import org.eclipse.lsp4j.ShowMessageRequestParams
|
||||||
import org.eclipse.lsp4j.TextDocumentIdentifier
|
import org.eclipse.lsp4j.TextDocumentClientCapabilities
|
||||||
import org.eclipse.lsp4j.TextDocumentItem
|
import org.eclipse.lsp4j.TextDocumentItem
|
||||||
import org.eclipse.lsp4j.launch.LSPLauncher
|
import org.eclipse.lsp4j.launch.LSPLauncher
|
||||||
import org.eclipse.lsp4j.services.LanguageClient
|
import org.eclipse.lsp4j.services.LanguageClient
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import java.util.concurrent.CompletableFuture
|
import java.util.concurrent.CompletableFuture
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
@@ -56,7 +58,11 @@ class LspServerRegistry(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** LSP 3.17 pull-diagnostics adapter. Missing servers degrade to the static one-shot floor. */
|
/**
|
||||||
|
* LSP push-diagnostics adapter: `didOpen` each file, then collect the `publishDiagnostics`
|
||||||
|
* the server pushes back. tsserver only supports push (rejects the 3.17 pull method);
|
||||||
|
* rust-analyzer/gopls/clangd push on open too. Missing servers degrade to the static floor.
|
||||||
|
*/
|
||||||
class Lsp4jDiagnosticsRunner(
|
class Lsp4jDiagnosticsRunner(
|
||||||
private val registry: LspServerRegistry = LspServerRegistry(),
|
private val registry: LspServerRegistry = LspServerRegistry(),
|
||||||
private val timeoutSeconds: Long = 30,
|
private val timeoutSeconds: Long = 30,
|
||||||
@@ -91,27 +97,34 @@ class Lsp4jDiagnosticsRunner(
|
|||||||
): List<LspDiagnostic> {
|
): List<LspDiagnostic> {
|
||||||
val process = ProcessBuilder(spec.command).directory(request.workspaceRoot.toFile()).start()
|
val process = ProcessBuilder(spec.command).directory(request.workspaceRoot.toFile()).start()
|
||||||
val stderrDrain = Thread.ofVirtual().start { process.errorStream.bufferedReader().use { it.readText() } }
|
val stderrDrain = Thread.ofVirtual().start { process.errorStream.bufferedReader().use { it.readText() } }
|
||||||
val launcher = LSPLauncher.createClientLauncher(QuietLanguageClient, process.inputStream, process.outputStream)
|
val client = CollectingLanguageClient()
|
||||||
|
val launcher = LSPLauncher.createClientLauncher(client, process.inputStream, process.outputStream)
|
||||||
val listening = launcher.startListening()
|
val listening = launcher.startListening()
|
||||||
val server = launcher.remoteProxy
|
val server = launcher.remoteProxy
|
||||||
return try {
|
return try {
|
||||||
server.initialize(
|
server.initialize(
|
||||||
InitializeParams().apply {
|
InitializeParams().apply {
|
||||||
rootUri = request.workspaceRoot.toUri().toString()
|
rootUri = request.workspaceRoot.toUri().toString()
|
||||||
capabilities = ClientCapabilities()
|
capabilities = ClientCapabilities().apply {
|
||||||
|
textDocument = TextDocumentClientCapabilities().apply {
|
||||||
|
publishDiagnostics = PublishDiagnosticsCapabilities()
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
).get(timeoutSeconds, TimeUnit.SECONDS)
|
).get(timeoutSeconds, TimeUnit.SECONDS)
|
||||||
server.initialized(InitializedParams())
|
server.initialized(InitializedParams())
|
||||||
paths.flatMap { path ->
|
val uriToPath = paths.associateBy { path ->
|
||||||
|
request.workspaceRoot.resolve(path).normalize().toUri().toString()
|
||||||
|
}
|
||||||
|
uriToPath.forEach { (uri, path) ->
|
||||||
val file = request.workspaceRoot.resolve(path).normalize()
|
val file = request.workspaceRoot.resolve(path).normalize()
|
||||||
val uri = file.toUri().toString()
|
|
||||||
server.textDocumentService.didOpen(
|
server.textDocumentService.didOpen(
|
||||||
DidOpenTextDocumentParams(TextDocumentItem(uri, spec.languageId, 1, Files.readString(file))),
|
DidOpenTextDocumentParams(TextDocumentItem(uri, spec.languageId, 1, Files.readString(file))),
|
||||||
)
|
)
|
||||||
val report = server.textDocumentService.diagnostic(
|
}
|
||||||
DocumentDiagnosticParams(TextDocumentIdentifier(uri)),
|
awaitDiagnostics(client, uriToPath.keys)
|
||||||
).get(timeoutSeconds, TimeUnit.SECONDS)
|
uriToPath.flatMap { (uri, path) ->
|
||||||
report.relatedFullDocumentDiagnosticReport.items.orEmpty().map { diagnostic ->
|
client.latestFor(uri).map { diagnostic ->
|
||||||
LspDiagnostic(
|
LspDiagnostic(
|
||||||
path = path,
|
path = path,
|
||||||
line = diagnostic.range.start.line,
|
line = diagnostic.range.start.line,
|
||||||
@@ -131,12 +144,35 @@ class Lsp4jDiagnosticsRunner(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private object QuietLanguageClient : LanguageClient {
|
/**
|
||||||
|
* Block until every opened URI has received at least one push (or the timeout elapses), then
|
||||||
|
* a short settle window so servers that push an empty report first, real diagnostics second
|
||||||
|
* (tsserver does this after project load) land their final result before we read it.
|
||||||
|
*/
|
||||||
|
private fun awaitDiagnostics(client: CollectingLanguageClient, uris: Set<String>) {
|
||||||
|
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds)
|
||||||
|
while (System.nanoTime() < deadline && !client.hasAll(uris)) {
|
||||||
|
Thread.sleep(POLL_MS)
|
||||||
|
}
|
||||||
|
Thread.sleep(SETTLE_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class CollectingLanguageClient : LanguageClient {
|
||||||
|
private val byUri = ConcurrentHashMap<String, List<Diagnostic>>()
|
||||||
|
fun latestFor(uri: String): List<Diagnostic> = byUri[uri].orEmpty()
|
||||||
|
fun hasAll(uris: Set<String>): Boolean = byUri.keys.containsAll(uris)
|
||||||
|
override fun publishDiagnostics(diagnostics: PublishDiagnosticsParams) {
|
||||||
|
byUri[diagnostics.uri] = diagnostics.diagnostics.orEmpty()
|
||||||
|
}
|
||||||
override fun telemetryEvent(`object`: Any?) = Unit
|
override fun telemetryEvent(`object`: Any?) = Unit
|
||||||
override fun publishDiagnostics(diagnostics: PublishDiagnosticsParams) = Unit
|
|
||||||
override fun showMessage(messageParams: MessageParams) = Unit
|
override fun showMessage(messageParams: MessageParams) = Unit
|
||||||
override fun showMessageRequest(requestParams: ShowMessageRequestParams): CompletableFuture<MessageActionItem> =
|
override fun showMessageRequest(requestParams: ShowMessageRequestParams): CompletableFuture<MessageActionItem> =
|
||||||
CompletableFuture.completedFuture(null)
|
CompletableFuture.completedFuture(null)
|
||||||
override fun logMessage(message: MessageParams) = Unit
|
override fun logMessage(message: MessageParams) = Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val POLL_MS = 100L
|
||||||
|
const val SETTLE_MS = 750L
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
package com.correx.infrastructure.workflow
|
||||||
|
|
||||||
|
import com.correx.core.kernel.orchestration.LspDiagnosticsRequest
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.jupiter.api.Assumptions.assumeTrue
|
||||||
|
import kotlin.io.path.createTempDirectory
|
||||||
|
import kotlin.io.path.writeText
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class Lsp4jRunnerLiveProof {
|
||||||
|
@Test
|
||||||
|
fun `push diagnostics catch a TS syntax error`(): Unit = runBlocking {
|
||||||
|
assumeTrue(onPath("typescript-language-server"), "typescript-language-server not installed")
|
||||||
|
val dir = createTempDirectory("lspproof")
|
||||||
|
dir.resolve("broken.ts").writeText(
|
||||||
|
"""
|
||||||
|
export const useArtifacts = () => {
|
||||||
|
return 1
|
||||||
|
// missing closing brace/semicolon
|
||||||
|
const x: number = "string"
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
val result = Lsp4jDiagnosticsRunner().pull(LspDiagnosticsRequest(dir, listOf("broken.ts")))
|
||||||
|
println("skippedReason=${result.skippedReason} server=${result.server}")
|
||||||
|
result.diagnostics.forEach { println(" ${it.line}:${it.character} ${it.severity} ${it.message}") }
|
||||||
|
assertTrue(result.skippedReason == null, "gate must not skip: ${result.skippedReason}")
|
||||||
|
assertTrue(result.diagnostics.any { it.severity == "error" }, "expected at least one error diagnostic")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onPath(cmd: String): Boolean =
|
||||||
|
System.getenv("PATH").orEmpty().split(':').any { java.io.File(it, cmd).canExecute() }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user