@martinloop/mcp 0.8.7 → 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.
- package/dist/package-version.d.ts +1 -1
- package/dist/package-version.js +1 -1
- package/dist/tools/preflight.js +1 -1
- package/dist/tools/run-loop.js +7 -1
- package/dist/vendor/adapters/claude-cli.d.ts +4 -0
- package/dist/vendor/adapters/claude-cli.js +55 -8
- package/dist/vendor/contracts/index.d.ts +60 -2
- package/dist/vendor/contracts/index.js +34 -0
- package/dist/vendor/core/index.d.ts +38 -0
- package/dist/vendor/core/index.js +473 -79
- package/dist/vendor/core/policy.d.ts +18 -1
- package/dist/vendor/core/policy.js +113 -10
- package/dist/vendor/core/rollback.js +43 -13
- package/dist/vendor/core/test-integrity.js +36 -13
- package/dist/vendor/core/test-surface.js +4 -1
- package/dist/vendor/core/verifier-input.d.ts +3 -1
- package/dist/vendor/core/verifier-input.js +50 -2
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -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";
|
|
@@ -166,11 +166,15 @@ export async function runMartin(input) {
|
|
|
166
166
|
const idFactory = input.idFactory;
|
|
167
167
|
const providerExecutionTimeoutMs = normalizeProviderExecutionTimeoutMs(input.task.providerExecutionTimeoutMs);
|
|
168
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 };
|
|
169
173
|
let loop = createLoopRecord({
|
|
170
174
|
workspaceId: input.workspaceId,
|
|
171
175
|
projectId: input.projectId,
|
|
172
176
|
task: { ...input.task, agentExecutionIntent, providerExecutionTimeoutMs },
|
|
173
|
-
budget:
|
|
177
|
+
budget: effectiveBudget,
|
|
174
178
|
...(input.savingsBaseline ? { cost: { savingsBaseline: input.savingsBaseline } } : {}),
|
|
175
179
|
...(input.receiptScope
|
|
176
180
|
? { receiptScope: { ...input.receiptScope, agentExecutionIntent, providerExecutionTimeoutMs } }
|
|
@@ -427,6 +431,11 @@ export async function runMartin(input) {
|
|
|
427
431
|
// Explicit PolicyPhase state machine — starts at GATHER, advances per attempt
|
|
428
432
|
let currentPhase = "GATHER";
|
|
429
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;
|
|
430
439
|
// Routing economics: track first meaningful workspace delta
|
|
431
440
|
const runStartMs = Date.now();
|
|
432
441
|
let firstDeltaDetected = false;
|
|
@@ -565,8 +574,16 @@ export async function runMartin(input) {
|
|
|
565
574
|
...(observedExternalEvent !== undefined ? { externalEvent: observedExternalEvent } : {})
|
|
566
575
|
};
|
|
567
576
|
}
|
|
568
|
-
//
|
|
569
|
-
|
|
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) {
|
|
570
587
|
if (terminationPromise !== undefined)
|
|
571
588
|
return terminationPromise;
|
|
572
589
|
const envelope = {
|
|
@@ -576,9 +593,9 @@ export async function runMartin(input) {
|
|
|
576
593
|
};
|
|
577
594
|
terminationPromise = (async () => {
|
|
578
595
|
if (input.store) {
|
|
579
|
-
//
|
|
580
|
-
//
|
|
581
|
-
|
|
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().
|
|
582
599
|
await input.store.appendLedger(loop.loopId, makeLedgerEvent({
|
|
583
600
|
kind: "run.terminated",
|
|
584
601
|
runId: loop.loopId,
|
|
@@ -600,14 +617,107 @@ export async function runMartin(input) {
|
|
|
600
617
|
if (signalMonitorError !== undefined)
|
|
601
618
|
throw signalMonitorError;
|
|
602
619
|
const evaluation = evaluateExitPolicy(exitPolicy, currentExitSnapshot(phase, result));
|
|
603
|
-
|
|
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
|
+
}
|
|
604
714
|
}
|
|
605
715
|
async function terminateRun(envelope) {
|
|
606
716
|
if (envelope.class !== "operational_exit") {
|
|
607
717
|
throw new Error("terminateRun: unexpected non-operational envelope");
|
|
608
718
|
}
|
|
609
719
|
const legacyDecision = toLegacyExitDecision(envelope.exit, observedExternalEvent?.disposition);
|
|
610
|
-
|
|
720
|
+
let exitDecision = {
|
|
611
721
|
shouldExit: legacyDecision.shouldExit,
|
|
612
722
|
lifecycleState: legacyDecision.lifecycleState,
|
|
613
723
|
status: legacyDecision.status,
|
|
@@ -632,12 +742,38 @@ export async function runMartin(input) {
|
|
|
632
742
|
firstDeltaChangeType,
|
|
633
743
|
preDeltaCostUsd
|
|
634
744
|
};
|
|
635
|
-
// Drain all pending diagnostic ledger writes BEFORE
|
|
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.
|
|
745
|
+
// Drain all pending diagnostic ledger writes BEFORE the cleanup helper may add more.
|
|
638
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
|
+
}
|
|
639
775
|
const finalizedLoop = await finalizeCurrentLoop(exitDecision, now(), routingInput);
|
|
640
|
-
const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope:
|
|
776
|
+
const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope: finalEnvelope };
|
|
641
777
|
await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
|
|
642
778
|
return { loop: finalizedWithEnvelope, decision: exitDecision };
|
|
643
779
|
}
|
|
@@ -667,7 +803,9 @@ export async function runMartin(input) {
|
|
|
667
803
|
remainingBudgetUsd: distilled.constraints.remainingBudgetUsd,
|
|
668
804
|
remainingTokens: distilled.constraints.remainingTokens,
|
|
669
805
|
minimumViableTokens: executingAdapter.metadata.budgetPreflight?.minimumViableTokens,
|
|
670
|
-
|
|
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
|
|
671
809
|
});
|
|
672
810
|
if (!budgetPreflight.allowed) {
|
|
673
811
|
const preflightExitDecision = {
|
|
@@ -690,11 +828,13 @@ export async function runMartin(input) {
|
|
|
690
828
|
}));
|
|
691
829
|
}
|
|
692
830
|
await stopSignalMonitorAndDrainDiagnostics();
|
|
693
|
-
|
|
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());
|
|
694
834
|
await persistLoopRecordIfSupported(input.store, finalizedLoop);
|
|
695
835
|
return {
|
|
696
836
|
loop: finalizedLoop,
|
|
697
|
-
decision:
|
|
837
|
+
decision: finalPreflightDecision
|
|
698
838
|
};
|
|
699
839
|
}
|
|
700
840
|
// GATHER → ADMIT: run admission control before executing
|
|
@@ -739,13 +879,15 @@ export async function runMartin(input) {
|
|
|
739
879
|
}));
|
|
740
880
|
}
|
|
741
881
|
await stopSignalMonitorAndDrainDiagnostics();
|
|
742
|
-
|
|
743
|
-
const
|
|
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());
|
|
744
886
|
const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope };
|
|
745
887
|
await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
|
|
746
888
|
return {
|
|
747
889
|
loop: finalizedWithEnvelope,
|
|
748
|
-
decision:
|
|
890
|
+
decision: finalPoisoningDecision
|
|
749
891
|
};
|
|
750
892
|
}
|
|
751
893
|
const admissionDecision = evaluateAttemptPolicy({
|
|
@@ -783,11 +925,16 @@ export async function runMartin(input) {
|
|
|
783
925
|
});
|
|
784
926
|
if (!admissionDecision.allowed) {
|
|
785
927
|
const exitReason = admissionDecision.reason;
|
|
786
|
-
|
|
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 = {
|
|
787
936
|
shouldExit: true,
|
|
788
|
-
lifecycleState:
|
|
789
|
-
? "human_escalation"
|
|
790
|
-
: "budget_exit",
|
|
937
|
+
lifecycleState: admissionLifecycleState,
|
|
791
938
|
status: "exited",
|
|
792
939
|
reason: exitReason
|
|
793
940
|
};
|
|
@@ -801,15 +948,17 @@ export async function runMartin(input) {
|
|
|
801
948
|
await input.store.appendLedger(loop.loopId, makeLedgerEvent({
|
|
802
949
|
kind: "run.exited",
|
|
803
950
|
runId: loop.loopId,
|
|
804
|
-
payload: createRunExitPayload(
|
|
951
|
+
payload: createRunExitPayload(admissionExitDecision)
|
|
805
952
|
}));
|
|
806
953
|
}
|
|
807
954
|
await stopSignalMonitorAndDrainDiagnostics();
|
|
808
|
-
|
|
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());
|
|
809
958
|
await persistLoopRecordIfSupported(input.store, finalizedLoop);
|
|
810
959
|
return {
|
|
811
960
|
loop: finalizedLoop,
|
|
812
|
-
decision:
|
|
961
|
+
decision: finalAdmissionDecision
|
|
813
962
|
};
|
|
814
963
|
}
|
|
815
964
|
if (input.store) {
|
|
@@ -869,6 +1018,12 @@ export async function runMartin(input) {
|
|
|
869
1018
|
remainingTokens: distilled.constraints.remainingTokens,
|
|
870
1019
|
...(input.verificationExecutionOwner
|
|
871
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 }
|
|
872
1027
|
: {})
|
|
873
1028
|
},
|
|
874
1029
|
previousAttempts: loop.attempts,
|
|
@@ -907,6 +1062,36 @@ export async function runMartin(input) {
|
|
|
907
1062
|
const abortTerminal = await evaluateCheckpoint("post_attempt");
|
|
908
1063
|
if (abortTerminal !== undefined)
|
|
909
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.
|
|
910
1095
|
throw execError;
|
|
911
1096
|
}
|
|
912
1097
|
finally {
|
|
@@ -923,6 +1108,12 @@ export async function runMartin(input) {
|
|
|
923
1108
|
? classifyFailure({ attempts: loop.attempts, result })
|
|
924
1109
|
: undefined;
|
|
925
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;
|
|
926
1117
|
const attempt = {
|
|
927
1118
|
attemptId,
|
|
928
1119
|
index: currentAttemptIndex,
|
|
@@ -935,7 +1126,10 @@ export async function runMartin(input) {
|
|
|
935
1126
|
...(failure?.recommendedIntervention
|
|
936
1127
|
? { intervention: failure.recommendedIntervention }
|
|
937
1128
|
: {}),
|
|
938
|
-
...(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
|
|
939
1133
|
};
|
|
940
1134
|
loop = {
|
|
941
1135
|
...loop,
|
|
@@ -1076,15 +1270,70 @@ export async function runMartin(input) {
|
|
|
1076
1270
|
}));
|
|
1077
1271
|
}
|
|
1078
1272
|
let changedFiles = [];
|
|
1273
|
+
let ignoredChanges = { created: [], modified: [], deleted: [] };
|
|
1274
|
+
let escapingLinks = [];
|
|
1275
|
+
let weakenedTestFiles = [];
|
|
1079
1276
|
try {
|
|
1080
1277
|
changedFiles = tracksWorkspaceMutations
|
|
1081
1278
|
? resolveChangedFiles(result, request.context.repoRoot, rollbackBoundary)
|
|
1082
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
|
+
: [];
|
|
1083
1295
|
}
|
|
1084
1296
|
catch (error) {
|
|
1085
1297
|
for (const filePath of normalizeFileEffectPaths(result.execution?.changedFiles ?? [])) {
|
|
1086
1298
|
touchedDuringExecution.add(filePath);
|
|
1087
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
|
+
}
|
|
1088
1337
|
const observationExitDecision = {
|
|
1089
1338
|
shouldExit: true,
|
|
1090
1339
|
lifecycleState: "human_escalation",
|
|
@@ -1093,6 +1342,11 @@ export async function runMartin(input) {
|
|
|
1093
1342
|
reasonCode: "workspace_observation_unavailable"
|
|
1094
1343
|
};
|
|
1095
1344
|
if (input.store) {
|
|
1345
|
+
await input.store.writeAttemptArtifacts(loop.loopId, currentAttemptIndex, {
|
|
1346
|
+
compiledContext,
|
|
1347
|
+
...(rollbackBoundary ? { rollbackBoundary } : {}),
|
|
1348
|
+
rollbackOutcome
|
|
1349
|
+
});
|
|
1096
1350
|
await input.store.appendLedger(loop.loopId, makeLedgerEvent({
|
|
1097
1351
|
kind: "run.exited",
|
|
1098
1352
|
runId: loop.loopId,
|
|
@@ -1100,11 +1354,13 @@ export async function runMartin(input) {
|
|
|
1100
1354
|
}));
|
|
1101
1355
|
}
|
|
1102
1356
|
await stopSignalMonitorAndDrainDiagnostics();
|
|
1103
|
-
|
|
1104
|
-
const
|
|
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());
|
|
1105
1361
|
const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope };
|
|
1106
1362
|
await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
|
|
1107
|
-
return { loop: finalizedWithEnvelope, decision:
|
|
1363
|
+
return { loop: finalizedWithEnvelope, decision: finalObservationDecision };
|
|
1108
1364
|
}
|
|
1109
1365
|
for (const filePath of normalizeFileEffectPaths(changedFiles)) {
|
|
1110
1366
|
touchedDuringExecution.add(filePath);
|
|
@@ -1127,12 +1383,6 @@ export async function runMartin(input) {
|
|
|
1127
1383
|
// returned a non-empty list. A repoRoot alone is insufficient — git may fail (e.g. not
|
|
1128
1384
|
// a git repo) and silently return [], which would falsely trigger no_code_change.
|
|
1129
1385
|
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
1386
|
const filesystemDecision = evaluateFilesystemLeash({
|
|
1137
1387
|
repoRoot: request.context.repoRoot,
|
|
1138
1388
|
changedFiles,
|
|
@@ -1161,19 +1411,27 @@ export async function runMartin(input) {
|
|
|
1161
1411
|
reason: filesystemDecision.reason ?? "Safety leash blocked filesystem changes.",
|
|
1162
1412
|
...classifySafetyLeashExit(filesystemDecision, "filesystem")
|
|
1163
1413
|
};
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
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
|
+
}
|
|
1177
1435
|
if (input.store) {
|
|
1178
1436
|
await input.store.writeAttemptArtifacts(loop.loopId, currentAttemptIndex, {
|
|
1179
1437
|
compiledContext,
|
|
@@ -1212,22 +1470,18 @@ export async function runMartin(input) {
|
|
|
1212
1470
|
}));
|
|
1213
1471
|
}
|
|
1214
1472
|
await stopSignalMonitorAndDrainDiagnostics();
|
|
1215
|
-
|
|
1216
|
-
|
|
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());
|
|
1217
1478
|
const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope };
|
|
1218
1479
|
await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
|
|
1219
1480
|
return {
|
|
1220
1481
|
loop: finalizedWithEnvelope,
|
|
1221
|
-
decision:
|
|
1482
|
+
decision: finalFilesystemDecision
|
|
1222
1483
|
};
|
|
1223
1484
|
}
|
|
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
1485
|
const changeApprovalDecision = evaluateChangeApprovalLeash({
|
|
1232
1486
|
changedFiles,
|
|
1233
1487
|
weakenedTestFiles,
|
|
@@ -1255,12 +1509,21 @@ export async function runMartin(input) {
|
|
|
1255
1509
|
"Safety leash blocked dependency or migration changes that require approval.",
|
|
1256
1510
|
...classifySafetyLeashExit(changeApprovalDecision, "dependency")
|
|
1257
1511
|
};
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
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
|
+
}
|
|
1264
1527
|
if (input.store) {
|
|
1265
1528
|
await input.store.writeAttemptArtifacts(loop.loopId, currentAttemptIndex, {
|
|
1266
1529
|
compiledContext,
|
|
@@ -1300,13 +1563,15 @@ export async function runMartin(input) {
|
|
|
1300
1563
|
}));
|
|
1301
1564
|
}
|
|
1302
1565
|
await stopSignalMonitorAndDrainDiagnostics();
|
|
1303
|
-
|
|
1304
|
-
const
|
|
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());
|
|
1305
1570
|
const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope };
|
|
1306
1571
|
await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
|
|
1307
1572
|
return {
|
|
1308
1573
|
loop: finalizedWithEnvelope,
|
|
1309
|
-
decision:
|
|
1574
|
+
decision: finalApprovalDecision
|
|
1310
1575
|
};
|
|
1311
1576
|
}
|
|
1312
1577
|
// VERIFY: Run grounding scan on patch diff if available
|
|
@@ -1354,16 +1619,40 @@ export async function runMartin(input) {
|
|
|
1354
1619
|
summary: result.summary
|
|
1355
1620
|
});
|
|
1356
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;
|
|
1357
1645
|
let rollbackOutcome;
|
|
1358
|
-
if (
|
|
1646
|
+
if (candidateIsUnsafe && result.status !== "failed") {
|
|
1359
1647
|
rollbackOutcome = await restoreRollbackBoundary({
|
|
1360
1648
|
repoRoot: request.context.repoRoot,
|
|
1361
1649
|
boundary: rollbackBoundary,
|
|
1362
1650
|
restoredAt: attemptCompletedAt,
|
|
1363
|
-
decision: patchDecision
|
|
1651
|
+
decision: patchDecision?.decision ?? "DISCARD"
|
|
1364
1652
|
});
|
|
1365
1653
|
}
|
|
1366
1654
|
else if (result.status === "failed") {
|
|
1655
|
+
// Provider failure: workspace state unknown — always rollback.
|
|
1367
1656
|
rollbackOutcome = await restoreRollbackBoundary({
|
|
1368
1657
|
repoRoot: request.context.repoRoot,
|
|
1369
1658
|
boundary: rollbackBoundary,
|
|
@@ -1371,6 +1660,19 @@ export async function runMartin(input) {
|
|
|
1371
1660
|
decision: "DISCARD"
|
|
1372
1661
|
});
|
|
1373
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
|
+
}
|
|
1374
1676
|
if (input.store) {
|
|
1375
1677
|
await input.store.writeAttemptArtifacts(loop.loopId, currentAttemptIndex, {
|
|
1376
1678
|
compiledContext,
|
|
@@ -1445,11 +1747,13 @@ export async function runMartin(input) {
|
|
|
1445
1747
|
}));
|
|
1446
1748
|
}
|
|
1447
1749
|
await stopSignalMonitorAndDrainDiagnostics();
|
|
1448
|
-
|
|
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());
|
|
1449
1753
|
await persistLoopRecordIfSupported(input.store, finalizedLoop);
|
|
1450
1754
|
return {
|
|
1451
1755
|
loop: finalizedLoop,
|
|
1452
|
-
decision:
|
|
1756
|
+
decision: finalPatchExitDecision
|
|
1453
1757
|
};
|
|
1454
1758
|
}
|
|
1455
1759
|
const effectiveResult = patchDecision && patchDecision.decision !== "KEEP"
|
|
@@ -1468,6 +1772,25 @@ export async function runMartin(input) {
|
|
|
1468
1772
|
}
|
|
1469
1773
|
}
|
|
1470
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
|
+
}
|
|
1471
1794
|
const decision = inferExit({
|
|
1472
1795
|
loop,
|
|
1473
1796
|
lastResult: effectiveResult,
|
|
@@ -1484,10 +1807,7 @@ export async function runMartin(input) {
|
|
|
1484
1807
|
phaseRetryCount++;
|
|
1485
1808
|
else
|
|
1486
1809
|
phaseRetryCount = 0;
|
|
1487
|
-
// Post-attempt:
|
|
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);
|
|
1810
|
+
// Post-attempt: verification.completed event uses the same authoritative effectiveVerification.
|
|
1491
1811
|
loop = appendLoopEvent(loop, {
|
|
1492
1812
|
type: "verification.completed",
|
|
1493
1813
|
lifecycleState: effectiveVerification.passed ? "completed" : "verifying",
|
|
@@ -1518,6 +1838,37 @@ export async function runMartin(input) {
|
|
|
1518
1838
|
}
|
|
1519
1839
|
}));
|
|
1520
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
|
+
}
|
|
1521
1872
|
const postAttemptTerminal = await evaluateCheckpoint("post_attempt", {
|
|
1522
1873
|
status: effectiveResult.status === "completed" ? "completed" : "failed",
|
|
1523
1874
|
verificationPassed: effectiveVerification.passed
|
|
@@ -1538,12 +1889,15 @@ export async function runMartin(input) {
|
|
|
1538
1889
|
}
|
|
1539
1890
|
// Drain pending diagnostic ledger writes before receipt signing.
|
|
1540
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);
|
|
1541
1895
|
const routingInput = { runStartMs, firstDeltaDetected, firstDeltaTimestampMs, firstDeltaFilePath, firstDeltaChangeType, preDeltaCostUsd };
|
|
1542
|
-
const finalizedLoop = await finalizeCurrentLoop(
|
|
1896
|
+
const finalizedLoop = await finalizeCurrentLoop(finalInferDecision, now(), routingInput);
|
|
1543
1897
|
await persistLoopRecordIfSupported(input.store, finalizedLoop);
|
|
1544
1898
|
return {
|
|
1545
1899
|
loop: finalizedLoop,
|
|
1546
|
-
decision
|
|
1900
|
+
decision: finalInferDecision
|
|
1547
1901
|
};
|
|
1548
1902
|
}
|
|
1549
1903
|
}
|
|
@@ -1562,6 +1916,24 @@ export async function runMartin(input) {
|
|
|
1562
1916
|
activeAttemptController?.abort();
|
|
1563
1917
|
// Flush any diagnostic ledger writes queued from signal callbacks
|
|
1564
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
|
+
}
|
|
1565
1937
|
}
|
|
1566
1938
|
}
|
|
1567
1939
|
function createRunExitPayload(decision) {
|
|
@@ -1867,6 +2239,27 @@ function getLastVerifierScore(loop) {
|
|
|
1867
2239
|
function truncateVerificationDetail(text, maxLength) {
|
|
1868
2240
|
return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`;
|
|
1869
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
|
+
}
|
|
1870
2263
|
function resolveExpectedVerifierBinding(request) {
|
|
1871
2264
|
const commands = request.context.verificationStack?.length
|
|
1872
2265
|
? request.context.verificationStack.map((step) => step.command)
|
|
@@ -2027,3 +2420,4 @@ export { readSwarmReceiptProjection, verifySwarmReceiptProjection, } from "./swa
|
|
|
2027
2420
|
export { buildSwarmShareProjection, readSwarmDossier, verifySwarmEvidence, } from "./swarm/evidence-projection.js";
|
|
2028
2421
|
export { buildSwarmHostedEnvelope } from "./swarm/hosted-export.js";
|
|
2029
2422
|
export { assertSwarmPathIdentifier } from "./swarm/workspaces.js";
|
|
2423
|
+
// buildRepairHeader removed — test scaffolding, not product code
|