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