@martinloop/mcp 0.8.8 → 0.8.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,7 @@ import { runContextIntegrityPrecheck } from "./context-integrity.js";
14
14
  import { decideCircuitBreak } from "./trajectory.js";
15
15
  import { verifyContextHandoff, decideContextCircuitBreak } from "./context-handoff.js";
16
16
  import { verifierActuallyPassed, } from "./verified-handoff.js";
17
+ import { acquireRunExecutionLease, evaluateRunResumability, loadResumeCandidateBoundary, loadResumeVerifierFailure } from "./resume.js";
17
18
  export { DEFAULT_AGENT_EXECUTION_INTENT, DEFAULT_PROVIDER_EXECUTION_TIMEOUT_MS, GOVERNED_AUTONOMOUS_BOUNDARY, normalizeProviderExecutionTimeoutMs } from "../contracts/index.js";
18
19
  export { classifyFailure, computeEvidenceVector, evaluatePatchDecision, evaluateCostGovernor, evaluateBudgetPreflight, inferExit, nextPolicyPhase, policyPhaseToLifecycleState, scorePatchDecision, selectRecoveryRecipe, evaluateVerificationLeash, evaluateFilesystemLeash, evaluateChangeApprovalLeash, evaluateSecretLeash, resolveExecutionProfile, redactSecretsFromText, buildRepoGroundingIndex, loadOrBuildRepoGroundingIndex, queryRepoGroundingIndex, scanPatchForGroundingViolations, captureRollbackBoundary, listAttemptChangedFilesSinceBoundary, listIgnoredFileChangesSinceBoundary, restoreRollbackBoundary };
19
20
  export { describeMalformedVerifier } from "./verifier-input.js";
@@ -23,6 +24,7 @@ export { createLocalIdentityAuthority, hasIdentityScope, issueIdentityToken, ver
23
24
  export { compileExecutionPolicy } from "./policy-compiler.js";
24
25
  export { assessTrajectory, decideCircuitBreak } from "./trajectory.js";
25
26
  export { calculateAvoidedUsd, calculateLoopAvoidedUsd } from "./savings.js";
27
+ export { acquireRunExecutionLease, evaluateRunResumability, loadResumeCandidateBoundary, loadResumeVerifierFailure };
26
28
  // ─── Routing economics ─────────────────────────────────────────────────────
27
29
  export { classifyRoute, evaluatePreworkBurnPolicy } from "./routing.js";
28
30
  // ─── Test Integrity Validation ──────────────────────────────────────────────
@@ -164,746 +166,920 @@ export function distillContext(loop, options = {}) {
164
166
  export async function runMartin(input) {
165
167
  const now = input.now ?? (() => new Date().toISOString());
166
168
  const idFactory = input.idFactory;
167
- const providerExecutionTimeoutMs = normalizeProviderExecutionTimeoutMs(input.task.providerExecutionTimeoutMs);
168
- const agentExecutionIntent = input.task.agentExecutionIntent ?? DEFAULT_AGENT_EXECUTION_INTENT;
169
- // D1: apply task-class-aware iteration defaults when the caller did not set an
170
- // explicit maxIterations. reversible_edit → 3, verification_only/irreversible → 1.
171
- const effectiveMaxIterations = resolveEffectiveMaxIterations(input.task, input.budget.maxIterations);
172
- const effectiveBudget = { ...input.budget, maxIterations: effectiveMaxIterations };
173
- let loop = createLoopRecord({
174
- workspaceId: input.workspaceId,
175
- projectId: input.projectId,
176
- task: { ...input.task, agentExecutionIntent, providerExecutionTimeoutMs },
177
- budget: effectiveBudget,
178
- ...(input.savingsBaseline ? { cost: { savingsBaseline: input.savingsBaseline } } : {}),
179
- ...(input.receiptScope
180
- ? { receiptScope: { ...input.receiptScope, agentExecutionIntent, providerExecutionTimeoutMs } }
181
- : {}),
182
- ...(input.teamId ? { teamId: input.teamId } : {}),
183
- ...(input.metadata ? { metadata: input.metadata } : {})
184
- }, { now: now(), idFactory });
185
- loop = appendLoopEvent(loop, {
186
- type: "run.started",
187
- lifecycleState: "running",
188
- payload: {
189
- adapterId: input.adapter.adapterId,
190
- providerId: input.adapter.metadata.providerId,
191
- model: input.adapter.metadata.model,
192
- transport: getAdapterTransport(input.adapter)
193
- }
194
- }, { now: now(), idFactory });
195
- const adapterChain = [input.adapter, ...(input.fallbackAdapters ?? [])];
196
- const runsRoot = input.store?.runsRoot ?? resolveRunsRoot();
197
- const observesWorkspaceMutations = input.task.repoRoot !== undefined &&
198
- adapterChain.some((adapter) => adapter.metadata.capabilities?.workspaceMutations !== false);
199
- const touchedDuringExecution = new Set();
200
- let completionBoundary;
201
- let completionBoundaryReason;
202
- if (observesWorkspaceMutations) {
203
- try {
204
- completionBoundary = await captureRollbackBoundary({
205
- repoRoot: input.task.repoRoot,
206
- capturedAt: loop.createdAt
207
- });
208
- if (!completionBoundary) {
209
- completionBoundaryReason = "A run-wide file-effect observation boundary was not available.";
210
- }
169
+ const resumeLoop = input.resume?.loop;
170
+ if (resumeLoop) {
171
+ const resumability = evaluateRunResumability(resumeLoop);
172
+ if (!resumability.allowed) {
173
+ throw new Error(`Run ${resumeLoop.loopId} cannot be continued: ${resumability.reason}`);
211
174
  }
212
- catch (error) {
213
- completionBoundaryReason = `Run-wide file-effect observation was unavailable: ${fileEffectErrorMessage(error)}`;
175
+ if (resumeLoop.workspaceId !== input.workspaceId ||
176
+ resumeLoop.projectId !== input.projectId) {
177
+ throw new Error("Resume authority does not match the persisted workspace/project identity.");
214
178
  }
215
179
  }
216
- else {
217
- completionBoundaryReason =
218
- "A mutation-capable run-wide file-effect observation boundary was not available.";
219
- }
220
- async function finalizeCurrentLoop(decision, timestamp, routingEconomicsInput) {
221
- const fileEffects = observeRunFileEffects({
222
- repoRoot: input.task.repoRoot,
223
- boundary: completionBoundary,
224
- touchedDuringExecution,
225
- unavailableReason: completionBoundaryReason
226
- });
227
- return finalizeLoop(loop, decision, timestamp, idFactory, routingEconomicsInput, fileEffects);
228
- }
229
- async function persistGuardStop(decision) {
230
- const envelope = {
231
- schemaVersion: "termination/1",
232
- class: "guard_stop",
233
- guard: {
234
- reasonCode: decision.reasonCode ?? "guard_stop",
235
- reason: decision.reason,
236
- ...(decision.failureClass ? { failureClass: decision.failureClass } : {}),
237
- ...(decision.safetySurface ? { safetySurface: decision.safetySurface } : {})
180
+ const task = resumeLoop?.task ?? input.task;
181
+ const providerExecutionTimeoutMs = normalizeProviderExecutionTimeoutMs(task.providerExecutionTimeoutMs);
182
+ const agentExecutionIntent = task.agentExecutionIntent ?? DEFAULT_AGENT_EXECUTION_INTENT;
183
+ // D1: apply task-class-aware iteration defaults only for a new run. A resumed
184
+ // run inherits the exact persisted budget authority.
185
+ const effectiveMaxIterations = resumeLoop
186
+ ? resumeLoop.budget.maxIterations
187
+ : resolveEffectiveMaxIterations(task, input.budget.maxIterations);
188
+ const effectiveBudget = resumeLoop
189
+ ? { ...resumeLoop.budget }
190
+ : { ...input.budget, maxIterations: effectiveMaxIterations };
191
+ let loop = resumeLoop
192
+ ? {
193
+ ...resumeLoop,
194
+ status: "running",
195
+ lifecycleState: "running",
196
+ updatedAt: now(),
197
+ metadata: {
198
+ ...resumeLoop.metadata,
199
+ ...(input.metadata ?? {}),
200
+ resumedFromPersistence: "true"
238
201
  }
239
- };
240
- if (input.store) {
241
- await persistTerminationEnvelope(runDir(runsRoot, loop.loopId), envelope);
242
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
243
- kind: "run.terminated",
244
- runId: loop.loopId,
245
- payload: {
246
- terminationClass: envelope.class,
247
- reasonCode: envelope.guard.reasonCode,
248
- reason: envelope.guard.reason,
249
- ...(envelope.guard.failureClass ? { failureClass: envelope.guard.failureClass } : {}),
250
- ...(envelope.guard.safetySurface ? { safetySurface: envelope.guard.safetySurface } : {})
251
- }
252
- }));
253
202
  }
254
- return envelope;
255
- }
256
- if (input.store) {
257
- await input.store.initRun({
258
- runId: loop.loopId,
203
+ : createLoopRecord({
259
204
  workspaceId: input.workspaceId,
260
205
  projectId: input.projectId,
261
- task: input.task,
262
- budget: input.budget,
263
- createdAt: loop.createdAt,
206
+ task: { ...task, agentExecutionIntent, providerExecutionTimeoutMs },
207
+ budget: effectiveBudget,
208
+ ...(input.savingsBaseline ? { cost: { savingsBaseline: input.savingsBaseline } } : {}),
209
+ ...(input.receiptScope
210
+ ? { receiptScope: { ...input.receiptScope, agentExecutionIntent, providerExecutionTimeoutMs } }
211
+ : {}),
212
+ ...(input.teamId ? { teamId: input.teamId } : {}),
264
213
  ...(input.metadata ? { metadata: input.metadata } : {})
265
- });
266
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
267
- kind: "contract.created",
214
+ }, { now: now(), idFactory });
215
+ const executionLease = input.store?.runsRoot
216
+ ? await acquireRunExecutionLease({
217
+ runsRoot: input.store.runsRoot,
268
218
  runId: loop.loopId,
269
- payload: { workspaceId: input.workspaceId, projectId: input.projectId }
270
- }));
271
- await persistLoopRecordIfSupported(input.store, loop);
272
- }
273
- // ── A-CTX-2 context handoff gate ─────────────────────────────────────────
274
- // The gate runs before ANY adapter call. A blocked handoff hard-stops the run
275
- // and emits ledger evidence — the adapter is never invoked.
276
- if (input.contextHandoff !== undefined) {
277
- const handoffVerification = verifyContextHandoff({
278
- handoff: input.contextHandoff,
279
- producerReceiptVerified: input.producerReceiptVerified ?? false,
280
- availableArtifacts: input.availableArtifacts ?? new Map()
281
- });
282
- if (input.store) {
283
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
284
- kind: "context.handoff.received",
285
- runId: loop.loopId,
286
- payload: {
287
- handoffId: input.contextHandoff.handoffId,
288
- chainId: input.contextHandoff.chainId,
289
- producerRunId: input.contextHandoff.producerRunId,
290
- upstreamIntegrity: input.contextHandoff.upstreamIntegrity
291
- }
292
- }));
293
- }
294
- const handoffGate = decideContextCircuitBreak(handoffVerification);
295
- if (handoffGate.shouldStop) {
296
- if (input.store) {
297
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
298
- kind: "context.handoff.blocked",
299
- runId: loop.loopId,
300
- payload: {
301
- reasonCode: handoffGate.reasonCode,
302
- message: handoffGate.message,
303
- integrity: handoffVerification.integrity,
304
- reasons: handoffVerification.reasons
305
- }
306
- }));
307
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
308
- kind: "run.exited",
309
- runId: loop.loopId,
310
- payload: {
311
- status: "exited",
312
- lifecycleState: "human_escalation",
313
- reason: handoffGate.message ?? "Context handoff verification failed.",
314
- reasonCode: handoffGate.reasonCode ?? "handoff_verification_failed"
315
- }
316
- }));
219
+ owner: resumeLoop ? "runMartin:resume" : "runMartin",
220
+ now: () => loop.updatedAt
221
+ })
222
+ : undefined;
223
+ try {
224
+ loop = appendLoopEvent(loop, {
225
+ type: "run.started",
226
+ lifecycleState: "running",
227
+ payload: {
228
+ adapterId: input.adapter.adapterId,
229
+ providerId: input.adapter.metadata.providerId,
230
+ model: input.adapter.metadata.model,
231
+ transport: getAdapterTransport(input.adapter),
232
+ ...(resumeLoop
233
+ ? { resumed: true, priorAttemptCount: resumeLoop.attempts.length }
234
+ : {})
317
235
  }
318
- const handoffExitDecision = {
319
- shouldExit: true,
320
- lifecycleState: "human_escalation",
321
- status: "exited",
322
- reason: handoffGate.message ?? "Context handoff verification failed.",
323
- reasonCode: handoffGate.reasonCode ?? "handoff_verification_failed"
324
- };
325
- const finalizedLoop = await finalizeCurrentLoop(handoffExitDecision, now());
326
- await persistLoopRecordIfSupported(input.store, finalizedLoop);
327
- return { loop: finalizedLoop, decision: handoffExitDecision };
328
- }
329
- if (input.store) {
330
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
331
- kind: "context.handoff.verified",
332
- runId: loop.loopId,
333
- payload: {
334
- handoffId: input.contextHandoff.handoffId,
335
- chainId: input.contextHandoff.chainId,
336
- producerRunId: input.contextHandoff.producerRunId,
337
- integrity: handoffVerification.integrity
338
- }
339
- }));
340
- }
341
- }
342
- let currentAdapterIndex = 0;
343
- let currentAdapter = adapterChain[currentAdapterIndex] ?? input.adapter;
344
- let useCompressedContext = false;
345
- const executionProfile = resolveExecutionProfile({
346
- executionProfile: input.task.executionProfile,
347
- allowedNetworkDomains: input.task.allowedNetworkDomains
348
- });
349
- // Safety leash: block destructive verifier commands before any attempt
350
- const leashDecision = evaluateVerificationLeash({
351
- verificationPlan: input.task.verificationPlan,
352
- verificationStack: input.task.verificationStack,
353
- executionProfile: input.task.executionProfile,
354
- allowedNetworkDomains: input.task.allowedNetworkDomains
355
- });
356
- if (!leashDecision.allowed) {
357
- const reason = `${leashDecision.reason ?? "Safety leash blocked verifier commands."} Blocked: ${leashDecision.blockedCommands.join(", ")}`;
358
- const leashExitDecision = {
359
- shouldExit: true,
360
- lifecycleState: "human_escalation",
361
- status: "exited",
362
- reason,
363
- ...classifySafetyLeashExit(leashDecision, "verifier")
364
- };
365
- if (input.store) {
366
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
367
- kind: "safety.violations_found",
368
- runId: loop.loopId,
369
- payload: {
370
- surface: leashDecision.surface,
371
- blocked: true,
372
- profile: leashDecision.profile ?? executionProfile.name,
373
- violations: serializeSafetyViolations(leashDecision)
374
- }
375
- }));
376
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
377
- kind: "run.exited",
378
- runId: loop.loopId,
379
- payload: createRunExitPayload(leashExitDecision)
380
- }));
381
- }
382
- const terminationEnvelope = await persistGuardStop(leashExitDecision);
383
- const finalizedLoop = await finalizeCurrentLoop(leashExitDecision, now());
384
- const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope };
385
- await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
386
- return {
387
- loop: finalizedWithEnvelope,
388
- decision: leashExitDecision
389
- };
390
- }
391
- const secretDecision = evaluateSecretLeash({
392
- values: [
393
- input.task.title,
394
- input.task.objective,
395
- ...(input.task.acceptanceCriteria ?? [])
396
- ]
397
- });
398
- if (!secretDecision.allowed) {
399
- const secretExitDecision = {
400
- shouldExit: true,
401
- lifecycleState: "human_escalation",
402
- status: "exited",
403
- reason: secretDecision.reason ?? "Safety leash blocked secret-like values in the runtime context.",
404
- ...classifySafetyLeashExit(secretDecision, "secret")
405
- };
406
- if (input.store) {
407
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
408
- kind: "safety.violations_found",
409
- runId: loop.loopId,
410
- payload: {
411
- surface: "secret",
412
- blocked: true,
413
- violations: secretDecision.violations.map((violation) => violation.match ?? violation.message)
236
+ }, { now: now(), idFactory });
237
+ const adapterChain = [input.adapter, ...(input.fallbackAdapters ?? [])];
238
+ const runsRoot = input.store?.runsRoot ?? resolveRunsRoot();
239
+ const observesWorkspaceMutations = task.repoRoot !== undefined &&
240
+ adapterChain.some((adapter) => adapter.metadata.capabilities?.workspaceMutations !== false);
241
+ const touchedDuringExecution = new Set();
242
+ // A retained candidate is already present when resuming; its first rollback
243
+ // boundary is also the original run-wide completion boundary.
244
+ let completionBoundary = input.resume?.preCandidateBoundary;
245
+ let completionBoundaryReason;
246
+ if (observesWorkspaceMutations && !completionBoundary) {
247
+ try {
248
+ completionBoundary = await captureRollbackBoundary({
249
+ repoRoot: task.repoRoot,
250
+ capturedAt: loop.createdAt
251
+ });
252
+ if (!completionBoundary) {
253
+ completionBoundaryReason = "A run-wide file-effect observation boundary was not available.";
414
254
  }
415
- }));
416
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
417
- kind: "run.exited",
418
- runId: loop.loopId,
419
- payload: createRunExitPayload(secretExitDecision)
420
- }));
421
- }
422
- const terminationEnvelope = await persistGuardStop(secretExitDecision);
423
- const finalizedLoop = await finalizeCurrentLoop(secretExitDecision, now());
424
- const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope };
425
- await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
426
- return {
427
- loop: finalizedWithEnvelope,
428
- decision: secretExitDecision
429
- };
430
- }
431
- // Explicit PolicyPhase state machine — starts at GATHER, advances per attempt
432
- let currentPhase = "GATHER";
433
- let phaseRetryCount = 0;
434
- let lastVerifierFailure;
435
- // C1 terminal cleanup: boundary captured immediately before the first retained candidate.
436
- // Used to restore the workspace if the run terminates without VERIFIED.
437
- // The invariant is: if this is set, the current workspace contains unverified candidate code.
438
- let preCandidateBoundary;
439
- // Routing economics: track first meaningful workspace delta
440
- const runStartMs = Date.now();
441
- let firstDeltaDetected = false;
442
- let firstDeltaTimestampMs;
443
- let firstDeltaFilePath;
444
- let firstDeltaChangeType;
445
- let preDeltaCostUsd = 0;
446
- // ── Eight-exit runtime control state ─────────────────────────────────────
447
- const nowMs = input.nowMs ?? (() => Date.now());
448
- const exitPolicy = createDefaultExitPolicy(input.budget, input.exitPolicy);
449
- let activeAttemptController;
450
- let observedHumanInterrupt;
451
- let observedExternalEvent;
452
- let terminationPromise;
453
- const recentStateHashes = [];
454
- let consecutiveErrors = 0;
455
- // governedTurnsUsed starts from the already-completed attempt count so
456
- // resumed runs count correctly; incremented once per governed attempt.
457
- let governedTurnsUsed = loop.attempts.length;
458
- const exitSignalSource = input.exitSignalSource ?? createFileExitSignalSource(runsRoot);
459
- // Diagnostic writes fire from sync callbacks — track them so the finally
460
- // block can await completion before the run tears down.
461
- const pendingControlWrites = [];
462
- // Non-null when the signal monitor itself fails — rethrown at the next
463
- // evaluateCheckpoint so the run surfaces the error rather than silently
464
- // losing cancellation.
465
- let signalMonitorError;
466
- function observeSignals(signals) {
467
- for (const s of signals) {
468
- if (s.kind === "human_interrupt")
469
- observedHumanInterrupt = s;
470
- else if (s.kind === "external_event" && s.externalEvent !== undefined) {
471
- if (observedExternalEvent !== undefined &&
472
- observedExternalEvent.disposition !== "satisfied" &&
473
- s.externalEvent.disposition === "satisfied")
474
- continue;
475
- observedExternalEvent = s.externalEvent;
476
255
  }
477
- }
478
- }
479
- let parentAbortObserved = false;
480
- function observeParentAbort() {
481
- if (parentAbortObserved || input.signal === undefined)
482
- return;
483
- parentAbortObserved = true;
484
- const reason = input.signal.reason instanceof Error
485
- ? input.signal.reason.message
486
- : typeof input.signal.reason === "string"
487
- ? input.signal.reason
488
- : "parent cancelled governed child run";
489
- const parentSignal = {
490
- kind: "human_interrupt",
491
- schemaVersion: "exit-signal/1",
492
- runId: loop.loopId,
493
- reason: `Parent cancellation: ${reason}`,
494
- requestedAt: now(),
495
- requestedBy: "parent_swarm"
496
- };
497
- observeSignals([parentSignal]);
498
- activeAttemptController?.abort(input.signal.reason ?? parentSignal);
499
- }
500
- if (input.signal?.aborted)
501
- observeParentAbort();
502
- else if (input.signal) {
503
- input.signal.addEventListener("abort", observeParentAbort, { once: true });
504
- if (input.signal.aborted)
505
- observeParentAbort();
506
- }
507
- function queueControlDiagnostic(code, details) {
508
- if (!input.store)
509
- return;
510
- pendingControlWrites.push(input.store.appendLedger(loop.loopId, makeLedgerEvent({
511
- kind: "run.diagnostic",
512
- runId: loop.loopId,
513
- payload: { code, details }
514
- })));
515
- }
516
- const runController = new AbortController();
517
- const wallTimer = exitPolicy.wallClock.maxElapsedMs > 0
518
- ? setTimeout(() => {
519
- activeAttemptController?.abort(new Error("MartinLoop wall-clock exit reached"));
520
- }, exitPolicy.wallClock.maxElapsedMs)
521
- : undefined;
522
- wallTimer?.unref?.();
523
- const disposeSignalMonitor = startExitSignalMonitor({
524
- source: exitSignalSource,
525
- runId: loop.loopId,
526
- controller: runController,
527
- pollIntervalMs: input.exitSignalPollIntervalMs,
528
- onSignal: (signals) => {
529
- observeSignals(signals);
530
- // Only abort the active attempt for terminal signals.
531
- // A satisfied external event is recorded but must not abort the provider.
532
- if (signals.some(isTerminalExitSignal)) {
533
- activeAttemptController?.abort(signals);
256
+ catch (error) {
257
+ completionBoundaryReason = `Run-wide file-effect observation was unavailable: ${fileEffectErrorMessage(error)}`;
534
258
  }
535
- },
536
- onDiagnostic: (diagnostics) => {
537
- queueControlDiagnostic("exit_signal_invalid", diagnostics.map(({ kind, error }) => ({ kind, error })));
538
- },
539
- onError: (err) => {
540
- signalMonitorError = err instanceof Error ? err : new Error(String(err));
541
- activeAttemptController?.abort(err);
542
259
  }
543
- });
544
- async function stopSignalMonitorAndDrainDiagnostics() {
545
- // Fence future polls and in-flight callbacks before taking the final ledger
546
- // snapshot. Draining alone leaves a window for new writes during signing.
547
- disposeSignalMonitor();
548
- runController.abort();
549
- input.signal?.removeEventListener("abort", observeParentAbort);
550
- await Promise.all(pendingControlWrites.splice(0));
551
- }
552
- // Snapshot of the current run state for eight-exit evaluation
553
- function currentExitSnapshot(phase, result) {
554
- return {
555
- phase,
556
- evaluatedAt: now(),
557
- runStartedAtMs: runStartMs,
558
- nowMs: nowMs(),
559
- turnsUsed: governedTurnsUsed,
560
- actualUsd: loop.cost.actualUsd,
561
- tokensUsed: loop.cost.tokensIn + loop.cost.tokensOut,
562
- recentStateHashes: [...recentStateHashes],
563
- consecutiveErrors,
564
- ...(result !== undefined
565
- ? {
566
- result: {
567
- status: result.status,
568
- verificationPassed: result.verificationPassed,
569
- verifierScore: result.verificationPassed ? 1 : 0
570
- }
260
+ else {
261
+ completionBoundaryReason =
262
+ "A mutation-capable run-wide file-effect observation boundary was not available.";
263
+ }
264
+ async function finalizeCurrentLoop(decision, timestamp, routingEconomicsInput) {
265
+ const fileEffects = observeRunFileEffects({
266
+ repoRoot: task.repoRoot,
267
+ boundary: completionBoundary,
268
+ touchedDuringExecution,
269
+ unavailableReason: completionBoundaryReason
270
+ });
271
+ return finalizeLoop(loop, decision, timestamp, idFactory, routingEconomicsInput, fileEffects);
272
+ }
273
+ async function persistGuardStop(decision) {
274
+ const envelope = {
275
+ schemaVersion: "termination/1",
276
+ class: "guard_stop",
277
+ guard: {
278
+ reasonCode: decision.reasonCode ?? "guard_stop",
279
+ reason: decision.reason,
280
+ ...(decision.failureClass ? { failureClass: decision.failureClass } : {}),
281
+ ...(decision.safetySurface ? { safetySurface: decision.safetySurface } : {})
571
282
  }
572
- : {}),
573
- ...(observedHumanInterrupt !== undefined ? { humanInterrupt: observedHumanInterrupt } : {}),
574
- ...(observedExternalEvent !== undefined ? { externalEvent: observedExternalEvent } : {})
575
- };
576
- }
577
- // Termination selection with deferred disk write.
578
- //
579
- // The "run.terminated" ledger event is written at the original timing (here, before
580
- // terminateRun runs) to preserve ledger ordering. The DISK write of termination.json
581
- // is deferred to terminateRun() so that it reflects the true final terminal state
582
- // AFTER retained-candidate cleanup — not a potentially stale pre-cleanup state.
583
- //
584
- // First-write-wins semantics on disk are preserved: persistTerminationEnvelope is
585
- // called exactly once per run, from terminateRun(), after cleanup resolves.
586
- function selectTerminalEvaluation(evaluation) {
587
- if (terminationPromise !== undefined)
588
- return terminationPromise;
589
- const envelope = {
590
- schemaVersion: "termination/1",
591
- class: "operational_exit",
592
- exit: evaluation
593
- };
594
- terminationPromise = (async () => {
283
+ };
595
284
  if (input.store) {
596
- // Write the "run.terminated" ledger entry at the original timing — this
597
- // preserves ledger ordering and receipt integrity hashes.
598
- // The DISK termination.json is deliberately deferred to terminateRun().
285
+ await persistTerminationEnvelope(runDir(runsRoot, loop.loopId), envelope);
599
286
  await input.store.appendLedger(loop.loopId, makeLedgerEvent({
600
287
  kind: "run.terminated",
601
288
  runId: loop.loopId,
602
289
  payload: {
603
- selectedExit: evaluation.primary,
604
- matchedExits: evaluation.matched,
605
- policyVersion: evaluation.policyVersion,
606
- evaluationVersion: evaluation.schemaVersion,
607
- terminationPhase: evaluation.phase
290
+ terminationClass: envelope.class,
291
+ reasonCode: envelope.guard.reasonCode,
292
+ reason: envelope.guard.reason,
293
+ ...(envelope.guard.failureClass ? { failureClass: envelope.guard.failureClass } : {}),
294
+ ...(envelope.guard.safetySurface ? { safetySurface: envelope.guard.safetySurface } : {})
608
295
  }
609
296
  }));
610
297
  }
611
298
  return envelope;
612
- })();
613
- return terminationPromise;
614
- }
615
- async function evaluateCheckpoint(phase, result) {
616
- // Surface signal-monitor failures immediately rather than silently losing cancellation
617
- if (signalMonitorError !== undefined)
618
- throw signalMonitorError;
619
- const evaluation = evaluateExitPolicy(exitPolicy, currentExitSnapshot(phase, result));
620
- if (!evaluation.shouldExit)
621
- return undefined;
622
- return await selectTerminalEvaluation(evaluation);
623
- }
624
- /**
625
- * Blocker 1 / Blocker 2 — Authoritative retained-candidate terminal restoration.
626
- *
627
- * Must be called at EVERY terminal exit that does not produce lifecycleState="completed".
628
- * If preCandidateBoundary is set, the workspace contains unverified candidate code.
629
- * This helper:
630
- * 1. Detects whether retained unverified candidate state exists.
631
- * 2. Restores the exact pre-candidate boundary (not the per-attempt boundary).
632
- * 3. Independently inspects the resulting workspace via the rollback outcome.
633
- * 4. Persists rollback outcome/evidence to the ledger.
634
- * 5. Clears preCandidateBoundary ONLY after proven restoration.
635
- * 6. On failure: surfaces workspace_restore_failed and escalates the exit decision.
636
- *
637
- * NEVER silently swallows restore errors. NEVER returns a clean-looking exit
638
- * while residual unverified code may remain in the repository.
639
- */
640
- async function restoreRetainedCandidateForTerminalExit(exitDecision) {
641
- // Step 1: determine whether retained unverified candidate state exists.
642
- if (!preCandidateBoundary || exitDecision.lifecycleState === "completed") {
643
- return exitDecision;
644
299
  }
645
- const capturedBoundary = preCandidateBoundary;
646
- const restoredAt = now();
647
- try {
648
- // Step 2: restore the exact pre-candidate boundary.
649
- const rollbackOutcome = await restoreRollbackBoundary({
650
- repoRoot: input.task.repoRoot,
651
- boundary: capturedBoundary,
652
- restoredAt,
653
- decision: "DISCARD"
654
- });
655
- // Step 3: independently inspect the resulting workspace.
656
- if (!rollbackOutcome) {
657
- throw new Error("Workspace restore returned no outcome — repoRoot may be unavailable or boundary invalid.");
658
- }
659
- // Step 4: prove the restoration — only success/partial are acceptable outcomes.
660
- if (rollbackOutcome.status === "failed" || rollbackOutcome.status === "unavailable") {
661
- throw new Error(rollbackOutcome.error ??
662
- `Workspace restore returned status "${rollbackOutcome.status}" — pre-candidate state not confirmed.`);
300
+ if (input.store) {
301
+ if (!resumeLoop) {
302
+ await input.store.initRun({
303
+ runId: loop.loopId,
304
+ workspaceId: loop.workspaceId,
305
+ projectId: loop.projectId,
306
+ task,
307
+ budget: effectiveBudget,
308
+ createdAt: loop.createdAt,
309
+ ...(input.metadata ? { metadata: input.metadata } : {})
310
+ });
311
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
312
+ kind: "contract.created",
313
+ runId: loop.loopId,
314
+ payload: { workspaceId: loop.workspaceId, projectId: loop.projectId }
315
+ }));
663
316
  }
664
- // Persist rollback outcome evidence.
317
+ await persistLoopRecordIfSupported(input.store, loop);
318
+ }
319
+ // ── A-CTX-2 context handoff gate ─────────────────────────────────────────
320
+ // The gate runs before ANY adapter call. A blocked handoff hard-stops the run
321
+ // and emits ledger evidence — the adapter is never invoked.
322
+ if (input.contextHandoff !== undefined) {
323
+ const handoffVerification = verifyContextHandoff({
324
+ handoff: input.contextHandoff,
325
+ producerReceiptVerified: input.producerReceiptVerified ?? false,
326
+ availableArtifacts: input.availableArtifacts ?? new Map()
327
+ });
665
328
  if (input.store) {
666
329
  await input.store.appendLedger(loop.loopId, makeLedgerEvent({
667
- kind: "run.diagnostic",
330
+ kind: "context.handoff.received",
668
331
  runId: loop.loopId,
669
332
  payload: {
670
- code: "terminal_candidate_restore",
671
- details: {
672
- status: rollbackOutcome.status,
673
- restoredFiles: rollbackOutcome.restoredFiles?.length ?? 0,
674
- deletedFiles: rollbackOutcome.deletedFiles?.length ?? 0,
675
- capturedAt: capturedBoundary.capturedAt
676
- }
333
+ handoffId: input.contextHandoff.handoffId,
334
+ chainId: input.contextHandoff.chainId,
335
+ producerRunId: input.contextHandoff.producerRunId,
336
+ upstreamIntegrity: input.contextHandoff.upstreamIntegrity
677
337
  }
678
338
  }));
679
339
  }
680
- // Step 5/6: clear retained-candidate authority only after proven restoration.
681
- preCandidateBoundary = undefined;
682
- return exitDecision;
683
- }
684
- catch (restoreError) {
685
- // Persist the failure evidence.
686
- if (input.store) {
687
- try {
340
+ const handoffGate = decideContextCircuitBreak(handoffVerification);
341
+ if (handoffGate.shouldStop) {
342
+ if (input.store) {
688
343
  await input.store.appendLedger(loop.loopId, makeLedgerEvent({
689
- kind: "run.diagnostic",
344
+ kind: "context.handoff.blocked",
690
345
  runId: loop.loopId,
691
346
  payload: {
692
- code: "terminal_candidate_restore_failed",
693
- details: {
694
- error: restoreError instanceof Error ? restoreError.message : String(restoreError),
695
- capturedAt: capturedBoundary.capturedAt
696
- }
347
+ reasonCode: handoffGate.reasonCode,
348
+ message: handoffGate.message,
349
+ integrity: handoffVerification.integrity,
350
+ reasons: handoffVerification.reasons
351
+ }
352
+ }));
353
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
354
+ kind: "run.exited",
355
+ runId: loop.loopId,
356
+ payload: {
357
+ status: "exited",
358
+ lifecycleState: "human_escalation",
359
+ reason: handoffGate.message ?? "Context handoff verification failed.",
360
+ reasonCode: handoffGate.reasonCode ?? "handoff_verification_failed"
697
361
  }
698
362
  }));
699
363
  }
700
- catch {
701
- // Ledger write failure is secondary — the restore error is what matters.
702
- }
364
+ const handoffExitDecision = {
365
+ shouldExit: true,
366
+ lifecycleState: "human_escalation",
367
+ status: "exited",
368
+ reason: handoffGate.message ?? "Context handoff verification failed.",
369
+ reasonCode: handoffGate.reasonCode ?? "handoff_verification_failed"
370
+ };
371
+ const finalizedLoop = await finalizeCurrentLoop(handoffExitDecision, now());
372
+ await persistLoopRecordIfSupported(input.store, finalizedLoop);
373
+ return { loop: finalizedLoop, decision: handoffExitDecision };
703
374
  }
704
- // DO NOT clear preCandidateBoundary — workspace state is unknown.
705
- // Surface explicit unsafe status; never return a clean-looking exit.
706
- return {
707
- ...exitDecision,
375
+ if (input.store) {
376
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
377
+ kind: "context.handoff.verified",
378
+ runId: loop.loopId,
379
+ payload: {
380
+ handoffId: input.contextHandoff.handoffId,
381
+ chainId: input.contextHandoff.chainId,
382
+ producerRunId: input.contextHandoff.producerRunId,
383
+ integrity: handoffVerification.integrity
384
+ }
385
+ }));
386
+ }
387
+ }
388
+ const previousAdapterId = resumeLoop?.attempts.at(-1)?.adapterId;
389
+ const resumedAdapterIndex = previousAdapterId
390
+ ? adapterChain.findIndex((adapter) => adapter.adapterId === previousAdapterId)
391
+ : -1;
392
+ let currentAdapterIndex = resumedAdapterIndex >= 0 ? resumedAdapterIndex : 0;
393
+ let currentAdapter = adapterChain[currentAdapterIndex] ?? input.adapter;
394
+ let useCompressedContext = false;
395
+ const executionProfile = resolveExecutionProfile({
396
+ executionProfile: task.executionProfile,
397
+ allowedNetworkDomains: task.allowedNetworkDomains
398
+ });
399
+ // Safety leash: block destructive verifier commands before any attempt
400
+ const leashDecision = evaluateVerificationLeash({
401
+ verificationPlan: task.verificationPlan,
402
+ verificationStack: task.verificationStack,
403
+ executionProfile: task.executionProfile,
404
+ allowedNetworkDomains: task.allowedNetworkDomains
405
+ });
406
+ if (!leashDecision.allowed) {
407
+ const reason = `${leashDecision.reason ?? "Safety leash blocked verifier commands."} Blocked: ${leashDecision.blockedCommands.join(", ")}`;
408
+ const leashExitDecision = {
409
+ shouldExit: true,
708
410
  lifecycleState: "human_escalation",
709
411
  status: "exited",
710
- reasonCode: "workspace_restore_failed",
711
- reason: `MartinLoop retained an unverified candidate but could not restore the pre-candidate workspace: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}. Manual workspace inspection required.`
412
+ reason,
413
+ ...classifySafetyLeashExit(leashDecision, "verifier")
712
414
  };
713
- }
714
- }
715
- async function terminateRun(envelope) {
716
- if (envelope.class !== "operational_exit") {
717
- throw new Error("terminateRun: unexpected non-operational envelope");
718
- }
719
- const legacyDecision = toLegacyExitDecision(envelope.exit, observedExternalEvent?.disposition);
720
- let exitDecision = {
721
- shouldExit: legacyDecision.shouldExit,
722
- lifecycleState: legacyDecision.lifecycleState,
723
- status: legacyDecision.status,
724
- reason: legacyDecision.reason,
725
- ...(legacyDecision.reasonCode ? { reasonCode: legacyDecision.reasonCode } : {}),
726
- ...(legacyDecision.failureClass
727
- ? { failureClass: legacyDecision.failureClass }
728
- : {})
729
- };
730
- if (input.store) {
731
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
732
- kind: "run.exited",
733
- runId: loop.loopId,
734
- payload: createRunExitPayload(exitDecision)
735
- }));
736
- }
737
- const routingInput = {
738
- runStartMs,
739
- firstDeltaDetected,
740
- firstDeltaTimestampMs,
741
- firstDeltaFilePath,
742
- firstDeltaChangeType,
743
- preDeltaCostUsd
744
- };
745
- // Drain all pending diagnostic ledger writes BEFORE the cleanup helper may add more.
746
- await stopSignalMonitorAndDrainDiagnostics();
747
- // Retained-candidate cleanup runs BEFORE any durable termination envelope is
748
- // written to disk. This guarantees that termination.json reflects the TRUE
749
- // final state (original exit OR workspace_restore_failed) in a single atomic
750
- // first-write-wins write. selectTerminalEvaluation() already memoized the winner
751
- // in memory; disk persistence happens only here, after cleanup resolves.
752
- exitDecision = await restoreRetainedCandidateForTerminalExit(exitDecision);
753
- // Persist the authoritative termination envelope exactly once, after cleanup.
754
- let finalEnvelope;
755
- if (exitDecision.reasonCode === "workspace_restore_failed") {
756
- // Cleanup failed: write guard_stop(workspace_restore_failed) as the sole record.
757
415
  if (input.store) {
416
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
417
+ kind: "safety.violations_found",
418
+ runId: loop.loopId,
419
+ payload: {
420
+ surface: leashDecision.surface,
421
+ blocked: true,
422
+ profile: leashDecision.profile ?? executionProfile.name,
423
+ violations: serializeSafetyViolations(leashDecision)
424
+ }
425
+ }));
758
426
  await input.store.appendLedger(loop.loopId, makeLedgerEvent({
759
427
  kind: "run.exited",
760
428
  runId: loop.loopId,
761
- payload: createRunExitPayload(exitDecision)
429
+ payload: createRunExitPayload(leashExitDecision)
762
430
  }));
