@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.
- package/dist/package-version.d.ts +1 -1
- package/dist/package-version.js +1 -1
- package/dist/tools/preflight.js +8 -4
- 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 +81 -13
- package/dist/vendor/contracts/index.d.ts +79 -0
- package/dist/vendor/contracts/index.js +34 -0
- package/dist/vendor/contracts/swarm.js +1 -1
- package/dist/vendor/core/index.d.ts +43 -4
- package/dist/vendor/core/index.js +529 -74
- package/dist/vendor/core/leash.d.ts +11 -1
- package/dist/vendor/core/leash.js +77 -3
- package/dist/vendor/core/output-directories.d.ts +7 -0
- package/dist/vendor/core/output-directories.js +36 -0
- package/dist/vendor/core/policy.d.ts +18 -1
- package/dist/vendor/core/policy.js +113 -10
- package/dist/vendor/core/rollback.d.ts +24 -0
- package/dist/vendor/core/rollback.js +402 -19
- package/dist/vendor/core/test-integrity.d.ts +18 -0
- package/dist/vendor/core/test-integrity.js +114 -1
- package/dist/vendor/core/test-surface.d.ts +27 -0
- package/dist/vendor/core/test-surface.js +231 -0
- package/dist/vendor/core/verified-handoff.d.ts +1 -0
- package/dist/vendor/core/verified-handoff.js +6 -0
- package/dist/vendor/core/verifier-input.d.ts +4 -0
- package/dist/vendor/core/verifier-input.js +107 -0
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -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:
|
|
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
|
-
//
|
|
567
|
-
|
|
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
|
-
//
|
|
578
|
-
//
|
|
579
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
741
|
-
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());
|
|
742
886
|
const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope };
|
|
743
887
|
await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
|
|
744
888
|
return {
|
|
745
889
|
loop: finalizedWithEnvelope,
|
|
746
|
-
decision:
|
|
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
|
-
|
|
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:
|
|
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(
|
|
951
|
+
payload: createRunExitPayload(admissionExitDecision)
|
|
803
952
|
}));
|
|
804
953
|
}
|
|
805
954
|
await stopSignalMonitorAndDrainDiagnostics();
|
|
806
|
-
|
|
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:
|
|
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
|
-
|
|
1077
|
-
|
|
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
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
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
|
-
|
|
1171
|
-
|
|
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:
|
|
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
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
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
|
-
|
|
1251
|
-
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());
|
|
1252
1570
|
const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope };
|
|
1253
1571
|
await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
|
|
1254
1572
|
return {
|
|
1255
1573
|
loop: finalizedWithEnvelope,
|
|
1256
|
-
decision:
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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(
|
|
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
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
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
|