From ae0b23df3ffe5fa588d9f9547d474af2effb2d5a Mon Sep 17 00:00:00 2001 From: kami Date: Tue, 21 Jul 2026 00:36:25 +0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Rdo9fe7SujNVeyZA8YkpkD --- HANDOFF-codex.md | 27 ++++++++ HANDOFF-opencode.md | 23 +++++++ HANDOFF-sonnet.md | 36 +++++++++++ .../workflow/Lsp4jDiagnosticsRunner.kt | 62 +++++++++++++++---- .../workflow/Lsp4jRunnerLiveProof.kt | 33 ++++++++++ 5 files changed, 168 insertions(+), 13 deletions(-) create mode 100644 HANDOFF-codex.md create mode 100644 HANDOFF-opencode.md create mode 100644 HANDOFF-sonnet.md create mode 100644 infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/Lsp4jRunnerLiveProof.kt diff --git a/HANDOFF-codex.md b/HANDOFF-codex.md new file mode 100644 index 00000000..202e3883 --- /dev/null +++ b/HANDOFF-codex.md @@ -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 `. +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:` 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. diff --git a/HANDOFF-opencode.md b/HANDOFF-opencode.md new file mode 100644 index 00000000..c9ce9201 --- /dev/null +++ b/HANDOFF-opencode.md @@ -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 `. +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. diff --git a/HANDOFF-sonnet.md b/HANDOFF-sonnet.md new file mode 100644 index 00000000..83da013e --- /dev/null +++ b/HANDOFF-sonnet.md @@ -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 `. +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. diff --git a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/Lsp4jDiagnosticsRunner.kt b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/Lsp4jDiagnosticsRunner.kt index e70dbdc7..b2c62e68 100644 --- a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/Lsp4jDiagnosticsRunner.kt +++ b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/Lsp4jDiagnosticsRunner.kt @@ -7,20 +7,22 @@ import com.correx.core.kernel.orchestration.LspDiagnosticsRunner import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.eclipse.lsp4j.ClientCapabilities +import org.eclipse.lsp4j.Diagnostic import org.eclipse.lsp4j.DidOpenTextDocumentParams -import org.eclipse.lsp4j.DocumentDiagnosticParams import org.eclipse.lsp4j.InitializeParams import org.eclipse.lsp4j.InitializedParams import org.eclipse.lsp4j.MessageActionItem import org.eclipse.lsp4j.MessageParams +import org.eclipse.lsp4j.PublishDiagnosticsCapabilities import org.eclipse.lsp4j.PublishDiagnosticsParams import org.eclipse.lsp4j.ShowMessageRequestParams -import org.eclipse.lsp4j.TextDocumentIdentifier +import org.eclipse.lsp4j.TextDocumentClientCapabilities import org.eclipse.lsp4j.TextDocumentItem import org.eclipse.lsp4j.launch.LSPLauncher import org.eclipse.lsp4j.services.LanguageClient import java.nio.file.Files import java.util.concurrent.CompletableFuture +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit 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( private val registry: LspServerRegistry = LspServerRegistry(), private val timeoutSeconds: Long = 30, @@ -91,27 +97,34 @@ class Lsp4jDiagnosticsRunner( ): List { val process = ProcessBuilder(spec.command).directory(request.workspaceRoot.toFile()).start() 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 server = launcher.remoteProxy return try { server.initialize( InitializeParams().apply { rootUri = request.workspaceRoot.toUri().toString() - capabilities = ClientCapabilities() + capabilities = ClientCapabilities().apply { + textDocument = TextDocumentClientCapabilities().apply { + publishDiagnostics = PublishDiagnosticsCapabilities() + } + } }, ).get(timeoutSeconds, TimeUnit.SECONDS) 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 uri = file.toUri().toString() server.textDocumentService.didOpen( DidOpenTextDocumentParams(TextDocumentItem(uri, spec.languageId, 1, Files.readString(file))), ) - val report = server.textDocumentService.diagnostic( - DocumentDiagnosticParams(TextDocumentIdentifier(uri)), - ).get(timeoutSeconds, TimeUnit.SECONDS) - report.relatedFullDocumentDiagnosticReport.items.orEmpty().map { diagnostic -> + } + awaitDiagnostics(client, uriToPath.keys) + uriToPath.flatMap { (uri, path) -> + client.latestFor(uri).map { diagnostic -> LspDiagnostic( path = path, 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) { + 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>() + fun latestFor(uri: String): List = byUri[uri].orEmpty() + fun hasAll(uris: Set): Boolean = byUri.keys.containsAll(uris) + override fun publishDiagnostics(diagnostics: PublishDiagnosticsParams) { + byUri[diagnostics.uri] = diagnostics.diagnostics.orEmpty() + } override fun telemetryEvent(`object`: Any?) = Unit - override fun publishDiagnostics(diagnostics: PublishDiagnosticsParams) = Unit override fun showMessage(messageParams: MessageParams) = Unit override fun showMessageRequest(requestParams: ShowMessageRequestParams): CompletableFuture = CompletableFuture.completedFuture(null) override fun logMessage(message: MessageParams) = Unit } + + private companion object { + const val POLL_MS = 100L + const val SETTLE_MS = 750L + } } diff --git a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/Lsp4jRunnerLiveProof.kt b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/Lsp4jRunnerLiveProof.kt new file mode 100644 index 00000000..fa11c953 --- /dev/null +++ b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/Lsp4jRunnerLiveProof.kt @@ -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() } +}