@martinloop/mcp 0.8.6 → 0.8.8

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,8 +1,9 @@
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
- import { captureRollbackBoundary, listAttemptChangedFilesSinceBoundary, restoreRollbackBoundary } from "./rollback.js";
5
+ import { captureRollbackBoundary, findEscapingLinks, listAttemptChangedFilesSinceBoundary, listIgnoredFileChangesSinceBoundary, restoreRollbackBoundary } from "./rollback.js";
6
+ import { findWeakenedTestFiles } from "./test-integrity.js";
6
7
  import { compilePromptPacket } from "./compiler.js";
7
8
  import { makeLedgerEvent, resolveRunsRoot, runDir } from "./persistence/index.js";
8
9
  import { createDefaultExitPolicy, evaluateExitPolicy, hashProgressState, toLegacyExitDecision } from "./exits.js";
@@ -14,7 +15,8 @@ import { decideCircuitBreak } from "./trajectory.js";
14
15
  import { verifyContextHandoff, decideContextCircuitBreak } from "./context-handoff.js";
15
16
  import { verifierActuallyPassed, } from "./verified-handoff.js";
16
17
  export { DEFAULT_AGENT_EXECUTION_INTENT, DEFAULT_PROVIDER_EXECUTION_TIMEOUT_MS, GOVERNED_AUTONOMOUS_BOUNDARY, normalizeProviderExecutionTimeoutMs } from "../contracts/index.js";
17
- export { classifyFailure, computeEvidenceVector, evaluatePatchDecision, evaluateCostGovernor, evaluateBudgetPreflight, inferExit, nextPolicyPhase, policyPhaseToLifecycleState, scorePatchDecision, selectRecoveryRecipe, evaluateVerificationLeash, evaluateFilesystemLeash, evaluateChangeApprovalLeash, evaluateSecretLeash, resolveExecutionProfile, redactSecretsFromText, buildRepoGroundingIndex, loadOrBuildRepoGroundingIndex, queryRepoGroundingIndex, scanPatchForGroundingViolations, captureRollbackBoundary, listAttemptChangedFilesSinceBoundary, restoreRollbackBoundary };
18
+ 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
+ export { describeMalformedVerifier } from "./verifier-input.js";
18
20
  export { buildContextGraphSnapshot, queryContextGraph } from "./context-graph.js";
19
21
  export { ExternalOutcomeContractError, ExternalOutcomePolicyError, hashExternalOutcomeContract, verifyExternalOutcomes, writeExternalOutcomeEvidence, } from "./external-outcome.js";
20
22
  export { createLocalIdentityAuthority, hasIdentityScope, issueIdentityToken, verifyIdentityToken } from "./identity.js";
@@ -24,7 +26,7 @@ export { calculateAvoidedUsd, calculateLoopAvoidedUsd } from "./savings.js";
24
26
  // ─── Routing economics ─────────────────────────────────────────────────────
25
27
  export { classifyRoute, evaluatePreworkBurnPolicy } from "./routing.js";
26
28
  // ─── Test Integrity Validation ──────────────────────────────────────────────
27
- export { validateTestIntegrity, snapshotTestFiles, identifyNewTests, findTestFiles, isTestFile, extractImports, hasTrivialAssertionsOnly, importsPreExistingCode } from "./test-integrity.js";
29
+ export { validateTestIntegrity, snapshotTestFiles, identifyNewTests, findTestFiles, isTestFile, extractImports, hasTrivialAssertionsOnly, importsPreExistingCode, isTestPath, findWeakenedTestFiles } from "./test-integrity.js";
28
30
  // ─── Repo Style Analyzer ────────────────────────────────────────────────────
29
31
  export { analyzeRepoStyle, buildPromptDirectives } from "./repo-analyzer.js";
30
32
  // ─── Context Integrity Pre-gate ──────────────────────────────────────────────
@@ -32,7 +34,7 @@ export { runContextIntegrityPrecheck } from "./context-integrity.js";
32
34
  // ─── Prompt packet compiler ──────────────────────────────────────────────────
33
35
  export { compilePromptPacket } from "./compiler.js";
34
36
  // ─── Verified Handoff builder ────────────────────────────────────────────────
35
- export { buildVerifiedHandoff, deriveVerifiedHandoffLifecycleTruth, deriveVerifiedHandoffExecutionBoundary, resolveVerifiedHandoffOutcome, toTestIntegrityVerdict, verifierActuallyPassed, } from "./verified-handoff.js";
37
+ export { buildVerifiedHandoff, NO_FILE_CHANGES_WARNING, deriveVerifiedHandoffLifecycleTruth, deriveVerifiedHandoffExecutionBoundary, resolveVerifiedHandoffOutcome, toTestIntegrityVerdict, verifierActuallyPassed, } from "./verified-handoff.js";
36
38
  // ─── Persistence (RunStore, LedgerEvent, FileRunStore) ──────────────────────
37
39
  export { createFileRunStore, makeLedgerEvent, readAllLoopRecords, readLatestLoopRecord, readLatestLoopRecordFromFile, readLoopRecordsFromFile, resolveRunsRoot, resolveReceiptIntegrityPath, verifyReceiptIntegrityFromFiles, writeReceiptIntegrityMaterial } from "./persistence/index.js";
38
40
  export { compileAndPersistContext } from "./persistence/index.js";