763
431
  }
764
- finalEnvelope = await persistGuardStop(exitDecision);
432
+ const terminationEnvelope = await persistGuardStop(leashExitDecision);
433
+ const finalizedLoop = await finalizeCurrentLoop(leashExitDecision, now());
434
+ const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope };
435
+ await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
436
+ return {
437
+ loop: finalizedWithEnvelope,
438
+ decision: leashExitDecision
439
+ };
765
440
  }
766
- else {
767
- // Cleanup succeeded (or was not needed): persist the original operational_exit.
768
- // "run.terminated" was already written by selectTerminalEvaluation() at the
769
- // original timing (before terminateRun runs) to preserve ledger ordering.
770
- finalEnvelope = envelope;
441
+ const secretDecision = evaluateSecretLeash({
442
+ values: [
443
+ task.title,
444
+ task.objective,
445
+ ...(task.acceptanceCriteria ?? [])
446
+ ]
447
+ });
448
+ if (!secretDecision.allowed) {
449
+ const secretExitDecision = {
450
+ shouldExit: true,
451
+ lifecycleState: "human_escalation",
452
+ status: "exited",
453
+ reason: secretDecision.reason ?? "Safety leash blocked secret-like values in the runtime context.",
454
+ ...classifySafetyLeashExit(secretDecision, "secret")
455
+ };
771
456
  if (input.store) {
772
- await persistTerminationEnvelope(runDir(runsRoot, loop.loopId), finalEnvelope);
457
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
458
+ kind: "safety.violations_found",
459
+ runId: loop.loopId,
460
+ payload: {
461
+ surface: "secret",
462
+ blocked: true,
463
+ violations: secretDecision.violations.map((violation) => violation.match ?? violation.message)
464
+ }
465
+ }));
466
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
467
+ kind: "run.exited",
468
+ runId: loop.loopId,
469
+ payload: createRunExitPayload(secretExitDecision)
470
+ }));
773
471
  }
472
+ const terminationEnvelope = await persistGuardStop(secretExitDecision);
473
+ const finalizedLoop = await finalizeCurrentLoop(secretExitDecision, now());
474
+ const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope };
475
+ await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
476
+ return {
477
+ loop: finalizedWithEnvelope,
478
+ decision: secretExitDecision
479
+ };
774
480
  }
