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:
2026-07-21 00:36:25 +04:00
parent 1acb5cc8ff
commit ae0b23df3f
5 changed files with 168 additions and 13 deletions
+27
View File
@@ -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.
+23
View File
@@ -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.
+36
View File
@@ -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.
@@ -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
}
} }
@@ -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() }
}