@@ -164,11 +166,15 @@ export async function runMartin(input) {
164
166
  const idFactory = input.idFactory;
165
167
  const providerExecutionTimeoutMs = normalizeProviderExecutionTimeoutMs(input.task.providerExecutionTimeoutMs);
166
168
  const agentExecutionIntent = input.task.agentExecutionIntent ?? DEFAULT_AGENT_EXECUTION_INTENT;
169
+ // D1: apply task-class-aware iteration defaults when the caller did not set an
170
+ // explicit maxIterations. reversible_edit → 3, verification_only/irreversible → 1.
171
+ const effectiveMaxIterations = resolveEffectiveMaxIterations(input.task, input.budget.maxIterations);
172
+ const effectiveBudget = { ...input.budget, maxIterations: effectiveMaxIterations };
167
173
  let loop = createLoopRecord({
168
174
  workspaceId: input.workspaceId,
169
175
  projectId: input.projectId,
170
176
  task: { ...input.task, agentExecutionIntent, providerExecutionTimeoutMs },
171
- budget: input.budget,
177
+ budget: effectiveBudget,
172
178
  ...(input.savingsBaseline ? { cost: { savingsBaseline: input.savingsBaseline } } : {}),
173
179
  ...(input.receiptScope
174
180
  ? { receiptScope: { ...input.receiptScope, agentExecutionIntent, providerExecutionTimeoutMs } }
@@ -425,6 +431,11 @@ export async function runMartin(input) {
425
431
  // Explicit PolicyPhase state machine — starts at GATHER, advances per attempt
426
432
  let currentPhase = "GATHER";
427
433
  let phaseRetryCount = 0;
434
+ let lastVerifierFailure;
435
+ // C1 terminal cleanup: boundary captured immediately before the first retained candidate.
436
+ // Used to restore the workspace if the run terminates without VERIFIED.
437
+ // The invariant is: if this is set, the current workspace contains unverified candidate code.
438
+ let preCandidateBoundary;
428
439
  // Routing economics: track first meaningful workspace delta
429
440
  const runStartMs = Date.now();
430
441
  let firstDeltaDetected = false;
@@ -563,8 +574,16 @@ export async function runMartin(input) {
563
574
  ...(observedExternalEvent !== undefined ? { externalEvent: observedExternalEvent } : {})
564
575
  };
565
576
  }
566
- // Idempotent termination: only the first caller writes the envelope (A1 fix)
567
- function finishFromEvaluation(evaluation) {
577
+ // Termination selection with deferred disk write.
578
+ //
579
+ // The "run.terminated" ledger event is written at the original timing (here, before
580
+ // terminateRun runs) to preserve ledger ordering. The DISK write of termination.json
581
+ // is deferred to terminateRun() so that it reflects the true final terminal state
582
+ // AFTER retained-candidate cleanup — not a potentially stale pre-cleanup state.
583
+ //
584
+ // First-write-wins semantics on disk are preserved: persistTerminationEnvelope is
585
+ // called exactly once per run, from terminateRun(), after cleanup resolves.
586
+ function selectTerminalEvaluation(evaluation) {
568
587
  if (terminationPromise !== undefined)
569
588
  return terminationPromise;
570
589
  const envelope = {
@@ -574,9 +593,9 @@ export async function runMartin(input) {
574
593
  };
575
594
  terminationPromise = (async () => {
576
595
  if (input.store) {
577
- // persistTerminationEnvelope is atomic and returns the winning envelope
578
- // (may differ from ours if another path won the race — that's fine).
579
- await persistTerminationEnvelope(runDir(runsRoot, loop.loopId), envelope);
596
+ // Write the "run.terminated" ledger entry at the original timing — this
597
+ // preserves ledger ordering and receipt integrity hashes.
598
+ // The DISK termination.json is deliberately deferred to terminateRun().
580
599
  await input.store.appendLedger(loop.loopId, makeLedgerEvent({
581
600
  kind: "run.terminated",
582
601
  runId: loop.loopId,
@@ -598,14 +617,107 @@ export async function runMartin(input) {
598
617
  if (signalMonitorError !== undefined)
599
618
  throw signalMonitorError;
600
619
  const evaluation = evaluateExitPolicy(exitPolicy, currentExitSnapshot(phase, result));
601
- return evaluation.shouldExit ? finishFromEvaluation(evaluation) : undefined;
620
+ if (!evaluation.shouldExit)
621
+ return undefined;
622
+ return await selectTerminalEvaluation(evaluation);
623
+ }
624
+ /**
625
+ * Blocker 1 / Blocker 2 — Authoritative retained-candidate terminal restoration.
626
+ *
627
+ * Must be called at EVERY terminal exit that does not produce lifecycleState="completed".
628
+ * If preCandidateBoundary is set, the workspace contains unverified candidate code.
629
+ * This helper:
630
+ * 1. Detects whether retained unverified candidate state exists.
631
+ * 2. Restores the exact pre-candidate boundary (not the per-attempt boundary).
632
+ * 3. Independently inspects the resulting workspace via the rollback outcome.
633
+ * 4. Persists rollback outcome/evidence to the ledger.
634
+ * 5. Clears preCandidateBoundary ONLY after proven restoration.
635
+ * 6. On failure: surfaces workspace_restore_failed and escalates the exit decision.
636
+ *
637
+ * NEVER silently swallows restore errors. NEVER returns a clean-looking exit
638
+ * while residual unverified code may remain in the repository.
639
+ */
640
+ async function restoreRetainedCandidateForTerminalExit(exitDecision) {
641
+ // Step 1: determine whether retained unverified candidate state exists.
642
+ if (!preCandidateBoundary || exitDecision.lifecycleState === "completed") {
643
+ return exitDecision;
644
+ }
645
+ const capturedBoundary = preCandidateBoundary;
646
+ const restoredAt = now();
647
+ try {
648
+ // Step 2: restore the exact pre-candidate boundary.
649
+ const rollbackOutcome = await restoreRollbackBoundary({
650
+ repoRoot: input.task.repoRoot,
651
+ boundary: capturedBoundary,
652
+ restoredAt,
653
+ decision: "DISCARD"
654
+ });
655
+ // Step 3: independently inspect the resulting workspace.
656
+ if (!rollbackOutcome) {
657
+ throw new Error("Workspace restore returned no outcome — repoRoot may be unavailable or boundary invalid.");
658
+ }
659
+ // Step 4: prove the restoration — only success/partial are acceptable outcomes.
660
+ if (rollbackOutcome.status === "failed" || rollbackOutcome.status === "unavailable") {
661
+ throw new Error(rollbackOutcome.error ??
662
+ `Workspace restore returned status "${rollbackOutcome.status}" — pre-candidate state not confirmed.`);
663
+ }
664
+ // Persist rollback outcome evidence.
665
+ if (input.store) {
666
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
667
+ kind: "run.diagnostic",
668
+ runId: loop.loopId,
669
+ payload: {
670
+ code: "terminal_candidate_restore",
671
+ details: {
672
+ status: rollbackOutcome.status,
673
+ restoredFiles: rollbackOutcome.restoredFiles?.length ?? 0,
674
+ deletedFiles: rollbackOutcome.deletedFiles?.length ?? 0,
675
+ capturedAt: capturedBoundary.capturedAt
676
+ }
677
+ }
678
+ }));
679
+ }
680
+ // Step 5/6: clear retained-candidate authority only after proven restoration.
681
+ preCandidateBoundary = undefined;
682
+ return exitDecision;
683
+ }
684
+ catch (restoreError) {
685
+ // Persist the failure evidence.
686
+ if (input.store) {
687
+ try {
688
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
689
+ kind: "run.diagnostic",
690
+ runId: loop.loopId,
691
+ payload: {
692
+ code: "terminal_candidate_restore_failed",
693
+ details: {
694
+ error: restoreError instanceof Error ? restoreError.message : String(restoreError),
695
+ capturedAt: capturedBoundary.capturedAt
696
+ }
697
+ }
698
+ }));
699
+ }
700
+ catch {
701
+ // Ledger write failure is secondary — the restore error is what matters.
702
+ }
703
+ }
704
+ // DO NOT clear preCandidateBoundary — workspace state is unknown.
705
+ // Surface explicit unsafe status; never return a clean-looking exit.
706
+ return {
707
+ ...exitDecision,
708
+ lifecycleState: "human_escalation",
709
+ status: "exited",
710
+ reasonCode: "workspace_restore_failed",
711
+ reason: `MartinLoop retained an unverified candidate but could not restore the pre-candidate workspace: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}. Manual workspace inspection required.`
712
+ };
713
+ }
602
714
  }
603
715
  async function terminateRun(envelope) {
604
716
  if (envelope.class !== "operational_exit") {
605
717
  throw new Error("terminateRun: unexpected non-operational envelope");
606
718
  }
607
719
  const legacyDecision = toLegacyExitDecision(envelope.exit, observedExternalEvent?.disposition);
608
- const exitDecision = {
720
+ let exitDecision = {
609
721
  shouldExit: legacyDecision.shouldExit,
610
722
  lifecycleState: legacyDecision.lifecycleState,
611
723
  status: legacyDecision.status,
@@ -630,12 +742,38 @@ export async function runMartin(input) {
630
742
  firstDeltaChangeType,
631
743
  preDeltaCostUsd
632
744
  };
633
- // Drain all pending diagnostic ledger writes BEFORE signing the receipt.
634
- // persistLoopRecordIfSupported reads ledger.jsonl to compute the integrity hash;
635
- // any write that lands after that read produces a false ledger_hash_mismatch on verify.
745
+ // Drain all pending diagnostic ledger writes BEFORE the cleanup helper may add more.
636
746
  await stopSignalMonitorAndDrainDiagnostics();
747
+ // Retained-candidate cleanup runs BEFORE any durable termination envelope is
748
+ // written to disk. This guarantees that termination.json reflects the TRUE
749
+ // final state (original exit OR workspace_restore_failed) in a single atomic
750
+ // first-write-wins write. selectTerminalEvaluation() already memoized the winner
751
+ // in memory; disk persistence happens only here, after cleanup resolves.
752
+ exitDecision = await restoreRetainedCandidateForTerminalExit(exitDecision);
753
+ // Persist the authoritative termination envelope exactly once, after cleanup.
754
+ let finalEnvelope;
755
+ if (exitDecision.reasonCode === "workspace_restore_failed") {
756
+ // Cleanup failed: write guard_stop(workspace_restore_failed) as the sole record.
757
+ if (input.store) {
758
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
759
+ kind: "run.exited",
760
+ runId: loop.loopId,
761
+ payload: createRunExitPayload(exitDecision)
762
+ }));
763
+ }
764
+ finalEnvelope = await persistGuardStop(exitDecision);
765
+ }
766
+ else {
767
+ // Cleanup succeeded (or was not needed): persist the original operational_exit.
768
+ // "run.terminated" was already written by selectTerminalEvaluation() at the
769
+ // original timing (before terminateRun runs) to preserve ledger ordering.
770
+ finalEnvelope = envelope;
771
+ if (input.store) {
772
+ await persistTerminationEnvelope(runDir(runsRoot, loop.loopId), finalEnvelope);
773
+ }
774
+ }
637
775
  const finalizedLoop = await finalizeCurrentLoop(exitDecision, now(), routingInput);
638
- const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope: envelope };
776
+ const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope: finalEnvelope };
639
777
  await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
640
778
  return { loop: finalizedWithEnvelope, decision: exitDecision };
641
779
  }
@@ -665,7 +803,9 @@ export async function runMartin(input) {
665
803
  remainingBudgetUsd: distilled.constraints.remainingBudgetUsd,
666
804
  remainingTokens: distilled.constraints.remainingTokens,
667
805
  minimumViableTokens: executingAdapter.metadata.budgetPreflight?.minimumViableTokens,
668
- perAttemptCapUsd: loop.budget.maxUsd * 0.25
806
+ // D2: pass remaining iterations so the preflight can compute a budget-aware cap
807
+ // rather than using the old rigid maxUsd * 0.25 that blocked viable attempts.
808
+ remainingIterations: distilled.constraints.remainingIterations
669
809
  });
670
810
  if (!budgetPreflight.allowed) {
671
811
  const preflightExitDecision = {
@@ -688,11 +828,13 @@ export async function runMartin(input) {
688
828
  }));
689
829
  }
690
830
  await stopSignalMonitorAndDrainDiagnostics();
691
- const finalizedLoop = await finalizeCurrentLoop(preflightExitDecision, now());
831
+ // Blocker 2: budget preflight may fire when a prior-attempt candidate is retained.
832
+ const finalPreflightDecision = await restoreRetainedCandidateForTerminalExit(preflightExitDecision);
833
+ const finalizedLoop = await finalizeCurrentLoop(finalPreflightDecision, now());
692
834
  await persistLoopRecordIfSupported(input.store, finalizedLoop);
693
835
  return {
694
836
  loop: finalizedLoop,
695
- decision: preflightExitDecision
837
+ decision: finalPreflightDecision
696
838
  };
697
839
  }
698
840
  // GATHER → ADMIT: run admission control before executing
@@ -737,13 +879,15 @@ export async function runMartin(input) {
737
879
  }));