775
- const finalizedLoop = await finalizeCurrentLoop(exitDecision, now(), routingInput);
776
- const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope: finalEnvelope };
777
- await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
778
- return { loop: finalizedWithEnvelope, decision: exitDecision };
779
- }
780
- try {
781
- // Poll once synchronously before the first attempt so any signal written
782
- // before the run started is observed immediately (not after the first poll interval).
783
- const initial = await exitSignalSource.poll(loop.loopId);
784
- observeSignals(initial.signals);
785
- if (initial.diagnostics.length > 0) {
786
- queueControlDiagnostic("exit_signal_invalid", initial.diagnostics);
481
+ // Explicit PolicyPhase state machine — starts at GATHER, advances per attempt
482
+ let currentPhase = "GATHER";
483
+ let phaseRetryCount = 0;
484
+ let lastVerifierFailure = input.resume?.lastVerifierFailure;
485
+ // C1 terminal cleanup: boundary captured immediately before the first retained candidate.
486
+ // Used to restore the workspace if the run terminates without VERIFIED.
487
+ // The invariant is: if this is set, the current workspace contains unverified candidate code.
488
+ let preCandidateBoundary = input.resume?.preCandidateBoundary;
489
+ // Routing economics + hard wall-clock authority. A resumed run inherits the
490
+ // original createdAt boundary rather than receiving a fresh time budget.
491
+ const nowMs = input.nowMs ?? (() => Date.now());
492
+ const persistedRunStartMs = Date.parse(loop.createdAt);
493
+ const runStartMs = resumeLoop && Number.isFinite(persistedRunStartMs)
494
+ ? persistedRunStartMs
495
+ : nowMs();
496
+ let firstDeltaDetected = false;
497
+ let firstDeltaTimestampMs;
498
+ let firstDeltaFilePath;
499
+ let firstDeltaChangeType;
500
+ let preDeltaCostUsd = 0;
501
+ // ── Eight-exit runtime control state ─────────────────────────────────────
502
+ const exitPolicy = createDefaultExitPolicy(loop.budget, input.exitPolicy);
503
+ let activeAttemptController;
504
+ let observedHumanInterrupt;
505
+ let observedExternalEvent;
506
+ let terminationPromise;
507
+ const recentStateHashes = [];
508
+ let consecutiveErrors = 0;
509
+ // governedTurnsUsed starts from the already-completed attempt count so
510
+ // resumed runs count correctly; incremented once per governed attempt.
511
+ let governedTurnsUsed = loop.attempts.length;
512
+ const exitSignalSource = input.exitSignalSource ?? createFileExitSignalSource(runsRoot);
513
+ // Diagnostic writes fire from sync callbacks — track them so the finally
514
+ // block can await completion before the run tears down.
515
+ const pendingControlWrites = [];
516
+ // Non-null when the signal monitor itself fails — rethrown at the next
517
+ // evaluateCheckpoint so the run surfaces the error rather than silently
518
+ // losing cancellation.
519
+ let signalMonitorError;
520
+ function observeSignals(signals) {
521
+ for (const s of signals) {
522
+ if (s.kind === "human_interrupt")
523
+ observedHumanInterrupt = s;
524
+ else if (s.kind === "external_event" && s.externalEvent !== undefined) {
525
+ if (observedExternalEvent !== undefined &&
526
+ observedExternalEvent.disposition !== "satisfied" &&
527
+ s.externalEvent.disposition === "satisfied")
528
+ continue;
529
+ observedExternalEvent = s.externalEvent;
530
+ }
531
+ }
787
532
  }
788
- // Pre-run check — catches signals written before the first attempt starts
789
- const preRunTerminal = await evaluateCheckpoint("pre_run");
790
- if (preRunTerminal !== undefined)
791
- return await terminateRun(preRunTerminal);
792
- while (loop.attempts.length < loop.budget.maxIterations) {
793
- const distilled = distillContext(loop, {
794
- maxRecentAttempts: useCompressedContext ? 1 : (input.maxRecentAttempts ?? 3)
795
- });
796
- useCompressedContext = false;
797
- const attemptStartedAt = now();
798
- const attemptId = makeId("att", idFactory);
799
- const executingAdapter = currentAdapter;
800
- const budgetPreflight = evaluateBudgetPreflight({
801
- promptCharCount: distilled.focus.length + loop.task.objective.length * 3,
802
- attemptCount: loop.attempts.length,
803
- remainingBudgetUsd: distilled.constraints.remainingBudgetUsd,
804
- remainingTokens: distilled.constraints.remainingTokens,
805
- minimumViableTokens: executingAdapter.metadata.budgetPreflight?.minimumViableTokens,
806
- // D2: pass remaining iterations so the preflight can compute a budget-aware cap
807
- // rather than using the old rigid maxUsd * 0.25 that blocked viable attempts.
808
- remainingIterations: distilled.constraints.remainingIterations
809
- });
810
- if (!budgetPreflight.allowed) {
811
- const preflightExitDecision = {
812
- shouldExit: true,
813
- lifecycleState: "budget_exit",
814
- status: "exited",
815
- reason: budgetPreflight.reason
816
- };
817
- if (input.store) {
818
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
819
- kind: "attempt.rejected",
820
- runId: loop.loopId,
821
- attemptIndex: loop.attempts.length + 1,
822
- payload: { reason: budgetPreflight.reason, source: "budget_preflight" }
823
- }));
824
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
825
- kind: "run.exited",
826
- runId: loop.loopId,
827
- payload: createRunExitPayload(preflightExitDecision)
828
- }));
533
+ let parentAbortObserved = false;
534
+ function observeParentAbort() {
535
+ if (parentAbortObserved || input.signal === undefined)
536
+ return;
537
+ parentAbortObserved = true;
538
+ const reason = input.signal.reason instanceof Error
539
+ ? input.signal.reason.message
540
+ : typeof input.signal.reason === "string"
541
+ ? input.signal.reason
542
+ : "parent cancelled governed child run";
543
+ const parentSignal = {
544
+ kind: "human_interrupt",
545
+ schemaVersion: "exit-signal/1",
546
+ runId: loop.loopId,
547
+ reason: `Parent cancellation: ${reason}`,
548
+ requestedAt: now(),
549
+ requestedBy: "parent_swarm"
550
+ };
551
+ observeSignals([parentSignal]);
552
+ activeAttemptController?.abort(input.signal.reason ?? parentSignal);
553
+ }
554
+ if (input.signal?.aborted)
555
+ observeParentAbort();
556
+ else if (input.signal) {
557
+ input.signal.addEventListener("abort", observeParentAbort, { once: true });
558
+ if (input.signal.aborted)
559
+ observeParentAbort();
560
+ }
561
+ function queueControlDiagnostic(code, details) {
562
+ if (!input.store)
563
+ return;
564
+ pendingControlWrites.push(input.store.appendLedger(loop.loopId, makeLedgerEvent({
565
+ kind: "run.diagnostic",
566
+ runId: loop.loopId,
567
+ payload: { code, details }
568
+ })));
569
+ }
570
+ const runController = new AbortController();
571
+ const elapsedBeforeProcessStartMs = Math.max(0, nowMs() - runStartMs);
572
+ const remainingWallClockMs = Math.max(0, exitPolicy.wallClock.maxElapsedMs - elapsedBeforeProcessStartMs);
573
+ const wallTimer = exitPolicy.wallClock.maxElapsedMs > 0
574
+ ? setTimeout(() => {
575
+ activeAttemptController?.abort(new Error("MartinLoop wall-clock exit reached"));
576
+ }, remainingWallClockMs)
577
+ : undefined;
578
+ wallTimer?.unref?.();
579
+ const disposeSignalMonitor = startExitSignalMonitor({
580
+ source: exitSignalSource,
581
+ runId: loop.loopId,
582
+ controller: runController,
583
+ pollIntervalMs: input.exitSignalPollIntervalMs,
584
+ onSignal: (signals) => {
585
+ observeSignals(signals);
586
+ // Only abort the active attempt for terminal signals.
587
+ // A satisfied external event is recorded but must not abort the provider.
588
+ if (signals.some(isTerminalExitSignal)) {
589
+ activeAttemptController?.abort(signals);
829
590
  }
830
- await stopSignalMonitorAndDrainDiagnostics();
831
- // Blocker 2: budget preflight may fire when a prior-attempt candidate is retained.
832
- const finalPreflightDecision = await restoreRetainedCandidateForTerminalExit(preflightExitDecision);
833
- const finalizedLoop = await finalizeCurrentLoop(finalPreflightDecision, now());
834
- await persistLoopRecordIfSupported(input.store, finalizedLoop);
835
- return {
836
- loop: finalizedLoop,
837
- decision: finalPreflightDecision
838
- };
591
+ },
592
+ onDiagnostic: (diagnostics) => {
593
+ queueControlDiagnostic("exit_signal_invalid", diagnostics.map(({ kind, error }) => ({ kind, error })));
594
+ },
595
+ onError: (err) => {
596
+ signalMonitorError = err instanceof Error ? err : new Error(String(err));
597
+ activeAttemptController?.abort(err);
839
598
  }
840
- // GATHER → ADMIT: run admission control before executing
841
- currentPhase = "ADMIT";
842
- // T05: Context Integrity Pre-gate — blocks authority inversion / injection before reasoning
843
- //
844
- // Untrusted "tool output" re-entering the loop is verifier command output from prior
845
- // attempts (e.g. test runners echoing attacker-controlled strings). Pull that text from
846
- // already-persisted verification.completed events so the gate scans what actually
847
- // re-enters subsequent prompts, matching the documented "tool output / test output" scope.
848
- const priorVerifierOutput = loop.events
849
- .filter((event) => event.type === "verification.completed")
850
- .flatMap((event) => event.payload?.steps ?? [])
851
- .map((step) => step.detail)
852
- .filter((detail) => Boolean(detail))
853
- .join("\n---\n");
854
- const contextPrecheck = await runContextIntegrityPrecheck(loop.loopId, loop.attempts.length + 1, runDir(resolveActiveRunsRoot(input.store), loop.loopId), {
855
- userPrompt: distilled.focus,
856
- toolOutput: priorVerifierOutput || undefined,
857
- history: loop.attempts.map(a => a.summary).join("\n")
858
- });
859
- if (contextPrecheck.verdict === "context_poisoning_block") {
860
- currentPhase = "ABORT";
861
- const poisoningExitDecision = {
862
- shouldExit: true,
863
- lifecycleState: "human_escalation",
864
- status: "exited",
865
- reason: "Context Integrity Pre-gate: context poisoning attempt detected.",
866
- failureClass: "safety_leash_blocked",
867
- safetySurface: "context_integrity",
868
- reasonCode: "context_poisoning_blocked"
869
- };
599
+ });
600
+ async function stopSignalMonitorAndDrainDiagnostics() {
601
+ // Fence future polls and in-flight callbacks before taking the final ledger
602
+ // snapshot. Draining alone leaves a window for new writes during signing.
603
+ disposeSignalMonitor();
604
+ runController.abort();
605
+ input.signal?.removeEventListener("abort", observeParentAbort);
606
+ await Promise.all(pendingControlWrites.splice(0));
607
+ }
608
+ // Snapshot of the current run state for eight-exit evaluation
609
+ function currentExitSnapshot(phase, result) {
610
+ return {
611
+ phase,
612
+ evaluatedAt: now(),
613
+ runStartedAtMs: runStartMs,
614
+ nowMs: nowMs(),
615
+ turnsUsed: governedTurnsUsed,
616
+ actualUsd: loop.cost.actualUsd,
617
+ tokensUsed: loop.cost.tokensIn + loop.cost.tokensOut,
618
+ recentStateHashes: [...recentStateHashes],
619
+ consecutiveErrors,
620
+ ...(result !== undefined
621
+ ? {
622
+ result: {
623
+ status: result.status,
624
+ verificationPassed: result.verificationPassed,
625
+ verifierScore: result.verificationPassed ? 1 : 0
626
+ }
627
+ }
628
+ : {}),
629
+ ...(observedHumanInterrupt !== undefined ? { humanInterrupt: observedHumanInterrupt } : {}),
630
+ ...(observedExternalEvent !== undefined ? { externalEvent: observedExternalEvent } : {})
631
+ };
632
+ }
633
+ // Termination selection with deferred disk write.
634
+ //
635
+ // The "run.terminated" ledger event is written at the original timing (here, before
636
+ // terminateRun runs) to preserve ledger ordering. The DISK write of termination.json
637
+ // is deferred to terminateRun() so that it reflects the true final terminal state
638
+ // AFTER retained-candidate cleanup — not a potentially stale pre-cleanup state.
639
+ //
640
+ // First-write-wins semantics on disk are preserved: persistTerminationEnvelope is
641
+ // called exactly once per run, from terminateRun(), after cleanup resolves.
642
+ function selectTerminalEvaluation(evaluation) {
643
+ if (terminationPromise !== undefined)
644
+ return terminationPromise;
645
+ const envelope = {
646
+ schemaVersion: "termination/1",
647
+ class: "operational_exit",
648
+ exit: evaluation
649
+ };
650
+ terminationPromise = (async () => {
870
651
  if (input.store) {
652
+ // Write the "run.terminated" ledger entry at the original timing — this
653
+ // preserves ledger ordering and receipt integrity hashes.
654
+ // The DISK termination.json is deliberately deferred to terminateRun().
871
655
  await input.store.appendLedger(loop.loopId, makeLedgerEvent({
872
- kind: "safety.violations_found",
656
+ kind: "run.terminated",
873
657
  runId: loop.loopId,
874
658
  payload: {
875
- verdict: contextPrecheck.verdict,
876
- signals: contextPrecheck.detectedSignals,
877
- source: "context_integrity_pregate"
659
+ selectedExit: evaluation.primary,
660
+ matchedExits: evaluation.matched,
661
+ policyVersion: evaluation.policyVersion,
662
+ evaluationVersion: evaluation.schemaVersion,
663
+ terminationPhase: evaluation.phase
878
664
  }
879
665
  }));
880
666
  }
881
- await stopSignalMonitorAndDrainDiagnostics();
882
- // Blocker 2: poisoning check may fire between attempts while a candidate is retained.
883
- const finalPoisoningDecision = await restoreRetainedCandidateForTerminalExit(poisoningExitDecision);
884
- const terminationEnvelope = await persistGuardStop(finalPoisoningDecision);
885
- const finalizedLoop = await finalizeCurrentLoop(finalPoisoningDecision, now());
886
- const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope };
887
- await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
888
- return {
889
- loop: finalizedWithEnvelope,
890
- decision: finalPoisoningDecision
891
- };
667
+ return envelope;
668
+ })();
669
+ return terminationPromise;
670
+ }
671
+ async function evaluateCheckpoint(phase, result) {
672
+ // Surface signal-monitor failures immediately rather than silently losing cancellation
673
+ if (signalMonitorError !== undefined)
674
+ throw signalMonitorError;
675
+ const evaluation = evaluateExitPolicy(exitPolicy, currentExitSnapshot(phase, result));
676
+ if (!evaluation.shouldExit)
677
+ return undefined;
678
+ return await selectTerminalEvaluation(evaluation);
679
+ }
680
+ /**
681
+ * Blocker 1 / Blocker 2 — Authoritative retained-candidate terminal restoration.
682
+ *
683
+ * Must be called at EVERY terminal exit that does not produce lifecycleState="completed".
684
+ * If preCandidateBoundary is set, the workspace contains unverified candidate code.
685
+ * This helper:
686
+ * 1. Detects whether retained unverified candidate state exists.
687
+ * 2. Restores the exact pre-candidate boundary (not the per-attempt boundary).
688
+ * 3. Independently inspects the resulting workspace via the rollback outcome.
689
+ * 4. Persists rollback outcome/evidence to the ledger.
690
+ * 5. Clears preCandidateBoundary ONLY after proven restoration.
691
+ * 6. On failure: surfaces workspace_restore_failed and escalates the exit decision.
692
+ *
693
+ * NEVER silently swallows restore errors. NEVER returns a clean-looking exit
694
+ * while residual unverified code may remain in the repository.
695
+ */
696
+ async function restoreRetainedCandidateForTerminalExit(exitDecision) {
697
+ // Step 1: determine whether retained unverified candidate state exists.
698
+ if (!preCandidateBoundary) {
699
+ return exitDecision;
892
700
  }
893
- const admissionDecision = evaluateAttemptPolicy({
894
- request: {
895
- loopId: loop.loopId,
896
- workspaceId: loop.workspaceId,
897
- attemptId,
701
+ // A verified completed exit accepts the retained candidate. Clear rollback
702
+ // authority so the outer finally block cannot discard the successful repair.
703
+ if (exitDecision.lifecycleState === "completed") {
704
+ preCandidateBoundary = undefined;
705
+ return exitDecision;
706
+ }
707
+ const capturedBoundary = preCandidateBoundary;
708
+ const restoredAt = now();
709
+ try {
710
+ // Step 2: restore the exact pre-candidate boundary.
711
+ const rollbackOutcome = await restoreRollbackBoundary({
712
+ repoRoot: task.repoRoot,
713
+ boundary: capturedBoundary,
714
+ restoredAt,
715
+ decision: "DISCARD"
716
+ });
717
+ // Step 3: independently inspect the resulting workspace.
718
+ if (!rollbackOutcome) {
719
+ throw new Error("Workspace restore returned no outcome — repoRoot may be unavailable or boundary invalid.");
720
+ }
721
+ // Step 4: prove the restoration — only success/partial are acceptable outcomes.
722
+ if (rollbackOutcome.status === "failed" || rollbackOutcome.status === "unavailable") {
723
+ throw new Error(rollbackOutcome.error ??
724
+ `Workspace restore returned status "${rollbackOutcome.status}" — pre-candidate state not confirmed.`);
725
+ }
726
+ // Persist rollback outcome evidence.
727
+ if (input.store) {
728
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
729
+ kind: "run.diagnostic",
730
+ runId: loop.loopId,
731
+ payload: {
732
+ code: "terminal_candidate_restore",
733
+ details: {
734
+ status: rollbackOutcome.status,
735
+ restoredFiles: rollbackOutcome.restoredFiles?.length ?? 0,
736
+ deletedFiles: rollbackOutcome.deletedFiles?.length ?? 0,
737
+ capturedAt: capturedBoundary.capturedAt
738
+ }
739
+ }
740
+ }));
741
+ }
742
+ // Step 5/6: clear retained-candidate authority only after proven restoration.
743
+ preCandidateBoundary = undefined;
744
+ return exitDecision;
745
+ }
746
+ catch (restoreError) {
747
+ // Persist the failure evidence.
748
+ if (input.store) {
749
+ try {
750
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
751
+ kind: "run.diagnostic",
752
+ runId: loop.loopId,
753
+ payload: {
754
+ code: "terminal_candidate_restore_failed",
755
+ details: {
756
+ error: restoreError instanceof Error ? restoreError.message : String(restoreError),
757
+ capturedAt: capturedBoundary.capturedAt
758
+ }
759
+ }
760
+ }));
761
+ }
762
+ catch {
763
+ // Ledger write failure is secondary — the restore error is what matters.
764
+ }
765
+ }
766
+ // DO NOT clear preCandidateBoundary — workspace state is unknown.
767
+ // Surface explicit unsafe status; never return a clean-looking exit.
768
+ return {
769
+ ...exitDecision,
770
+ lifecycleState: "human_escalation",
771
+ status: "exited",
772
+ reasonCode: "workspace_restore_failed",
773
+ reason: `MartinLoop retained an unverified candidate but could not restore the pre-candidate workspace: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}. Manual workspace inspection required.`
774
+ };
775
+ }
776
+ }
777
+ async function terminateRun(envelope) {
778
+ if (envelope.class !== "operational_exit") {
779
+ throw new Error("terminateRun: unexpected non-operational envelope");
780
+ }
781
+ const legacyDecision = toLegacyExitDecision(envelope.exit, observedExternalEvent?.disposition);
782
+ let exitDecision = {
783
+ shouldExit: legacyDecision.shouldExit,
784
+ lifecycleState: legacyDecision.lifecycleState,
785
+ status: legacyDecision.status,
786
+ reason: legacyDecision.reason,
787
+ ...(legacyDecision.reasonCode ? { reasonCode: legacyDecision.reasonCode } : {}),
788
+ ...(legacyDecision.failureClass
789
+ ? { failureClass: legacyDecision.failureClass }
790
+ : {})
791
+ };
792
+ if (input.store) {
793
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
794
+ kind: "run.exited",
795
+ runId: loop.loopId,
796
+ payload: createRunExitPayload(exitDecision)
797
+ }));
798
+ }
799
+ const routingInput = {
800
+ runStartMs,
801
+ firstDeltaDetected,
802
+ firstDeltaTimestampMs,
803
+ firstDeltaFilePath,
804
+ firstDeltaChangeType,
805
+ preDeltaCostUsd
806
+ };
807
+ // Drain all pending diagnostic ledger writes BEFORE the cleanup helper may add more.
808
+ await stopSignalMonitorAndDrainDiagnostics();
809
+ // Retained-candidate cleanup runs BEFORE any durable termination envelope is
810
+ // written to disk. This guarantees that termination.json reflects the TRUE
811
+ // final state (original exit OR workspace_restore_failed) in a single atomic
812
+ // first-write-wins write. selectTerminalEvaluation() already memoized the winner
813
+ // in memory; disk persistence happens only here, after cleanup resolves.
814
+ exitDecision = await restoreRetainedCandidateForTerminalExit(exitDecision);
815
+ // Persist the authoritative termination envelope exactly once, after cleanup.
816
+ let finalEnvelope;
817
+ if (exitDecision.reasonCode === "workspace_restore_failed") {
818
+ // Cleanup failed: write guard_stop(workspace_restore_failed) as the sole record.
819
+ if (input.store) {
820
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
821
+ kind: "run.exited",
822
+ runId: loop.loopId,
823
+ payload: createRunExitPayload(exitDecision)
824
+ }));
825
+ }
826
+ finalEnvelope = await persistGuardStop(exitDecision);
827
+ }
828
+ else {
829
+ // Cleanup succeeded (or was not needed): persist the original operational_exit.
830
+ // "run.terminated" was already written by selectTerminalEvaluation() at the
831
+ // original timing (before terminateRun runs) to preserve ledger ordering.
832
+ finalEnvelope = envelope;
833
+ if (input.store) {
834
+ await persistTerminationEnvelope(runDir(runsRoot, loop.loopId), finalEnvelope);
835
+ }
836
+ }
837
+ const finalizedLoop = await finalizeCurrentLoop(exitDecision, now(), routingInput);
838
+ const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope: finalEnvelope };
839
+ await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
840
+ return { loop: finalizedWithEnvelope, decision: exitDecision };
841
+ }
842
+ if (resumeLoop && input.store) {
843
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
844
+ kind: "run.diagnostic",
845
+ runId: loop.loopId,
846
+ payload: {
847
+ code: "run_resumed",
848
+ details: {
849
+ priorAttemptCount: resumeLoop.attempts.length,
850
+ remainingIterations: Math.max(resumeLoop.budget.maxIterations - resumeLoop.attempts.length, 0),
851
+ leaseOwner: executionLease?.lease.owner
852
+ }
853
+ }
854
+ }));
855
+ await persistLoopRecordIfSupported(input.store, loop);
856
+ }
857
+ try {
858
+ // Poll once synchronously before the first attempt so any signal written
859
+ // before the run started is observed immediately (not after the first poll interval).
860
+ const initial = await exitSignalSource.poll(loop.loopId);
861
+ observeSignals(initial.signals);
862
+ if (initial.diagnostics.length > 0) {
863
+ queueControlDiagnostic("exit_signal_invalid", initial.diagnostics);
864
+ }
865
+ // Pre-run check — catches signals written before the first attempt starts
866
+ const preRunTerminal = await evaluateCheckpoint("pre_run");
867
+ if (preRunTerminal !== undefined)
868
+ return await terminateRun(preRunTerminal);
869
+ while (loop.attempts.length < loop.budget.maxIterations) {
870
+ const distilled = distillContext(loop, {
871
+ maxRecentAttempts: useCompressedContext ? 1 : (input.maxRecentAttempts ?? 3)
872
+ });
873
+ useCompressedContext = false;
874
+ const attemptStartedAt = now();
875
+ const attemptId = makeId("att", idFactory);
876
+ const executingAdapter = currentAdapter;
877
+ const budgetPreflight = evaluateBudgetPreflight({
878
+ promptCharCount: distilled.focus.length + loop.task.objective.length * 3,
879
+ attemptCount: loop.attempts.length,
880
+ remainingBudgetUsd: distilled.constraints.remainingBudgetUsd,
881
+ remainingTokens: distilled.constraints.remainingTokens,
882
+ minimumViableTokens: executingAdapter.metadata.budgetPreflight?.minimumViableTokens,
883
+ pricePerMTokenUsd: executingAdapter.metadata.budgetPreflight?.pricePerMTokenUsd,
884
+ // D2: pass remaining iterations so the preflight can compute a budget-aware cap
885
+ // rather than using the old rigid maxUsd * 0.25 that blocked viable attempts.
886
+ remainingIterations: distilled.constraints.remainingIterations
887
+ });
888
+ if (!budgetPreflight.allowed) {
889
+ const preflightExitDecision = {
890
+ shouldExit: true,
891
+ lifecycleState: "budget_exit",
892
+ status: "exited",
893
+ reason: budgetPreflight.reason
894
+ };
895
+ if (input.store) {
896
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
897
+ kind: "attempt.rejected",
898
+ runId: loop.loopId,
899
+ attemptIndex: loop.attempts.length + 1,
900
+ payload: { reason: budgetPreflight.reason, source: "budget_preflight" }
901
+ }));
902
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
903
+ kind: "run.exited",
904
+ runId: loop.loopId,
905
+ payload: createRunExitPayload(preflightExitDecision)
906
+ }));
907
+ }
908
+ await stopSignalMonitorAndDrainDiagnostics();
909
+ // Blocker 2: budget preflight may fire when a prior-attempt candidate is retained.
910
+ const finalPreflightDecision = await restoreRetainedCandidateForTerminalExit(preflightExitDecision);
911
+ const finalizedLoop = await finalizeCurrentLoop(finalPreflightDecision, now());
912
+ await persistLoopRecordIfSupported(input.store, finalizedLoop);
913
+ return {
914
+ loop: finalizedLoop,
915
+ decision: finalPreflightDecision
916
+ };
917
+ }
918
+ // GATHER → ADMIT: run admission control before executing
919
+ currentPhase = "ADMIT";
920
+ // T05: Context Integrity Pre-gate — blocks authority inversion / injection before reasoning
921
+ //
922
+ // Untrusted "tool output" re-entering the loop is verifier command output from prior
923
+ // attempts (e.g. test runners echoing attacker-controlled strings). Pull that text from
924
+ // already-persisted verification.completed events so the gate scans what actually
925
+ // re-enters subsequent prompts, matching the documented "tool output / test output" scope.
926
+ const priorVerifierOutput = loop.events
927
+ .filter((event) => event.type === "verification.completed")
928
+ .flatMap((event) => event.payload?.steps ?? [])
929
+ .map((step) => step.detail)
930
+ .filter((detail) => Boolean(detail))
931
+ .join("\n---\n");
932
+ const contextPrecheck = await runContextIntegrityPrecheck(loop.loopId, loop.attempts.length + 1, runDir(resolveActiveRunsRoot(input.store), loop.loopId), {
933
+ userPrompt: distilled.focus,
934
+ toolOutput: priorVerifierOutput || undefined,
935
+ history: loop.attempts.map(a => a.summary).join("\n")
936
+ });
937
+ if (contextPrecheck.verdict === "context_poisoning_block") {
938
+ currentPhase = "ABORT";
939
+ const poisoningExitDecision = {
940
+ shouldExit: true,
941
+ lifecycleState: "human_escalation",
942
+ status: "exited",
943
+ reason: "Context Integrity Pre-gate: context poisoning attempt detected.",
944
+ failureClass: "safety_leash_blocked",
945
+ safetySurface: "context_integrity",
946
+ reasonCode: "context_poisoning_blocked"
947
+ };
948
+ if (input.store) {
949
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
950
+ kind: "safety.violations_found",
951
+ runId: loop.loopId,
952
+ payload: {
953
+ verdict: contextPrecheck.verdict,
954
+ signals: contextPrecheck.detectedSignals,
955
+ source: "context_integrity_pregate"
956
+ }
957
+ }));
958
+ }
959
+ await stopSignalMonitorAndDrainDiagnostics();
960
+ // Blocker 2: poisoning check may fire between attempts while a candidate is retained.
961
+ const finalPoisoningDecision = await restoreRetainedCandidateForTerminalExit(poisoningExitDecision);
962
+ const terminationEnvelope = await persistGuardStop(finalPoisoningDecision);
963
+ const finalizedLoop = await finalizeCurrentLoop(finalPoisoningDecision, now());
964
+ const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope };
965
+ await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
966
+ return {
967
+ loop: finalizedWithEnvelope,
968
+ decision: finalPoisoningDecision
969
+ };
970
+ }
971
+ const admissionDecision = evaluateAttemptPolicy({
972
+ request: {
973
+ loopId: loop.loopId,
974
+ workspaceId: loop.workspaceId,
975
+ attemptId,
976
+ context: {
977
+ taskTitle: loop.task.title,
978
+ objective: loop.task.objective,
979
+ verificationPlan: loop.task.verificationPlan,
980
+ ...(loop.task.verificationStack ? { verificationStack: loop.task.verificationStack } : {}),
981
+ ...(loop.task.definitionOfDonePreSatisfied !== undefined
982
+ ? { definitionOfDonePreSatisfied: loop.task.definitionOfDonePreSatisfied }
983
+ : {}),
984
+ ...(loop.task.repoRoot ? { repoRoot: loop.task.repoRoot } : {}),
985
+ ...(loop.task.allowedPaths ? { allowedPaths: loop.task.allowedPaths } : {}),
986
+ ...(loop.task.deniedPaths ? { deniedPaths: loop.task.deniedPaths } : {}),
987
+ ...(loop.task.acceptanceCriteria ? { acceptanceCriteria: loop.task.acceptanceCriteria } : {}),
988
+ ...(loop.task.executionProfile ? { executionProfile: loop.task.executionProfile } : {}),
989
+ ...(loop.task.allowedNetworkDomains
990
+ ? { allowedNetworkDomains: loop.task.allowedNetworkDomains }
991
+ : {}),
992
+ ...(loop.task.approvalPolicy ? { approvalPolicy: loop.task.approvalPolicy } : {}),
993
+ agentExecutionIntent: loop.task.agentExecutionIntent ?? DEFAULT_AGENT_EXECUTION_INTENT,
994
+ providerExecutionTimeoutMs: normalizeProviderExecutionTimeoutMs(loop.task.providerExecutionTimeoutMs),
995
+ focus: distilled.focus,
996
+ remainingBudgetUsd: distilled.constraints.remainingBudgetUsd,
997
+ remainingIterations: distilled.constraints.remainingIterations,
998
+ remainingTokens: distilled.constraints.remainingTokens
999
+ },
1000
+ previousAttempts: loop.attempts
1001
+ },
1002
+ projectedUsd: budgetPreflight.estimate.estimatedAttemptCostUsd
1003
+ });
1004
+ if (!admissionDecision.allowed) {
1005
+ const exitReason = admissionDecision.reason;
1006
+ // Fix false budget_exit: trajectory circuit-break is diminishing_returns, not budget_exit.
1007
+ // A trajectory/no-progress stop is not a resource exhaustion.
1008
+ const admissionLifecycleState = admissionDecision.recommendedIntervention === "escalate_human"
1009
+ ? "human_escalation"
1010
+ : admissionDecision.circuitBreakDecision?.shouldStop
1011
+ ? "diminishing_returns"
1012
+ : "budget_exit";
1013
+ const admissionExitDecision = {
1014
+ shouldExit: true,
1015
+ lifecycleState: admissionLifecycleState,
1016
+ status: "exited",
1017
+ reason: exitReason
1018
+ };
1019
+ if (input.store) {
1020
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1021
+ kind: "attempt.rejected",
1022
+ runId: loop.loopId,
1023
+ attemptIndex: loop.attempts.length + 1,
1024
+ payload: { reason: admissionDecision.reason }
1025
+ }));
1026
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1027
+ kind: "run.exited",
1028
+ runId: loop.loopId,
1029
+ payload: createRunExitPayload(admissionExitDecision)
1030
+ }));
1031
+ }
1032
+ await stopSignalMonitorAndDrainDiagnostics();
1033
+ // Blocker 2: admission may be denied between attempts while a candidate is retained.
1034
+ const finalAdmissionDecision = await restoreRetainedCandidateForTerminalExit(admissionExitDecision);
1035
+ const finalizedLoop = await finalizeCurrentLoop(finalAdmissionDecision, now());
1036
+ await persistLoopRecordIfSupported(input.store, finalizedLoop);
1037
+ return {
1038
+ loop: finalizedLoop,
1039
+ decision: finalAdmissionDecision
1040
+ };
1041
+ }
1042
+ if (input.store) {
1043
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1044
+ kind: "attempt.admitted",
1045
+ runId: loop.loopId,
1046
+ attemptIndex: loop.attempts.length + 1,
1047
+ payload: {
1048
+ attemptId,
1049
+ adapterId: executingAdapter.adapterId,
1050
+ providerId: executingAdapter.metadata.providerId,
1051
+ model: executingAdapter.metadata.model,
1052
+ transport: getAdapterTransport(executingAdapter)
1053
+ }
1054
+ }));
1055
+ }
1056
+ // ADMIT → PATCH
1057
+ currentPhase = "PATCH";
1058
+ loop = appendLoopEvent(loop, {
1059
+ type: "attempt.started",
1060
+ lifecycleState: "running",
1061
+ payload: {
1062
+ attemptId,
1063
+ adapterId: executingAdapter.adapterId,
1064
+ model: executingAdapter.metadata.model,
1065
+ policyPhase: currentPhase
1066
+ }
1067
+ }, { now: attemptStartedAt, idFactory });
1068
+ const request = {
1069
+ loopId: loop.loopId,
1070
+ workspaceId: loop.workspaceId,
1071
+ attemptId,
898
1072
  context: {
899
1073
  taskTitle: loop.task.title,
900
1074
  objective: loop.task.objective,
901
1075
  verificationPlan: loop.task.verificationPlan,
902
1076
  ...(loop.task.verificationStack ? { verificationStack: loop.task.verificationStack } : {}),
1077
+ ...(loop.task.mutationMode ? { mutationMode: loop.task.mutationMode } : {}),
903
1078
  ...(loop.task.definitionOfDonePreSatisfied !== undefined
904
1079
  ? { definitionOfDonePreSatisfied: loop.task.definitionOfDonePreSatisfied }
905
1080
  : {}),
906
1081
  ...(loop.task.repoRoot ? { repoRoot: loop.task.repoRoot } : {}),
1082
+ ...(input.store?.runsRoot ? { runsRoot: input.store.runsRoot } : {}),
907
1083
  ...(loop.task.allowedPaths ? { allowedPaths: loop.task.allowedPaths } : {}),
908
1084
  ...(loop.task.deniedPaths ? { deniedPaths: loop.task.deniedPaths } : {}),
909
1085
  ...(loop.task.acceptanceCriteria ? { acceptanceCriteria: loop.task.acceptanceCriteria } : {}),
@@ -917,918 +1093,805 @@ export async function runMartin(input) {
917
1093
  focus: distilled.focus,
918
1094
  remainingBudgetUsd: distilled.constraints.remainingBudgetUsd,
919
1095
  remainingIterations: distilled.constraints.remainingIterations,
920
- remainingTokens: distilled.constraints.remainingTokens
1096
+ remainingTokens: distilled.constraints.remainingTokens,
1097
+ ...(input.verificationExecutionOwner
1098
+ ? { verificationExecutionOwner: input.verificationExecutionOwner }
1099
+ : {}),
1100
+ ...(lastVerifierFailure ? { lastVerifierFailure } : {}),
1101
+ // C1: tell the adapter whether the previous candidate was retained so it can
1102
+ // repair rather than rebuild.
1103
+ ...(loop.attempts.at(-1)?.candidateRetained
1104
+ ? { previousCandidateRetained: true }
1105
+ : {})
921
1106
  },
922
- previousAttempts: loop.attempts
923
- },
924
- projectedUsd: budgetPreflight.estimate.estimatedAttemptCostUsd
925
- });
926
- if (!admissionDecision.allowed) {
927
- const exitReason = admissionDecision.reason;
928
- // Fix false budget_exit: trajectory circuit-break is diminishing_returns, not budget_exit.
929
- // A trajectory/no-progress stop is not a resource exhaustion.
930
- const admissionLifecycleState = admissionDecision.recommendedIntervention === "escalate_human"
931
- ? "human_escalation"
932
- : admissionDecision.circuitBreakDecision?.shouldStop
933
- ? "diminishing_returns"
934
- : "budget_exit";
935
- const admissionExitDecision = {
936
- shouldExit: true,
937
- lifecycleState: admissionLifecycleState,
938
- status: "exited",
939
- reason: exitReason
940
- };
941
- if (input.store) {
942
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
943
- kind: "attempt.rejected",
944
- runId: loop.loopId,
945
- attemptIndex: loop.attempts.length + 1,
946
- payload: { reason: admissionDecision.reason }
947
- }));
948
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
949
- kind: "run.exited",
950
- runId: loop.loopId,
951
- payload: createRunExitPayload(admissionExitDecision)
952
- }));
953
- }
954
- await stopSignalMonitorAndDrainDiagnostics();
955
- // Blocker 2: admission may be denied between attempts while a candidate is retained.
956
- const finalAdmissionDecision = await restoreRetainedCandidateForTerminalExit(admissionExitDecision);
957
- const finalizedLoop = await finalizeCurrentLoop(finalAdmissionDecision, now());
958
- await persistLoopRecordIfSupported(input.store, finalizedLoop);
959
- return {
960
- loop: finalizedLoop,
961
- decision: finalAdmissionDecision
962
- };
963
- }
964
- if (input.store) {
965
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
966
- kind: "attempt.admitted",
967
- runId: loop.loopId,
968
- attemptIndex: loop.attempts.length + 1,
969
- payload: {
970
- attemptId,
971
- adapterId: executingAdapter.adapterId,
972
- providerId: executingAdapter.metadata.providerId,
973
- model: executingAdapter.metadata.model,
974
- transport: getAdapterTransport(executingAdapter)
975
- }
976
- }));
977
- }
978
- // ADMIT → PATCH
979
- currentPhase = "PATCH";
980
- loop = appendLoopEvent(loop, {
981
- type: "attempt.started",
982
- lifecycleState: "running",
983
- payload: {
984
- attemptId,
985
- adapterId: executingAdapter.adapterId,
986
- model: executingAdapter.metadata.model,
987
- policyPhase: currentPhase
988
- }
989
- }, { now: attemptStartedAt, idFactory });
990
- const request = {
991
- loopId: loop.loopId,
992
- workspaceId: loop.workspaceId,
993
- attemptId,
994
- context: {
995
- taskTitle: loop.task.title,
996
- objective: loop.task.objective,
997
- verificationPlan: loop.task.verificationPlan,
998
- ...(loop.task.verificationStack ? { verificationStack: loop.task.verificationStack } : {}),
999
- ...(loop.task.mutationMode ? { mutationMode: loop.task.mutationMode } : {}),
1000
- ...(loop.task.definitionOfDonePreSatisfied !== undefined
1001
- ? { definitionOfDonePreSatisfied: loop.task.definitionOfDonePreSatisfied }
1002
- : {}),
1003
- ...(loop.task.repoRoot ? { repoRoot: loop.task.repoRoot } : {}),
1004
- ...(input.store?.runsRoot ? { runsRoot: input.store.runsRoot } : {}),
1005
- ...(loop.task.allowedPaths ? { allowedPaths: loop.task.allowedPaths } : {}),
1006
- ...(loop.task.deniedPaths ? { deniedPaths: loop.task.deniedPaths } : {}),
1007
- ...(loop.task.acceptanceCriteria ? { acceptanceCriteria: loop.task.acceptanceCriteria } : {}),
1008
- ...(loop.task.executionProfile ? { executionProfile: loop.task.executionProfile } : {}),
1009
- ...(loop.task.allowedNetworkDomains
1010
- ? { allowedNetworkDomains: loop.task.allowedNetworkDomains }
1011
- : {}),
1012
- ...(loop.task.approvalPolicy ? { approvalPolicy: loop.task.approvalPolicy } : {}),
1013
- agentExecutionIntent: loop.task.agentExecutionIntent ?? DEFAULT_AGENT_EXECUTION_INTENT,
1014
- providerExecutionTimeoutMs: normalizeProviderExecutionTimeoutMs(loop.task.providerExecutionTimeoutMs),
1015
- focus: distilled.focus,
1016
- remainingBudgetUsd: distilled.constraints.remainingBudgetUsd,
1017
- remainingIterations: distilled.constraints.remainingIterations,
1018
- remainingTokens: distilled.constraints.remainingTokens,
1019
- ...(input.verificationExecutionOwner
1020
- ? { verificationExecutionOwner: input.verificationExecutionOwner }
1021
- : {}),
1022
- ...(lastVerifierFailure ? { lastVerifierFailure } : {}),
1023
- // C1: tell the adapter whether the previous candidate was retained so it can
1024
- // repair rather than rebuild.
1025
- ...(loop.attempts.at(-1)?.candidateRetained
1026
- ? { previousCandidateRetained: true }
1027
- : {})
1028
- },
1029
- previousAttempts: loop.attempts,
1030
- ...(input.observedUsageGovernor ? { observedUsageGovernor: input.observedUsageGovernor } : {})
1031
- };
1032
- const tracksWorkspaceMutations = request.context.repoRoot !== undefined &&
1033
- executingAdapter.metadata.capabilities?.workspaceMutations !== false;
1034
- // Pre-attempt: check signals that arrived during guard evaluation
1035
- const preAttemptTerminal = await evaluateCheckpoint("pre_attempt");
1036
- if (preAttemptTerminal !== undefined)
1037
- return await terminateRun(preAttemptTerminal);
1038
- activeAttemptController = new AbortController();
1039
- governedTurnsUsed += 1;
1040
- const rollbackBoundary = tracksWorkspaceMutations
1041
- ? await captureRollbackBoundary({
1042
- repoRoot: request.context.repoRoot,
1043
- capturedAt: attemptStartedAt
1044
- })
1045
- : undefined;
1046
- // Persist attempt.started state before blocking on provider execution.
1047
- // If the MCP client transport times out (default 300 s), a status query against
1048
- // the stable run ID must see this attempt as active rather than observing
1049
- // attempts=0 with no in-progress state — which falsely implies nothing happened
1050
- // and causes callers to start a concurrent recovery run before the backend finishes.
1051
- await persistLoopRecordIfSupported(input.store, loop);
1052
- let result;
1053
- try {
1054
- result = await executingAdapter.execute({
1055
- ...request,
1056
- signal: activeAttemptController.signal
1057
- });
1058
- }
1059
- catch (execError) {
1060
- // Convert an abort (wall-clock or signal-driven) into the correct exit
1061
- // rather than letting it propagate as an unhandled exception.
1062
- const abortTerminal = await evaluateCheckpoint("post_attempt");
1063
- if (abortTerminal !== undefined)
1064
- return await terminateRun(abortTerminal);
1065
- // Blocker 2 (exception path): provider threw a genuine non-abort exception.
1066
- // Restore any retained candidate BEFORE re-throwing so the workspace is
1067
- // never left with unverified code after an exception escape.
1068
- const exceptionBaseDecision = {
1069
- shouldExit: true,
1070
- lifecycleState: "human_escalation",
1071
- status: "exited",
1072
- reason: "Provider threw an unexpected exception.",
1073
- reasonCode: "provider_exception"
1107
+ previousAttempts: loop.attempts,
1108
+ ...(input.observedUsageGovernor ? { observedUsageGovernor: input.observedUsageGovernor } : {})
1074
1109
  };
1075
- let cleanedExceptionDecision = exceptionBaseDecision;
1110
+ const tracksWorkspaceMutations = request.context.repoRoot !== undefined &&
1111
+ executingAdapter.metadata.capabilities?.workspaceMutations !== false;
1112
+ // Pre-attempt: check signals that arrived during guard evaluation
1113
+ const preAttemptTerminal = await evaluateCheckpoint("pre_attempt");
1114
+ if (preAttemptTerminal !== undefined)
1115
+ return await terminateRun(preAttemptTerminal);
1116
+ activeAttemptController = new AbortController();
1117
+ governedTurnsUsed += 1;
1118
+ const rollbackBoundary = tracksWorkspaceMutations
1119
+ ? await captureRollbackBoundary({
1120
+ repoRoot: request.context.repoRoot,
1121
+ capturedAt: attemptStartedAt
1122
+ })
1123
+ : undefined;
1124
+ // Persist attempt.started state before blocking on provider execution.
1125
+ // If the MCP client transport times out (default 300 s), a status query against
1126
+ // the stable run ID must see this attempt as active rather than observing
1127
+ // attempts=0 with no in-progress state — which falsely implies nothing happened
1128
+ // and causes callers to start a concurrent recovery run before the backend finishes.
1129
+ await persistLoopRecordIfSupported(input.store, loop);
1130
+ let result;
1076
1131
  try {
1077
- cleanedExceptionDecision = await restoreRetainedCandidateForTerminalExit(exceptionBaseDecision);
1078
- }
1079
- catch (cleanupError) {
1080
- queueControlDiagnostic("terminal_restore_on_provider_exception_failed", {
1081
- error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
1132
+ result = await executingAdapter.execute({
1133
+ ...request,
1134
+ signal: activeAttemptController.signal
1082
1135
  });
1083
1136
  }
1084
- if (cleanedExceptionDecision.reasonCode === "workspace_restore_failed") {
1085
- // Cleanup failed: persist workspace_restore_failed as the terminal authority
1086
- // so the operator knows the workspace state is unsafe. Then surface both errors.
1137
+ catch (execError) {
1138
+ // Convert an abort (wall-clock or signal-driven) into the correct exit
1139
+ // rather than letting it propagate as an unhandled exception.
1140
+ const abortTerminal = await evaluateCheckpoint("post_attempt");
1141
+ if (abortTerminal !== undefined)
1142
+ return await terminateRun(abortTerminal);
1143
+ // Blocker 2 (exception path): provider threw a genuine non-abort exception.
1144
+ // Restore any retained candidate BEFORE re-throwing so the workspace is
1145
+ // never left with unverified code after an exception escape.
1146
+ const exceptionBaseDecision = {
1147
+ shouldExit: true,
1148
+ lifecycleState: "human_escalation",
1149
+ status: "exited",
1150
+ reason: "Provider threw an unexpected exception.",
1151
+ reasonCode: "provider_exception"
1152
+ };
1153
+ let cleanedExceptionDecision = exceptionBaseDecision;
1087
1154
  try {
1088
- await persistGuardStop(cleanedExceptionDecision);
1155
+ cleanedExceptionDecision = await restoreRetainedCandidateForTerminalExit(exceptionBaseDecision);
1089
1156
  }
1090
- catch { /* persist best-effort */ }
1091
- const wsError = new Error(cleanedExceptionDecision.reason, { cause: execError instanceof Error ? execError : new Error(String(execError)) });
1092
- throw wsError;
1093
- }
1094
- // Cleanup succeeded — rethrow the original provider error.
1095
- throw execError;
1096
- }
1097
- finally {
1098
- activeAttemptController = undefined;
1099
- }
1100
- const attemptCompletedAt = now();
1101
- const compiledContext = compilePromptPacket(request);
1102
- const expectedVerifierBinding = resolveExpectedVerifierBinding(request);
1103
- const verifierConfigured = expectedVerifierBinding.commands.length > 0;
1104
- const verification = normalizeVerificationOutcome(result, expectedVerifierBinding);
1105
- // PATCH → VERIFY
1106
- currentPhase = "VERIFY";
1107
- let failure = result.status === "failed"
1108
- ? classifyFailure({ attempts: loop.attempts, result })
1109
- : undefined;
1110
- const currentAttemptIndex = loop.attempts.length + 1;
1111
- // Progress evidence is computed below from effectiveVerification (which includes
1112
- // host-run results). Initialize from the normalized (not raw) verification so
1113
- // that initial values are already binding-checked. Back-patched with the final
1114
- // authoritative values after effectiveVerification is available (Blocker 4).
1115
- let currentVerifierScore = verification.passed ? 1 : 0;
1116
- let currentFailingVerifierStepCount;
1117
- const attempt = {
1118
- attemptId,
1119
- index: currentAttemptIndex,
1120
- adapterId: executingAdapter.adapterId,
1121
- ...(executingAdapter.metadata.model ? { model: executingAdapter.metadata.model } : {}),
1122
- startedAt: attemptStartedAt,
1123
- completedAt: attemptCompletedAt,
1124
- summary: result.summary,
1125
- ...(failure?.failureClass ? { failureClass: failure.failureClass } : {}),
1126
- ...(failure?.recommendedIntervention
1127
- ? { intervention: failure.recommendedIntervention }
1128
- : {}),
1129
- ...(failure?.diagnosticHint ? { diagnosticHint: failure.diagnosticHint } : {}),
1130
- // C2: real progress signals — back-patched from effectiveVerification below
1131
- verifierScore: currentVerifierScore
1132
- // C1: candidateRetained is set below after rollback decision is made
1133
- };
1134
- loop = {
1135
- ...loop,
1136
- attempts: [...loop.attempts, attempt],
1137
- cost: {
1138
- actualUsd: roundUsd(loop.cost.actualUsd + getUsageUsd(result.usage)),
1139
- avoidedUsd: loop.cost.avoidedUsd,
1140
- tokensIn: loop.cost.tokensIn + result.usage.tokensIn,
1141
- tokensOut: loop.cost.tokensOut + result.usage.tokensOut,
1142
- ...(result.usage.estimatedUsd !== undefined
1143
- ? {
1144
- estimatedUsd: roundUsd((loop.cost.estimatedUsd ?? loop.cost.actualUsd) + result.usage.estimatedUsd)
1157
+ catch (cleanupError) {
1158
+ queueControlDiagnostic("terminal_restore_on_provider_exception_failed", {
1159
+ error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
1160
+ });
1161
+ }
1162
+ if (cleanedExceptionDecision.reasonCode === "workspace_restore_failed") {
1163
+ // Cleanup failed: persist workspace_restore_failed as the terminal authority
1164
+ // so the operator knows the workspace state is unsafe. Then surface both errors.
1165
+ try {
1166
+ await persistGuardStop(cleanedExceptionDecision);
1145
1167
  }
1146
- : {}),
1147
- provenance: mergeCostProvenance(loop.cost.provenance, getUsageProvenance(result.usage)),
1148
- ...(result.usage.providerSettlement
1149
- ? { providerSettlement: result.usage.providerSettlement }
1150
- : {}),
1151
- ...(loop.cost.savingsBaseline
1152
- ? { savingsBaseline: loop.cost.savingsBaseline }
1153
- : {})
1154
- },
1155
- updatedAt: attemptCompletedAt
1156
- };
1157
- // ── Eight-exit state tracking ─────────────────────────────────────────
1158
- consecutiveErrors = result.status === "failed" ? consecutiveErrors + 1 : 0;
1159
- const stateHash = hashProgressState({
1160
- summary: result.summary,
1161
- verificationPassed: verification.passed,
1162
- status: result.status
1163
- });
1164
- if (recentStateHashes.length >= exitPolicy.progress.windowSize)
1165
- recentStateHashes.shift();
1166
- recentStateHashes.push(stateHash);
1167
- loop = appendLoopEvent(loop, {
1168
- type: "attempt.completed",
1169
- lifecycleState: "running",
1170
- payload: { attemptId, status: result.status, summary: result.summary }
1171
- }, { now: attemptCompletedAt, idFactory });
1172
- // post_attempt check is deferred until after verification.completed and all
1173
- // per-attempt ledger writes — see the check just before before_retry below.
1174
- const previousVerifierScore = getLastVerifierScore(loop);
1175
- if (failure) {
1176
- if (failure.recommendedIntervention === "compress_context") {
1177
- useCompressedContext = true;
1178
- }
1179
- let adapterSwitched = false;
1180
- if (failure.recommendedIntervention === "switch_adapter") {
1181
- const nextAdapter = adapterChain[currentAdapterIndex + 1];
1182
- if (nextAdapter) {
1183
- currentAdapterIndex += 1;
1184
- currentAdapter = nextAdapter;
1185
- adapterSwitched = true;
1168
+ catch { /* persist best-effort */ }
1169
+ const wsError = new Error(cleanedExceptionDecision.reason, { cause: execError instanceof Error ? execError : new Error(String(execError)) });
1170
+ throw wsError;
1186
1171
  }
1172
+ // Cleanup succeeded — rethrow the original provider error.
1173
+ throw execError;
1187
1174
  }
1175
+ finally {
1176
+ activeAttemptController = undefined;
1177
+ }
1178
+ const attemptCompletedAt = now();
1179
+ const compiledContext = compilePromptPacket(request);
1180
+ const expectedVerifierBinding = resolveExpectedVerifierBinding(request);
1181
+ const verifierConfigured = expectedVerifierBinding.commands.length > 0;
1182
+ const verification = normalizeVerificationOutcome(result, expectedVerifierBinding);
1183
+ // PATCH → VERIFY
1184
+ currentPhase = "VERIFY";
1185
+ let failure = result.status === "failed"
1186
+ ? classifyFailure({ attempts: loop.attempts, result })
1187
+ : undefined;
1188
+ const currentAttemptIndex = loop.attempts.length + 1;
1189
+ // Progress evidence is computed below from effectiveVerification (which includes
1190
+ // host-run results). Initialize from the normalized (not raw) verification so
1191
+ // that initial values are already binding-checked. Back-patched with the final
1192
+ // authoritative values after effectiveVerification is available (Blocker 4).
1193
+ let currentVerifierScore = verification.passed ? 1 : 0;
1194
+ let currentFailingVerifierStepCount;
1195
+ const attempt = {
1196
+ attemptId,
1197
+ index: currentAttemptIndex,
1198
+ adapterId: executingAdapter.adapterId,
1199
+ ...(executingAdapter.metadata.model ? { model: executingAdapter.metadata.model } : {}),
1200
+ startedAt: attemptStartedAt,
1201
+ completedAt: attemptCompletedAt,
1202
+ summary: result.summary,
1203
+ ...(failure?.failureClass ? { failureClass: failure.failureClass } : {}),
1204
+ ...(failure?.recommendedIntervention
1205
+ ? { intervention: failure.recommendedIntervention }
1206
+ : {}),
1207
+ ...(failure?.diagnosticHint ? { diagnosticHint: failure.diagnosticHint } : {}),
1208
+ // C2: real progress signals — back-patched from effectiveVerification below
1209
+ verifierScore: currentVerifierScore
1210
+ // C1: candidateRetained is set below after rollback decision is made
1211
+ };
1212
+ loop = {
1213
+ ...loop,
1214
+ attempts: [...loop.attempts, attempt],
1215
+ cost: {
1216
+ actualUsd: roundUsd(loop.cost.actualUsd + getUsageUsd(result.usage)),
1217
+ avoidedUsd: loop.cost.avoidedUsd,
1218
+ tokensIn: loop.cost.tokensIn + result.usage.tokensIn,
1219
+ tokensOut: loop.cost.tokensOut + result.usage.tokensOut,
1220
+ ...(result.usage.estimatedUsd !== undefined
1221
+ ? {
1222
+ estimatedUsd: roundUsd((loop.cost.estimatedUsd ?? loop.cost.actualUsd) + result.usage.estimatedUsd)
1223
+ }
1224
+ : {}),
1225
+ provenance: mergeCostProvenance(loop.cost.provenance, getUsageProvenance(result.usage)),
1226
+ ...(result.usage.providerSettlement
1227
+ ? { providerSettlement: result.usage.providerSettlement }
1228
+ : {}),
1229
+ ...(loop.cost.savingsBaseline
1230
+ ? { savingsBaseline: loop.cost.savingsBaseline }
1231
+ : {})
1232
+ },
1233
+ updatedAt: attemptCompletedAt
1234
+ };
1235
+ // ── Eight-exit state tracking ─────────────────────────────────────────
1236
+ consecutiveErrors = result.status === "failed" ? consecutiveErrors + 1 : 0;
1237
+ const stateHash = hashProgressState({
1238
+ summary: result.summary,
1239
+ verificationPassed: verification.passed,
1240
+ status: result.status
1241
+ });
1242
+ if (recentStateHashes.length >= exitPolicy.progress.windowSize)
1243
+ recentStateHashes.shift();
1244
+ recentStateHashes.push(stateHash);
1188
1245
  loop = appendLoopEvent(loop, {
1189
- type: "failure.classified",
1190
- lifecycleState: "running",
1191
- payload: {
1192
- attemptId,
1193
- failureClass: failure.failureClass,
1194
- rationale: failure.rationale
1195
- }
1196
- }, { now: attemptCompletedAt, idFactory });
1197
- loop = appendLoopEvent(loop, {
1198
- type: "intervention.selected",
1246
+ type: "attempt.completed",
1199
1247
  lifecycleState: "running",
1200
- payload: { attemptId, intervention: failure.recommendedIntervention }
1248
+ payload: { attemptId, status: result.status, summary: result.summary }
1201
1249
  }, { now: attemptCompletedAt, idFactory });
1202
- if (adapterSwitched) {
1250
+ // post_attempt check is deferred until after verification.completed and all
1251
+ // per-attempt ledger writes — see the check just before before_retry below.
1252
+ const previousVerifierScore = getLastVerifierScore(loop);
1253
+ if (failure) {
1254
+ if (failure.recommendedIntervention === "compress_context") {
1255
+ useCompressedContext = true;
1256
+ }
1257
+ let adapterSwitched = false;
1258
+ if (failure.recommendedIntervention === "switch_adapter") {
1259
+ const nextAdapter = adapterChain[currentAdapterIndex + 1];
1260
+ if (nextAdapter) {
1261
+ currentAdapterIndex += 1;
1262
+ currentAdapter = nextAdapter;
1263
+ adapterSwitched = true;
1264
+ }
1265
+ }
1203
1266
  loop = appendLoopEvent(loop, {
1204
- type: "intervention.selected",
1267
+ type: "failure.classified",
1205
1268
  lifecycleState: "running",
1206
1269
  payload: {
1207
1270
  attemptId,
1208
- intervention: "switch_adapter",
1209
- nextAdapterId: currentAdapter.adapterId,
1210
- transport: getAdapterTransport(currentAdapter)
1271
+ failureClass: failure.failureClass,
1272
+ rationale: failure.rationale
1211
1273
  }
1212
1274
  }, { now: attemptCompletedAt, idFactory });
1275
+ loop = appendLoopEvent(loop, {
1276
+ type: "intervention.selected",
1277
+ lifecycleState: "running",
1278
+ payload: { attemptId, intervention: failure.recommendedIntervention }
1279
+ }, { now: attemptCompletedAt, idFactory });
1280
+ if (adapterSwitched) {
1281
+ loop = appendLoopEvent(loop, {
1282
+ type: "intervention.selected",
1283
+ lifecycleState: "running",
1284
+ payload: {
1285
+ attemptId,
1286
+ intervention: "switch_adapter",
1287
+ nextAdapterId: currentAdapter.adapterId,
1288
+ transport: getAdapterTransport(currentAdapter)
1289
+ }
1290
+ }, { now: attemptCompletedAt, idFactory });
1291
+ }
1213
1292
  }
1214
- }
1215
- const costState = evaluateCostGovernor({
1216
- budget: loop.budget,
1217
- cost: loop.cost,
1218
- attemptsUsed: loop.attempts.length
1219
- });
1220
- loop = appendLoopEvent(loop, {
1221
- type: "budget.updated",
1222
- lifecycleState: costState.shouldStop ? "budget_exit" : "running",
1223
- payload: {
1224
- actualUsd: loop.cost.actualUsd,
1225
- remainingBudgetUsd: costState.remainingBudgetUsd,
1226
- pressure: costState.pressure,
1227
- provenance: loop.cost.provenance
1228
- }
1229
- }, { now: now(), idFactory });
1230
- if (input.store) {
1231
- const settlement = createBudgetSettlement({
1232
- runId: loop.loopId,
1233
- attemptIndex: currentAttemptIndex,
1234
- usage: result.usage,
1235
- estimate: budgetPreflight.estimate,
1236
- settledAt: attemptCompletedAt
1293
+ const costState = evaluateCostGovernor({
1294
+ budget: loop.budget,
1295
+ cost: loop.cost,
1296
+ attemptsUsed: loop.attempts.length
1237
1297
  });
1238
- await input.store.writeAttemptArtifacts(loop.loopId, currentAttemptIndex, {
1239
- compiledContext,
1240
- verification,
1241
- ...(rollbackBoundary ? { rollbackBoundary } : {})
1242
- });
1243
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1244
- kind: "patch.generated",
1245
- runId: loop.loopId,
1246
- attemptIndex: currentAttemptIndex,
1247
- payload: { status: result.status, summary: result.summary }
1248
- }));
1249
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1250
- kind: "budget.settled",
1251
- runId: loop.loopId,
1252
- attemptIndex: currentAttemptIndex,
1298
+ loop = appendLoopEvent(loop, {
1299
+ type: "budget.updated",
1300
+ lifecycleState: costState.shouldStop ? "budget_exit" : "running",
1253
1301
  payload: {
1254
- actualUsd: settlement.totalActualUsd,
1255
- estimatedUsd: result.usage.estimatedUsd,
1256
- tokensIn: result.usage.tokensIn,
1257
- tokensOut: result.usage.tokensOut,
1258
- cachedInputTokens: result.usage.cachedInputTokens,
1259
- reasoningTokensOut: result.usage.reasoningTokensOut,
1260
- provenance: getUsageProvenance(result.usage),
1261
- transport: getAdapterTransport(executingAdapter),
1262
- providerId: executingAdapter.metadata.providerId,
1263
- model: executingAdapter.metadata.model,
1264
- providerSettlement: result.usage.providerSettlement,
1265
- patchCost: settlement.patchCost,
1266
- verificationCost: settlement.verificationCost,
1267
- varianceUsd: settlement.varianceUsd,
1268
- preflightEstimateUsd: settlement.preflightEstimateUsd
1302
+ actualUsd: loop.cost.actualUsd,
1303
+ remainingBudgetUsd: costState.remainingBudgetUsd,
1304
+ pressure: costState.pressure,
1305
+ provenance: loop.cost.provenance
1269
1306
  }
1270
- }));
1271
- }
1272
- let changedFiles = [];
1273
- let ignoredChanges = { created: [], modified: [], deleted: [] };
1274
- let escapingLinks = [];
1275
- let weakenedTestFiles = [];
1276
- try {
1277
- changedFiles = tracksWorkspaceMutations
1278
- ? resolveChangedFiles(result, request.context.repoRoot, rollbackBoundary)
1279
- : [];
1280
- ignoredChanges = tracksWorkspaceMutations
1281
- ? listIgnoredFileChangesSinceBoundary({ repoRoot: request.context.repoRoot, boundary: rollbackBoundary })
1282
- : ignoredChanges;
1283
- escapingLinks =
1284
- tracksWorkspaceMutations && request.context.repoRoot
1285
- ? findEscapingLinks(request.context.repoRoot, [...changedFiles, ...ignoredChanges.created])
1307
+ }, { now: now(), idFactory });
1308
+ if (input.store) {
1309
+ const settlement = createBudgetSettlement({
1310
+ runId: loop.loopId,
1311
+ attemptIndex: currentAttemptIndex,
1312
+ usage: result.usage,
1313
+ estimate: budgetPreflight.estimate,
1314
+ settledAt: attemptCompletedAt
1315
+ });
1316
+ await input.store.writeAttemptArtifacts(loop.loopId, currentAttemptIndex, {
1317
+ compiledContext,
1318
+ verification,
1319
+ ...(rollbackBoundary ? { rollbackBoundary } : {})
1320
+ });
1321
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1322
+ kind: "patch.generated",
1323
+ runId: loop.loopId,
1324
+ attemptIndex: currentAttemptIndex,
1325
+ payload: { status: result.status, summary: result.summary }
1326
+ }));
1327
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1328
+ kind: "budget.settled",
1329
+ runId: loop.loopId,
1330
+ attemptIndex: currentAttemptIndex,
1331
+ payload: {
1332
+ actualUsd: settlement.totalActualUsd,
1333
+ estimatedUsd: result.usage.estimatedUsd,
1334
+ tokensIn: result.usage.tokensIn,
1335
+ tokensOut: result.usage.tokensOut,
1336
+ cachedInputTokens: result.usage.cachedInputTokens,
1337
+ reasoningTokensOut: result.usage.reasoningTokensOut,
1338
+ provenance: getUsageProvenance(result.usage),
1339
+ transport: getAdapterTransport(executingAdapter),
1340
+ providerId: executingAdapter.metadata.providerId,
1341
+ model: executingAdapter.metadata.model,
1342
+ providerSettlement: result.usage.providerSettlement,
1343
+ patchCost: settlement.patchCost,
1344
+ verificationCost: settlement.verificationCost,
1345
+ varianceUsd: settlement.varianceUsd,
1346
+ preflightEstimateUsd: settlement.preflightEstimateUsd
1347
+ }
1348
+ }));
1349
+ }
1350
+ let changedFiles = [];
1351
+ let ignoredChanges = { created: [], modified: [], deleted: [] };
1352
+ let escapingLinks = [];
1353
+ let weakenedTestFiles = [];
1354
+ try {
1355
+ changedFiles = tracksWorkspaceMutations
1356
+ ? resolveChangedFiles(result, request.context.repoRoot, rollbackBoundary)
1286
1357
  : [];
1287
- weakenedTestFiles =
1288
- tracksWorkspaceMutations && request.context.repoRoot && rollbackBoundary?.headRef
1289
- ? findWeakenedTestFiles({
1358
+ ignoredChanges = tracksWorkspaceMutations
1359
+ ? listIgnoredFileChangesSinceBoundary({ repoRoot: request.context.repoRoot, boundary: rollbackBoundary })
1360
+ : ignoredChanges;
1361
+ escapingLinks =
1362
+ tracksWorkspaceMutations && request.context.repoRoot
1363
+ ? findEscapingLinks(request.context.repoRoot, [...changedFiles, ...ignoredChanges.created])
1364
+ : [];
1365
+ weakenedTestFiles =
1366
+ tracksWorkspaceMutations && request.context.repoRoot && rollbackBoundary?.headRef
1367
+ ? findWeakenedTestFiles({
1368
+ repoRoot: request.context.repoRoot,
1369
+ baseRef: rollbackBoundary.headRef,
1370
+ changedFiles
1371
+ })
1372
+ : [];
1373
+ }
1374
+ catch (error) {
1375
+ for (const filePath of normalizeFileEffectPaths(result.execution?.changedFiles ?? [])) {
1376
+ touchedDuringExecution.add(filePath);
1377
+ }
1378
+ for (const filePath of normalizeFileEffectPaths(changedFiles)) {
1379
+ touchedDuringExecution.add(filePath);
1380
+ }
1381
+ let rollbackOutcome;
1382
+ try {
1383
+ rollbackOutcome = (await restoreRollbackBoundary({
1290
1384
  repoRoot: request.context.repoRoot,
1291
- baseRef: rollbackBoundary.headRef,
1292
- changedFiles
1293
- })
1294
- : [];
1295
- }
1296
- catch (error) {
1297
- for (const filePath of normalizeFileEffectPaths(result.execution?.changedFiles ?? [])) {
1298
- touchedDuringExecution.add(filePath);
1385
+ boundary: rollbackBoundary,
1386
+ restoredAt: attemptCompletedAt,
1387
+ decision: "DISCARD",
1388
+ removePaths: ignoredChanges.created,
1389
+ restoreIgnoredPaths: [...ignoredChanges.modified, ...ignoredChanges.deleted]
1390
+ })) ?? {
1391
+ attempted: false,
1392
+ status: "unavailable",
1393
+ restoredAt: attemptCompletedAt,
1394
+ decision: "DISCARD",
1395
+ before: { trackedDirtyFiles: [], untrackedFiles: [] },
1396
+ after: { trackedDirtyFiles: [], untrackedFiles: [] },
1397
+ restoredFiles: [],
1398
+ deletedFiles: [],
1399
+ error: "Rollback could not run because no repository root was available."
1400
+ };
1401
+ }
1402
+ catch (rollbackError) {
1403
+ rollbackOutcome = {
1404
+ attempted: true,
1405
+ status: "failed",
1406
+ restoredAt: attemptCompletedAt,
1407
+ decision: "DISCARD",
1408
+ before: { trackedDirtyFiles: [], untrackedFiles: [] },
1409
+ after: { trackedDirtyFiles: [], untrackedFiles: [] },
1410
+ restoredFiles: [],
1411
+ deletedFiles: [],
1412
+ error: `Rollback failed after workspace observation became unavailable: ${fileEffectErrorMessage(rollbackError)}`
1413
+ };
1414
+ }
1415
+ const observationExitDecision = {
1416
+ shouldExit: true,
1417
+ lifecycleState: "human_escalation",
1418
+ status: "exited",
1419
+ reason: `Workspace change observation became unavailable: ${fileEffectErrorMessage(error)}`,
1420
+ reasonCode: "workspace_observation_unavailable"
1421
+ };
1422
+ if (input.store) {
1423
+ await input.store.writeAttemptArtifacts(loop.loopId, currentAttemptIndex, {
1424
+ compiledContext,
1425
+ ...(rollbackBoundary ? { rollbackBoundary } : {}),
1426
+ rollbackOutcome
1427
+ });
1428
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1429
+ kind: "run.exited",
1430
+ runId: loop.loopId,
1431
+ payload: createRunExitPayload(observationExitDecision)
1432
+ }));
1433
+ }
1434
+ await stopSignalMonitorAndDrainDiagnostics();
1435
+ // Blocker 2: observation failure is terminal; restore pre-candidate boundary if set.
1436
+ const finalObservationDecision = await restoreRetainedCandidateForTerminalExit(observationExitDecision);
1437
+ const terminationEnvelope = await persistGuardStop(finalObservationDecision);
1438
+ const finalizedLoop = await finalizeCurrentLoop(finalObservationDecision, now());
1439
+ const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope };
1440
+ await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
1441
+ return { loop: finalizedWithEnvelope, decision: finalObservationDecision };
1299
1442
  }
