feat(recovery,context): tier-2 intent-holder arbiter + remaining-delta pinning + surfaced signal events

- recovery: two-tier repair ladder — owner then arbiter (recovery stage recast
  as intent-holder, holds initial intent, reconciles cross-file contract disputes)
  with independent INTENT_ROUTE_BUDGET; RecoveryRoutingTest coverage
- context: remaining-delta checklist pinning (RemainingDelta{Pinning,Entry}Test)
- surface write-only events (session name, quality signals) into stats/browse
- parseToolArguments lenient-Json fallback for bare-key drift
- tools: ChildProcess seam
This commit is contained in:
2026-07-11 23:56:52 +04:00
parent 3d5e05c1fb
commit 15248cae8a
55 changed files with 1932 additions and 231 deletions
@@ -16,3 +16,18 @@ data class InitialIntentEvent(
val sessionId: SessionId,
val intent: String,
) : EventPayload
/**
* A short, human-readable title for the session (e.g. "Add auth to account service"), derived
* once from the initial intent by the Talkie model. Recorded as an event (invariant #9) so the
* nondeterministic name is generated a single time and replays identically — the TUI shows this
* instead of the opaque workflow id. Non-authoritative display metadata; nothing in the core
* decision path reads it.
*/
@Serializable
@SerialName("SessionNamed")
data class SessionNamedEvent(
val sessionId: SessionId,
val name: String,
val timestampMs: Long,
) : EventPayload
@@ -36,6 +36,23 @@ data class WorkflowFailedEvent(
val retryExhausted: Boolean,
) : EventPayload
/**
* Records that the operator approved access to a specific path OUTSIDE the workspace root
* (a `file_read`/`list_dir` target). The intent plane raises PROMPT_USER for any out-of-workspace
* read; on approval this event is recorded so (a) subsequent reads of the same path this session
* skip the prompt, and (b) the tool jail is widened to admit it. [path] is the lexically-resolved
* absolute path — the recorded fact replay reads instead of re-prompting (invariants #8/#9).
* Scope is the session: nothing here persists across sessions. Privileged locations are BLOCKED
* upstream and never reach this event.
*/
@Serializable
@SerialName("OutsidePathAccessGranted")
data class OutsidePathAccessGrantedEvent(
val sessionId: SessionId,
val stageId: StageId,
val path: String,
) : EventPayload
@Serializable
@SerialName("OrchestrationPaused")
data class OrchestrationPausedEvent(
@@ -115,6 +132,12 @@ data class FailureTicketOpenedEvent(
// Fingerprint of [evidence] (whitespace/digit-normalised, see FailureFingerprint) at route time.
// The next route compares against this to decide progress vs. no-progress. Empty on old events.
val fingerprint: String = "",
// Tier-2 escalation marker. False = tier-1, the ticket routed to the file's own author to patch its
// own layer. True = the author loop exhausted its budget, so the ticket escalated to the arbiter
// (the recovery stage recast as intent-holder): the failure is a cross-file CONTRACT dispute no
// single owner can settle, so the arbiter is given the initial intent as authority and told to
// reconcile ALL the named files in one pass. Defaulted for backward-compatible replay.
val escalated: Boolean = false,
) : EventPayload
/**
@@ -13,5 +13,9 @@ data class ToolRequest(
val stageId: StageId,
val toolName: String,
@Serializable(with = AnyMapSerializer::class)
val parameters: Map<String, Any> = emptyMap()
val parameters: Map<String, Any> = emptyMap(),
// Paths outside the workspace root that the operator has approved for this call (recorded via
// OutsidePathAccessGrantedEvent). Read tools widen their containment jail to admit these; write
// tools ignore them, so writes stay workspace-jailed. Empty by default = pure workspace jail.
val grantedPaths: Set<String> = emptySet(),
)
@@ -49,6 +49,7 @@ import com.correx.core.events.events.L3MemoryRetrievedEvent
import com.correx.core.events.events.LowQualityExtractionEvent
import com.correx.core.events.events.InferenceCompletedEvent
import com.correx.core.events.events.InitialIntentEvent
import com.correx.core.events.events.SessionNamedEvent
import com.correx.core.events.events.InferenceFailedEvent
import com.correx.core.events.events.InferenceStartedEvent
import com.correx.core.events.events.InferenceTimeoutEvent
@@ -63,6 +64,7 @@ import com.correx.core.events.events.RepoMapComputedEvent
import com.correx.core.events.events.RetryAttemptedEvent
import com.correx.core.events.events.RetrySalvageDecidedEvent
import com.correx.core.events.events.FailureTicketOpenedEvent
import com.correx.core.events.events.OutsidePathAccessGrantedEvent
import com.correx.core.events.events.WorkspaceStateObservedEvent
import com.correx.core.events.events.RiskAssessedEvent
import com.correx.core.events.events.SourceFetchedEvent
@@ -123,6 +125,7 @@ val eventModule = SerializersModule {
subclass(PreemptRedirectEvent::class)
subclass(PreemptRedirectBlockedEvent::class)
subclass(InitialIntentEvent::class)
subclass(SessionNamedEvent::class)
subclass(StageFailedEvent::class)
subclass(StageCompletedEvent::class)
subclass(TransitionExecutedEvent::class)
@@ -151,6 +154,7 @@ val eventModule = SerializersModule {
subclass(RetryAttemptedEvent::class)
subclass(RetrySalvageDecidedEvent::class)
subclass(FailureTicketOpenedEvent::class)
subclass(OutsidePathAccessGrantedEvent::class)
subclass(RefinementIterationEvent::class)
subclass(RepoMapComputedEvent::class)
subclass(WorkspaceStateObservedEvent::class)