738
880
  }
739
881
  await stopSignalMonitorAndDrainDiagnostics();
740
- const terminationEnvelope = await persistGuardStop(poisoningExitDecision);
741
- const finalizedLoop = await finalizeCurrentLoop(poisoningExitDecision, now());
882
+ // Blocker 2: poisoning check may fire between attempts while a candidate is retained.
883
+ const finalPoisoningDecision = await restoreRetainedCandidateForTerminalExit(poisoningExitDecision);
884
+ const terminationEnvelope = await persistGuardStop(finalPoisoningDecision);
885
+ const finalizedLoop = await finalizeCurrentLoop(finalPoisoningDecision, now());
742
886
  const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope };
743
887
  await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
744
888
  return {
745
889
  loop: finalizedWithEnvelope,
746
- decision: poisoningExitDecision
890
+ decision: finalPoisoningDecision
747
891
  };
748
892
  }
749
893
  const admissionDecision = evaluateAttemptPolicy({
@@ -781,11 +925,16 @@ export async function runMartin(input) {
781
925
  });
782
926
  if (!admissionDecision.allowed) {
783
927
  const exitReason = admissionDecision.reason;
784
- const exitDecision = {
928
+ // Fix false budget_exit: trajectory circuit-break is diminishing_returns, not budget_exit.
929
+ // A trajectory/no-progress stop is not a resource exhaustion.
930
+ const admissionLifecycleState = admissionDecision.recommendedIntervention === "escalate_human"
931
+ ? "human_escalation"
932
+ : admissionDecision.circuitBreakDecision?.shouldStop
933
+ ? "diminishing_returns"
934
+ : "budget_exit";
935
+ const admissionExitDecision = {
785
936
  shouldExit: true,
786
- lifecycleState: admissionDecision.recommendedIntervention === "escalate_human"
787
- ? "human_escalation"
788
- : "budget_exit",
937
+ lifecycleState: admissionLifecycleState,
789
938
  status: "exited",
790
939
  reason: exitReason
791
940
  };
@@ -799,15 +948,17 @@ export async function runMartin(input) {
799
948
  await input.store.appendLedger(loop.loopId, makeLedgerEvent({
800
949
  kind: "run.exited",
801
950
  runId: loop.loopId,
802
- payload: createRunExitPayload(exitDecision)
951
+ payload: createRunExitPayload(admissionExitDecision)
803
952
  }));
804
953
  }
805
954
  await stopSignalMonitorAndDrainDiagnostics();
806
- const finalizedLoop = await finalizeCurrentLoop(exitDecision, now());
955
+ // Blocker 2: admission may be denied between attempts while a candidate is retained.
956
+ const finalAdmissionDecision = await restoreRetainedCandidateForTerminalExit(admissionExitDecision);
957
+ const finalizedLoop = await finalizeCurrentLoop(finalAdmissionDecision, now());
807
958
  await persistLoopRecordIfSupported(input.store, finalizedLoop);
808
959
  return {
809
960
  loop: finalizedLoop,
810
- decision: exitDecision
961
+ decision: finalAdmissionDecision
811
962
  };
812
963
  }
813
964
  if (input.store) {
@@ -867,6 +1018,12 @@ export async function runMartin(input) {
867
1018
  remainingTokens: distilled.constraints.remainingTokens,
868
1019
  ...(input.verificationExecutionOwner
869
1020
  ? { verificationExecutionOwner: input.verificationExecutionOwner }
1021
+ : {}),
1022
+ ...(lastVerifierFailure ? { lastVerifierFailure } : {}),
1023
+ // C1: tell the adapter whether the previous candidate was retained so it can
1024
+ // repair rather than rebuild.
1025
+ ...(loop.attempts.at(-1)?.candidateRetained
1026
+ ? { previousCandidateRetained: true }
870
1027
  : {})
871
1028
  },
872
1029
  previousAttempts: loop.attempts,
@@ -905,6 +1062,36 @@ export async function runMartin(input) {
905
1062
  const abortTerminal = await evaluateCheckpoint("post_attempt");
906
1063
  if (abortTerminal !== undefined)
907
1064
  return await terminateRun(abortTerminal);
1065
+ // Blocker 2 (exception path): provider threw a genuine non-abort exception.
1066
+ // Restore any retained candidate BEFORE re-throwing so the workspace is
1067
+ // never left with unverified code after an exception escape.
1068
+ const exceptionBaseDecision = {
1069
+ shouldExit: true,
1070
+ lifecycleState: "human_escalation",
1071
+ status: "exited",
1072
+ reason: "Provider threw an unexpected exception.",
1073
+ reasonCode: "provider_exception"
1074
+ };
1075
+ let cleanedExceptionDecision = exceptionBaseDecision;
1076
+ try {
1077
+ cleanedExceptionDecision = await restoreRetainedCandidateForTerminalExit(exceptionBaseDecision);
1078
+ }
1079
+ catch (cleanupError) {
1080
+ queueControlDiagnostic("terminal_restore_on_provider_exception_failed", {
1081
+ error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
1082
+ });
1083
+ }
1084
+ if (cleanedExceptionDecision.reasonCode === "workspace_restore_failed") {
1085
+ // Cleanup failed: persist workspace_restore_failed as the terminal authority
1086
+ // so the operator knows the workspace state is unsafe. Then surface both errors.
1087
+ try {
1088
+ await persistGuardStop(cleanedExceptionDecision);
1089
+ }
1090
+ catch { /* persist best-effort */ }
1091
+ const wsError = new Error(cleanedExceptionDecision.reason, { cause: execError instanceof Error ? execError : new Error(String(execError)) });
1092
+ throw wsError;
1093
+ }
1094
+ // Cleanup succeeded — rethrow the original provider error.
908
1095
  throw execError;
909
1096
  }
910
1097
  finally {
@@ -921,6 +1108,12 @@ export async function runMartin(input) {
921
1108
  ? classifyFailure({ attempts: loop.attempts, result })
922
1109
  : undefined;
923
1110
  const currentAttemptIndex = loop.attempts.length + 1;
1111
+ // Progress evidence is computed below from effectiveVerification (which includes
1112
+ // host-run results). Initialize from the normalized (not raw) verification so
1113
+ // that initial values are already binding-checked. Back-patched with the final
1114
+ // authoritative values after effectiveVerification is available (Blocker 4).
1115
+ let currentVerifierScore = verification.passed ? 1 : 0;
1116
+ let currentFailingVerifierStepCount;
924
1117
  const attempt = {
925
1118
  attemptId,
926
1119
  index: currentAttemptIndex,
@@ -933,7 +1126,10 @@ export async function runMartin(input) {
933
1126
  ...(failure?.recommendedIntervention
934
1127
  ? { intervention: failure.recommendedIntervention }
935
1128
  : {}),
936
- ...(failure?.diagnosticHint ? { diagnosticHint: failure.diagnosticHint } : {})
1129
+ ...(failure?.diagnosticHint ? { diagnosticHint: failure.diagnosticHint } : {}),
1130
+ // C2: real progress signals — back-patched from effectiveVerification below
1131
+ verifierScore: currentVerifierScore
1132
+ // C1: candidateRetained is set below after rollback decision is made
937
1133
  };
938
1134
  loop = {
939
1135
  ...loop,
@@ -1073,9 +1269,99 @@ export async function runMartin(input) {
1073
1269
  }
1074
1270
  }));
1075
1271
  }
1076
- const changedFiles = tracksWorkspaceMutations
1077
- ? resolveChangedFiles(result, request.context.repoRoot, rollbackBoundary)
1078
- : [];
1272
+ let changedFiles = [];
1273
+ let ignoredChanges = { created: [], modified: [], deleted: [] };
1274
+ let escapingLinks = [];
1275
+ let weakenedTestFiles = [];
1276
+ try {
1277
+ changedFiles = tracksWorkspaceMutations
1278
+ ? resolveChangedFiles(result, request.context.repoRoot, rollbackBoundary)
1279
+ : [];
1280
+ ignoredChanges = tracksWorkspaceMutations
1281
+ ? listIgnoredFileChangesSinceBoundary({ repoRoot: request.context.repoRoot, boundary: rollbackBoundary })
1282
+ : ignoredChanges;
1283
+ escapingLinks =
1284
+ tracksWorkspaceMutations && request.context.repoRoot
1285
+ ? findEscapingLinks(request.context.repoRoot, [...changedFiles, ...ignoredChanges.created])
1286
+ : [];
1287
+ weakenedTestFiles =
1288
+ tracksWorkspaceMutations && request.context.repoRoot && rollbackBoundary?.headRef
1289
+ ? findWeakenedTestFiles({
1290
+ repoRoot: request.context.repoRoot,
1291
+ baseRef: rollbackBoundary.headRef,
1292
+ changedFiles
1293
+ })
1294
+ : [];
1295
+ }
1296
+ catch (error) {
1297
+ for (const filePath of normalizeFileEffectPaths(result.execution?.changedFiles ?? [])) {
1298
+ touchedDuringExecution.add(filePath);
1299
+ }
1300
+ for (const filePath of normalizeFileEffectPaths(changedFiles)) {
1301
+ touchedDuringExecution.add(filePath);
1302
+ }
1303
+ let rollbackOutcome;
1304
+ try {
1305
+ rollbackOutcome = (await restoreRollbackBoundary({
1306
+ repoRoot: request.context.repoRoot,
1307
+ boundary: rollbackBoundary,
1308
+ restoredAt: attemptCompletedAt,
1309
+ decision: "DISCARD",
1310
+ removePaths: ignoredChanges.created,
1311
+ restoreIgnoredPaths: [...ignoredChanges.modified, ...ignoredChanges.deleted]
1312
+ })) ?? {
1313
+ attempted: false,
1314
+ status: "unavailable",
1315
+ restoredAt: attemptCompletedAt,
1316
+ decision: "DISCARD",
1317
+ before: { trackedDirtyFiles: [], untrackedFiles: [] },
1318
+ after: { trackedDirtyFiles: [], untrackedFiles: [] },
1319
+ restoredFiles: [],
1320
+ deletedFiles: [],
1321
+ error: "Rollback could not run because no repository root was available."
1322
+ };
1323
+ }
1324
+ catch (rollbackError) {
1325
+ rollbackOutcome = {
1326
+ attempted: true,
1327
+ status: "failed",
1328
+ restoredAt: attemptCompletedAt,
1329
+ decision: "DISCARD",
1330
+ before: { trackedDirtyFiles: [], untrackedFiles: [] },
1331
+ after: { trackedDirtyFiles: [], untrackedFiles: [] },
1332
+ restoredFiles: [],
1333
+ deletedFiles: [],
1334
+ error: `Rollback failed after workspace observation became unavailable: ${fileEffectErrorMessage(rollbackError)}`
1335
+ };
1336
+ }
1337
+ const observationExitDecision = {
1338
+ shouldExit: true,
1339
+ lifecycleState: "human_escalation",
1340
+ status: "exited",
1341
+ reason: `Workspace change observation became unavailable: ${fileEffectErrorMessage(error)}`,
1342
+ reasonCode: "workspace_observation_unavailable"
1343
+ };
1344
+ if (input.store) {
1345
+ await input.store.writeAttemptArtifacts(loop.loopId, currentAttemptIndex, {
1346
+ compiledContext,
1347
+ ...(rollbackBoundary ? { rollbackBoundary } : {}),
1348
+ rollbackOutcome
1349
+ });
1350
+ await input.store.appendLedger(loop.loopId, makeLedgerEvent({
1351
+ kind: "run.exited",
1352
+ runId: loop.loopId,
1353
+ payload: createRunExitPayload(observationExitDecision)
1354
+ }));
1355
+ }
1356
+ await stopSignalMonitorAndDrainDiagnostics();
1357
+ // Blocker 2: observation failure is terminal; restore pre-candidate boundary if set.
1358
+ const finalObservationDecision = await restoreRetainedCandidateForTerminalExit(observationExitDecision);
1359
+ const terminationEnvelope = await persistGuardStop(finalObservationDecision);
1360
+ const finalizedLoop = await finalizeCurrentLoop(finalObservationDecision, now());
1361
+ const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope };
1362
+ await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
1363
+ return { loop: finalizedWithEnvelope, decision: finalObservationDecision };
1364
+ }
1079
1365
  for (const filePath of normalizeFileEffectPaths(changedFiles)) {
1080
1366
  touchedDuringExecution.add(filePath);
1081
1367
  }
@@ -1100,6 +1386,8 @@ export async function runMartin(input) {
1100
1386
  const filesystemDecision = evaluateFilesystemLeash({
1101
1387
  repoRoot: request.context.repoRoot,
1102
1388
  changedFiles,
1389
+ ignoredFiles: [...ignoredChanges.created, ...ignoredChanges.modified, ...ignoredChanges.deleted],
1390
+ escapingLinks,
1103
1391
  allowedPaths: request.context.allowedPaths,
1104
1392
  deniedPaths: request.context.deniedPaths,
1105
1393
  mutationMode: request.context.mutationMode
@@ -1123,12 +1411,27 @@ export async function runMartin(input) {
1123
1411
  reason: filesystemDecision.reason ?? "Safety leash blocked filesystem changes.",
1124
1412
  ...classifySafetyLeashExit(filesystemDecision, "filesystem")
1125
1413
  };
1126
- const rollbackOutcome = await restoreRollbackBoundary({
1127
- repoRoot: request.context.repoRoot,
1128
- boundary: rollbackBoundary,
1129
- restoredAt: attemptCompletedAt,
1130
- decision: patchDecision.decision
1131
- });
1414
+ // Wrap per-attempt rollback so that if it throws, preCandidateBoundary
1415
+ // restoration still runs via the cleanup helper below.
1416
+ let rollbackOutcome;
1417
+ try {
1418
+ rollbackOutcome = await restoreRollbackBoundary({
1419
+ repoRoot: request.context.repoRoot,
1420
+ boundary: rollbackBoundary,
1421
+ restoredAt: attemptCompletedAt,
1422
+ decision: patchDecision.decision,
1423
+ removePaths: violatingIgnoredPaths(filesystemDecision.violations, ignoredChanges.created),
1424
+ restoreIgnoredPaths: violatingIgnoredPaths(filesystemDecision.violations, [
1425
+ ...ignoredChanges.modified,
1426
+ ...ignoredChanges.deleted
1427
+ ])
1428
+ }) ?? undefined;
1429
+ }
1430
+ catch (rollbackError) {
1431
+ queueControlDiagnostic("attempt_rollback_failed_on_filesystem_exit", {
1432
+ error: rollbackError instanceof Error ? rollbackError.message : String(rollbackError)
1433
+ });
1434
+ }
1132
1435
  if (input.store) {
1133
1436
  await input.store.writeAttemptArtifacts(loop.loopId, currentAttemptIndex, {
1134
1437
  compiledContext,
@@ -1167,17 +1470,21 @@ export async function runMartin(input) {
1167
1470
  }));
1168
1471
  }
1169
1472
  await stopSignalMonitorAndDrainDiagnostics();
1170
- const terminationEnvelope = await persistGuardStop(filesystemExitDecision);
1171
- const finalizedLoop = await finalizeCurrentLoop(filesystemExitDecision, now());
1473
+ // Blocker 2: attempt-2 rollback restores to state-with-attempt-1-candidate.
1474
+ // We must also restore preCandidateBoundary to fully remove retained unverified code.
1475
+ const finalFilesystemDecision = await restoreRetainedCandidateForTerminalExit(filesystemExitDecision);
1476
+ const terminationEnvelope = await persistGuardStop(finalFilesystemDecision);
1477
+ const finalizedLoop = await finalizeCurrentLoop(finalFilesystemDecision, now());
1172
1478
  const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope };
1173
1479
  await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
1174
1480
  return {
1175
1481
  loop: finalizedWithEnvelope,
1176
- decision: filesystemExitDecision
1482
+ decision: finalFilesystemDecision
1177
1483
  };
1178
1484
  }
1179
1485
  const changeApprovalDecision = evaluateChangeApprovalLeash({
1180
1486
  changedFiles,
1487
+ weakenedTestFiles,
1181
1488
  executionProfile: request.context.executionProfile,
1182
1489
  approvalPolicy: request.context.approvalPolicy
1183
1490
  });
@@ -1202,12 +1509,21 @@ export async function runMartin(input) {
1202
1509
  "Safety leash blocked dependency or migration changes that require approval.",
1203
1510
  ...classifySafetyLeashExit(changeApprovalDecision, "dependency")
1204
1511
  };
1205
- const rollbackOutcome = await restoreRollbackBoundary({
1206
- repoRoot: request.context.repoRoot,
1207
- boundary: rollbackBoundary,
1208
- restoredAt: attemptCompletedAt,
1209
- decision: patchDecision.decision
1210
- });
1512
+ // Wrap per-attempt rollback so that preCandidateBoundary restore still runs below.
1513
+ let rollbackOutcome;
1514
+ try {
1515
+ rollbackOutcome = await restoreRollbackBoundary({
1516
+ repoRoot: request.context.repoRoot,
1517
+ boundary: rollbackBoundary,
1518
+ restoredAt: attemptCompletedAt,
1519
+ decision: patchDecision.decision
1520
+ }) ?? undefined;
1521
+ }
1522
+ catch (rollbackError) {
1523
+ queueControlDiagnostic("attempt_rollback_failed_on_approval_exit", {
1524
+ error: rollbackError instanceof Error ? rollbackError.message : String(rollbackError)
1525
+ });
1526
+ }
1211
1527
  if (input.store) {
1212
1528
  await input.store.writeAttemptArtifacts(loop.loopId, currentAttemptIndex, {
1213
1529
  compiledContext,
@@ -1247,13 +1563,15 @@ export async function runMartin(input) {
1247
1563
  }));
1248
1564
  }
1249
1565
  await stopSignalMonitorAndDrainDiagnostics();
1250
- const terminationEnvelope = await persistGuardStop(approvalExitDecision);
1251
- const finalizedLoop = await finalizeCurrentLoop(approvalExitDecision, now());
1566
+ // Blocker 2: per-attempt rollback above only restores to state-with-retained-candidate.
1567
+ const finalApprovalDecision = await restoreRetainedCandidateForTerminalExit(approvalExitDecision);
1568
+ const terminationEnvelope = await persistGuardStop(finalApprovalDecision);
1569
+ const finalizedLoop = await finalizeCurrentLoop(finalApprovalDecision, now());
1252
1570
  const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope };
1253
1571
  await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
1254
1572
  return {
1255
1573
  loop: finalizedWithEnvelope,
1256
- decision: approvalExitDecision
1574
+ decision: finalApprovalDecision
1257
1575
  };
1258
1576
  }
1259
1577
  // VERIFY: Run grounding scan on patch diff if available
@@ -1301,16 +1619,40 @@ export async function runMartin(input) {
1301
1619
  summary: result.summary
1302
1620
  });