1300
1443
  for (const filePath of normalizeFileEffectPaths(changedFiles)) {
1301
1444
  touchedDuringExecution.add(filePath);
1302
1445
  }
1303
- let rollbackOutcome;
1304
- try {
1305
- rollbackOutcome = (await restoreRollbackBoundary({
1306
- repoRoot: request.context.repoRoot,
1307
- boundary: rollbackBoundary,
1308
- restoredAt: attemptCompletedAt,
1309
- decision: "DISCARD",
1310
- removePaths: ignoredChanges.created,
1311
- restoreIgnoredPaths: [...ignoredChanges.modified, ...ignoredChanges.deleted]
1312
- })) ?? {
1313
- attempted: false,
1314
- status: "unavailable",
1315
- restoredAt: attemptCompletedAt,
1316
- decision: "DISCARD",
1317
- before: { trackedDirtyFiles: [], untrackedFiles: [] },
1318
- after: { trackedDirtyFiles: [], untrackedFiles: [] },
1319
- restoredFiles: [],
1320
- deletedFiles: [],
1321
- error: "Rollback could not run because no repository root was available."
1446
+ // Routing economics: detect first meaningful workspace delta
1447
+ if (!firstDeltaDetected && changedFiles.length > 0) {
1448
+ const meaningfulFile = changedFiles.find((f) => !isMartinLoopMetadata(f));
1449
+ if (meaningfulFile) {
1450
+ firstDeltaDetected = true;
1451
+ firstDeltaTimestampMs = Date.now();
1452
+ firstDeltaFilePath = meaningfulFile;
1453
+ firstDeltaChangeType = "modify";
1454
+ preDeltaCostUsd = roundUsd(loop.cost.actualUsd - getUsageUsd(result.usage));
1455
+ }
1456
+ }
1457
+ if (!firstDeltaDetected) {
1458
+ preDeltaCostUsd = loop.cost.actualUsd;
1459
+ }
1460
+ // Evidence is only reliable when the adapter explicitly reported files OR git actually
1461
+ // returned a non-empty list. A repoRoot alone is insufficient — git may fail (e.g. not
1462
+ // a git repo) and silently return [], which would falsely trigger no_code_change.
1463
+ const changedFileEvidenceAvailable = result.execution?.changedFiles !== undefined || changedFiles.length > 0;
1464
+ const filesystemDecision = evaluateFilesystemLeash({
1465
+ repoRoot: request.context.repoRoot,
1466
+ changedFiles,
1467
+ ignoredFiles: [...ignoredChanges.created, ...ignoredChanges.modified, ...ignoredChanges.deleted],
1468
+ escapingLinks,
1469
+ allowedPaths: request.context.allowedPaths,
1470
+ deniedPaths: request.context.deniedPaths,
1471
+ mutationMode: request.context.mutationMode
1472
+ });
1473
+ if (!filesystemDecision.allowed) {
1474
+ const patchDecision = evaluatePatchDecision({
1475
+ verificationPassed: result.verification.passed,
1476
+ previousVerifierScore,
1477
+ verifierScore: result.verification.passed ? 1 : 0,
1478
+ scopeViolationCount: filesystemDecision.violations.length,
1479
+ changedFileCount: changedFiles.length,
1480
+ diffNovelty: changedFiles.length > 0 ? 1 : 0,
1481
+ diffStats: result.execution?.diffStats,
1482
+ costUsd: getUsageUsd(result.usage),
1483
+ summary: result.summary
1484
+ });
1485
+ const filesystemExitDecision = {
1486
+ shouldExit: true,
1487
+ lifecycleState: "human_escalation",
1488
+ status: "exited",
1489
+ reason: filesystemDecision.reason ?? "Safety leash blocked filesystem changes.",
1490
+ ...classifySafetyLeashExit(filesystemDecision, "filesystem")
1491
+ };
1492
+ // Wrap per-attempt rollback so that if it throws, preCandidateBoundary
1493
+ // restoration still runs via the cleanup helper below.
1494
+ let rollbackOutcome;
1495
+ try {
1496
+ rollbackOutcome = await restoreRollbackBoundary({
1497
+ repoRoot: request.context.repoRoot,
1498
+ boundary: rollbackBoundary,
1499
+ restoredAt: attemptCompletedAt,
1500
+ decision: patchDecision.decision,
1501
+ removePaths: violatingIgnoredPaths(filesystemDecision.violations, ignoredChanges.created),
1502
+ restoreIgnoredPaths: violatingIgnoredPaths(filesystemDecision.violations, [
1503
+ ...ignoredChanges.modified,
1504
+ ...ignoredChanges.deleted
1505
+ ])
1506
+ }) ?? undefined;
1507
+ }
1508
+ catch (rollbackError) {
1509
+ queueControlDiagnostic("attempt_rollback_failed_on_filesystem_exit", {
1510
+ error: rollbackError instanceof Error ? rollbackError.message : String(rollbackError)
1511
+ });
1512
+ }
1513
+ if (input.store) {
1514
+ await input.store.writeAttemptArtifacts(loop.loopId, currentAttemptIndex, {
1515
+ compiledContext,
1516
+ leash: createLeashArtifact(filesystemDecision, currentAttemptIndex),
1517
+ patchScore: patchDecision.score,
1518
+ patchDecision: toPatchDecisionArtifact(patchDecision),
1519
+ ...(rollbackBoundary ? { rollbackBoundary } : {}),
1520
+ ...(rollbackOutcome ? { rollbackOutcome } : {})
1521
+ });
1522
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1523
+ kind: "safety.violations_found",
1524
+ runId: loop.loopId,
1525
+ attemptIndex: currentAttemptIndex,
1526
+ payload: {
1527
+ surface: "filesystem",
1528
+ blocked: true,
1529
+ attemptIndex: currentAttemptIndex,
1530
+ violations: filesystemDecision.violations
1531
+ }
1532
+ }));
1533
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1534
+ kind: "attempt.discarded",
1535
+ runId: loop.loopId,
1536
+ attemptIndex: currentAttemptIndex,
1537
+ payload: {
1538
+ decision: patchDecision.decision,
1539
+ reason: patchDecision.summary,
1540
+ reasonCodes: patchDecision.reasonCodes,
1541
+ score: patchDecision.score.score
1542
+ }
1543
+ }));
1544
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1545
+ kind: "run.exited",
1546
+ runId: loop.loopId,
1547
+ payload: createRunExitPayload(filesystemExitDecision)
1548
+ }));
1549
+ }
1550
+ await stopSignalMonitorAndDrainDiagnostics();
1551
+ // Blocker 2: attempt-2 rollback restores to state-with-attempt-1-candidate.
1552
+ // We must also restore preCandidateBoundary to fully remove retained unverified code.
1553
+ const finalFilesystemDecision = await restoreRetainedCandidateForTerminalExit(filesystemExitDecision);
1554
+ const terminationEnvelope = await persistGuardStop(finalFilesystemDecision);
1555
+ const finalizedLoop = await finalizeCurrentLoop(finalFilesystemDecision, now());
1556
+ const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope };
1557
+ await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
1558
+ return {
1559
+ loop: finalizedWithEnvelope,
1560
+ decision: finalFilesystemDecision
1561
+ };
1562
+ }
1563
+ const changeApprovalDecision = evaluateChangeApprovalLeash({
1564
+ changedFiles,
1565
+ weakenedTestFiles,
1566
+ executionProfile: request.context.executionProfile,
1567
+ approvalPolicy: request.context.approvalPolicy
1568
+ });
1569
+ if (!changeApprovalDecision.allowed) {
1570
+ const patchDecision = evaluatePatchDecision({
1571
+ verificationPassed: result.verification.passed,
1572
+ previousVerifierScore,
1573
+ verifierScore: result.verification.passed ? 1 : 0,
1574
+ safetyViolationCount: changeApprovalDecision.violations.length,
1575
+ changedFileCount: changedFiles.length,
1576
+ diffNovelty: changedFiles.length > 0 ? 1 : 0,
1577
+ diffStats: result.execution?.diffStats,
1578
+ costUsd: getUsageUsd(result.usage),
1579
+ humanApprovalRequired: true,
1580
+ summary: result.summary
1581
+ });
1582
+ const approvalExitDecision = {
1583
+ shouldExit: true,
1584
+ lifecycleState: "human_escalation",
1585
+ status: "exited",
1586
+ reason: changeApprovalDecision.reason ??
1587
+ "Safety leash blocked dependency or migration changes that require approval.",
1588
+ ...classifySafetyLeashExit(changeApprovalDecision, "dependency")
1589
+ };
1590
+ // Wrap per-attempt rollback so that preCandidateBoundary restore still runs below.
1591
+ let rollbackOutcome;
1592
+ try {
1593
+ rollbackOutcome = await restoreRollbackBoundary({
1594
+ repoRoot: request.context.repoRoot,
1595
+ boundary: rollbackBoundary,
1596
+ restoredAt: attemptCompletedAt,
1597
+ decision: patchDecision.decision
1598
+ }) ?? undefined;
1599
+ }
1600
+ catch (rollbackError) {
1601
+ queueControlDiagnostic("attempt_rollback_failed_on_approval_exit", {
1602
+ error: rollbackError instanceof Error ? rollbackError.message : String(rollbackError)
1603
+ });
1604
+ }
1605
+ if (input.store) {
1606
+ await input.store.writeAttemptArtifacts(loop.loopId, currentAttemptIndex, {
1607
+ compiledContext,
1608
+ leash: createLeashArtifact(changeApprovalDecision, currentAttemptIndex),
1609
+ patchScore: patchDecision.score,
1610
+ patchDecision: toPatchDecisionArtifact(patchDecision),
1611
+ ...(rollbackBoundary ? { rollbackBoundary } : {}),
1612
+ ...(rollbackOutcome ? { rollbackOutcome } : {})
1613
+ });
1614
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1615
+ kind: "safety.violations_found",
1616
+ runId: loop.loopId,
1617
+ attemptIndex: currentAttemptIndex,
1618
+ payload: {
1619
+ surface: "dependency",
1620
+ blocked: true,
1621
+ profile: changeApprovalDecision.profile ?? executionProfile.name,
1622
+ attemptIndex: currentAttemptIndex,
1623
+ violations: changeApprovalDecision.violations
1624
+ }
1625
+ }));
1626
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1627
+ kind: "attempt.discarded",
1628
+ runId: loop.loopId,
1629
+ attemptIndex: currentAttemptIndex,
1630
+ payload: {
1631
+ decision: patchDecision.decision,
1632
+ reason: patchDecision.summary,
1633
+ reasonCodes: patchDecision.reasonCodes,
1634
+ score: patchDecision.score.score
1635
+ }
1636
+ }));
1637
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1638
+ kind: "run.exited",
1639
+ runId: loop.loopId,
1640
+ payload: createRunExitPayload(approvalExitDecision)
1641
+ }));
1642
+ }
1643
+ await stopSignalMonitorAndDrainDiagnostics();
1644
+ // Blocker 2: per-attempt rollback above only restores to state-with-retained-candidate.
1645
+ const finalApprovalDecision = await restoreRetainedCandidateForTerminalExit(approvalExitDecision);
1646
+ const terminationEnvelope = await persistGuardStop(finalApprovalDecision);
1647
+ const finalizedLoop = await finalizeCurrentLoop(finalApprovalDecision, now());
1648
+ const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope };
1649
+ await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
1650
+ return {
1651
+ loop: finalizedWithEnvelope,
1652
+ decision: finalApprovalDecision
1322
1653
  };
