1 Commits

Author SHA1 Message Date
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
10 changed files with 8 additions and 137 deletions
@@ -1,3 +1,3 @@
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() {
host := flag.String("host", "localhost", "server host")
port := flag.Int("port", 8080, "server port")
port := flag.Int("port", 8090, "server port")
flag.Parse()
if path := os.Getenv("CORREX_TUI_LOG"); path != "" {
@@ -184,7 +184,7 @@ object ProfileLoader {
}
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_EMBEDDER_DIMENSION = 1536
private const val DEFAULT_L3_DIM = 1536
@@ -184,7 +184,7 @@ data class ArtifactKindConfig(
@Serializable
data class ServerConfig(
val host: String = "localhost",
val port: Int = 8080,
val port: Int = 8090,
)
@Serializable
@@ -9,7 +9,7 @@ class ConfigLoaderTest {
fun `load returns defaults when config file missing`() {
val config = CorrexConfig()
assertEquals("localhost", config.server.host)
assertEquals(8080, config.server.port)
assertEquals(8090, config.server.port)
assertEquals("dark", config.tui.theme)
assertEquals(5, config.tui.sessionListLimit)
assertEquals("human", config.cli.defaultOutput)
@@ -1,80 +0,0 @@
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,13 +538,6 @@ internal suspend fun SessionOrchestrator.executeStage(
val refreshed = remainingDeltaResults?.let { buildRemainingDeltaEntry(contractFailureItems(it)) }
accumulatedEntries = accumulatedEntries.filterNot { it.sourceType == "remainingDelta" } +
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(
id = ContextPackId(UUID.randomUUID().toString()),
@@ -107,7 +107,7 @@ internal suspend fun SessionOrchestrator.runLspDiagnostics(
return StageExecutionResult.Failure(
"stage ${stageId.value} has LSP diagnostics in files it wrote. Fix these before proceeding:\n$detail",
retryable = true,
gate = LSP_DIAGNOSTICS_GATE,
gate = "lsp_diagnostics",
)
}
@@ -1,42 +0,0 @@
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
```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:
./gradlew :apps:server:installDist
apps/server/build/install/server/bin/server
@@ -90,7 +90,7 @@ apps/server/build/install/server/bin/server
```bash
cd apps/tui-go
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)