1303
1621
  }
1622
+ // C1 — Safe candidate retention.
1623
+ // Distinguish UNSAFE discards (must roll back) from SAFE_UNVERIFIED discards
1624
+ // (retain candidate for the next repair attempt).
1625
+ //
1626
+ // UNSAFE: scope/safety violations — rollback immediately; hard boundaries.
1627
+ // SAFE_UNVERIFIED: only soft heuristics (grounding, low novelty, verifier
1628
+ // not yet improved) — leave the workspace in the candidate state so the
1629
+ // next agent can repair rather than rebuild from scratch.
1630
+ // provider failure (result.status === "failed"): rollback — the workspace
1631
+ // state is unknown and may be partially corrupt.
1632
+ // When patchDecision is undefined (no patch truth evaluation, e.g. verifierConfigured=false),
1633
+ // preserve original behavior: only rollback on provider failure. Do not treat the absence
1634
+ // of a patch decision as "unsafe" — it simply means patch evaluation was not applicable.
1635
+ const candidateIsUnsafe = patchDecision !== undefined && (patchDecision.decision === "ESCALATE" ||
1636
+ (patchDecision.decision === "DISCARD" &&
1637
+ !isSafeUnverifiedDiscard(patchDecision.reasonCodes)));
1638
+ // Blocker 3: candidate may only be retained if an authoritative rollback boundary
1639
+ // exists to enable exact restoration. Without a boundary, we cannot prove we can
1640
+ // undo the retention — fail closed and discard instead.
1641
+ const candidateRetainedThisAttempt = patchDecision?.decision === "DISCARD" &&
1642
+ isSafeUnverifiedDiscard(patchDecision.reasonCodes) &&
1643
+ result.status !== "failed" &&
1644
+ rollbackBoundary !== undefined;
1304
1645
  let rollbackOutcome;