1323
1654
  }
1324
- catch (rollbackError) {
1325
- rollbackOutcome = {
1326
- attempted: true,
1327
- status: "failed",
1328
- restoredAt: attemptCompletedAt,
1329
- decision: "DISCARD",
1330
- before: { trackedDirtyFiles: [], untrackedFiles: [] },
1331
- after: { trackedDirtyFiles: [], untrackedFiles: [] },
1332
- restoredFiles: [],
1333
- deletedFiles: [],
1334
- error: `Rollback failed after workspace observation became unavailable: ${fileEffectErrorMessage(rollbackError)}`
1335
- };
1655
+ // VERIFY: Run grounding scan on patch diff if available
1656
+ // Uses the task's repoRoot to build/load the grounding index, then scans any diff
1657
+ let groundingScanResult;
1658
+ const patchDiff = buildPatchDiff(result, changedFiles);
1659
+ if (patchDiff && task.repoRoot) {
1660
+ try {
1661
+ const groundingIndex = await loadOrBuildRepoGroundingIndex(task.repoRoot);
1662
+ groundingScanResult = scanPatchForGroundingViolations(patchDiff, groundingIndex, {
1663
+ allowedPaths: task.allowedPaths
1664
+ });
1665
+ if (input.store && groundingScanResult.violations.length > 0) {
1666
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1667
+ kind: "grounding.violations_found",
1668
+ runId: loop.loopId,
1669
+ attemptIndex: currentAttemptIndex,
1670
+ payload: {
1671
+ violationCount: groundingScanResult.violations.length,
1672
+ resolvedFiles: groundingScanResult.resolvedFiles,
1673
+ contentOnly: groundingScanResult.contentOnly,
1674
+ violations: groundingScanResult.violations.slice(0, 10)
1675
+ }
1676
+ }));
1677
+ }
1678
+ }
1679
+ catch {
1680
+ // Grounding scan is best-effort — never fail the loop because of a scan error
1681
+ }
1336
1682
  }
