4 Commits

Author SHA1 Message Date
claude 53f1ebfea9 Merge pull request 'Move the correx server off :8080 to :8090 (mavgpud port conflict)' (#3) from task/695-move-the-correx-server-off-8080-to-8090 into master 2026-08-11 12:13:12 +02:00
claude 4113ee9af4 Merge pull request 'Refresh the failure mandate in-loop when a write lands on a file the failure names' (#2) from task/461-refresh-the-failure-mandate-in-loop-when into master 2026-08-11 12:13:08 +02:00
claude 7dbfdcf081 fix(config,cli,tui): move the correx server off :8080 to :8090 (#695)
mavgpud.service, the Maven GPU supervisor, is an enabled systemd user unit that
binds *:8080 and restarts on kill. The correx server wanted the same port, so
qa-stack died with BindException and the QA stack never came up. Worse,
qa-stack's --stop ran `fuser -k 8080/tcp`, which killed mavgpud rather than a
correx server; systemd then restarted it straight into the port it had just
freed, so it won the race every time.

Moves the default to 8090 in the four places that have to agree: ServerConfig,
ConfigLoader's fallback, the CLI's DEFAULT_PORT, and the TUI's -port flag. A
mismatch between any two of them is a client that cannot find its own server.

The machine-local halves are not in this diff and were applied on disk:
`~/.config/correx/config.toml` pinned `port = 8080` explicitly, which overrides
the code default, and `scripts/` is gitignored so qa-stack.sh's five references
(including the --stop kill, now aimed at 8090) live only on this box.

Verified live: the server binds 8090 and answers /health while mavgpud keeps
8080. Left open in #695: mavgpud also spawns a llama-server on :10000, which is
qa-stack's router port, and qa-stack pkills that pattern.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 14:06:36 +04:00
claude 24b94aab28 feat(kernel): refresh a stale lsp_diagnostics mandate in-loop (#461)
On a gate-repair retry the failure text was frozen for the whole tool loop. The
agent edited the offending file, was told "written successfully", and kept
editing against a diagnostic it may already have cleared — it only found out
after stage_complete, when runPostStageGates re-ran from the top.

refreshLspRetryMandate hangs off the existing wroteThisRound hook: when the
pending retry mandate is an lsp_diagnostics failure and a write landed on a path
that failure names, re-pull diagnostics, record LspDiagnosticsCompletedEvent,
and rebuild the retryFeedback entry. buildRetryFeedbackEntry already renders
per-file freshness from that event, so the ledger flips to "done, leave it"
in-loop with no new message format and no context rebuild.

Two calls worth stating. The pull covers the stage's whole written set, not just
the named paths, because a path absent from the recorded event reads as clean to
fileRepairOutcomes — a partial pull would mark unrelated files repaired. A
skipped pull returns null rather than recording empty diagnostics, for the same
reason: telling the model to leave a still-broken file alone is worse than
leaving the stale text in place.

Scoped to LSP. A tsc or npm run build re-run per write is too expensive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:28:12 +04:00
10 changed files with 137 additions and 8 deletions
@@ -1,3 +1,3 @@
package com.correx.apps.cli package com.correx.apps.cli
internal const val DEFAULT_PORT = 8080 internal const val DEFAULT_PORT = 8090
+1 -1
View File
@@ -15,7 +15,7 @@ import (
func main() { func main() {
host := flag.String("host", "localhost", "server host") host := flag.String("host", "localhost", "server host")
port := flag.Int("port", 8080, "server port") port := flag.Int("port", 8090, "server port")
flag.Parse() flag.Parse()
if path := os.Getenv("CORREX_TUI_LOG"); path != "" { if path := os.Getenv("CORREX_TUI_LOG"); path != "" {
@@ -184,7 +184,7 @@ object ProfileLoader {
} }
object ConfigLoader { object ConfigLoader {
private const val DEFAULT_SERVER_PORT = 8080 private const val DEFAULT_SERVER_PORT = 8090
private const val DEFAULT_SESSION_LIST_LIMIT = 5 private const val DEFAULT_SESSION_LIST_LIMIT = 5
private const val DEFAULT_EMBEDDER_DIMENSION = 1536 private const val DEFAULT_EMBEDDER_DIMENSION = 1536
private const val DEFAULT_L3_DIM = 1536 private const val DEFAULT_L3_DIM = 1536
@@ -184,7 +184,7 @@ data class ArtifactKindConfig(
@Serializable @Serializable
data class ServerConfig( data class ServerConfig(
val host: String = "localhost", val host: String = "localhost",
val port: Int = 8080, val port: Int = 8090,
) )
@Serializable @Serializable
@@ -9,7 +9,7 @@ class ConfigLoaderTest {
fun `load returns defaults when config file missing`() { fun `load returns defaults when config file missing`() {
val config = CorrexConfig() val config = CorrexConfig()
assertEquals("localhost", config.server.host) assertEquals("localhost", config.server.host)
assertEquals(8080, config.server.port) assertEquals(8090, config.server.port)
assertEquals("dark", config.tui.theme) assertEquals("dark", config.tui.theme)
assertEquals(5, config.tui.sessionListLimit) assertEquals(5, config.tui.sessionListLimit)
assertEquals("human", config.cli.defaultOutput) assertEquals("human", config.cli.defaultOutput)
@@ -0,0 +1,80 @@
package com.correx.core.kernel.orchestration
// Split into its own file (same reason as RecoveryFileLoopBreak.kt) to keep
// SessionOrchestratorGates2.kt under detekt's per-file function-count cap.
import com.correx.core.context.model.ContextEntry
import com.correx.core.events.events.LspDiagnosticsCompletedEvent
import com.correx.core.events.events.RetryAttemptedEvent
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
internal const val LSP_DIAGNOSTICS_GATE = "lsp_diagnostics"
/**
* True when [failureReason] names at least one of [writtenPaths] (Vikunja #461). The frozen mandate
* quotes diagnostics as `src/App.tsx:12:5 TS2322 ...`, so the path token is matched with the same
* suffix rule [resolveTicketOwner] uses on ticket evidence — the failure may name `App.tsx` while
* the write manifest holds the workspace-relative `src/App.tsx`.
*/
internal fun failureNamesWrittenPath(failureReason: String, writtenPaths: List<String>): Boolean {
val named = EVIDENCE_PATH_RE.findAll(failureReason)
.map { it.value.substringBefore(':').replace('\\', '/') }
.filter { it.length >= MIN_EVIDENCE_TOKEN }
.toSet()
if (named.isEmpty()) return false
return writtenPaths.any { written ->
val norm = written.replace('\\', '/')
named.any { norm == it || norm.endsWith("/$it") }
}
}
/**
* In-loop refresh of a stale `lsp_diagnostics` repair mandate (Vikunja #461). On a gate-repair retry
* the failure text is frozen for the whole tool loop: the agent edits the offending file, is told
* "written successfully", and keeps editing against a diagnostic it may already have cleared — it
* only finds out after `stage_complete`, when [runPostStageGates] re-runs from the top. Called from
* the existing `wroteThisRound` hook, this re-pulls diagnostics and records them, so
* [buildRetryFeedbackEntry]'s per-file ledger flips to "done, leave it" in-loop. Rebuilding from the
* recorded event (invariant #9) is what keeps the fresh truth replayable, and is why there is no new
* message format here.
*
* Scoped to LSP by design: a `tsc` / `npm run build` re-run per write is too expensive. Fires only
* when the write landed on a path the frozen failure actually names, so an unrelated write in the
* same loop costs nothing.
*
* Returns the rebuilt `retryFeedback` entry, or null when nothing applies (no runner, wrong gate, no
* overlap, or the pull was skipped). Returns null on a skipped pull deliberately: an empty
* diagnostics list from a server that never started reads as "clean" to the ledger, and telling the
* model to leave a still-broken file alone is worse than leaving the stale text in place.
*/
@Suppress("ReturnCount")
internal suspend fun SessionOrchestrator.refreshLspRetryMandate(
sessionId: SessionId,
stageId: StageId,
effectives: RunEffectives,
): ContextEntry? {
val runner = lspDiagnosticsRunner ?: return null
val workspaceRoot = effectives.policy?.workspaceRoot ?: return null
val pending = eventStore.read(sessionId)
.mapNotNull { it.payload as? RetryAttemptedEvent }
.lastOrNull { it.stageId == stageId }
?: return null
if (pending.gate != LSP_DIAGNOSTICS_GATE) return null
// Pull for the stage's WHOLE written set, not just the paths the failure names, even though the
// overlap is what triggers the refresh: the recorded event is read back per path, and a path
// absent from it reads as clean. A partial pull would mark every other file the stage wrote
// "done, leave it" on no evidence.
val paths = stageWrittenPaths(sessionId, stageId)
if (!failureNamesWrittenPath(pending.failureReason, paths)) return null
val result = runner.pull(LspDiagnosticsRequest(workspaceRoot, paths))
if (result.skippedReason != null) return null
val diagnostics = result.diagnostics.filter { it.path in paths }
emit(
sessionId,
LspDiagnosticsCompletedEvent(sessionId, stageId, result.server, diagnostics, result.skippedReason),
)
return buildRetryFeedbackEntry(eventStore.read(sessionId), stageId)
}
@@ -538,6 +538,13 @@ internal suspend fun SessionOrchestrator.executeStage(
val refreshed = remainingDeltaResults?.let { buildRemainingDeltaEntry(contractFailureItems(it)) } val refreshed = remainingDeltaResults?.let { buildRemainingDeltaEntry(contractFailureItems(it)) }
accumulatedEntries = accumulatedEntries.filterNot { it.sourceType == "remainingDelta" } + accumulatedEntries = accumulatedEntries.filterNot { it.sourceType == "remainingDelta" } +
listOfNotNull(refreshed) listOfNotNull(refreshed)
// #461: the same cache-until-write logic applied to a frozen lsp_diagnostics repair
// mandate. Without it the agent keeps editing against a diagnostic it may already have
// cleared and only learns otherwise after stage_complete re-runs the gate.
refreshLspRetryMandate(sessionId, stageId, effectives)?.let { mandate ->
accumulatedEntries = accumulatedEntries.filterNot { it.sourceType == "retryFeedback" } +
mandate
}
} }
currentContext = contextPackBuilder.build( currentContext = contextPackBuilder.build(
id = ContextPackId(UUID.randomUUID().toString()), id = ContextPackId(UUID.randomUUID().toString()),
@@ -107,7 +107,7 @@ internal suspend fun SessionOrchestrator.runLspDiagnostics(
return StageExecutionResult.Failure( return StageExecutionResult.Failure(
"stage ${stageId.value} has LSP diagnostics in files it wrote. Fix these before proceeding:\n$detail", "stage ${stageId.value} has LSP diagnostics in files it wrote. Fix these before proceeding:\n$detail",
retryable = true, retryable = true,
gate = "lsp_diagnostics", gate = LSP_DIAGNOSTICS_GATE,
) )
} }
@@ -0,0 +1,42 @@
package com.correx.core.kernel.orchestration
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* The trigger predicate for the in-loop mandate refresh (#461). It decides whether an LSP re-pull
* fires at all, so a false negative leaves the agent editing against a stale diagnostic and a false
* positive re-pulls on every unrelated write.
*/
class LspMandateRefreshTest {
private val failure = "stage build has LSP diagnostics in files it wrote. Fix these before proceeding:\n" +
"- src/api/queries.ts:39:3 TS1005 '}' expected"
@Test
fun `write on the path the failure names triggers a refresh`() {
assertTrue(failureNamesWrittenPath(failure, listOf("src/api/queries.ts")))
}
@Test
fun `a failure naming a bare filename still matches the workspace-relative write`() {
assertTrue(failureNamesWrittenPath("queries.ts(39,3): '}' expected", listOf("src/api/queries.ts")))
}
@Test
fun `an unrelated write does not trigger a refresh`() {
assertFalse(failureNamesWrittenPath(failure, listOf("src/api/other.ts", "README.md")))
}
@Test
fun `a suffix that is not a path boundary does not match`() {
// "notqueries.ts" ends with the named token as a substring but is a different file.
assertFalse(failureNamesWrittenPath(failure, listOf("src/api/notqueries.ts")))
}
@Test
fun `a failure naming no path never triggers a refresh`() {
assertFalse(failureNamesWrittenPath("stage build failed: server exited", listOf("src/api/queries.ts")))
}
}
+2 -2
View File
@@ -76,7 +76,7 @@ scripts/qa/searxng-down.sh
## 4. Start the server ## 4. Start the server
```bash ```bash
./gradlew :apps:server:run # mainClass com.correx.apps.server.MainKt, listens on :8080 ./gradlew :apps:server:run # mainClass com.correx.apps.server.MainKt, listens on :8090
# or build a runnable dist once and reuse it: # or build a runnable dist once and reuse it:
./gradlew :apps:server:installDist ./gradlew :apps:server:installDist
apps/server/build/install/server/bin/server apps/server/build/install/server/bin/server
@@ -90,7 +90,7 @@ apps/server/build/install/server/bin/server
```bash ```bash
cd apps/tui-go cd apps/tui-go
GOTOOLCHAIN=auto go build -o correx-tui . GOTOOLCHAIN=auto go build -o correx-tui .
./correx-tui -host localhost -port 8080 # flags default to localhost:8080 ./correx-tui -host localhost -port 8090 # flags default to localhost:8090
``` ```
## 6. Evidence tools (what the plans cite) ## 6. Evidence tools (what the plans cite)