1305
- if (patchDecision && patchDecision.decision !== "KEEP") {
1646
+ if (candidateIsUnsafe && result.status !== "failed") {
1306
1647
  rollbackOutcome = await restoreRollbackBoundary({
1307
1648
  repoRoot: request.context.repoRoot,
1308
1649
  boundary: rollbackBoundary,
1309
1650
  restoredAt: attemptCompletedAt,
1310
- decision: patchDecision.decision
1651
+ decision: patchDecision?.decision ?? "DISCARD"
1311
1652
  });
1312
1653
  }
1313
1654
  else if (result.status === "failed") {
1655
+ // Provider failure: workspace state unknown — always rollback.
1314
1656
  rollbackOutcome = await restoreRollbackBoundary({
1315
1657
  repoRoot: request.context.repoRoot,
1316
1658
  boundary: rollbackBoundary,
@@ -1318,6 +1660,19 @@ export async function runMartin(input) {
1318
1660
  decision: "DISCARD"
1319
1661
  });
1320
1662
  }
1663
+ // else: candidateRetainedThisAttempt or KEEP — leave workspace as-is.
1664
+ // C1: back-patch candidateRetained onto the attempt that was already appended.
1665
+ if (candidateRetainedThisAttempt) {
1666
+ // First retention: record the boundary BEFORE this candidate was applied,
1667
+ // so terminal cleanup can restore the workspace if the run never reaches VERIFIED.
1668
+ if (!preCandidateBoundary && rollbackBoundary) {
1669
+ preCandidateBoundary = rollbackBoundary;
1670
+ }
1671
+ loop = {
1672
+ ...loop,
1673
+ attempts: loop.attempts.map((a) => a.attemptId === attemptId ? { ...a, candidateRetained: true } : a)
1674
+ };
1675
+ }
1321
1676
  if (input.store) {
1322
1677
  await input.store.writeAttemptArtifacts(loop.loopId, currentAttemptIndex, {
1323
1678
  compiledContext,
@@ -1392,11 +1747,13 @@ export async function runMartin(input) {
1392
1747
  }));
1393
1748
  }
1394
1749
  await stopSignalMonitorAndDrainDiagnostics();
1395
- const finalizedLoop = await finalizeCurrentLoop(patchExitDecision, now());
1750
+ // Blocker 2: ESCALATE/HANDOFF exits can occur after a candidate is retained.
1751
+ const finalPatchExitDecision = await restoreRetainedCandidateForTerminalExit(patchExitDecision);
1752
+ const finalizedLoop = await finalizeCurrentLoop(finalPatchExitDecision, now());
1396
1753
  await persistLoopRecordIfSupported(input.store, finalizedLoop);
1397
1754
  return {
1398
1755
  loop: finalizedLoop,
1399
- decision: patchExitDecision
1756
+ decision: finalPatchExitDecision
1400
1757
  };
1401
1758
  }
1402
1759
  const effectiveResult = patchDecision && patchDecision.decision !== "KEEP"
@@ -1415,6 +1772,25 @@ export async function runMartin(input) {
1415
1772
  }
1416
1773
  }