1337
- const observationExitDecision = {
1338
- shouldExit: true,
1339
- lifecycleState: "human_escalation",
1340
- status: "exited",
1341
- reason: `Workspace change observation became unavailable: ${fileEffectErrorMessage(error)}`,
1342
- reasonCode: "workspace_observation_unavailable"
1343
- };
1344
- if (input.store) {
1345
- await input.store.writeAttemptArtifacts(loop.loopId, currentAttemptIndex, {
1346
- compiledContext,
1347
- ...(rollbackBoundary ? { rollbackBoundary } : {}),
1348
- rollbackOutcome
1683
+ const shouldEvaluatePatchTruth = result.status === "completed" && tracksWorkspaceMutations && verifierConfigured;
1684
+ let patchDecision;
1685
+ if (shouldEvaluatePatchTruth) {
1686
+ patchDecision = evaluatePatchDecision({
1687
+ verificationPassed: verification.passed,
1688
+ previousVerifierScore,
1689
+ verifierScore: verification.passed ? 1 : 0,
1690
+ groundingViolationCount: groundingScanResult?.violations.length ?? 0,
1691
+ changedFileCount: changedFileEvidenceAvailable ? changedFiles.length : undefined,
1692
+ mutationRequired: request.context.mutationMode === "edit",
1693
+ definitionOfDonePreSatisfied: request.context.definitionOfDonePreSatisfied,
1694
+ diffNovelty: changedFileEvidenceAvailable ? (changedFiles.length > 0 ? 1 : 0) : undefined,
1695
+ diffStats: result.execution?.diffStats,
1696
+ costUsd: getUsageUsd(result.usage),
1697
+ summary: result.summary
1349
1698
  });
1350
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1351
- kind: "run.exited",
1352
- runId: loop.loopId,
1353
- payload: createRunExitPayload(observationExitDecision)
1354
- }));
1355
- }
1356
- await stopSignalMonitorAndDrainDiagnostics();
1357
- // Blocker 2: observation failure is terminal; restore pre-candidate boundary if set.
1358
- const finalObservationDecision = await restoreRetainedCandidateForTerminalExit(observationExitDecision);
1359
- const terminationEnvelope = await persistGuardStop(finalObservationDecision);
1360
- const finalizedLoop = await finalizeCurrentLoop(finalObservationDecision, now());
1361
- const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope };
1362
- await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
1363
- return { loop: finalizedWithEnvelope, decision: finalObservationDecision };
1364
- }
1365
- for (const filePath of normalizeFileEffectPaths(changedFiles)) {
1366
- touchedDuringExecution.add(filePath);
1367
- }
1368
- // Routing economics: detect first meaningful workspace delta
1369
- if (!firstDeltaDetected && changedFiles.length > 0) {
1370
- const meaningfulFile = changedFiles.find((f) => !isMartinLoopMetadata(f));
1371
- if (meaningfulFile) {
1372
- firstDeltaDetected = true;
1373
- firstDeltaTimestampMs = Date.now();
1374
- firstDeltaFilePath = meaningfulFile;
1375
- firstDeltaChangeType = "modify";
1376
- preDeltaCostUsd = roundUsd(loop.cost.actualUsd - getUsageUsd(result.usage));
1377
1699
  }
1378
- }
1379
- if (!firstDeltaDetected) {
1380
- preDeltaCostUsd = loop.cost.actualUsd;
1381
- }
1382
- // Evidence is only reliable when the adapter explicitly reported files OR git actually
1383
- // returned a non-empty list. A repoRoot alone is insufficient — git may fail (e.g. not
1384
- // a git repo) and silently return [], which would falsely trigger no_code_change.
1385
- const changedFileEvidenceAvailable = result.execution?.changedFiles !== undefined || changedFiles.length > 0;
1386
- const filesystemDecision = evaluateFilesystemLeash({
1387
- repoRoot: request.context.repoRoot,
1388
- changedFiles,
1389
- ignoredFiles: [...ignoredChanges.created, ...ignoredChanges.modified, ...ignoredChanges.deleted],
1390
- escapingLinks,
1391
- allowedPaths: request.context.allowedPaths,
1392
- deniedPaths: request.context.deniedPaths,
1393
- mutationMode: request.context.mutationMode
1394
- });
1395
- if (!filesystemDecision.allowed) {
1396
- const patchDecision = evaluatePatchDecision({
1397
- verificationPassed: result.verification.passed,
1398
- previousVerifierScore,
1399
- verifierScore: result.verification.passed ? 1 : 0,
1400
- scopeViolationCount: filesystemDecision.violations.length,
1401
- changedFileCount: changedFiles.length,
1402
- diffNovelty: changedFiles.length > 0 ? 1 : 0,
1403
- diffStats: result.execution?.diffStats,
1404
- costUsd: getUsageUsd(result.usage),
1405
- summary: result.summary
1406
- });
1407
- const filesystemExitDecision = {
1408
- shouldExit: true,
1409
- lifecycleState: "human_escalation",
1410
- status: "exited",
1411
- reason: filesystemDecision.reason ?? "Safety leash blocked filesystem changes.",
1412
- ...classifySafetyLeashExit(filesystemDecision, "filesystem")
1413
- };
1414
- // Wrap per-attempt rollback so that if it throws, preCandidateBoundary
1415
- // restoration still runs via the cleanup helper below.
1700
+ // C1 — Safe candidate retention.
1701
+ // Distinguish UNSAFE discards (must roll back) from SAFE_UNVERIFIED discards
1702
+ // (retain candidate for the next repair attempt).
1703
+ //
1704
+ // UNSAFE: scope/safety violations — rollback immediately; hard boundaries.
1705
+ // SAFE_UNVERIFIED: only soft heuristics (grounding, low novelty, verifier
1706
+ // not yet improved) — leave the workspace in the candidate state so the
1707
+ // next agent can repair rather than rebuild from scratch.
1708
+ // provider failure (result.status === "failed"): rollback — the workspace
1709
+ // state is unknown and may be partially corrupt.
1710
+ // When patchDecision is undefined (no patch truth evaluation, e.g. verifierConfigured=false),
1711
+ // preserve original behavior: only rollback on provider failure. Do not treat the absence
1712
+ // of a patch decision as "unsafe" — it simply means patch evaluation was not applicable.
1713
+ const candidateIsUnsafe = patchDecision !== undefined && (patchDecision.decision === "ESCALATE" ||
1714
+ (patchDecision.decision === "DISCARD" &&
1715
+ !isSafeUnverifiedDiscard(patchDecision.reasonCodes)));
1716
+ // Blocker 3: candidate may only be retained if an authoritative rollback boundary
1717
+ // exists to enable exact restoration. Without a boundary, we cannot prove we can
1718
+ // undo the retention — fail closed and discard instead.
1719
+ const candidateRetainedThisAttempt = patchDecision?.decision === "DISCARD" &&
1720
+ isSafeUnverifiedDiscard(patchDecision.reasonCodes) &&
1721
+ result.status !== "failed" &&
1722
+ rollbackBoundary !== undefined;
1416
1723
  let rollbackOutcome;
1417
- try {
1724
+ if (candidateIsUnsafe && result.status !== "failed") {
1418
1725
  rollbackOutcome = await restoreRollbackBoundary({
1419
1726
  repoRoot: request.context.repoRoot,
1420
1727
  boundary: rollbackBoundary,
1421
1728
  restoredAt: attemptCompletedAt,
1422
- decision: patchDecision.decision,
1423
- removePaths: violatingIgnoredPaths(filesystemDecision.violations, ignoredChanges.created),
1424
- restoreIgnoredPaths: violatingIgnoredPaths(filesystemDecision.violations, [
1425
- ...ignoredChanges.modified,
1426
- ...ignoredChanges.deleted
1427
- ])
1428
- }) ?? undefined;
1429
- }
1430
- catch (rollbackError) {
1431
- queueControlDiagnostic("attempt_rollback_failed_on_filesystem_exit", {
1432
- error: rollbackError instanceof Error ? rollbackError.message : String(rollbackError)
1433
- });
1434
- }
1435
- if (input.store) {
1436
- await input.store.writeAttemptArtifacts(loop.loopId, currentAttemptIndex, {
1437
- compiledContext,
1438
- leash: createLeashArtifact(filesystemDecision, currentAttemptIndex),
1439
- patchScore: patchDecision.score,
1440
- patchDecision: toPatchDecisionArtifact(patchDecision),
1441
- ...(rollbackBoundary ? { rollbackBoundary } : {}),
1442
- ...(rollbackOutcome ? { rollbackOutcome } : {})
1729
+ decision: patchDecision?.decision ?? "DISCARD"
1443
1730
  });
1444
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1445
- kind: "safety.violations_found",
1446
- runId: loop.loopId,
1447
- attemptIndex: currentAttemptIndex,
1448
- payload: {
1449
- surface: "filesystem",
1450
- blocked: true,
1451
- attemptIndex: currentAttemptIndex,
1452
- violations: filesystemDecision.violations
1453
- }
1454
- }));
1455
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1456
- kind: "attempt.discarded",
1457
- runId: loop.loopId,
1458
- attemptIndex: currentAttemptIndex,
1459
- payload: {
1460
- decision: patchDecision.decision,
1461
- reason: patchDecision.summary,
1462
- reasonCodes: patchDecision.reasonCodes,
1463
- score: patchDecision.score.score
1464
- }
1465
- }));
1466
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1467
- kind: "run.exited",
1468
- runId: loop.loopId,
1469
- payload: createRunExitPayload(filesystemExitDecision)
1470
- }));
1471
1731
  }
