Replaces the single shared per-stage retryCount (reset on TransitionExecuted,
shared across all post-stage gates) with a per-gate budget. Motivated by run
2e468f9f, where a promising freestyle run was terminally FAILED because the
contract gate burned all 3 shared retries on one trivial miss before the
semantic-review gate ever ran.
- StageExecutionResult.Failure gains a `gate` id; each gate tags its failures.
- OrchestrationState gains gateRetryBudgets / gateFailureFingerprints /
gateSalvageUsed (all rebuilt from events, reset per-stage on TransitionExecuted).
- FailureFingerprint normalizes+hashes the failure reason; RetryCoordinator.decide
charges a gate's budget only when the fingerprint is unchanged (no progress),
so a moving run is never penalised. Returns Retry | Exhausted.
- Hybrid exhaustion: deterministic gates fail; the review gate consults a
SalvageJudge (LLM, or a deterministic allow-one-reset fallback), recorded as
RetrySalvageDecidedEvent (invariant #9). CONTINUE resets the gate budget once;
a second exhaustion is terminal.
- Review gate is now the sole authority on review-retry termination; the old
REVIEW_BLOCK_RETRY_CAP is repurposed as a high absolute backstop only.
- ServerModule escaped-exception path now records a truthful terminal
WorkflowFailed (real stage/reason/retryExhausted) via recordUnhandledFailure.
Tests: DefaultRetryCoordinatorTest (fingerprint charging) +
GateRetryBudgetExhaustionTest (deterministic exhaust / review CONTINUE-then-
terminal / review FAIL / null-judge fallback) + reducer coverage.
Context rework from the 2026-07-05 context-poisoning diagnosis (session
6ca236c4 read-loop):
- ContextBucket on ContextEntry: REQUIRED (task, constraints, needed
artifacts, failure feedback) is never pruned/compressed/dropped; the
build fails fast (RequiredContextOverflowException -> non-retryable
stage failure) if required alone exceeds the stage budget.
- OptionalCeilings: per-sourceType token caps for the optional block
(repoMap 1200, relevantFiles 1600, journal 800, profiles 400/600,
vocabulary 400), tuned for the 9-26B tier at 16k; enforced by
truncation (journal keeps its tail, others their head).
- Repo map is now a structural 'what exists' listing (alphabetical,
per-directory, no recency ranking, no symbols); .md/docs paths are
gated behind an explicit docs mention in the stage prompt — retrieval
hits remain the only other path for docs to enter context.
- file_written needs render as a manifest of ALL files the producing
stage wrote (projected from ArtifactContentStored -> ToolInvocation
-> FileWritten events), fixing the last-write-wins collapse; content
stays lazy behind file_read.
- Decision journal folds a stage's RETRY records into one summary line
once a later transition leaves that stage (render-time only).
- Dedupe retrieved-file lines in buildRelevantFilesEntry.
Narration was generic ("Stage X completed. Summarise.") because nothing about
what the stage actually did reached the narrator — the L2 summary is a
placeholder and StageCompletedEvent carries no output. narrate() now reads the
stage's ToolInvocationRequested events from the log and feeds the narrator a
concrete 'Actions taken this stage: file_read(path), ...' entry, and the system
prompt asks it to prefer those concrete actions over restating the stage name.
The 'router' name was misleading — it's the always-on conversational front-end
(CHAT triage + STEERING into a running workflow), not a routing layer, and it's
distinct from InferenceRouter. Rename core:router -> core:talkie, package
com.correx.core.router -> com.correx.core.talkie, and RouterFacade/Config/State/
Repository/ContextBuilder/Projector/Reducer/Response -> Talkie*. Config section
[router] -> [talkie] (legacy [router] still read as fallback).
Persisted wire formats are preserved: ChatTurnRole.ROUTER and the
RouterNarrationEvent @SerialName("RouterNarration") stay, so existing event
logs still replay. RouterNarrationEvent the class is now TalkieNarrationEvent.
Three tool-path correctness fixes:
- Tool-execution events were emitted twice — SandboxedToolExecutor and
SessionOrchestrator.recordToolExecution both emitted Completed/Failed/
FileWritten per call (double TUI rows, double-counted metrics). Split
ownership: orchestrator is the sole recorder of Completed/Failed (truncates
output, drives the read-before-write gate); executor owns Started +
FileWritten (pre/post-image hashes it alone can capture).
- computeToolPreview still gated on operation == "write", but file_write lost
its operation param when delete was split out, so it bailed to raw-args for
every write (approval card showed raw JSON). Drop the dead guard.
- A file_read blocked by REFERENCE_EXISTS can never complete, so it could never
lift read-only mode: a stage writing a NEW file deadlocked (writes filtered,
every read of the not-yet-created target blocked) until MAX_TOOL_ROUNDS.
Lift read-only on a REFERENCE_EXISTS block, and reword the block message to
tell the model to file_write directly. Regression test added.
Two copies had drifted — the core:sessions one and the testing:projections one — and one had rotted into asserting the ACTIVE-forever bug as correct. Port the unique boundProfile + freestyle-planning cases into the comprehensive projections copy and delete the core:sessions duplicate.
DefaultSessionReducer skipped WorkflowCompletedEvent to avoid terminalizing on freestyle planning's mid-session completion, but that left execution/normal completions stuck ACTIVE forever — headless watchers and approval loops never saw a terminal state. Add workflowId to WorkflowCompletedEvent (defaulted for replay compat), pass graph.id at all completeWorkflow sites, and terminalize to COMPLETED unless it's the freestyle_planning handoff. Correct stale tests that pinned the ACTIVE-forever behavior.
Uncommitted work from the session-robustness-and-dox branch sweep
(docs/qa/QA-session-robustness-and-dox.md), verified alongside the
compression/context fixes:
- SandboxedToolExecutor: validate tool args centrally before dispatch. A
malformed/missing-arg call becomes a recoverable ERROR: (surfaced with the
tool's arg schema so the model can correct + retry) instead of stranding
the stage with no artifact. + validation test.
- PlanLinter: seed artifacts (analysis) produced by the planning phase count
as available producers, so a plan stage that `needs` them isn't flagged as
an unproduced-need; H1 unproduced-needs + trap-state checks. + tests.
- DefaultSessionOrchestrator: live-QA robustness fixes (event-tail /
per-stage budget + retry handling).
- workflow prompts/configs: DOX AGENTS.md alignment + freestyle/task/role
prompt tweaks.
- SessionOrchestratorIntegrationTest: coverage for the above.
- FreestylePlanningWorkflowTest: allow list_dir in analyst tools (follows the
list_dir wiring in 968cbfa).
- QA plan doc for the branch sweep.
Three defects surfaced by a live headless freestyle_planning run (all made a
long execution workflow fail or stall in ways invisible to a REST operator):
1. retryCount was session-global (DefaultOrchestrationReducer): TransitionExecuted
updated currentStageId but never reset retryCount, while shouldRetry gates on
it per-stage. An early stage that burned its 3 retries starved every later
stage of its own — the next stage's first *retryable* failure went terminal.
Reset retryCount on stage entry; back-edge loops stay bounded by the separate
refinementIterations counter. Regression test added.
2. Freestyle stages ran at StageConfig's 4096-token default (ExecutionPlanCompiler
never set a budget) vs 16384-32768 for static role_pipeline stages. A tool-heavy
stage truncated its context every round, evicting the file_read results it just
gathered, so it re-read forever and never accumulated enough to write. Set 16384.
3. GET /events returned the OLDEST `limit` events (readFrom(0) then take): for a
session past `limit` events the tail — including a pending ApprovalRequested —
was invisible to a headless/REST poller, the exact caller POST /approve serves.
Default now tails; explicit fromSeq still paginates forward.
New TasksReady condition driven by EvaluationContext.readyTaskCount,
fed at resolve time via a kernel-declared ReadyTaskCounter interface
implemented in apps/server over TaskService (keeps core:kernel
decoupled from core:tasks). Loops a graph while ready>0, exits at 0.
Root Child DOX Index assembled, plus a per-module AGENTS.md across the tree
(core/*, infrastructure/*, apps/*, testing/*, and docs/examples/frontend/etc),
each following the DOX section shape: Purpose, Ownership, Local Contracts,
Work Guidance, Verification, Child DOX Index.
Two orchestrator tool-gating behaviors (both touch SessionOrchestrator):
- Read-only switch: when a tool call is BLOCKed with READ_BEFORE_WRITE, derive a
read-only mode from the event log (block until a later FILE_READ completes) and
withhold FILE_WRITE tools from the next inference turn(s), so a weak model is
pushed down the read-then-write path instead of looping on the blocked write.
- require_task_decompose stage flag (TomlWorkflowLoader) + owesTaskDecompose guard:
a stage so marked cannot stage_complete until a task_decompose/task_create has
succeeded this stage. New task_planning workflow + prompt are the first consumer
(decompose-only: swoop codebase, emit a parent + DEPENDS_ON task graph, stop).
Two reviewer-reliability tracks, both deterministic and unit-verified (no model/network).
§B-§5 — static_check stage seam. The StaticAnalysisRunner + StaticFindingsRecordedEvent +
reviewer-context filter already existed; what was missing was a stage that runs the tool and
emits the event. Added:
- StaticCheckStageExecutor: reads stage metadata (static_tool/static_argv), runs the configured
command via StaticAnalysisRunner, returns a StaticFindingsRecordedEvent. No-op (empty findings)
when no runner is wired or no command is configured — safe to carry unconfigured.
- A deterministic-stage seam in DefaultSessionOrchestrator.enterStage: any stage with
metadata["stage_type"] == "static_check" is run by the executor instead of the LLM subagent,
then advances on its (unconditional) exit edge.
- TomlWorkflowLoader: stage_type/static_tool/static_argv fields → StageConfig.metadata.
- role_pipeline.toml: a static_check stage between implementer and reviewer (no-op until
static_argv + a CommandRunner are set; activation is live-QA-gated, see StaticAnalysisRunner doc).
§B-§6 — critique-outcome producer. The CritiqueFinding type + CriticCalibrationProjection existed
but nothing fed them. Added:
- CritiqueFindingsRecordedEvent (+ CritiqueVerdict): the producing side — a critic's findings +
verdict for one review iteration, carrying modelHash for per-model calibration.
- CritiqueOutcomeCorrelator: pure loop-resolution logic deciding UPHELD (fixed between rounds) /
DISMISSED (persisted into an approved final) / INCONCLUSIVE (open at a non-approved terminal),
per critic (role + modelHash) and per finding id.
- A hook in completeWorkflow/failWorkflow that correlates recorded findings into
CritiqueOutcomeCorrelatedEvents at loop resolution — no-op when none recorded, idempotent.
(LLM-side finding emission stays a separate model-gated activation.)
Tests: StaticCheckStageExecutorTest (4), StaticCheckStageTest integration (1, fake runner →
event + transition), CritiqueOutcomeCorrelatorTest (8), CritiqueCalibrationWiringTest integration
(1, seeded findings → outcomes at completion), updated RolePipelineWorkflowTest. Full suites for
core:events/kernel/critique, infrastructure:workflow, testing:integration green; detekt clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On session start, discover CLAUDE.md and AGENTS.md at the bound workspace root and inject
their (concatenated, header-labeled) contents as an L0 system context entry for every stage —
mirroring the .correx/project.toml ProjectProfile path end to end. Recorded as
AgentInstructionsBoundEvent (invariant #9) so replay reads the recorded fact, not the live
file; folded into SessionState.boundAgentInstructions and rendered by buildAgentInstructionsEntry
right after the project profile. AgentInstructionsLoader reads root-only, both files if present.
Bound via ServerModule.bindAgentInstructions alongside bindProjectProfile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The rubber-duck path emits a fenced {"ideas":[…]} block; Ideas.parse strips it
from the chat turn and onUserInput records an IdeaCapturedEvent each (CHAT only).
IdeaReader folds eventStore.allEvents() into the active cross-session board, and
DefaultRouterContextBuilder injects a capped "## Ideas" entry — router-only, so
workflow stages never see it. Extended SYSTEM_PROMPT option (d) to capture ideas.
The triage system prompt now appends a fenced json propose_workflows block;
WorkflowProposals.parse strips it from the visible chat turn and onUserInput
records a WorkflowProposedEvent (CHAT only). Candidate ids are validated against
the registered workflows (invariant #7) so an invented id never reaches the
operator. Bumped a token-budget test for the larger prompt.
When a stage produces an LLM artifact carrying a non-empty questions
array, the orchestrator parks the run, records ClarificationRequested,
awaits the operator's answers (submitClarification seam), records
ClarificationAnswered, injects the answered Q&A as an L2 USER entry, and
re-runs the same stage so the questions are resolved by the role that
asked them. Runs on a dedicated pendingClarifications map and the
enterStage success-branch loop, so a clarification round never consumes
the failure-retry budget; capped at three rounds. Integration test covers
the happy path and the cap.
Provides a reusable builder that packages the event-log-in → derived-state-out pattern for deterministic tests. Implements fixed clock and deterministic event ID generation to ensure byte-identical event JSON across test runs with same payloads.
Acceptance criteria met:
- Two harnesses fed same payloads yield byte-identical stored event JSON
- Fixed clock ensures timestamp determinism (2026-01-01T00:00:00Z)
- Event IDs are deterministically generated (event-0, event-1, etc.)
- Supports both givenEvents (append) and rebuild (replay via projection)
LlamaCppTokenizer posted {"tokens":[text]} but llama.cpp's /tokenize expects
{"content":text}; the server silently answered {"tokens":[]}, so countTokens
returned 0 for every string. Every context entry carried tokenEstimate=0 and
the whole budget/trim pipeline was blind — live QA saw a 34k-token prompt
sail through a 16384 stage budget (three raw file_read results of plan docs),
crater t/s, degrade output, and fail the stage on validation 3x.
Also guard estimateTokens: a zero count for non-blank content falls back to
the chars/4 heuristic so a lying tokenizer can never blind budgeting again.
Strict chat templates (Qwen3.5) reject any system message after index 0.
Recalled L3 memory, L2 stage summaries, and retrieval entries carry
EntryRole.SYSTEM and rendered as standalone mid-conversation system turns,
making llama-server 400 on every router turn once L3 recall returned hits.
Adds two test files to prove B4/B3 invariants end-to-end:
- RepoKnowledgeReplayTest (testing/replay): projection-based replay tests that
verify recorded RepoKnowledgeRetrievedEvent drives relevantFiles context assembly
without consulting a retriever (test A), and that a log without the retrieval event
falls back to the RepoMapComputedEvent legacy path (test B).
- RepoMapReuseIntegrationTest (testing/integration): end-to-end test using the real
ProjectMemoryService + L3RepoKnowledgeRetriever + InMemoryL3MemoryStore proving
exactly one index walk across two sessions sharing the same stateKey, and that the
second session receives non-empty repo hits via the shared L3 embeddings despite
having no per-session RepoMapComputedEvent.
New test deps (noted per constraint):
- testing/replay: adds core:context (needed to import ContextLayer/ContextEntry from
the buildRelevantFilesEntry return type).
- testing/integration: adds apps:server (ProjectMemoryService, L3RepoKnowledgeRetriever,
FakeWorkspaceStateProbe), core:router (InMemoryL3MemoryStore), core:config (ProjectConfig).
No new inter-core or upward production dependencies introduced.
- Add existsByTurnIdPrefix(prefix) to L3MemoryStore interface; implemented in
InMemoryL3MemoryStore (mutex-guarded) and TurboVecL3MemoryStore (metadata map).
All test fakes (TrackingL3MemoryStore, CapturingStore) updated.
- Introduce RepoMapIndexerPort interface; RepoMapIndexer implements it. Allows
injection of test doubles without framework overhead.
- ProjectMemoryService.indexAndRecord: reads latest WorkspaceStateObservedEvent
from event store (invariant #9 — recorded fact, not re-probe); skips scan+embed
when L3 already holds entries for "repomap:<root>:<stateKey>"; passes stateKey
to RepoMapComputedEvent; embeds each scanned entry after append; per-entry
failures warn-logged (CancellationException rethrown).
- Tests: InMemoryL3MemoryStoreTest +2 cases; new ProjectMemoryServiceReuseTest
covers same-key skip, changed-key re-index, no-observation always-reindex,
and embedding turnId tag verification.
emitLlmArtifacts previously emitted ArtifactCreated/Validating/Validated for every
llm-emitted slot unconditionally, fabricating "validated" artifacts when the model
produced no content. Gate now mirrors the F-015 file_written check: slots with null
or blank cached content are skipped with a warn log. Regression test added in
NeedsArtifactInjectionTest verifies no artifact events appear for a blank-response stage.
[router.narration] model_id selects which provider handles narration turns
instead of generic capability routing — lets a small/fast model own narration
while the main model keeps CHAT/STEERING.
- NarrationSettings.modelId parsed from TOML, threaded via RouterConfig
.narrationModelId into DefaultRouterFacade.narrate (3-arg route)
- DefaultInferenceRouter: exact-id match against healthy providers, with a
post-select health check; any miss logs WARN and falls back to capability
routing (never fails the narration turn)
- onUserInput unchanged — only narrate uses the pinned model (tested)
Implements the replay-safe half of preemptive redirect (graph re-routing). An
operator-confirmed PreemptRedirectEvent overrides the resolver's edge at the next
transition boundary; the pure resolver stays untouched.
- PreemptRedirectEvent / PreemptRedirectBlockedEvent (+ serialization registration)
- TransitionExecutedEvent + TransitionDecision.Move gain optional redirectId — the
durable 'consumed once' marker
- PreemptRedirect.decide: pure decision over the event log (override / block / none),
so replay reaches the identical outcome without re-classifying (invariant #8)
- override wired in DefaultSessionOrchestrator.step above resolveTransition; back-edge
jumps count against the existing maxRetries cap via executeMove
- blocks jumps to unknown stages or targets with unsatisfied needs
- DomainEventMapper: redirect events mapped to null (operator surface ships with the
LLM-proposal + approval-confirm front-half)
- tests: override+consume-once, block-on-needs, block-on-unknown, ignore-consumed
Front-half (LLM proposal -> approval-gate confirm -> emit PreemptRedirectEvent) is
deferred; needs live LLM verification. Until it ships no redirect event is emitted in
production, so the override is inert. Spec: docs/plans/2026-06-10-freestyle-graph-rerouting.md
- generic SystemResourceProbe: owner-agnostic /proc/meminfo system RAM
overlaid on the vendor GPU probe, wired on both managed and static paths
so the VRAM/GPU/RAM gauge renders with an external llama-server (was dormant)
- F-002: classify provider 4xx (e.g. llama.cpp 400) as terminal, not retryable
(except 408/429); stops retrying deterministic request failures to exhaustion
- F-003: FileSystemPromptLoader expands leading ~ / ~/ to user home
- F-004: map InferenceFailedEvent + RetryAttemptedEvent to new
ServerMessage.InferenceFailed / RetryAttempted, decoded+rendered in tui-go;
ArtifactContentStoredEvent explicitly mapped to null (internal, no warn)
- tests: tilde expansion, SystemResourceProbe (static/managed/fail-soft)
rehydrate() now scans ArtifactContentStoredEvent (the durable slot->CAS-hash
bridge) and fetches stored bytes by content hash, keying the cache by the
logical slot name. The slot name is never passed to the hash-keyed CAS, so the
odd-length-hex crash is gone and artifact content is restored on cold-start
resume. RehydrateTest reworked to use a content-addressed fake store (slot name
!= content hash) so it actually exercises the bug. Replay (#8) is unaffected —
rehydrate is live-resume-only.
Also bump router narration maxTokens 1024 -> 4096: reasoning models were
spending the whole budget thinking and emitting empty content (finishReason=
length), so narrations never reached the TUI.
Removes the stale TUI-refactor progress doc.
Extract a SubagentRunner fun-interface (SubagentRunRequest/SubagentRunResult)
and an InSessionSubagentRunner default that delegates to the existing
executeStage path, with per-run effectives captured in the lambda closure so
the seam stays free of the internal RunEffectives type.
DefaultSessionOrchestrator.enterStage now dispatches through subagentRunner.run
instead of calling executeStage inline; effectives threading is dropped from
run/step/executeMove/enterStage/resume. ReplayOrchestrator supplies its own
runner. Behaviour is identical — RefinementLoopTest and full check stay green.
Stages declaring needs=[...] now receive each needed artifact's validated
JSON (from artifactContentCache) as an L1/USER neededArtifact context entry,
so a one-shot subagent sees its inputs.
Also emit llm-emitted artifact lifecycle events (Created/Validating/Validated)
on successful validation via emitLlmArtifacts, closing a latent gap where
verifyProduces would fail for stages producing only an llm-emitted artifact.
Events are emitted only after validation passes, preserving invariant #7.