1417
1774
  : result;
1775
+ // Authoritative verification evidence from effectiveResult (grounding/patch-truth applied).
1776
+ // Back-patch verifierScore/failingVerifierStepCount BEFORE inferExit so that progress
1777
+ // decisions use normalized, binding-checked evidence — never raw adapter-reported values.
1778
+ const effectiveVerification = normalizeVerificationOutcome(effectiveResult, expectedVerifierBinding);
1779
+ {
1780
+ const steps = effectiveVerification.steps ?? [];
1781
+ const launched = steps.filter(s => s.launched);
1782
+ const passing = launched.filter(s => s.exitCode === 0 && !s.timedOut && !s.crashed);
1783
+ const failing = launched.filter(s => s.exitCode !== 0 || s.timedOut || s.crashed);
1784
+ currentVerifierScore = effectiveVerification.passed
1785
+ ? 1 : launched.length > 0 ? passing.length / launched.length : 0;
1786
+ currentFailingVerifierStepCount = failing.length > 0 ? failing.length : undefined;
1787
+ loop = {
1788
+ ...loop,
1789
+ attempts: loop.attempts.map(a => a.attemptId === attemptId
1790
+ ? { ...a, verifierScore: currentVerifierScore, ...(currentFailingVerifierStepCount !== undefined ? { failingVerifierStepCount: currentFailingVerifierStepCount } : {}) }
1791
+ : a)
1792
+ };
1793
+ }
1418
1794
  const decision = inferExit({
1419
1795
  loop,
1420
1796
  lastResult: effectiveResult,
@@ -1431,10 +1807,7 @@ export async function runMartin(input) {
1431
1807
  phaseRetryCount++;
1432
1808
  else
1433
1809
  phaseRetryCount = 0;
1434
- // Post-attempt: persisted verification and exit decisions must use the same
1435
- // authoritative outcome. effectiveResult reflects any grounding/leash override,
1436
- // so verification.completed is written here — never from the raw adapter result.
1437
- const effectiveVerification = normalizeVerificationOutcome(effectiveResult, expectedVerifierBinding);
1810
+ // Post-attempt: verification.completed event uses the same authoritative effectiveVerification.
1438
1811
  loop = appendLoopEvent(loop, {
1439
1812
  type: "verification.completed",
1440
1813
  lifecycleState: effectiveVerification.passed ? "completed" : "verifying",
@@ -1465,6 +1838,37 @@ export async function runMartin(input) {
1465
1838
  }
1466
1839
  }));
1467
1840
  }
1841
+ if (!effectiveVerification.passed) {
1842
+ // Find the best failure evidence: prefer a step with a nonzero exit, then a
1843
+ // crashed/timed-out step, then fall back to the verification summary/warnings.
1844
+ const steps = effectiveVerification.steps ?? [];
1845
+ const failedStep = steps.find(s => !s.timedOut && !s.crashed && s.exitCode !== undefined && s.exitCode !== 0) ??
1846
+ steps.find(s => s.timedOut || s.crashed);
1847
+ // Build the failure excerpt: prefer step detail, fall back to verification
1848
+ // summary and warnings so attempt 2 never receives an empty failure block.
1849
+ const rawExcerpt = failedStep?.detail?.trim()
1850
+ ? failedStep.detail
1851
+ : [
1852
+ effectiveVerification.summary,
1853
+ ...(effectiveVerification.warnings ?? [])
1854
+ ].filter(Boolean).join("\n");
1855
+ lastVerifierFailure = {
1856
+ command: failedStep?.command ?? effectiveVerification.binding?.commands?.[0] ?? "unknown",
1857
+ // Only include exitCode for normal (non-crash, non-timeout) exits.
1858
+ // Crash/timeout sentinels like -1 are not meaningful native exit codes.
1859
+ ...(!failedStep?.crashed && !failedStep?.timedOut && failedStep?.exitCode !== undefined
1860
+ ? { exitCode: failedStep.exitCode }
1861
+ : {}),
1862
+ ...(failedStep?.crashed ? { crashed: true } : {}),
1863
+ ...(failedStep?.timedOut ? { timedOut: true } : {}),
1864
+ failureExcerpt: sanitizeVerifierExcerpt(rawExcerpt ?? ""),
1865
+ attemptId,
1866
+ attemptIndex: currentAttemptIndex,
1867
+ };
1868
+ }
1869
+ else {
1870
+ lastVerifierFailure = undefined;
1871
+ }
1468
1872
  const postAttemptTerminal = await evaluateCheckpoint("post_attempt", {
1469
1873
  status: effectiveResult.status === "completed" ? "completed" : "failed",
1470
1874
  verificationPassed: effectiveVerification.passed
@@ -1485,12 +1889,15 @@ export async function runMartin(input) {
1485
1889
  }
1486
1890
  // Drain pending diagnostic ledger writes before receipt signing.
1487
1891
  await stopSignalMonitorAndDrainDiagnostics();
1892
+ // Blocker 2: inferExit can return shouldExit=true (e.g. diminishing_returns) while
1893
+ // a prior-attempt candidate is retained. No-op when lifecycleState="completed".
1894
+ const finalInferDecision = await restoreRetainedCandidateForTerminalExit(decision);
1488
1895
  const routingInput = { runStartMs, firstDeltaDetected, firstDeltaTimestampMs, firstDeltaFilePath, firstDeltaChangeType, preDeltaCostUsd };
1489
- const finalizedLoop = await finalizeCurrentLoop(decision, now(), routingInput);
1896
+ const finalizedLoop = await finalizeCurrentLoop(finalInferDecision, now(), routingInput);
1490
1897
  await persistLoopRecordIfSupported(input.store, finalizedLoop);
1491
1898
  return {
1492
1899
  loop: finalizedLoop,
1493
- decision
1900
+ decision: finalInferDecision
1494
1901
  };
1495
1902
  }
1496
1903
  }
@@ -1509,6 +1916,24 @@ export async function runMartin(input) {
1509
1916
  activeAttemptController?.abort();
1510
1917
  // Flush any diagnostic ledger writes queued from signal callbacks
1511
1918
  await Promise.all(pendingControlWrites);
1919
+ // Last-resort: if an unhandled exception escaped the main loop and preCandidateBoundary
1920
+ // is still set, attempt to restore the workspace so unverified code is not left behind.
1921
+ // This path is reached only when none of the normal terminal paths ran successfully.
1922
+ if (preCandidateBoundary) {
1923
+ try {
1924
+ await restoreRollbackBoundary({
1925
+ repoRoot: input.task.repoRoot,
1926
+ boundary: preCandidateBoundary,
1927
+ restoredAt: now(),
1928
+ decision: "DISCARD"
1929
+ });
1930
+ preCandidateBoundary = undefined;
1931
+ }
1932
+ catch {
1933
+ // Cannot safely write to ledger here. The workspace state is unknown.
1934
+ // The exception that caused this path will propagate to the caller.
1935
+ }
1936
+ }
1512
1937
  }
1513
1938
  }