1472
- await stopSignalMonitorAndDrainDiagnostics();
1473
- // Blocker 2: attempt-2 rollback restores to state-with-attempt-1-candidate.
1474
- // We must also restore preCandidateBoundary to fully remove retained unverified code.
1475
- const finalFilesystemDecision = await restoreRetainedCandidateForTerminalExit(filesystemExitDecision);
1476
- const terminationEnvelope = await persistGuardStop(finalFilesystemDecision);
1477
- const finalizedLoop = await finalizeCurrentLoop(finalFilesystemDecision, now());
1478
- const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope };
1479
- await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
1480
- return {
1481
- loop: finalizedWithEnvelope,
1482
- decision: finalFilesystemDecision
1483
- };
1484
- }
1485
- const changeApprovalDecision = evaluateChangeApprovalLeash({
1486
- changedFiles,
1487
- weakenedTestFiles,
1488
- executionProfile: request.context.executionProfile,
1489
- approvalPolicy: request.context.approvalPolicy
1490
- });
1491
- if (!changeApprovalDecision.allowed) {
1492
- const patchDecision = evaluatePatchDecision({
1493
- verificationPassed: result.verification.passed,
1494
- previousVerifierScore,
1495
- verifierScore: result.verification.passed ? 1 : 0,
1496
- safetyViolationCount: changeApprovalDecision.violations.length,
1497
- changedFileCount: changedFiles.length,
1498
- diffNovelty: changedFiles.length > 0 ? 1 : 0,
1499
- diffStats: result.execution?.diffStats,
1500
- costUsd: getUsageUsd(result.usage),
1501
- humanApprovalRequired: true,
1502
- summary: result.summary
1503
- });
1504
- const approvalExitDecision = {
1505
- shouldExit: true,
1506
- lifecycleState: "human_escalation",
1507
- status: "exited",
1508
- reason: changeApprovalDecision.reason ??
1509
- "Safety leash blocked dependency or migration changes that require approval.",
1510
- ...classifySafetyLeashExit(changeApprovalDecision, "dependency")
1511
- };
1512
- // Wrap per-attempt rollback so that preCandidateBoundary restore still runs below.
1513
- let rollbackOutcome;
1514
- try {
1732
+ else if (result.status === "failed") {
1733
+ // Provider failure: workspace state unknown — always rollback.
1515
1734
  rollbackOutcome = await restoreRollbackBoundary({
1516
1735
  repoRoot: request.context.repoRoot,
1517
1736
  boundary: rollbackBoundary,
1518
1737
  restoredAt: attemptCompletedAt,
1519
- decision: patchDecision.decision
1520
- }) ?? undefined;
1521
- }
1522
- catch (rollbackError) {
1523
- queueControlDiagnostic("attempt_rollback_failed_on_approval_exit", {
1524
- error: rollbackError instanceof Error ? rollbackError.message : String(rollbackError)
1738
+ decision: "DISCARD"
1525
1739
  });
1526
1740
  }
1741
+ // else: candidateRetainedThisAttempt or KEEP — leave workspace as-is.
1742
+ // C1: back-patch candidateRetained onto the attempt that was already appended.
1743
+ if (candidateRetainedThisAttempt) {
1744
+ // First retention: record the boundary BEFORE this candidate was applied,
1745
+ // so terminal cleanup can restore the workspace if the run never reaches VERIFIED.
1746
+ if (!preCandidateBoundary && rollbackBoundary) {
1747
+ preCandidateBoundary = rollbackBoundary;
1748
+ }
1749
+ loop = {
1750
+ ...loop,
1751
+ attempts: loop.attempts.map((a) => a.attemptId === attemptId ? { ...a, candidateRetained: true } : a)
1752
+ };
1753
+ }
1527
1754
  if (input.store) {
1528
1755
  await input.store.writeAttemptArtifacts(loop.loopId, currentAttemptIndex, {
1529
1756
  compiledContext,
1530
- leash: createLeashArtifact(changeApprovalDecision, currentAttemptIndex),
1531
- patchScore: patchDecision.score,
1532
- patchDecision: toPatchDecisionArtifact(patchDecision),
1757
+ ...(patchDiff ? { diff: patchDiff } : {}),
1758
+ ...(groundingScanResult ? { groundingScan: groundingScanResult } : {}),
1759
+ ...(patchDecision ? { patchScore: patchDecision.score } : {}),
1760
+ ...(patchDecision ? { patchDecision: toPatchDecisionArtifact(patchDecision) } : {}),
1533
1761
  ...(rollbackBoundary ? { rollbackBoundary } : {}),
1534
1762
  ...(rollbackOutcome ? { rollbackOutcome } : {})
1535
1763
  });
1536
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1537
- kind: "safety.violations_found",
1538
- runId: loop.loopId,
1539
- attemptIndex: currentAttemptIndex,
1540
- payload: {
1541
- surface: "dependency",
1542
- blocked: true,
1543
- profile: changeApprovalDecision.profile ?? executionProfile.name,
1544
- attemptIndex: currentAttemptIndex,
1545
- violations: changeApprovalDecision.violations
1546
- }
1547
- }));
1548
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1549
- kind: "attempt.discarded",
1550
- runId: loop.loopId,
1551
- attemptIndex: currentAttemptIndex,
1552
- payload: {
1553
- decision: patchDecision.decision,
1554
- reason: patchDecision.summary,
1555
- reasonCodes: patchDecision.reasonCodes,
1556
- score: patchDecision.score.score
1557
- }
1558
- }));
1559
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1560
- kind: "run.exited",
1561
- runId: loop.loopId,
1562
- payload: createRunExitPayload(approvalExitDecision)
1563
- }));
1564
1764
  }
1565
- await stopSignalMonitorAndDrainDiagnostics();
1566
- // Blocker 2: per-attempt rollback above only restores to state-with-retained-candidate.
1567
- const finalApprovalDecision = await restoreRetainedCandidateForTerminalExit(approvalExitDecision);
1568
- const terminationEnvelope = await persistGuardStop(finalApprovalDecision);
1569
- const finalizedLoop = await finalizeCurrentLoop(finalApprovalDecision, now());
1570
- const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope };
1571
- await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
1572
- return {
1573
- loop: finalizedWithEnvelope,
1574
- decision: finalApprovalDecision
1575
- };
1576
- }
1577
- // VERIFY: Run grounding scan on patch diff if available
1578
- // Uses the task's repoRoot to build/load the grounding index, then scans any diff
1579
- let groundingScanResult;
1580
- const patchDiff = buildPatchDiff(result, changedFiles);
1581
- if (patchDiff && input.task.repoRoot) {
1582
- try {
1583
- const groundingIndex = await loadOrBuildRepoGroundingIndex(input.task.repoRoot);
1584
- groundingScanResult = scanPatchForGroundingViolations(patchDiff, groundingIndex, {
1585
- allowedPaths: input.task.allowedPaths
1586
- });
1587
- if (input.store && groundingScanResult.violations.length > 0) {
1765
+ if (input.store) {
1766
+ if (patchDecision) {
1588
1767
  await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1589
- kind: "grounding.violations_found",
1768
+ kind: patchDecision.decision === "KEEP" ? "attempt.kept" : "attempt.discarded",
1590
1769
  runId: loop.loopId,
1591
1770
  attemptIndex: currentAttemptIndex,
1592
1771
  payload: {
1593
- violationCount: groundingScanResult.violations.length,
1594
- resolvedFiles: groundingScanResult.resolvedFiles,
1595
- contentOnly: groundingScanResult.contentOnly,
1596
- violations: groundingScanResult.violations.slice(0, 10)
1772
+ decision: patchDecision.decision,
1773
+ reason: patchDecision.summary,
1774
+ reasonCodes: patchDecision.reasonCodes,
1775
+ score: patchDecision.score.score
1597
1776
  }
1598
1777
  }));
1599
1778
  }
1779
+ else {
1780
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1781
+ kind: verification.passed || !verifierConfigured ? "attempt.kept" : "attempt.discarded",
1782
+ runId: loop.loopId,
1783
+ attemptIndex: currentAttemptIndex,
1784
+ payload: { reason: verification.summary }
1785
+ }));
1786
+ }
1600
1787
  }
1601
- catch {
1602
- // Grounding scan is best-effort — never fail the loop because of a scan error
1603
- }
1604
- }
1605
- const shouldEvaluatePatchTruth = result.status === "completed" && tracksWorkspaceMutations && verifierConfigured;
1606
- let patchDecision;
1607
- if (shouldEvaluatePatchTruth) {
1608
- patchDecision = evaluatePatchDecision({
1609
- verificationPassed: verification.passed,
1610
- previousVerifierScore,
1611
- verifierScore: verification.passed ? 1 : 0,
1612
- groundingViolationCount: groundingScanResult?.violations.length ?? 0,
1613
- changedFileCount: changedFileEvidenceAvailable ? changedFiles.length : undefined,
1614
- mutationRequired: request.context.mutationMode === "edit",
1615
- definitionOfDonePreSatisfied: request.context.definitionOfDonePreSatisfied,
1616
- diffNovelty: changedFileEvidenceAvailable ? (changedFiles.length > 0 ? 1 : 0) : undefined,
1617
- diffStats: result.execution?.diffStats,
1618
- costUsd: getUsageUsd(result.usage),
1619
- summary: result.summary
1620
- });
1621
- }
1622
- // C1 — Safe candidate retention.
1623
- // Distinguish UNSAFE discards (must roll back) from SAFE_UNVERIFIED discards
1624
- // (retain candidate for the next repair attempt).
1625
- //
1626
- // UNSAFE: scope/safety violations — rollback immediately; hard boundaries.
1627
- // SAFE_UNVERIFIED: only soft heuristics (grounding, low novelty, verifier
1628
- // not yet improved) — leave the workspace in the candidate state so the
1629
- // next agent can repair rather than rebuild from scratch.
1630
- // provider failure (result.status === "failed"): rollback — the workspace
1631
- // state is unknown and may be partially corrupt.
1632
- // When patchDecision is undefined (no patch truth evaluation, e.g. verifierConfigured=false),
1633
- // preserve original behavior: only rollback on provider failure. Do not treat the absence
1634
- // of a patch decision as "unsafe" — it simply means patch evaluation was not applicable.
1635
- const candidateIsUnsafe = patchDecision !== undefined && (patchDecision.decision === "ESCALATE" ||
1636
- (patchDecision.decision === "DISCARD" &&
1637
- !isSafeUnverifiedDiscard(patchDecision.reasonCodes)));
1638
- // Blocker 3: candidate may only be retained if an authoritative rollback boundary
1639
- // exists to enable exact restoration. Without a boundary, we cannot prove we can
1640
- // undo the retention — fail closed and discard instead.
1641
- const candidateRetainedThisAttempt = patchDecision?.decision === "DISCARD" &&
1642
- isSafeUnverifiedDiscard(patchDecision.reasonCodes) &&
1643
- result.status !== "failed" &&
1644
- rollbackBoundary !== undefined;
1645
- let rollbackOutcome;
1646
- if (candidateIsUnsafe && result.status !== "failed") {
1647
- rollbackOutcome = await restoreRollbackBoundary({
1648
- repoRoot: request.context.repoRoot,
1649
- boundary: rollbackBoundary,
1650
- restoredAt: attemptCompletedAt,
1651
- decision: patchDecision?.decision ?? "DISCARD"
1652
- });
1653
- }
1654
- else if (result.status === "failed") {
1655
- // Provider failure: workspace state unknown — always rollback.
1656
- rollbackOutcome = await restoreRollbackBoundary({
1657
- repoRoot: request.context.repoRoot,
1658
- boundary: rollbackBoundary,
1659
- restoredAt: attemptCompletedAt,
1660
- decision: "DISCARD"
1661
- });
1662
- }
1663
- // else: candidateRetainedThisAttempt or KEEP — leave workspace as-is.
1664
- // C1: back-patch candidateRetained onto the attempt that was already appended.
1665
- if (candidateRetainedThisAttempt) {
1666
- // First retention: record the boundary BEFORE this candidate was applied,
1667
- // so terminal cleanup can restore the workspace if the run never reaches VERIFIED.
1668
- if (!preCandidateBoundary && rollbackBoundary) {
1669
- preCandidateBoundary = rollbackBoundary;
1670
- }
1671
- loop = {
1672
- ...loop,
1673
- attempts: loop.attempts.map((a) => a.attemptId === attemptId ? { ...a, candidateRetained: true } : a)
1674
- };
1675
- }
1676
- if (input.store) {
1677
- await input.store.writeAttemptArtifacts(loop.loopId, currentAttemptIndex, {
1678
- compiledContext,
1679
- ...(patchDiff ? { diff: patchDiff } : {}),
1680
- ...(groundingScanResult ? { groundingScan: groundingScanResult } : {}),
1681
- ...(patchDecision ? { patchScore: patchDecision.score } : {}),
1682
- ...(patchDecision ? { patchDecision: toPatchDecisionArtifact(patchDecision) } : {}),
1683
- ...(rollbackBoundary ? { rollbackBoundary } : {}),
1684
- ...(rollbackOutcome ? { rollbackOutcome } : {})
1685
- });
1686
- }
1687
- if (input.store) {
1688
- if (patchDecision) {
1689
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1690
- kind: patchDecision.decision === "KEEP" ? "attempt.kept" : "attempt.discarded",
1691
- runId: loop.loopId,
1692
- attemptIndex: currentAttemptIndex,
1788
+ if (patchDecision && patchDecision.decision !== "KEEP" && !failure) {
1789
+ failure = classifyPatchDecisionFailure(patchDecision, result);
1790
+ loop = applyPatchFailureToLoop(loop, {
1791
+ attemptId,
1792
+ summary: patchDecision.summary,
1793
+ failure
1794
+ });
1795
+ if (failure.recommendedIntervention === "compress_context") {
1796
+ useCompressedContext = true;
1797
+ }
1798
+ loop = appendLoopEvent(loop, {
1799
+ type: "failure.classified",
1800
+ lifecycleState: "running",
1693
1801
  payload: {
1694
- decision: patchDecision.decision,
1695
- reason: patchDecision.summary,
1696
- reasonCodes: patchDecision.reasonCodes,
1697
- score: patchDecision.score.score
1802
+ attemptId,
1803
+ failureClass: failure.failureClass,
1804
+ rationale: failure.rationale
1698
1805
  }
1699
- }));
1700
- }
1701
- else {
1702
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1703
- kind: verification.passed || !verifierConfigured ? "attempt.kept" : "attempt.discarded",
1704
- runId: loop.loopId,
1705
- attemptIndex: currentAttemptIndex,
1706
- payload: { reason: verification.summary }
1707
- }));
1708
- }
1709
- }
1710
- if (patchDecision && patchDecision.decision !== "KEEP" && !failure) {
1711
- failure = classifyPatchDecisionFailure(patchDecision, result);
1712
- loop = applyPatchFailureToLoop(loop, {
1713
- attemptId,
1714
- summary: patchDecision.summary,
1715
- failure
1716
- });
1717
- if (failure.recommendedIntervention === "compress_context") {
1718
- useCompressedContext = true;
1806
+ }, { now: attemptCompletedAt, idFactory });
1807
+ loop = appendLoopEvent(loop, {
1808
+ type: "intervention.selected",
1809
+ lifecycleState: "running",
1810
+ payload: { attemptId, intervention: failure.recommendedIntervention }
1811
+ }, { now: attemptCompletedAt, idFactory });
1719
1812
  }
