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
@@ -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<LspDiagnostic> {
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<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 publishDiagnostics(diagnostics: PublishDiagnosticsParams) = Unit
override fun showMessage(messageParams: MessageParams) = Unit
override fun showMessageRequest(requestParams: ShowMessageRequestParams): CompletableFuture<MessageActionItem> =
CompletableFuture.completedFuture(null)
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() }
}