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:
+4
-1
@@ -54,7 +54,10 @@ class DefaultContextPackBuilder(
|
||||
// decisionJournal is deliberately NOT here anymore: the journal is OPTIONAL-bucket, capped
|
||||
// by OptionalCeilings. Pinning is otherwise bucket-driven (ContextBucket.REQUIRED); this set
|
||||
// only covers entries built outside the orchestrator that never stamp buckets.
|
||||
private val neverDropSourceTypes = setOf("steeringNote", "eventHistory", "factSheet")
|
||||
// "remainingDelta" is the shrinking stage-contract checklist (stage-termination design
|
||||
// 2026-07-11): it must survive every budget/dedup pass, since its entire purpose is to give
|
||||
// the model a progress signal that outlives the truncation which otherwise wipes its memory.
|
||||
private val neverDropSourceTypes = setOf("steeringNote", "eventHistory", "factSheet", "remainingDelta")
|
||||
|
||||
private companion object {
|
||||
const val CHARS_PER_TOKEN = 4
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import com.correx.core.context.builder.DefaultContextPackBuilder
|
||||
import com.correx.core.context.compression.DefaultContextCompressor
|
||||
import com.correx.core.context.model.ContextEntry
|
||||
import com.correx.core.context.model.ContextLayer
|
||||
import com.correx.core.context.model.TokenBudget
|
||||
import com.correx.core.events.types.ContextEntryId
|
||||
import com.correx.core.events.types.ContextPackId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The remaining-delta checklist (stage-termination design 2026-07-11) must survive the budget/dedup
|
||||
* passes — its whole point is to outlive the truncation that gives the model amnesia. It is pinned
|
||||
* via sourceType "remainingDelta" in [DefaultContextPackBuilder.neverDropSourceTypes].
|
||||
*/
|
||||
class RemainingDeltaPinningTest {
|
||||
|
||||
private val builder = DefaultContextPackBuilder(DefaultContextCompressor())
|
||||
private val sessionId = SessionId("s1")
|
||||
private val stageId = StageId("stage-1")
|
||||
private val packId = ContextPackId("pack-1")
|
||||
|
||||
@Test
|
||||
fun `remainingDelta entry survives a wildly over-budget build`() = kotlinx.coroutines.runBlocking {
|
||||
val checklist = ContextEntry(
|
||||
id = ContextEntryId("delta-1"),
|
||||
layer = ContextLayer.L1,
|
||||
content = "## Remaining to finish this stage\n- [ ] Foo.tsx — exports_default_component",
|
||||
sourceType = "remainingDelta",
|
||||
sourceId = "remaining-delta",
|
||||
tokenEstimate = 500,
|
||||
)
|
||||
// A large freeform entry that would be dropped to fit the tiny budget.
|
||||
val filler = ContextEntry(
|
||||
id = ContextEntryId("filler-1"),
|
||||
layer = ContextLayer.L2,
|
||||
content = "lorem ipsum ".repeat(200),
|
||||
sourceType = "toolResult",
|
||||
sourceId = "filler-1",
|
||||
tokenEstimate = 5000,
|
||||
)
|
||||
val pack = builder.build(packId, sessionId, stageId, listOf(filler, checklist), TokenBudget(limit = 10))
|
||||
val retained = pack.layers.values.flatten()
|
||||
assertTrue(
|
||||
retained.any { it.sourceType == "remainingDelta" },
|
||||
"remainingDelta checklist must be retained even when the budget is blown",
|
||||
)
|
||||
Unit
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
package com.correx.core.kernel.execution
|
||||
|
||||
import com.correx.core.approvals.model.DomainApprovalRequest
|
||||
import com.correx.core.artifacts.model.Artifact
|
||||
import com.correx.core.validation.model.ValidationReport
|
||||
|
||||
sealed interface StageOutcome {
|
||||
data class Success(val artifact: Artifact) : StageOutcome
|
||||
/**
|
||||
* @param retryable set by the validator based on failure type; the orchestrator must read this
|
||||
* and never override it.
|
||||
* Structural errors (e.g. invalid graph topology) are not retryable. Transient errors may be.
|
||||
*/
|
||||
data class ValidationFailure(val report: ValidationReport, val retryable: Boolean) : StageOutcome
|
||||
data class InferenceFailure(val reason: String, val retryable: Boolean) : StageOutcome
|
||||
data class ApprovalRequired(val request: DomainApprovalRequest) : StageOutcome
|
||||
data object Cancelled : StageOutcome
|
||||
}
|
||||
+67
-5
@@ -9,6 +9,7 @@ import com.correx.core.context.model.ContextEntry
|
||||
import com.correx.core.context.model.ContextLayer
|
||||
import com.correx.core.context.model.EntryRole
|
||||
import com.correx.core.events.events.FailureTicketOpenedEvent
|
||||
import com.correx.core.events.events.InitialIntentEvent
|
||||
import com.correx.core.events.events.RefinementIterationEvent
|
||||
import com.correx.core.events.events.RetryAttemptedEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
@@ -50,11 +51,31 @@ fun buildRecoveryTicketEntry(events: List<StoredEvent>, stageId: StageId): Conte
|
||||
val ticket = events
|
||||
.mapNotNull { it.payload as? FailureTicketOpenedEvent }
|
||||
.lastOrNull { it.routeTo == stageId } ?: return null
|
||||
val content = "## Recovery ticket\n" +
|
||||
"Stage '${ticket.stageId.value}' failed the '${ticket.gate}' gate (${ticket.category}) and " +
|
||||
"lacks the '${ticket.requiredCapability}' capability to fix it, so the failure was routed to " +
|
||||
"you. Repair the underlying cause, then control returns to verification.\n" +
|
||||
"### Gate output\n${ticket.evidence}"
|
||||
val content = if (ticket.escalated) {
|
||||
// Tier 2: the owner loop couldn't settle it — a cross-file contract dispute. The arbiter holds
|
||||
// the intent and reconciles ALL sides in one pass.
|
||||
val intent = events.mapNotNull { it.payload as? InitialIntentEvent }.lastOrNull()?.intent
|
||||
"## Contract arbitration ticket\n" +
|
||||
"Stage '${ticket.stageId.value}' keeps failing the '${ticket.gate}' gate even after the " +
|
||||
"file owners repaired their own layers — so this is NOT a bug in one file. The files named " +
|
||||
"in the gate output below disagree on a shared contract (a prop/type/API shape): each side " +
|
||||
"is internally consistent but they do not agree with each other, so fixing one re-breaks " +
|
||||
"the other. You are the arbiter. Read EVERY file named below, decide the single authoritative " +
|
||||
"shape" + (intent?.let { " that serves the intent" } ?: "") + ", then edit ALL of them in " +
|
||||
"ONE pass so they agree. Do not rewrite files wholesale — change only what the contract " +
|
||||
"requires. Then control returns to verification." +
|
||||
(intent?.let { "\n### Intent (authoritative)\n$it" } ?: "") +
|
||||
"\n### Gate output\n${ticket.evidence}"
|
||||
} else {
|
||||
"## Recovery ticket\n" +
|
||||
"Stage '${ticket.stageId.value}' failed the '${ticket.gate}' gate (${ticket.category}) and " +
|
||||
"lacks the '${ticket.requiredCapability}' capability to fix it, so the failure was routed to " +
|
||||
"you — you own the file(s) the gate names below. Make the SMALLEST change that clears the " +
|
||||
"specific error: read the current file, then patch only the offending lines (file_edit). Do " +
|
||||
"NOT rewrite the file from scratch — a full regeneration discards your prior working code and " +
|
||||
"usually reintroduces the same defect. Then control returns to verification.\n" +
|
||||
"### Gate output\n${ticket.evidence}"
|
||||
}
|
||||
return ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L1,
|
||||
@@ -66,6 +87,47 @@ fun buildRecoveryTicketEntry(events: List<StoredEvent>, stageId: StageId): Conte
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The remaining-delta checklist (stage-termination design 2026-07-11 §1a). A forward-looking,
|
||||
* shrinking list of the stage's *unmet* file-contract assertions — "what is left to make true",
|
||||
* not "what I have done". Computed by the kernel as contract-minus-current-filesystem-reality and
|
||||
* refreshed within the tool loop each time a write lands on a contract file, so the model watches
|
||||
* items disappear as it satisfies them; an empty list is the completion signal the stage otherwise
|
||||
* lacks. Reading files never changes it, which is what starves the re-read spiral.
|
||||
*
|
||||
* Pinned as a neverDrop entry by [com.correx.core.context.builder.DefaultContextPackBuilder] so the
|
||||
* budget/dedup passes can never evict it — its whole value is surviving the truncation that gave the
|
||||
* model amnesia in the first place. Returns null when nothing is failing (list empty ⇒ done).
|
||||
*
|
||||
* [items] are (target, assertionId, evidence) triples of the currently-failing assertions.
|
||||
*/
|
||||
fun buildRemainingDeltaEntry(items: List<Triple<String, String, String>>): ContextEntry? {
|
||||
if (items.isEmpty()) return null
|
||||
val content = buildString {
|
||||
append("## Remaining to finish this stage\n")
|
||||
append(
|
||||
"Make each item below true, then call stage_complete. Items disappear from this list " +
|
||||
"as you satisfy them; when it is empty the stage is done. This list is recomputed " +
|
||||
"from the filesystem — re-reading files does NOT change it, only writing/editing the " +
|
||||
"named files does. Fix exactly these, do not re-explore.\n",
|
||||
)
|
||||
items.forEach { (target, assertionId, evidence) ->
|
||||
append("- [ ] ").append(target).append(" — ").append(assertionId)
|
||||
if (evidence.isNotBlank()) append(": ").append(evidence)
|
||||
append("\n")
|
||||
}
|
||||
}.trimEnd()
|
||||
return ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L1,
|
||||
content = content,
|
||||
sourceType = "remainingDelta",
|
||||
sourceId = "remaining-delta",
|
||||
tokenEstimate = content.length / 4,
|
||||
role = EntryRole.SYSTEM,
|
||||
)
|
||||
}
|
||||
|
||||
fun criticArtifactIds(
|
||||
events: List<StoredEvent>,
|
||||
graph: WorkflowGraph,
|
||||
|
||||
+14
-5
@@ -16,6 +16,10 @@ import com.correx.core.events.events.WorkflowStartedEvent
|
||||
import com.correx.core.events.orchestration.OrchestrationState
|
||||
import com.correx.core.events.orchestration.OrchestrationStatus
|
||||
|
||||
// Budget-key suffix distinguishing the tier-2 escalation (arbiter) budget from the tier-1 owner
|
||||
// budget for the same failing stage. Shared with DefaultSessionOrchestrator (same package).
|
||||
internal const val INTENT_BUDGET_SUFFIX = "#intent"
|
||||
|
||||
class DefaultOrchestrationReducer : OrchestrationReducer {
|
||||
override fun reduce(
|
||||
state: OrchestrationState,
|
||||
@@ -97,11 +101,16 @@ class DefaultOrchestrationReducer : OrchestrationReducer {
|
||||
|
||||
// Charge the failing stage's recovery-route budget. routeAttempt is the no-progress count the
|
||||
// orchestrator computed (progress-aware), so fold it directly (idempotent under replay), and
|
||||
// record the failure fingerprint the next route compares against.
|
||||
is FailureTicketOpenedEvent -> state.copy(
|
||||
recoveryRoutes = state.recoveryRoutes + (p.stageId.value to p.routeAttempt),
|
||||
recoveryFailureFingerprints = state.recoveryFailureFingerprints + (p.stageId.value to p.fingerprint),
|
||||
)
|
||||
// record the failure fingerprint the next route compares against. Keyed per TIER: the tier-1
|
||||
// owner loop and the tier-2 escalation (arbiter) hold independent budgets for the same failing
|
||||
// stage, so an escalated ticket ("#intent" suffix) never spends the owner budget and vice versa.
|
||||
is FailureTicketOpenedEvent -> {
|
||||
val budgetKey = p.stageId.value + if (p.escalated) INTENT_BUDGET_SUFFIX else ""
|
||||
state.copy(
|
||||
recoveryRoutes = state.recoveryRoutes + (budgetKey to p.routeAttempt),
|
||||
recoveryFailureFingerprints = state.recoveryFailureFingerprints + (budgetKey to p.fingerprint),
|
||||
)
|
||||
}
|
||||
|
||||
else -> state
|
||||
}
|
||||
|
||||
+138
-45
@@ -12,6 +12,8 @@ import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||
import com.correx.core.events.events.ArtifactContentStoredEvent
|
||||
import com.correx.core.events.events.FileWrittenEvent
|
||||
import com.correx.core.events.events.ToolInvocationRequestedEvent
|
||||
import com.correx.core.events.events.ClarificationAnswer
|
||||
import com.correx.core.events.events.ClarificationAnsweredEvent
|
||||
import com.correx.core.events.events.ArtifactValidatedEvent
|
||||
@@ -63,6 +65,24 @@ private const val DEFAULT_MAX_REFINEMENT = 3
|
||||
// per-gate retry budget — recovery is a purpose-built repair-then-reverify loop, not open-ended.
|
||||
private const val RECOVERY_ROUTE_BUDGET = 2
|
||||
|
||||
// Tier-2 escalation budget: how many NO-PROGRESS routes the arbiter (recovery-stage-as-intent-holder)
|
||||
// gets after the tier-1 owner loop exhausts, before the run fails terminally. Independent of the owner
|
||||
// budget (keyed with INTENT_BUDGET_SUFFIX) so escalation is a genuine second chance, not a shared pool.
|
||||
private const val INTENT_ROUTE_BUDGET = 2
|
||||
|
||||
// Transition ids for the failure-ticket loop. A ticket routes the failing gate's control to the
|
||||
// repair owner (TICKET_ROUTE); the owner, once done, hands control straight back to the gate that
|
||||
// opened the ticket (TICKET_RETURN). Both are kernel-driven, not plan edges — see ticketReturnMove.
|
||||
internal val TICKET_ROUTE = TransitionId("ticket-route")
|
||||
private val TICKET_RETURN = TransitionId("ticket-return")
|
||||
|
||||
// Shortest evidence token treated as a file reference — guards against matching a written path's
|
||||
// suffix on a trivially short token (an extension fragment, a one-char name).
|
||||
private const val MIN_EVIDENCE_TOKEN = 5
|
||||
|
||||
// File-like tokens in gate evidence (tsc/build output): "src/App.tsx", "frontend/x.ts:12:5".
|
||||
private val EVIDENCE_PATH_RE = Regex("""[\w./@-]+\.[A-Za-z0-9]+""")
|
||||
|
||||
// Deterministic gate id → capability the failing stage must hold to change the failure condition.
|
||||
// A gate not listed here is not eligible for recovery routing (retries in place as before).
|
||||
private val GATE_REQUIRED_CAPABILITY: Map<String, String> = mapOf(
|
||||
@@ -161,12 +181,12 @@ class DefaultSessionOrchestrator(
|
||||
val resolved = resolveTransition(
|
||||
enriched.graph, enriched.sessionId, enriched.currentStageId, stageArtifacts, artifactContent,
|
||||
)
|
||||
// Recovery return-to-sender: a recovery stage is entered by kernel routing from whichever
|
||||
// write-less stage failed a gate, so its exit must go BACK to that exact stage (re-run its
|
||||
// gate) — not follow a static edge that would skip intervening stages. Derived from the latest
|
||||
// failure ticket routed here (replay-deterministic). Overrides the resolved edge; an operator
|
||||
// preempt below still wins.
|
||||
val baseDecision = recoveryReturnMove(enriched) ?: resolved
|
||||
// Ticket return-to-sender: a repair stage (the file's author, or the fallback recovery stage)
|
||||
// is entered by kernel routing from whichever stage failed a gate, so its exit must go BACK to
|
||||
// that exact gate (re-run it) — not follow a plan edge that would walk a regenerating
|
||||
// implementer or skip intervening stages. Derived from the latest failure ticket routed here
|
||||
// (replay-deterministic). Overrides the resolved edge; an operator preempt below still wins.
|
||||
val baseDecision = ticketReturnMove(enriched) ?: resolved
|
||||
// Preemptive redirect override (freestyle graph re-routing): if the operator confirmed a
|
||||
// jump (a recorded, unconsumed PreemptRedirectEvent), it overrides the resolver's edge —
|
||||
// unless the target is unknown or its needs aren't satisfied, in which case it is blocked
|
||||
@@ -282,13 +302,24 @@ class DefaultSessionOrchestrator(
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a [FailureTicketOpenedEvent] and route [stageId]'s failure to the graph's recovery stage,
|
||||
* bounded by [RECOVERY_ROUTE_BUDGET]. Shared by the deterministic agency guard
|
||||
* ([maybeRouteToRecovery]) and the review-gate salvage judge ([decideGateExhaustion] on
|
||||
* [SalvageDecision.RECOVER]) so both routing paths reach the same recovery destination.
|
||||
* Open a [FailureTicketOpenedEvent] and route [stageId]'s failure down a two-tier repair ladder.
|
||||
*
|
||||
* Returns null when the graph declares no recovery stage (caller falls back to its legacy path);
|
||||
* a terminal [StepResult] when the route budget is spent; otherwise the recovery stage's result.
|
||||
* **Tier 1 (owner):** when the gate's evidence names a file a stage actually wrote, the ticket goes
|
||||
* to that author ([resolveTicketOwner]) — it owns the code and the contract it declared, so it
|
||||
* patches its own layer. Bounded by [RECOVERY_ROUTE_BUDGET].
|
||||
*
|
||||
* **Tier 2 (arbiter / intent-holder):** when the owner loop spends its budget without settling the
|
||||
* failure, the failure is a cross-file CONTRACT dispute no single owner can resolve (each side is
|
||||
* internally consistent; they disagree with each other). The ticket escalates to the synthesized
|
||||
* recovery stage ([findRecoveryStage]) recast as the intent-holder — given the initial intent as
|
||||
* authority (see buildRecoveryTicketEntry) and told to reconcile ALL the named files in one pass.
|
||||
* Bounded by its own independent [INTENT_ROUTE_BUDGET]. An orphan failure (no author) starts here.
|
||||
*
|
||||
* Both tiers charge progress-awarely: a route whose failure fingerprint changed from the previous
|
||||
* one moved the needle, so it is FREE; only a route reproducing the same failure charges the tier's
|
||||
* budget. Terminal only when every reachable tier is spent. Returns null when neither an owner nor a
|
||||
* recovery stage exists (caller falls back to the legacy per-gate retry path). Shared by the agency
|
||||
* guard ([maybeRouteToRecovery]) and the review-gate salvage judge ([decideGateExhaustion]).
|
||||
*/
|
||||
private suspend fun routeToRecovery(
|
||||
ctx: EnrichedExecutionContext,
|
||||
@@ -298,29 +329,38 @@ class DefaultSessionOrchestrator(
|
||||
reason: String,
|
||||
state: OrchestrationState,
|
||||
): StepResult? {
|
||||
val recoveryId = findRecoveryStage(ctx.graph, stageId) ?: return null // no recovery stage: legacy path
|
||||
// Progress-aware charging (mirrors the per-gate retry budget, design §5): compare this
|
||||
// failure's fingerprint to the previous route's. A changed fingerprint means the last
|
||||
// recovery round moved the needle — fixed one cause and surfaced a different one — so the
|
||||
// route is FREE (budget unchanged) and only the recorded fingerprint advances. Same
|
||||
// fingerprint = the round changed nothing, so it charges the small route budget. Terminal
|
||||
// only once the no-progress count is spent; the recovery→origin back-edge refinement guard
|
||||
// (executeMove, cap = origin stage maxRetries) remains the ultimate loop bound for the
|
||||
// pathological all-progress case.
|
||||
val events = repositories.eventStore.read(ctx.sessionId)
|
||||
val owner = resolveTicketOwner(events, reason)
|
||||
?.takeIf { it != stageId && requiredCapability in (ctx.graph.stages[it]?.allowedTools ?: emptySet()) }
|
||||
val arbiter = findRecoveryStage(ctx.graph, stageId)
|
||||
val fingerprint = FailureFingerprint.of(reason)
|
||||
val prev = state.recoveryFailureFingerprints[stageId.value]
|
||||
val progressed = prev != null && prev != fingerprint
|
||||
val used = state.recoveryRoutes[stageId.value] ?: 0
|
||||
if (!progressed && used >= RECOVERY_ROUTE_BUDGET) {
|
||||
val ownerKey = stageId.value
|
||||
val intentKey = stageId.value + INTENT_BUDGET_SUFFIX
|
||||
|
||||
// (target, budgetKey, budget, escalated). Prefer the owner tier while it has budget; escalate to
|
||||
// the arbiter tier once the owner loop is spent; null = no tier available now.
|
||||
val route = when {
|
||||
owner != null && !budgetExhausted(state, ownerKey, fingerprint, RECOVERY_ROUTE_BUDGET) ->
|
||||
RouteTier(owner, ownerKey, RECOVERY_ROUTE_BUDGET, escalated = false)
|
||||
arbiter != null && !budgetExhausted(state, intentKey, fingerprint, INTENT_ROUTE_BUDGET) ->
|
||||
RouteTier(arbiter, intentKey, INTENT_ROUTE_BUDGET, escalated = true)
|
||||
else -> null
|
||||
}
|
||||
if (route == null) {
|
||||
if (owner == null && arbiter == null) return null // nothing to route to: legacy retry path
|
||||
return StepResult.Terminal(
|
||||
failWorkflow(
|
||||
ctx.sessionId,
|
||||
stageId,
|
||||
"recovery route budget exhausted for stage ${stageId.value} (gate=$gate): $reason",
|
||||
"repair ladder exhausted for stage ${stageId.value} (gate=$gate): $reason",
|
||||
retryExhausted = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val prev = state.recoveryFailureFingerprints[route.budgetKey]
|
||||
val progressed = prev != null && prev != fingerprint
|
||||
val used = state.recoveryRoutes[route.budgetKey] ?: 0
|
||||
val routeAttempt = if (progressed) used else used + 1
|
||||
emit(
|
||||
ctx.sessionId,
|
||||
@@ -330,24 +370,51 @@ class DefaultSessionOrchestrator(
|
||||
gate = gate,
|
||||
category = ticketCategory(gate),
|
||||
requiredCapability = requiredCapability,
|
||||
routeTo = recoveryId,
|
||||
routeTo = route.target,
|
||||
evidence = reason,
|
||||
routeAttempt = routeAttempt,
|
||||
fingerprint = fingerprint,
|
||||
escalated = route.escalated,
|
||||
),
|
||||
)
|
||||
log.info(
|
||||
"[Orchestrator] failure ticket session={} stage={} gate={} lacks={} -> recovery={} " +
|
||||
"[Orchestrator] failure ticket session={} stage={} gate={} lacks={} -> {}={} " +
|
||||
"(charged={}/{}, progressed={})",
|
||||
ctx.sessionId.value, stageId.value, gate, requiredCapability,
|
||||
recoveryId.value, routeAttempt, RECOVERY_ROUTE_BUDGET, progressed,
|
||||
if (route.escalated) "arbiter" else "owner", route.target.value,
|
||||
routeAttempt, route.budget, progressed,
|
||||
)
|
||||
val advancedTo = advanceStage(
|
||||
ctx.sessionId,
|
||||
stageId,
|
||||
TransitionDecision.Move(transitionId = TransitionId("recovery-route"), to = recoveryId),
|
||||
TransitionDecision.Move(transitionId = TICKET_ROUTE, to = route.target),
|
||||
)
|
||||
return enterStage(ctx.copy(currentStageId = advancedTo), recoveryId)
|
||||
return enterStage(ctx.copy(currentStageId = advancedTo), route.target)
|
||||
}
|
||||
|
||||
/** A chosen rung of the repair ladder: where to route, which budget it charges, and its tier. */
|
||||
private data class RouteTier(
|
||||
val target: StageId,
|
||||
val budgetKey: String,
|
||||
val budget: Int,
|
||||
val escalated: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Progress-aware budget check for one ladder tier. A route whose failure fingerprint changed from
|
||||
* the previous route on the same [key] made progress (fixed one cause, surfaced another) → NOT
|
||||
* exhausted (progress is free). Only a no-progress streak that reaches [budget] is exhausted.
|
||||
*/
|
||||
private fun budgetExhausted(
|
||||
state: OrchestrationState,
|
||||
key: String,
|
||||
fingerprint: String,
|
||||
budget: Int,
|
||||
): Boolean {
|
||||
val prev = state.recoveryFailureFingerprints[key]
|
||||
val progressed = prev != null && prev != fingerprint
|
||||
val used = state.recoveryRoutes[key] ?: 0
|
||||
return !progressed && used >= budget
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -361,23 +428,49 @@ class DefaultSessionOrchestrator(
|
||||
}?.key
|
||||
|
||||
/**
|
||||
* When the current stage is a recovery stage that has finished, route it back to the stage the
|
||||
* failure came from — the origin of the latest [FailureTicketOpenedEvent] routed here — so the
|
||||
* gate that triggered the route is re-run. This makes recovery correct for arbitrary graph
|
||||
* topology (a write-less gate anywhere routes here and returns to exactly its own stage) rather
|
||||
* than relying on a static recovery→terminal edge that would skip intervening stages. Returns
|
||||
* null when the current stage is not a recovery stage or no ticket routed here (then the
|
||||
* resolver's normal edge — the compiler's fallback recovery→terminal — is used).
|
||||
* Route-to-owner resolution: map the failing gate's [evidence] (build/tsc output that names
|
||||
* files, e.g. "src/App.tsx: TS2322") to the stage that most recently WROTE one of those files,
|
||||
* via the recorded manifest chain (ToolInvocationRequestedEvent.stageId ← FileWrittenEvent by
|
||||
* invocationId → path). The last write of a named file wins — its author is who to hand the
|
||||
* ticket to. Pure over recorded events (replay-deterministic). Null when the evidence names no
|
||||
* written file (an orphan failure — caller falls back to the synthesized recovery stage).
|
||||
*/
|
||||
private fun recoveryReturnMove(ctx: EnrichedExecutionContext): TransitionDecision.Move? {
|
||||
val cfg = ctx.graph.stages[ctx.currentStageId] ?: return null
|
||||
val isRecovery = cfg.metadata["role"] == "recovery" || ctx.currentStageId.value == "recovery"
|
||||
if (!isRecovery) return null
|
||||
val origin = repositories.eventStore.read(ctx.sessionId)
|
||||
.mapNotNull { it.payload as? FailureTicketOpenedEvent }
|
||||
private fun resolveTicketOwner(events: List<StoredEvent>, evidence: String): StageId? {
|
||||
val tokens = EVIDENCE_PATH_RE.findAll(evidence)
|
||||
.map { it.value.substringBefore(':').replace('\\', '/') }
|
||||
.filter { it.length >= MIN_EVIDENCE_TOKEN }
|
||||
.toSet()
|
||||
if (tokens.isEmpty()) return null
|
||||
val invToStage = events.mapNotNull { it.payload as? ToolInvocationRequestedEvent }
|
||||
.associate { it.invocationId to it.stageId }
|
||||
return events.mapNotNull { it.payload as? FileWrittenEvent }
|
||||
.filter { it.postImageHash != null }
|
||||
.lastOrNull { fw ->
|
||||
val norm = fw.path.replace('\\', '/')
|
||||
invToStage.containsKey(fw.invocationId) && tokens.any { norm == it || norm.endsWith("/$it") }
|
||||
}
|
||||
?.let { invToStage[it.invocationId] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Return-to-sender for a routed repair stage. When the current stage was entered by a failure
|
||||
* ticket ([TICKET_ROUTE]) rather than a plan edge, its exit goes BACK to the gate that opened the
|
||||
* ticket (origin of the latest [FailureTicketOpenedEvent] routed here) so that gate re-runs. This
|
||||
* holds for any repair destination — the file's author OR the fallback recovery stage — and for
|
||||
* arbitrary topology, instead of following the plan's forward edge (which would walk a
|
||||
* regenerating implementer, or skip intervening stages). Returns null when the stage was NOT
|
||||
* entered via a ticket (a normal forward visit — the resolver's edge is used).
|
||||
*/
|
||||
private fun ticketReturnMove(ctx: EnrichedExecutionContext): TransitionDecision.Move? {
|
||||
val events = repositories.eventStore.read(ctx.sessionId)
|
||||
val enteredViaTicket = events.mapNotNull { it.payload as? TransitionExecutedEvent }
|
||||
.lastOrNull { it.to == ctx.currentStageId }
|
||||
?.transitionId == TICKET_ROUTE
|
||||
if (!enteredViaTicket) return null
|
||||
val origin = events.mapNotNull { it.payload as? FailureTicketOpenedEvent }
|
||||
.lastOrNull { it.routeTo == ctx.currentStageId }
|
||||
?.stageId ?: return null
|
||||
return TransitionDecision.Move(transitionId = TransitionId("recovery-return"), to = origin)
|
||||
return TransitionDecision.Move(transitionId = TICKET_RETURN, to = origin)
|
||||
}
|
||||
|
||||
/** Returns the cached validated artifact content for the given session + artifact id, or null if absent. */
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.tools.process.ChildProcess
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.async
|
||||
@@ -26,7 +27,7 @@ class ProcessStaticAnalysisRunner(
|
||||
val argv = command.trim().split(WHITESPACE).filter { it.isNotEmpty() }
|
||||
if (argv.isEmpty()) return@withContext StaticAnalysisRunResult(EXIT_NOT_RUN, "empty command")
|
||||
val process = runCatching {
|
||||
ProcessBuilder(argv).directory(workingDir.toFile()).redirectErrorStream(true).start()
|
||||
ChildProcess.builder(argv, workingDir.toFile()).redirectErrorStream(true).start()
|
||||
}.getOrElse {
|
||||
return@withContext StaticAnalysisRunResult(EXIT_NOT_RUN, "failed to start '$command': ${it.message}")
|
||||
}
|
||||
|
||||
+204
-14
@@ -71,6 +71,7 @@ import com.correx.core.events.events.InferenceTimeoutEvent
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.events.OrchestrationPausedEvent
|
||||
import com.correx.core.events.events.OrchestrationResumedEvent
|
||||
import com.correx.core.events.events.OutsidePathAccessGrantedEvent
|
||||
import com.correx.core.events.events.RiskAssessedEvent
|
||||
import com.correx.core.events.events.ExecutionPlanLockedEvent
|
||||
import com.correx.core.events.events.StageCheckpointFailedEvent
|
||||
@@ -167,13 +168,21 @@ import com.correx.core.journal.DefaultDecisionJournalRepository
|
||||
import com.correx.core.kernel.orchestration.subagent.SubagentRunner
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.util.*
|
||||
import java.util.concurrent.*
|
||||
import java.util.concurrent.atomic.*
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
private const val MAX_TOOL_ROUNDS = 10
|
||||
// Ceiling on agentic tool rounds per stage entry. Originally 10 as a tight leash on early local
|
||||
// models that would thrash tools; the model now drives tools reliably, and the tight cap started
|
||||
// starving legitimate multi-step work — notably the recovery stage, which spends most rounds
|
||||
// re-investigating (list_dir/read/rebuild) before it reaches the write, and got bounced out before
|
||||
// acting on a correct diagnosis. The real runaway guards are the read-loop and rejection-loop
|
||||
// nudges (both fire after 3 unproductive rounds), so this ceiling only needs to be generous enough
|
||||
// that productive stages aren't cut off mid-work.
|
||||
private const val MAX_TOOL_ROUNDS = 30
|
||||
|
||||
// Consecutive read-only tool rounds (no file_write/file_edit) that still owe a file_written
|
||||
// artifact before we force the write nudge. A model that keeps calling read tools every round
|
||||
@@ -251,6 +260,8 @@ private const val REVIEW_BLOCK_RETRY_CAP = 20
|
||||
private const val REVIEW_OBJECTIVE_CAP = 4_000
|
||||
private const val STATIC_ANALYSIS_SUMMARY_CAP = 2_000
|
||||
private const val STATIC_ANALYSIS_FEEDBACK_CAP = 6_000
|
||||
/** Profile command alias run before a build gate to make it runnable (e.g. `npm ci`). */
|
||||
private const val SETUP_COMMAND_ALIAS = "setup"
|
||||
|
||||
// KindInference kinds for build manifests — a written file of one of these declares a buildable
|
||||
// project toolchain even before any source lands, so the auto build gate fires (see
|
||||
@@ -414,7 +425,15 @@ abstract class SessionOrchestrator(
|
||||
),
|
||||
)
|
||||
} ?: emptyList()
|
||||
val promptEntries = stageConfig.metadata["promptInline"]
|
||||
// A stage re-entered to repair a gate failure has its own (often generative/"scaffold") prompt
|
||||
// SUPPRESSED: that mandate is what drove it to overwrite real files with stubs on re-entry. The
|
||||
// recovery-ticket entry (buildRecoveryTicketEntry) is the sole mandate here — it already carries
|
||||
// "make the smallest change, don't rewrite from scratch" plus the gate evidence. See
|
||||
// mandateSuppressedByTicket.
|
||||
val promptEntries = if (mandateSuppressedByTicket(sessionId, stageId, stageConfig)) {
|
||||
emptyList()
|
||||
} else {
|
||||
stageConfig.metadata["promptInline"]
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { text ->
|
||||
listOf(
|
||||
@@ -457,6 +476,7 @@ abstract class SessionOrchestrator(
|
||||
)
|
||||
}
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
val llmEmittedSlots = stageConfig.produces.filter { it.kind.llmEmitted }
|
||||
require(llmEmittedSlots.size <= 1) {
|
||||
@@ -553,11 +573,19 @@ abstract class SessionOrchestrator(
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
// Remaining-delta checklist (stage-termination design 2026-07-11 §1a): the stage's currently
|
||||
// unmet file-contract assertions as a forward-looking, shrinking TODO. Injected pinned
|
||||
// (neverDrop) so it survives the budget/dedup passes that otherwise wipe the model's memory of
|
||||
// progress; refreshed inside the tool loop each time a write lands (§1b, cache-until-write).
|
||||
var remainingDeltaResults = evaluateStageContract(sessionId, stageId, stageConfig, effectives)
|
||||
val remainingDeltaEntries = remainingDeltaResults
|
||||
?.let { buildRemainingDeltaEntry(contractFailureItems(it)) }
|
||||
?.let { listOf(it) } ?: emptyList()
|
||||
var accumulatedEntries = stampBuckets(
|
||||
systemPrompt + profileEntries + projectProfileEntries + agentInstructionsEntries +
|
||||
journalEntries + repoMapEntries + claimedTaskEntries +
|
||||
needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries +
|
||||
clarificationEntries + retryFeedbackEntries + recoveryTicketEntries,
|
||||
clarificationEntries + retryFeedbackEntries + recoveryTicketEntries + remainingDeltaEntries,
|
||||
)
|
||||
val contextPack = runCatching {
|
||||
contextPackBuilder.build(
|
||||
@@ -767,6 +795,22 @@ abstract class SessionOrchestrator(
|
||||
} else {
|
||||
consecutiveRejectedRounds = 0
|
||||
}
|
||||
// Refresh the remaining-delta checklist only when a write actually landed this round
|
||||
// (cache-until-write, §1b): reads can never change the contract verdict, so re-evaluating
|
||||
// on them would just burn filesystem work and re-emit identical events. On a successful
|
||||
// write, recompute contract-minus-reality and swap the pinned entry so the model watches
|
||||
// the list shrink toward the empty=done signal it previously lacked.
|
||||
val wroteThisRound = inferenceResult.response.toolCalls.any { it.function.name in WRITE_TOOL_NAMES } &&
|
||||
toolEntries.any {
|
||||
it.role == EntryRole.TOOL &&
|
||||
!it.content.startsWith("ERROR:") && !it.content.startsWith("BLOCKED:")
|
||||
}
|
||||
if (wroteThisRound) {
|
||||
remainingDeltaResults = evaluateStageContract(sessionId, stageId, stageConfig, effectives)
|
||||
val refreshed = remainingDeltaResults?.let { buildRemainingDeltaEntry(contractFailureItems(it)) }
|
||||
accumulatedEntries = accumulatedEntries.filterNot { it.sourceType == "remainingDelta" } +
|
||||
listOfNotNull(refreshed)
|
||||
}
|
||||
currentContext = contextPackBuilder.build(
|
||||
id = ContextPackId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
@@ -1084,6 +1128,19 @@ abstract class SessionOrchestrator(
|
||||
)
|
||||
val tool = effectives.registry?.resolve(toolCall.function.name)
|
||||
val tier = tool?.tier ?: Tier.T2
|
||||
// Out-of-workspace read access: a read whose target lexically resolves outside the
|
||||
// workspace root. The intent plane raises PROMPT_USER for it; once the operator approves
|
||||
// (recorded as OutsidePathAccessGrantedEvent), the same path this session skips the prompt
|
||||
// and the tool jail is widened for it. Writes never take this path (isRead gate).
|
||||
val isRead = tool?.requiredCapabilities?.contains(ToolCapability.FILE_READ) == true &&
|
||||
tool.requiredCapabilities.contains(ToolCapability.FILE_WRITE) != true
|
||||
val outsideReadTarget = if (isRead) {
|
||||
outsideWorkspaceTarget(parameters, effectives.policy?.workspaceRoot)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val grantedOutside = grantedOutsidePaths(sessionId)
|
||||
val alreadyGranted = outsideReadTarget != null && outsideReadTarget.toString() in grantedOutside
|
||||
emit(
|
||||
sessionId,
|
||||
ToolInvocationRequestedEvent(
|
||||
@@ -1202,8 +1259,9 @@ abstract class SessionOrchestrator(
|
||||
// A steering note attached to a human approval is captured here and injected after the
|
||||
// tool result, so the same-stage loop re-infers with it and the model acts on the note.
|
||||
var approvalNote: String? = null
|
||||
if (tier.isAtMost(Tier.T1) && !plane2Prompts) {
|
||||
// no approval needed
|
||||
if ((tier.isAtMost(Tier.T1) && !plane2Prompts) || alreadyGranted) {
|
||||
// no approval needed — either within the auto-approve tier, or this out-of-workspace
|
||||
// read path was already approved earlier this session (this-path-this-session).
|
||||
} else {
|
||||
// Grants in effect = this session's own (SESSION/STAGE) unioned with the
|
||||
// cross-session ledger (PROJECT/GLOBAL). projectId is derived from the bound
|
||||
@@ -1329,7 +1387,24 @@ abstract class SessionOrchestrator(
|
||||
emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
|
||||
}
|
||||
}
|
||||
val result = executor.execute(request)
|
||||
// Reaching here means the call is authorized (all deny/block/pending paths returned early).
|
||||
// For a first-time out-of-workspace read, record the grant so future reads of the same path
|
||||
// this session skip the prompt (invariants #8/#9 — replay reads the recorded fact). Then
|
||||
// widen the executed request's jail with every path approved outside the workspace.
|
||||
if (outsideReadTarget != null && !alreadyGranted) {
|
||||
emit(
|
||||
sessionId,
|
||||
OutsidePathAccessGrantedEvent(
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
path = outsideReadTarget.toString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
val executedRequest = outsideReadTarget
|
||||
?.let { request.copy(grantedPaths = grantedOutside + it.toString()) }
|
||||
?: request
|
||||
val result = executor.execute(executedRequest)
|
||||
|
||||
// Store ProcessResult artifact for every shell execution outcome
|
||||
for (slot in processResultSlots) {
|
||||
@@ -1496,6 +1571,33 @@ abstract class SessionOrchestrator(
|
||||
.fold(projection.initial()) { acc, event -> projection.apply(acc, event) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The out-of-workspace path a read tool targets, or null if the read stays inside the workspace
|
||||
* (or there's no workspace policy / path arg). Resolution is purely lexical (absolute-normalize,
|
||||
* or resolve-against-root for relatives) so the containment decision is deterministic and never
|
||||
* touches the filesystem. Symlink escapes and privileged locations are caught upstream by the
|
||||
* plane-2 PathContainmentRule (BLOCK before any prompt), and by the tool's own realize() jail.
|
||||
*/
|
||||
private fun outsideWorkspaceTarget(parameters: Map<String, Any>, workspaceRoot: Path?): Path? {
|
||||
workspaceRoot ?: return null
|
||||
val raw = parameters["path"] as? String ?: return null
|
||||
val candidate = runCatching { Path.of(raw) }.getOrNull() ?: return null
|
||||
val root = workspaceRoot.toAbsolutePath().normalize()
|
||||
val resolved = (if (candidate.isAbsolute) candidate else root.resolve(candidate)).normalize()
|
||||
return resolved.takeUnless { it.startsWith(root) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Paths outside the workspace the operator has approved this session (folded from
|
||||
* [OutsidePathAccessGrantedEvent]). Replay-safe: reads the log, never re-prompts or re-probes.
|
||||
*/
|
||||
private fun grantedOutsidePaths(sessionId: SessionId): Set<String> =
|
||||
eventStore.read(sessionId)
|
||||
.mapNotNull { it.payload as? OutsidePathAccessGrantedEvent }
|
||||
.filter { it.sessionId == sessionId }
|
||||
.map { it.path }
|
||||
.toSet()
|
||||
|
||||
/**
|
||||
* Returns true when the agent must be offered only read-only tools on the next inference turn.
|
||||
* Active from the moment a READ_BEFORE_WRITE block lands until a FILE_READ completion follows it.
|
||||
@@ -1721,6 +1823,25 @@ abstract class SessionOrchestrator(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* True when [stageId]'s own prompt must be suppressed because the stage was entered to repair a
|
||||
* gate failure. That happens when the most recent transition INTO this stage was a ticket route
|
||||
* ([TICKET_ROUTE], same entry check as DefaultSessionOrchestrator.ticketReturnMove) — the failure
|
||||
* ticket routes an owner stage back to fix a file it authored, and its original generative mandate
|
||||
* ("scaffold the project") is exactly what drives it to overwrite that file with a stub. The
|
||||
* recovery-ticket entry ([buildRecoveryTicketEntry]) becomes the sole mandate instead.
|
||||
*
|
||||
* The purpose-built recovery arbiter (role=recovery) is excluded: it is also ticket-entered but
|
||||
* its own prompt IS a tuned repair prompt, so it keeps it. Replay-safe (recorded events only).
|
||||
*/
|
||||
private fun mandateSuppressedByTicket(sessionId: SessionId, stageId: StageId, stageConfig: StageConfig): Boolean {
|
||||
if (stageConfig.metadata["role"] == "recovery") return false
|
||||
return eventStore.read(sessionId)
|
||||
.mapNotNull { it.payload as? TransitionExecutedEvent }
|
||||
.lastOrNull { it.to == stageId }
|
||||
?.transitionId == TICKET_ROUTE
|
||||
}
|
||||
|
||||
/**
|
||||
* Best real path in the repo map that shares [guessed]'s filename, ranked by shared trailing
|
||||
* path segments — so a wrong package prefix (e.g. `com/correx/server/...` for the real
|
||||
@@ -2304,27 +2425,35 @@ abstract class SessionOrchestrator(
|
||||
* empty/missing/malformed file rather than read-looping. COMPILER assertions are recorded as
|
||||
* skipped here; the static-analysis command gate is their authoritative check.
|
||||
*/
|
||||
private suspend fun runContractGate(
|
||||
/**
|
||||
* Evaluate the stage's file contract against current filesystem reality and record the verdict
|
||||
* in a [ContractGateEvaluatedEvent] (invariant #9). Returns the per-assertion results, or null
|
||||
* when the gate does not apply (no evaluator, no workspace root, no file_written produces, or no
|
||||
* paths). Shared by the post-stage [runContractGate] and the within-loop remaining-delta refresh
|
||||
* ([buildRemainingDeltaEntry]) so both read the same contract-minus-reality computation from a
|
||||
* single evaluation + single event emission.
|
||||
*/
|
||||
private suspend fun evaluateStageContract(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
stageConfig: StageConfig,
|
||||
effectives: RunEffectives,
|
||||
): StageExecutionResult {
|
||||
val evaluator = contractAssertionEvaluator ?: return StageExecutionResult.Success(emptyList())
|
||||
val workspaceRoot = effectives.policy?.workspaceRoot ?: return StageExecutionResult.Success(emptyList())
|
||||
if (stageConfig.produces.none { it.kind.id == "file_written" }) return StageExecutionResult.Success(emptyList())
|
||||
): List<ContractAssertionResult>? {
|
||||
val evaluator = contractAssertionEvaluator ?: return null
|
||||
val workspaceRoot = effectives.policy?.workspaceRoot ?: return null
|
||||
if (stageConfig.produces.none { it.kind.id == "file_written" }) return null
|
||||
|
||||
// Option A ∪ B: the files the stage actually wrote (runtime manifest) unioned with the
|
||||
// concrete files the plan declared it would produce (expectedFiles). A declared file that
|
||||
// was never written survives here and fails file_exists — that is the missing-file catch.
|
||||
val paths = (stageWrittenPaths(sessionId, stageId) + stageConfig.expectedFiles).distinct()
|
||||
if (paths.isEmpty()) return StageExecutionResult.Success(emptyList())
|
||||
if (paths.isEmpty()) return null
|
||||
|
||||
val assertions = paths.flatMap { path ->
|
||||
KindContractTable.assertionsFor(KindInference.kindFor(path) ?: "", path)
|
||||
}
|
||||
val results = assertions.map { a ->
|
||||
val v = contractAssertionEvaluator.evaluate(workspaceRoot, a)
|
||||
val v = evaluator.evaluate(workspaceRoot, a)
|
||||
ContractAssertionResult(
|
||||
assertionId = a.id,
|
||||
target = a.target,
|
||||
@@ -2335,6 +2464,21 @@ abstract class SessionOrchestrator(
|
||||
)
|
||||
}
|
||||
emit(sessionId, ContractGateEvaluatedEvent(sessionId, stageId, results))
|
||||
return results
|
||||
}
|
||||
|
||||
/** The currently-failing assertions as (target, assertionId, evidence) triples for the checklist. */
|
||||
private fun contractFailureItems(results: List<ContractAssertionResult>): List<Triple<String, String, String>> =
|
||||
results.filterNot { it.passed }.map { Triple(it.target, it.assertionId, it.evidence) }
|
||||
|
||||
private suspend fun runContractGate(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
stageConfig: StageConfig,
|
||||
effectives: RunEffectives,
|
||||
): StageExecutionResult {
|
||||
val results = evaluateStageContract(sessionId, stageId, stageConfig, effectives)
|
||||
?: return StageExecutionResult.Success(emptyList())
|
||||
|
||||
val failures = results.filterNot { it.passed }
|
||||
if (failures.isEmpty()) return StageExecutionResult.Success(emptyList())
|
||||
@@ -2528,6 +2672,8 @@ abstract class SessionOrchestrator(
|
||||
return StageExecutionResult.Success(emptyList())
|
||||
}
|
||||
|
||||
runSetupCommand(sessionId, stageId, workspaceRoot, profileCommands, runner)?.let { return it }
|
||||
|
||||
val run = runner.run(workspaceRoot, command)
|
||||
val finding = StaticAnalysisFinding(command, run.exitCode, run.output.takeLast(STATIC_ANALYSIS_SUMMARY_CAP))
|
||||
emit(sessionId, StaticAnalysisCompletedEvent(sessionId, stageId, listOf(finding)))
|
||||
@@ -2546,6 +2692,41 @@ abstract class SessionOrchestrator(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the operator-declared `setup` command (profile alias [SETUP_COMMAND_ALIAS]) before a build
|
||||
* gate, when one is configured. A build like `tsc && vite build` fails spuriously if the project
|
||||
* was never prepared (deps uninstalled, codegen not run) — the funnel reads that as a code failure
|
||||
* and routes into futile repair. The operator names the fix in their profile (`setup = "npm ci"`,
|
||||
* `"cargo fetch"`, `"./gradlew dependencies"`, …); the kernel stays ecosystem-agnostic. Runs in the
|
||||
* workspace root, recorded as a [StaticAnalysisCompletedEvent] (invariant #9). Returns a
|
||||
* [StageExecutionResult.Failure] when setup itself fails (a real, actionable error), else null (no
|
||||
* setup configured, or it succeeded — proceed to the build).
|
||||
*/
|
||||
private suspend fun runSetupCommand(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
workspaceRoot: Path,
|
||||
profileCommands: Map<String, String>,
|
||||
runner: StaticAnalysisRunner,
|
||||
): StageExecutionResult? {
|
||||
val setup = profileCommands[SETUP_COMMAND_ALIAS]?.takeIf { it.isNotBlank() } ?: return null
|
||||
log.info(
|
||||
"[Orchestrator] running setup command before build gate session={} stage={} command='{}'",
|
||||
sessionId.value, stageId.value, setup,
|
||||
)
|
||||
val run = runner.run(workspaceRoot, setup)
|
||||
val finding = StaticAnalysisFinding(setup, run.exitCode, run.output.takeLast(STATIC_ANALYSIS_SUMMARY_CAP))
|
||||
emit(sessionId, StaticAnalysisCompletedEvent(sessionId, stageId, listOf(finding)))
|
||||
if (finding.clean) return null
|
||||
return StageExecutionResult.Failure(
|
||||
"stage ${stageId.value} setup command failed before its build gate. " +
|
||||
"Fix these before proceeding:\n\n$ $setup (exit ${run.exitCode})\n" +
|
||||
run.output.takeLast(STATIC_ANALYSIS_FEEDBACK_CAP),
|
||||
retryable = true,
|
||||
gate = "execution",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate 3 — semantic (LLM) review (staged-verification § Gate 3). For a stage that opts in
|
||||
* (`semanticReview`), the injected [SemanticReviewer] reads the files the stage actually wrote
|
||||
@@ -3441,8 +3622,17 @@ internal sealed interface InferenceResult {
|
||||
* becomes its content string; objects and arrays-of-objects keep their JSON text for tools that
|
||||
* re-parse them (e.g. task_decompose). Malformed JSON yields an empty map.
|
||||
*/
|
||||
// Lenient fallback for small local models (gemma) that drift out of strict JSON on large tool
|
||||
// args — most commonly bare/unquoted keys (`path:` instead of `"path":`) and unquoted string
|
||||
// values. Strict parse is tried first; only genuinely-off syntax hits this. Triple-quoted
|
||||
// Python-style values (`"""..."""`) still fail here — that ambiguity isn't worth a fragile
|
||||
// hand-rolled repair; the loud "unparseable" path below tells the model to fix its format.
|
||||
private val lenientJson = Json { isLenient = true }
|
||||
|
||||
internal fun parseToolArguments(arguments: String): Map<String, Any> = runCatching {
|
||||
val obj = Json.parseToJsonElement(arguments) as? JsonObject ?: return@runCatching emptyMap()
|
||||
val element = runCatching { Json.parseToJsonElement(arguments) }
|
||||
.getOrElse { lenientJson.parseToJsonElement(arguments) }
|
||||
val obj = element as? JsonObject ?: return@runCatching emptyMap()
|
||||
obj.entries.associate { (k, v) ->
|
||||
k to when {
|
||||
v is JsonPrimitive -> v.content
|
||||
|
||||
+10
@@ -39,6 +39,16 @@ class ParseToolArgumentsTest {
|
||||
assertTrue(parseToolArguments("[]").isEmpty()) // top-level non-object
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lenient fallback recovers unquoted keys from gemma drift`() {
|
||||
// Small local models drift out of strict JSON on large args, most commonly bare keys.
|
||||
// Strict parse fails; the lenient fallback still yields both params so file_write no
|
||||
// longer sees an empty map and lies "Missing 'path'".
|
||||
val params = parseToolArguments("""{path: "styles/x.css", content: "body {}"}""")
|
||||
assertEquals("styles/x.css", params["path"])
|
||||
assertEquals("body {}", params["content"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decompose preview renders the epic and children with dependency labels`() {
|
||||
// Args arrive flattened: nested tasks/parent are JSON text, as in a real call.
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.context.model.EntryRole
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class RemainingDeltaEntryTest {
|
||||
|
||||
@Test
|
||||
fun `empty failing set yields no entry (list empty means done)`() {
|
||||
assertNull(buildRemainingDeltaEntry(emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `entry lists each failing assertion as an unchecked forward item`() {
|
||||
val entry = buildRemainingDeltaEntry(
|
||||
listOf(
|
||||
Triple("frontend/src/views/TaskView.tsx", "exports_default_component", "no default export"),
|
||||
Triple("frontend/src/views/SessionView.tsx", "exports_default_component", "no default export"),
|
||||
),
|
||||
)!!
|
||||
assertEquals("remainingDelta", entry.sourceType)
|
||||
assertEquals(EntryRole.SYSTEM, entry.role)
|
||||
// Forward-looking framing, not a history of what was done.
|
||||
assertTrue(entry.content.contains("Remaining to finish this stage"))
|
||||
assertTrue(entry.content.contains("- [ ] frontend/src/views/TaskView.tsx — exports_default_component"))
|
||||
assertTrue(entry.content.contains("SessionView.tsx"))
|
||||
// Must state that re-reading doesn't change it — the spiral-starver.
|
||||
assertTrue(entry.content.contains("re-reading files does NOT change it", ignoreCase = true))
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import kotlinx.serialization.Serializable
|
||||
@Serializable
|
||||
data class SessionSummary(
|
||||
val sessionId: SessionId,
|
||||
val name: String?,
|
||||
val intent: String?,
|
||||
val workflowId: String?,
|
||||
val status: String,
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.correx.core.sessions
|
||||
|
||||
import com.correx.core.events.events.InitialIntentEvent
|
||||
import com.correx.core.events.events.OrchestrationPausedEvent
|
||||
import com.correx.core.events.events.SessionNamedEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.events.TransitionExecutedEvent
|
||||
import com.correx.core.events.events.WorkflowCompletedEvent
|
||||
@@ -12,6 +13,7 @@ import com.correx.core.events.types.SessionId
|
||||
class SessionSummaryProjector {
|
||||
fun project(sessionId: SessionId, events: List<StoredEvent>): SessionSummary {
|
||||
var intent: String? = null
|
||||
var name: String? = null
|
||||
var workflowId: String? = null
|
||||
var stageCount = 0
|
||||
var status = "UNKNOWN"
|
||||
@@ -21,6 +23,7 @@ class SessionSummaryProjector {
|
||||
events.forEach { event ->
|
||||
when (val p = event.payload) {
|
||||
is InitialIntentEvent -> intent = p.intent
|
||||
is SessionNamedEvent -> name = p.name
|
||||
is WorkflowStartedEvent -> {
|
||||
workflowId = p.workflowId
|
||||
status = SessionStatus.ACTIVE.name
|
||||
@@ -35,6 +38,7 @@ class SessionSummaryProjector {
|
||||
|
||||
return SessionSummary(
|
||||
sessionId = sessionId,
|
||||
name = name,
|
||||
intent = intent,
|
||||
workflowId = workflowId,
|
||||
status = status,
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.correx.core.sessions
|
||||
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.InitialIntentEvent
|
||||
import com.correx.core.events.events.SessionNamedEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.events.TransitionExecutedEvent
|
||||
import com.correx.core.events.types.EventId
|
||||
@@ -58,6 +59,15 @@ class SessionSummaryProjectorTest {
|
||||
assertEquals("add auth layer", summary.intent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SessionNamedEvent surfaces the human name in the summary`() {
|
||||
val event = stored(SessionNamedEvent(sessionId, "Add auth layer", timestampMs = 0L))
|
||||
|
||||
val summary = projector.project(sessionId, listOf(event))
|
||||
|
||||
assertEquals("Add auth layer", summary.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two TransitionExecutedEvents yield stageCount of two`() {
|
||||
val t1 = stored(
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
package com.correx.core.talkie
|
||||
|
||||
import com.correx.core.context.model.CompressionMetadata
|
||||
import com.correx.core.context.model.ContextEntry
|
||||
import com.correx.core.context.model.ContextLayer
|
||||
import com.correx.core.context.model.ContextPack
|
||||
import com.correx.core.context.model.EntryRole
|
||||
import com.correx.core.events.events.ChatTurnEvent
|
||||
import com.correx.core.events.events.ChatTurnRole
|
||||
import com.correx.core.events.events.ContextTruncatedEvent
|
||||
@@ -10,9 +15,12 @@ import com.correx.core.events.events.L3RetrievedHit
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.events.ToolInvocationRequestedEvent
|
||||
import com.correx.core.events.events.TalkieNarrationEvent
|
||||
import com.correx.core.events.events.SessionNamedEvent
|
||||
import com.correx.core.events.events.SteeringNoteAddedEvent
|
||||
import com.correx.core.events.events.WorkflowProposedEvent
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.ContextEntryId
|
||||
import com.correx.core.events.types.ContextPackId
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.InferenceRequestId
|
||||
import com.correx.core.events.types.SessionId
|
||||
@@ -42,6 +50,11 @@ private val log = LoggerFactory.getLogger(DefaultTalkieFacade::class.java)
|
||||
private const val MAX_NARRATED_ACTIONS = 6
|
||||
private const val ACTION_ARG_MAX = 60
|
||||
|
||||
// Session-naming caps.
|
||||
private const val NAME_INTENT_MAX = 2000
|
||||
private const val NAME_MAX_CHARS = 64
|
||||
private const val TOKEN_CHARS = 4
|
||||
|
||||
interface TalkieFacade {
|
||||
suspend fun onUserInput(
|
||||
sessionId: SessionId,
|
||||
@@ -50,6 +63,13 @@ interface TalkieFacade {
|
||||
): TalkieResponse
|
||||
|
||||
suspend fun narrate(sessionId: SessionId, trigger: NarrationTrigger)
|
||||
|
||||
/**
|
||||
* Derives a short, human-readable title for the session from its initial [intent] and records
|
||||
* it once as a [SessionNamedEvent]. Nondeterministic (an LLM call) so the result is captured as
|
||||
* an event, never re-derived on replay (invariant #9). No-op on blank intent or empty output.
|
||||
*/
|
||||
suspend fun nameSession(sessionId: SessionId, intent: String)
|
||||
}
|
||||
|
||||
class DefaultTalkieFacade(
|
||||
@@ -254,6 +274,80 @@ class DefaultTalkieFacade(
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun nameSession(sessionId: SessionId, intent: String) {
|
||||
if (intent.isBlank()) return
|
||||
|
||||
val instruction = buildString {
|
||||
append("Produce a short, specific title (3-6 words, Title Case, no quotes, no trailing ")
|
||||
append("punctuation) that names the task described below. Reply with the title only.\n\n")
|
||||
append("Task: ")
|
||||
append(intent.trim().take(NAME_INTENT_MAX))
|
||||
}
|
||||
val contextPack = ContextPack(
|
||||
id = ContextPackId("${sessionId.value}-naming-pack"),
|
||||
sessionId = sessionId,
|
||||
stageId = StageId.NONE,
|
||||
layers = mapOf(
|
||||
ContextLayer.L0 to listOf(
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L0,
|
||||
content = instruction,
|
||||
sourceType = "sessionNaming",
|
||||
sourceId = sessionId.value,
|
||||
tokenEstimate = (instruction.length / TOKEN_CHARS).coerceAtLeast(1),
|
||||
role = EntryRole.USER,
|
||||
),
|
||||
),
|
||||
),
|
||||
budgetUsed = (instruction.length / TOKEN_CHARS).coerceAtLeast(1),
|
||||
budgetLimit = config.tokenBudget.limit,
|
||||
compressionMetadata = CompressionMetadata(appliedStrategies = listOf("SessionNaming")),
|
||||
)
|
||||
|
||||
val provider = inferenceRouter.route(StageId.NONE, setOf(ModelCapability.General), config.narrationModelId)
|
||||
val inferenceRequest = InferenceRequest(
|
||||
requestId = InferenceRequestId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
stageId = StageId.NONE,
|
||||
contextPack = contextPack,
|
||||
generationConfig = config.narrationGenerationConfig,
|
||||
responseFormat = ResponseFormat.Text,
|
||||
)
|
||||
val raw = provider.infer(inferenceRequest).text
|
||||
val name = sanitizeSessionName(raw)
|
||||
if (name.isBlank()) return
|
||||
|
||||
val now = Clock.System.now()
|
||||
eventStore.append(
|
||||
NewEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
timestamp = now,
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
payload = SessionNamedEvent(
|
||||
sessionId = sessionId,
|
||||
name = name,
|
||||
timestampMs = now.toEpochMilliseconds(),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Strips quotes/markdown/trailing punctuation and clamps a model-produced title to one line. */
|
||||
private fun sanitizeSessionName(raw: String): String =
|
||||
raw.lineSequence()
|
||||
.map { it.trim() }
|
||||
.firstOrNull { it.isNotBlank() }
|
||||
.orEmpty()
|
||||
.trim('"', '\'', '`', '*', '#', ' ', '.', ':')
|
||||
.take(NAME_MAX_CHARS)
|
||||
.trim()
|
||||
|
||||
/**
|
||||
* The most recent concrete tool actions in the session, read straight from the event log so
|
||||
* the narrator can say what actually happened (files read/written, tools run) instead of a
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.correx.core.tools.process
|
||||
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Single construction point for child processes spawned on behalf of a run — both model-authored
|
||||
* shell (ShellTool) and operator-trusted gate commands (static-analysis runner) go through here.
|
||||
*
|
||||
* Every child is configured for *unattended* execution, closing the two ways a subprocess hangs a
|
||||
* headless run:
|
||||
* 1. **stdin from /dev/null** — a tool that still prompts reads EOF and fails fast instead of
|
||||
* blocking on a dangling pipe until the caller's timeout kills it.
|
||||
* 2. **non-interactive env overlay** — package managers / scaffolders (`npm create`, `npm init`,
|
||||
* …) honour `CI`, `npm_config_yes`, etc. and take defaults rather than prompting at all.
|
||||
*
|
||||
* Output redirection is deliberately NOT set here: callers differ (ShellTool drains stdout/stderr
|
||||
* separately; the gate runner merges them via `redirectErrorStream`). They also keep their own
|
||||
* timeout backstop — this only builds the [ProcessBuilder].
|
||||
*/
|
||||
object ChildProcess {
|
||||
private val DEV_NULL = File("/dev/null")
|
||||
|
||||
// Overlaid on the inherited environment. Forces the common Node toolchain into non-interactive
|
||||
// mode so `npm create`/`npm init` and friends never block on a stdin prompt.
|
||||
private val NON_INTERACTIVE_ENV = mapOf(
|
||||
"CI" to "true",
|
||||
"npm_config_yes" to "true",
|
||||
"NPM_CONFIG_YES" to "true",
|
||||
"ADBLOCK" to "1",
|
||||
"DISABLE_OPENCOLLECTIVE" to "1",
|
||||
)
|
||||
|
||||
/**
|
||||
* A [ProcessBuilder] for [command] in [workingDir] (JVM cwd when null), with the non-interactive
|
||||
* env overlay and stdin redirected from /dev/null. The caller sets any output redirection and
|
||||
* owns the timeout.
|
||||
*/
|
||||
fun builder(command: List<String>, workingDir: File? = null): ProcessBuilder =
|
||||
ProcessBuilder(command).apply {
|
||||
workingDir?.let { directory(it) }
|
||||
environment().putAll(NON_INTERACTIVE_ENV)
|
||||
redirectInput(ProcessBuilder.Redirect.from(DEV_NULL))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user