1720
- loop = appendLoopEvent(loop, {
1721
- type: "failure.classified",
1722
- lifecycleState: "running",
1723
- payload: {
1724
- attemptId,
1725
- failureClass: failure.failureClass,
1726
- rationale: failure.rationale
1813
+ if (patchDecision?.decision === "ESCALATE" || patchDecision?.decision === "HANDOFF") {
1814
+ const patchExitDecision = {
1815
+ shouldExit: true,
1816
+ lifecycleState: "human_escalation",
1817
+ status: "exited",
1818
+ reason: patchDecision.summary
1819
+ };
1820
+ if (input.store) {
1821
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1822
+ kind: "run.exited",
1823
+ runId: loop.loopId,
1824
+ payload: createRunExitPayload(patchExitDecision)
1825
+ }));
1727
1826
  }
1728
- }, { now: attemptCompletedAt, idFactory });
1729
- loop = appendLoopEvent(loop, {
1730
- type: "intervention.selected",
1731
- lifecycleState: "running",
1732
- payload: { attemptId, intervention: failure.recommendedIntervention }
1733
- }, { now: attemptCompletedAt, idFactory });
1734
- }
1735
- if (patchDecision?.decision === "ESCALATE" || patchDecision?.decision === "HANDOFF") {
1736
- const patchExitDecision = {
1737
- shouldExit: true,
1738
- lifecycleState: "human_escalation",
1739
- status: "exited",
1740
- reason: patchDecision.summary
1741
- };
1742
- if (input.store) {
1743
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1744
- kind: "run.exited",
1745
- runId: loop.loopId,
1746
- payload: createRunExitPayload(patchExitDecision)
1747
- }));
1827
+ await stopSignalMonitorAndDrainDiagnostics();
1828
+ // Blocker 2: ESCALATE/HANDOFF exits can occur after a candidate is retained.
1829
+ const finalPatchExitDecision = await restoreRetainedCandidateForTerminalExit(patchExitDecision);
1830
+ const finalizedLoop = await finalizeCurrentLoop(finalPatchExitDecision, now());
1831
+ await persistLoopRecordIfSupported(input.store, finalizedLoop);
1832
+ return {
1833
+ loop: finalizedLoop,
1834
+ decision: finalPatchExitDecision
1835
+ };
1748
1836
  }
1749
- await stopSignalMonitorAndDrainDiagnostics();
1750
- // Blocker 2: ESCALATE/HANDOFF exits can occur after a candidate is retained.
1751
- const finalPatchExitDecision = await restoreRetainedCandidateForTerminalExit(patchExitDecision);
1752
- const finalizedLoop = await finalizeCurrentLoop(finalPatchExitDecision, now());
1753
- await persistLoopRecordIfSupported(input.store, finalizedLoop);
1754
- return {
1755
- loop: finalizedLoop,
1756
- decision: finalPatchExitDecision
1757
- };
1758
- }
1759
- const effectiveResult = patchDecision && patchDecision.decision !== "KEEP"
1760
- ? {
1761
- ...result,
1762
- status: "failed",
1763
- summary: patchDecision.summary,
1764
- verification: {
1765
- ...result.verification,
1766
- passed: false,
1767
- summary: patchDecision.summary
1768
- },
1769
- failure: {
1770
- message: patchDecision.summary,
1771
- ...(failure?.failureClass ? { classHint: failure.failureClass } : {})
1837
+ const effectiveResult = patchDecision && patchDecision.decision !== "KEEP"
1838
+ ? {
1839
+ ...result,
1840
+ status: "failed",
1841
+ summary: patchDecision.summary,
1842
+ verification: {
1843
+ ...result.verification,
1844
+ passed: false,
1845
+ summary: patchDecision.summary
1846
+ },
1847
+ failure: {
1848
+ message: patchDecision.summary,
1849
+ ...(failure?.failureClass ? { classHint: failure.failureClass } : {})
1850
+ }
1772
1851
  }
1852
+ : result;
1853
+ // Authoritative verification evidence from effectiveResult (grounding/patch-truth applied).
1854
+ // Back-patch verifierScore/failingVerifierStepCount BEFORE inferExit so that progress
1855
+ // decisions use normalized, binding-checked evidence — never raw adapter-reported values.
1856
+ const effectiveVerification = normalizeVerificationOutcome(effectiveResult, expectedVerifierBinding);
1857
+ {
1858
+ const steps = effectiveVerification.steps ?? [];
1859
+ const launched = steps.filter(s => s.launched);
1860
+ const passing = launched.filter(s => s.exitCode === 0 && !s.timedOut && !s.crashed);
1861
+ const failing = launched.filter(s => s.exitCode !== 0 || s.timedOut || s.crashed);
1862
+ currentVerifierScore = effectiveVerification.passed
1863
+ ? 1 : launched.length > 0 ? passing.length / launched.length : 0;
1864
+ currentFailingVerifierStepCount = failing.length > 0 ? failing.length : undefined;
1865
+ loop = {
1866
+ ...loop,
1867
+ attempts: loop.attempts.map(a => a.attemptId === attemptId
1868
+ ? { ...a, verifierScore: currentVerifierScore, ...(currentFailingVerifierStepCount !== undefined ? { failingVerifierStepCount: currentFailingVerifierStepCount } : {}) }
1869
+ : a)
1870
+ };
1773
1871
  }
1774
- : result;
1775
- // Authoritative verification evidence from effectiveResult (grounding/patch-truth applied).
1776
- // Back-patch verifierScore/failingVerifierStepCount BEFORE inferExit so that progress
1777
- // decisions use normalized, binding-checked evidence — never raw adapter-reported values.
1778
- const effectiveVerification = normalizeVerificationOutcome(effectiveResult, expectedVerifierBinding);
1779
- {
1780
- const steps = effectiveVerification.steps ?? [];
1781
- const launched = steps.filter(s => s.launched);
1782
- const passing = launched.filter(s => s.exitCode === 0 && !s.timedOut && !s.crashed);
1783
- const failing = launched.filter(s => s.exitCode !== 0 || s.timedOut || s.crashed);
1784
- currentVerifierScore = effectiveVerification.passed
1785
- ? 1 : launched.length > 0 ? passing.length / launched.length : 0;
1786
- currentFailingVerifierStepCount = failing.length > 0 ? failing.length : undefined;
1787
- loop = {
1788
- ...loop,
1789
- attempts: loop.attempts.map(a => a.attemptId === attemptId
1790
- ? { ...a, verifierScore: currentVerifierScore, ...(currentFailingVerifierStepCount !== undefined ? { failingVerifierStepCount: currentFailingVerifierStepCount } : {}) }
1791
- : a)
1792
- };
1793
- }
1794
- const decision = inferExit({
1795
- loop,
1796
- lastResult: effectiveResult,
1797
- lastFailure: failure,
1798
- costState,
1799
- verificationRequired: verifierConfigured,
1800
- canSwitchAdapter: failure?.recommendedIntervention === "switch_adapter" &&
1801
- adapterChain[currentAdapterIndex] !== undefined &&
1802
- currentAdapter.adapterId !== executingAdapter.adapterId
1803
- });
1804
- // Advance phase based on result
1805
- currentPhase = nextPolicyPhase(currentPhase, effectiveResult, costState, phaseRetryCount);
1806
- if (failure)
1807
- phaseRetryCount++;
1808
- else
1809
- phaseRetryCount = 0;
1810
- // Post-attempt: verification.completed event uses the same authoritative effectiveVerification.
1811
- loop = appendLoopEvent(loop, {
1812
- type: "verification.completed",
1813
- lifecycleState: effectiveVerification.passed ? "completed" : "verifying",
1814
- payload: {
1815
- attemptId,
1816
- attemptIndex: currentAttemptIndex,
1817
- passed: effectiveVerification.passed,
1818
- summary: effectiveVerification.summary,
1819
- ...(effectiveVerification.steps?.length ? { steps: effectiveVerification.steps } : {}),
1820
- ...(effectiveVerification.warnings?.length ? { warnings: effectiveVerification.warnings } : {}),
1821
- ...(effectiveVerification.binding ? { binding: effectiveVerification.binding } : {}),
1822
- changedFiles,
1823
- }
1824
- }, { now: attemptCompletedAt, idFactory });
1825
- if (input.store) {
1826
- await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1827
- kind: "verification.completed",
1828
- runId: loop.loopId,
1829
- attemptIndex: currentAttemptIndex,
1872
+ const decision = inferExit({
1873
+ loop,
1874
+ lastResult: effectiveResult,
1875
+ lastFailure: failure,
1876
+ costState,
1877
+ verificationRequired: verifierConfigured,
1878
+ canSwitchAdapter: failure?.recommendedIntervention === "switch_adapter" &&
1879
+ adapterChain[currentAdapterIndex] !== undefined &&
1880
+ currentAdapter.adapterId !== executingAdapter.adapterId
1881
+ });
1882
+ // Advance phase based on result
1883
+ currentPhase = nextPolicyPhase(currentPhase, effectiveResult, costState, phaseRetryCount);
1884
+ if (failure)
1885
+ phaseRetryCount++;
1886
+ else
1887
+ phaseRetryCount = 0;
1888
+ // Post-attempt: verification.completed event uses the same authoritative effectiveVerification.
1889
+ loop = appendLoopEvent(loop, {
1890
+ type: "verification.completed",
1891
+ lifecycleState: effectiveVerification.passed ? "completed" : "verifying",
1830
1892
  payload: {
1831
1893
  attemptId,
1894
+ attemptIndex: currentAttemptIndex,
1832
1895
  passed: effectiveVerification.passed,
1833
1896
  summary: effectiveVerification.summary,
1834
1897
  ...(effectiveVerification.steps?.length ? { steps: effectiveVerification.steps } : {}),
@@ -1836,103 +1899,124 @@ export async function runMartin(input) {
1836
1899
  ...(effectiveVerification.binding ? { binding: effectiveVerification.binding } : {}),
1837
1900
  changedFiles,
1838
1901
  }
1839
- }));
1840
- }
1841
- if (!effectiveVerification.passed) {
1842
- // Find the best failure evidence: prefer a step with a nonzero exit, then a
1843
- // crashed/timed-out step, then fall back to the verification summary/warnings.
1844
- const steps = effectiveVerification.steps ?? [];
1845
- const failedStep = steps.find(s => !s.timedOut && !s.crashed && s.exitCode !== undefined && s.exitCode !== 0) ??
1846
- steps.find(s => s.timedOut || s.crashed);
1847
- // Build the failure excerpt: prefer step detail, fall back to verification
1848
- // summary and warnings so attempt 2 never receives an empty failure block.
1849
- const rawExcerpt = failedStep?.detail?.trim()
1850
- ? failedStep.detail
1851
- : [
1852
- effectiveVerification.summary,
1853
- ...(effectiveVerification.warnings ?? [])
1854
- ].filter(Boolean).join("\n");
1855
- lastVerifierFailure = {
1856
- command: failedStep?.command ?? effectiveVerification.binding?.commands?.[0] ?? "unknown",
1857
- // Only include exitCode for normal (non-crash, non-timeout) exits.
1858
- // Crash/timeout sentinels like -1 are not meaningful native exit codes.
1859
- ...(!failedStep?.crashed && !failedStep?.timedOut && failedStep?.exitCode !== undefined
1860
- ? { exitCode: failedStep.exitCode }
1861
- : {}),
1862
- ...(failedStep?.crashed ? { crashed: true } : {}),
1863
- ...(failedStep?.timedOut ? { timedOut: true } : {}),
1864
- failureExcerpt: sanitizeVerifierExcerpt(rawExcerpt ?? ""),
1865
- attemptId,
1866
- attemptIndex: currentAttemptIndex,
1867
- };
1868
- }
1869
- else {
1870
- lastVerifierFailure = undefined;
1871
- }
1872
- const postAttemptTerminal = await evaluateCheckpoint("post_attempt", {
1873
- status: effectiveResult.status === "completed" ? "completed" : "failed",
1874
- verificationPassed: effectiveVerification.passed
1875
- });
1876
- if (postAttemptTerminal !== undefined)
1877
- return await terminateRun(postAttemptTerminal);
1878
- // Before-retry: check signals before scheduling the next attempt
1879
- const preRetryTerminal = await evaluateCheckpoint("before_retry");
1880
- if (preRetryTerminal !== undefined)
1881
- return await terminateRun(preRetryTerminal);
1882
- if (decision.shouldExit) {
1902
+ }, { now: attemptCompletedAt, idFactory });
1883
1903
  if (input.store) {
1884
1904
  await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1885
- kind: "run.exited",
1905
+ kind: "verification.completed",
1886
1906
  runId: loop.loopId,
1887
- payload: createRunExitPayload(decision)
1907
+ attemptIndex: currentAttemptIndex,
1908
+ payload: {
1909
+ attemptId,
1910
+ passed: effectiveVerification.passed,
1911
+ summary: effectiveVerification.summary,
1912
+ ...(effectiveVerification.steps?.length ? { steps: effectiveVerification.steps } : {}),
1913
+ ...(effectiveVerification.warnings?.length ? { warnings: effectiveVerification.warnings } : {}),
1914
+ ...(effectiveVerification.binding ? { binding: effectiveVerification.binding } : {}),
1915
+ changedFiles,
1916
+ }
1888
1917
  }));
1889
1918
  }
1890
- // Drain pending diagnostic ledger writes before receipt signing.
1891
- await stopSignalMonitorAndDrainDiagnostics();
1892
- // Blocker 2: inferExit can return shouldExit=true (e.g. diminishing_returns) while
1893
- // a prior-attempt candidate is retained. No-op when lifecycleState="completed".
1894
- const finalInferDecision = await restoreRetainedCandidateForTerminalExit(decision);
1895
- const routingInput = { runStartMs, firstDeltaDetected, firstDeltaTimestampMs, firstDeltaFilePath, firstDeltaChangeType, preDeltaCostUsd };
1896
- const finalizedLoop = await finalizeCurrentLoop(finalInferDecision, now(), routingInput);
1897
- await persistLoopRecordIfSupported(input.store, finalizedLoop);
1898
- return {
1899
- loop: finalizedLoop,
1900
- decision: finalInferDecision
1901
- };
1919
+ if (!effectiveVerification.passed) {
1920
+ // Find the best failure evidence: prefer a step with a nonzero exit, then a
1921
+ // crashed/timed-out step, then fall back to the verification summary/warnings.
1922
+ const steps = effectiveVerification.steps ?? [];
1923
+ const failedStep = steps.find(s => !s.timedOut && !s.crashed && s.exitCode !== undefined && s.exitCode !== 0) ??
1924
+ steps.find(s => s.timedOut || s.crashed);
1925
+ // Build the failure excerpt: prefer step detail, fall back to verification
1926
+ // summary and warnings so attempt 2 never receives an empty failure block.
1927
+ const rawExcerpt = failedStep?.detail?.trim()
1928
+ ? failedStep.detail
1929
+ : [
1930
+ effectiveVerification.summary,
1931
+ ...(effectiveVerification.warnings ?? [])
1932
+ ].filter(Boolean).join("\n");
1933
+ lastVerifierFailure = {
1934
+ command: failedStep?.command ?? effectiveVerification.binding?.commands?.[0] ?? "unknown",
1935
+ // Only include exitCode for normal (non-crash, non-timeout) exits.
1936
+ // Crash/timeout sentinels like -1 are not meaningful native exit codes.
1937
+ ...(!failedStep?.crashed && !failedStep?.timedOut && failedStep?.exitCode !== undefined
1938
+ ? { exitCode: failedStep.exitCode }
1939
+ : {}),
1940
+ ...(failedStep?.crashed ? { crashed: true } : {}),
1941
+ ...(failedStep?.timedOut ? { timedOut: true } : {}),
1942
+ failureExcerpt: sanitizeVerifierExcerpt(rawExcerpt ?? ""),
1943
+ attemptId,
1944
+ attemptIndex: currentAttemptIndex,
1945
+ };
1946
+ }
1947
+ else {
1948
+ lastVerifierFailure = undefined;
1949
+ }
1950
+ const postAttemptTerminal = await evaluateCheckpoint("post_attempt", {
1951
+ status: effectiveResult.status === "completed" ? "completed" : "failed",
1952
+ verificationPassed: effectiveVerification.passed
1953
+ });
1954
+ if (postAttemptTerminal !== undefined)
1955
+ return await terminateRun(postAttemptTerminal);
1956
+ // Before-retry: check signals before scheduling the next attempt
1957
+ const preRetryTerminal = await evaluateCheckpoint("before_retry");
1958
+ if (preRetryTerminal !== undefined)
1959
+ return await terminateRun(preRetryTerminal);
1960
+ if (decision.shouldExit) {
1961
+ if (input.store) {
1962
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1963
+ kind: "run.exited",
1964
+ runId: loop.loopId,
1965
+ payload: createRunExitPayload(decision)
1966
+ }));
1967
+ }
1968
+ // Drain pending diagnostic ledger writes before receipt signing.
1969
+ await stopSignalMonitorAndDrainDiagnostics();
1970
+ // Blocker 2: inferExit can return shouldExit=true (e.g. diminishing_returns) while
1971
+ // a prior-attempt candidate is retained. No-op when lifecycleState="completed".
1972
+ const finalInferDecision = await restoreRetainedCandidateForTerminalExit(decision);
1973
+ const routingInput = { runStartMs, firstDeltaDetected, firstDeltaTimestampMs, firstDeltaFilePath, firstDeltaChangeType, preDeltaCostUsd };
1974
+ const finalizedLoop = await finalizeCurrentLoop(finalInferDecision, now(), routingInput);
1975
+ await persistLoopRecordIfSupported(input.store, finalizedLoop);
1976
+ return {
1977
+ loop: finalizedLoop,
1978
+ decision: finalInferDecision
1979
+ };
1980
+ }
1981
+ }
1982
+ const exhaustedTerminal = await evaluateCheckpoint("before_retry");
1983
+ if (!exhaustedTerminal) {
1984
+ throw new Error("Loop exhausted its configured iterations without an exit-policy decision.");
1902
1985
  }
1986
+ return await terminateRun(exhaustedTerminal);
1903
1987
  }
1904
- const exhaustedTerminal = await evaluateCheckpoint("before_retry");
1905
- if (!exhaustedTerminal) {
1906
- throw new Error("Loop exhausted its configured iterations without an exit-policy decision.");
1988
+ finally {
1989
+ if (wallTimer !== undefined)
1990
+ clearTimeout(wallTimer);
1991
+ disposeSignalMonitor();
1992
+ runController.abort();
1993
+ input.signal?.removeEventListener("abort", observeParentAbort);
1994
+ activeAttemptController?.abort();
1995
+ // Flush any diagnostic ledger writes queued from signal callbacks
1996
+ await Promise.all(pendingControlWrites);
1997
+ // Last-resort: if an unhandled exception escaped the main loop and preCandidateBoundary
1998
+ // is still set, attempt to restore the workspace so unverified code is not left behind.
1999
+ // This path is reached only when none of the normal terminal paths ran successfully.
2000
+ if (preCandidateBoundary) {
2001
+ try {
2002
+ await restoreRollbackBoundary({
2003
+ repoRoot: task.repoRoot,
2004
+ boundary: preCandidateBoundary,
2005
+ restoredAt: now(),
2006
+ decision: "DISCARD"
2007
+ });
2008
+ preCandidateBoundary = undefined;
2009
+ }
2010
+ catch {
2011
+ // Cannot safely write to ledger here. The workspace state is unknown.
2012
+ // The exception that caused this path will propagate to the caller.
2013
+ }
2014
+ }
1907
2015
  }
1908
- return await terminateRun(exhaustedTerminal);
1909
2016
  }
1910
2017
  finally {
1911
- if (wallTimer !== undefined)
1912
- clearTimeout(wallTimer);
1913
- disposeSignalMonitor();
1914
- runController.abort();
1915
- input.signal?.removeEventListener("abort", observeParentAbort);
1916
- activeAttemptController?.abort();
1917
- // Flush any diagnostic ledger writes queued from signal callbacks
1918
- await Promise.all(pendingControlWrites);
1919
- // Last-resort: if an unhandled exception escaped the main loop and preCandidateBoundary
1920
- // is still set, attempt to restore the workspace so unverified code is not left behind.
1921
- // This path is reached only when none of the normal terminal paths ran successfully.
1922
- if (preCandidateBoundary) {
1923
- try {
1924
- await restoreRollbackBoundary({
1925
- repoRoot: input.task.repoRoot,
1926
- boundary: preCandidateBoundary,
1927
- restoredAt: now(),
1928
- decision: "DISCARD"
1929
- });
1930
- preCandidateBoundary = undefined;
1931
- }
1932
- catch {
1933
- // Cannot safely write to ledger here. The workspace state is unknown.
1934
- // The exception that caused this path will propagate to the caller.
1935
- }
2018
+ if (executionLease) {
2019
+ await executionLease.release().catch(() => undefined);
1936
2020
  }
1937
2021
  }
1938
2022
  }