1514
1939
  function createRunExitPayload(decision) {
@@ -1550,6 +1975,8 @@ function safetyLeashReasonCode(decision, safetySurface) {
1550
1975
  return "migration_approval_required";
1551
1976
  case "config_change_approval_required":
1552
1977
  return "config_change_approval_required";
1978
+ case "test_change_approval_required":
1979
+ return "test_change_approval_required";
1553
1980
  default:
1554
1981
  return `${safetySurface}_safety_block`;
1555
1982
  }
@@ -1595,7 +2022,8 @@ function observeRunFileEffects(input) {
1595
2022
  try {
1596
2023
  const netChangedAtCompletion = normalizeFileEffectPaths(listAttemptChangedFilesSinceBoundary({
1597
2024
  repoRoot: input.repoRoot,
1598
- boundary: input.boundary
2025
+ boundary: input.boundary,
2026
+ includeBaselineSnapshotChanges: true
1599
2027
  }));
1600
2028
  const netChanged = new Set(netChangedAtCompletion);
1601
2029
  return {
@@ -1714,18 +2142,23 @@ function mergeCostProvenance(previous, current) {
1714
2142
  }
1715
2143
  return COST_PROVENANCE_RANK[current] < COST_PROVENANCE_RANK[previous] ? current : previous;
1716
2144
  }
2145
+ /**
2146
+ * Git-backed observation that degrades the same way `resolveChangedFiles` does:
2147
+ * when Git becomes unavailable after the attempt, fall back only if the adapter
2148
+ * reported its own changed files; otherwise fail closed.
2149
+ */
2150
+ function violatingIgnoredPaths(violations, ignoredPaths) {
2151
+ const violating = new Set(violations.map((violation) => violation.file?.replace(/\/$/u, "")).filter(Boolean));
2152
+ return ignoredPaths.filter((filePath) => violating.has(filePath.replace(/\/$/u, "")));
2153
+ }
1717
2154
  function resolveChangedFiles(result, repoRoot, rollbackBoundary) {
1718
2155
  const adapterReported = normalizeFileEffectPaths(result.execution?.changedFiles ?? []);
1719
- try {
1720
- const gitObserved = listAttemptChangedFilesSinceBoundary({ repoRoot, boundary: rollbackBoundary });
1721
- return normalizeFileEffectPaths([...adapterReported, ...gitObserved]);
1722
- }
1723
- catch (error) {
1724
- if (result.execution?.changedFiles !== undefined) {
1725
- return adapterReported;
1726
- }
1727
- throw error;
1728
- }
2156
+ const gitObserved = listAttemptChangedFilesSinceBoundary({
2157
+ repoRoot,
2158
+ boundary: rollbackBoundary,
2159
+ includeBaselineSnapshotChanges: true
2160
+ });
2161
+ return normalizeFileEffectPaths([...adapterReported, ...gitObserved]);
1729
2162
  }
1730
2163
  function buildPatchDiff(result, changedFiles) {
1731
2164
  if (result.execution?.patch) {
@@ -1806,6 +2239,27 @@ function getLastVerifierScore(loop) {
1806
2239
  function truncateVerificationDetail(text, maxLength) {
1807
2240
  return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`;
1808
2241
  }
2242
+ /**
2243
+ * Sanitize verifier output before it is stored as a structured failure excerpt
2244
+ * that will eventually appear in a provider prompt.
2245
+ *
2246
+ * Two-layer contract:
2247
+ * Layer 1 (here, core): hard size cap + strip control characters and secrets.
2248
+ * Layer 2 (adapter): sanitizeForPrompt removes prompt-injection patterns.
2249
+ *
2250
+ * Rules:
2251
+ * - Keep tail (most recent lines are most relevant for repair).
2252
+ * - Strip null bytes and C0/C1 control characters except \n and \t.
2253
+ * - Hard cap at 1500 characters — large test output cannot flood the context.
2254
+ * - Tag the excerpt so downstream consumers know it is untrusted evidence.
2255
+ */
2256
+ export function sanitizeVerifierExcerpt(raw) {
2257
+ const HARD_CAP = 1500;
2258
+ // Strip control characters except newline (\n) and horizontal tab (\t)
2259
+ const stripped = raw.replace(/[\u0000-\u0008\u000b-\u000c\u000e-\u001f\u007f\u0080-\u009f]+/gu, " ");
2260
+ // Keep the tail — the most recent output is the most actionable failure
2261
+ return stripped.length <= HARD_CAP ? stripped : stripped.slice(-(HARD_CAP));
2262
+ }
1809
2263
  function resolveExpectedVerifierBinding(request) {
1810
2264
  const commands = request.context.verificationStack?.length
1811
2265
  ? request.context.verificationStack.map((step) => step.command)
@@ -1966,3 +2420,4 @@ export { readSwarmReceiptProjection, verifySwarmReceiptProjection, } from "./swa
1966
2420
  export { buildSwarmShareProjection, readSwarmDossier, verifySwarmEvidence, } from "./swarm/evidence-projection.js";
1967
2421
  export { buildSwarmHostedEnvelope } from "./swarm/hosted-export.js";
1968
2422
  export { assertSwarmPathIdentifier } from "./swarm/workspaces.js";
2423
+ // buildRepairHeader removed — test scaffolding, not product code