fix(kernel,tools,workflow): session-robustness QA sweep

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.
This commit is contained in:
2026-07-02 00:56:45 +04:00
parent 968cbfa973
commit 18cbd34739
13 changed files with 296 additions and 24 deletions
@@ -26,6 +26,7 @@ import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.RetryAttemptedEvent
import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.events.events.ToolRequest
@@ -70,6 +71,7 @@ import com.correx.core.sessions.projections.replay.EventReplayer
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.transitions.evaluation.PromptResolver
import com.correx.core.transitions.resolution.DefaultTransitionResolver
import com.correx.core.transitions.graph.StageConfig
import com.correx.core.transitions.graph.TransitionEdge
import com.correx.core.transitions.graph.WorkflowGraph
@@ -214,6 +216,46 @@ class SessionOrchestratorIntegrationTest {
assertTrue(failed.retryExhausted)
}
@Test
fun `stage that matches no transition retries through the budget before failing`(): Unit = runBlocking {
val sessionId = SessionId("s-no-transition-retry")
// Stage A succeeds (no produces, no tools) but its only outgoing edge never matches, so the
// resolver returns Stay. That must consume the retry budget, not fail the run on first sight.
val graph = WorkflowGraph(
id = "no-transition-retry-test",
stages = mapOf(StageId("A") to StageConfig()),
transitions = setOf(
TransitionEdge(TransitionId("t1"), StageId("A"), StageId("done"), condition = { false }),
),
start = StageId("A"),
)
val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 2, backoffMs = 0))
// The default fixture evaluator returns true unconditionally; use one that honours the
// condition so the { false } edge actually produces a Stay.
val orchestrator = DefaultSessionOrchestrator(
repositories = repositories,
engines = engines.copy(
transitionResolver = DefaultTransitionResolver { condition, ctx -> condition.evaluate(ctx) },
),
retryCoordinator = retryCoordinator,
artifactStore = artifactStore,
decisionJournalRepository = decisionJournalRepository,
)
orchestrator.run(sessionId, graph, config)
val events = eventStore.read(sessionId)
val retries = events.count { it.payload is RetryAttemptedEvent }
assertEquals(2, retries, "expected the no-transition outcome to exhaust the retry budget")
val failed = events.find { it.payload is WorkflowFailedEvent }?.payload as? WorkflowFailedEvent
assertNotNull(failed)
assertTrue(failed!!.retryExhausted, "expected retryExhausted=true once the budget is spent")
assertTrue(
failed.reason.contains("no transition condition matched"),
"unexpected failure reason: ${failed.reason}",
)
}
@Test
fun `workflow pauses for approval when required`(): Unit = runBlocking {
val sessionId = SessionId("s3")