fix(server,kernel,events): harden artifact read + restore workspace on reopen + cleanups

Follow-ups spotted while fixing the artifact-content bug:

- #1 robustness: listArtifacts resolves content via runCatching, degrading to null
  instead of failing the whole listing on one bad CAS read (resolveContent helper)
- #2 eviction visibility: WARN when a referenced artifact hash resolves to null
  (most likely evicted/compacted while still referenced by the event log)
- #3 ordering trap: document on ArtifactCreated/ArtifactContentStored that 'Created'
  fires at validation, AFTER ContentStored at inference — the latent fold hazard
- #4 reopen gap: SessionSnapshot now carries workspaceRoot (derived from
  SessionWorkspaceBound), so a reopened session restores its workspace label
- #5 orchestrator headroom: isBackEdge externalised to a top-level function so
  DefaultSessionOrchestrator drops below the TooManyFunctions threshold (was at it)

#6 (orphaned empty F-010-era artifacts) needs no code change — empty content is
already normalised to null ('(no content stored)'); emission is prevented going
forward by the shipped F-010/F-018/F-020 fixes.
This commit is contained in:
2026-06-10 12:58:28 +04:00
parent 5f7b6f1f18
commit cc6b402a23
6 changed files with 51 additions and 9 deletions
@@ -14,6 +14,7 @@ import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.ArtifactValidatedEvent
import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.RefinementIterationEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.TransitionExecutedEvent
import com.correx.core.events.orchestration.OrchestrationState
import com.correx.core.events.types.ApprovalDecisionId
@@ -274,7 +275,7 @@ class DefaultSessionOrchestrator(
// run, e.g. reviewer→implementer) increments a per-cycle counter recorded as an
// event, so the guard is replay-deterministic. Exceeding the cap escalates to a
// terminal failure instead of looping forever.
if (isBackEdge(ctx, nextStageId)) {
if (isBackEdge(repositories.eventStore.read(ctx.sessionId), nextStageId)) {
val cycleKey = "${ctx.currentStageId.value}->${nextStageId.value}"
val maxIterations = ctx.graph.stages[nextStageId]?.maxRetries ?: DEFAULT_MAX_REFINEMENT
val iteration = (orchestrationRepository.getState(ctx.sessionId).refinementIterations[cycleKey] ?: 0) + 1
@@ -391,10 +392,6 @@ class DefaultSessionOrchestrator(
}
}
private fun isBackEdge(ctx: EnrichedExecutionContext, target: StageId): Boolean =
repositories.eventStore.read(ctx.sessionId)
.any { (it.payload as? TransitionExecutedEvent)?.to == target }
private fun ExecutionContext.enrich() = EnrichedExecutionContext(
graph, sessionId, stageCount, currentStageId, config,
session = repositories.sessionRepository.getSession(sessionId),
@@ -402,6 +399,11 @@ class DefaultSessionOrchestrator(
)
}
// A back-edge re-enters a stage already transitioned into this run (e.g. reviewer→implementer).
// Top-level (not a member) to keep the orchestrator off the TooManyFunctions threshold.
private fun isBackEdge(events: List<StoredEvent>, target: StageId): Boolean =
events.any { (it.payload as? TransitionExecutedEvent)?.to == target }
private sealed class StepResult {
data class Continue(val ctx: EnrichedExecutionContext) : StepResult()
data class Terminal(val result: WorkflowResult) : StepResult()