@martinloop/mcp 0.8.7 → 0.8.9

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