@martinloop/mcp 0.8.8 → 0.8.10
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/adapters/process-supervisor.d.ts +12 -0
- package/dist/vendor/adapters/process-supervisor.js +40 -2
- package/dist/vendor/adapters/verifier-only.js +4 -0
- package/dist/vendor/core/index.d.ts +14 -1
- package/dist/vendor/core/index.js +1679 -1595
- package/dist/vendor/core/policy.js +11 -1
- package/dist/vendor/core/resume.d.ts +39 -0
- package/dist/vendor/core/resume.js +196 -0
- package/dist/vendor/core/verified-handoff.js +62 -2
- package/package.json +3 -7
- 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,920 @@ 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
|
+
pricePerMTokenUsd: executingAdapter.metadata.budgetPreflight?.pricePerMTokenUsd,
|
|
884
|
+
// D2: pass remaining iterations so the preflight can compute a budget-aware cap
|
|
885
|
+
// rather than using the old rigid maxUsd * 0.25 that blocked viable attempts.
|
|
886
|
+
remainingIterations: distilled.constraints.remainingIterations
|
|
887
|
+
});
|
|
888
|
+
if (!budgetPreflight.allowed) {
|
|
889
|
+
const preflightExitDecision = {
|
|
890
|
+
shouldExit: true,
|
|
891
|
+
lifecycleState: "budget_exit",
|
|
892
|
+
status: "exited",
|
|
893
|
+
reason: budgetPreflight.reason
|
|
894
|
+
};
|
|
895
|
+
if (input.store) {
|
|
896
|
+
await input.store.appendLedger(loop.loopId, makeLedgerEvent({
|
|
897
|
+
kind: "attempt.rejected",
|
|
898
|
+
runId: loop.loopId,
|
|
899
|
+
attemptIndex: loop.attempts.length + 1,
|
|
900
|
+
payload: { reason: budgetPreflight.reason, source: "budget_preflight" }
|
|
901
|
+
}));
|
|
902
|
+
await input.store.appendLedger(loop.loopId, makeLedgerEvent({
|
|
903
|
+
kind: "run.exited",
|
|
904
|
+
runId: loop.loopId,
|
|
905
|
+
payload: createRunExitPayload(preflightExitDecision)
|
|
906
|
+
}));
|
|
907
|
+
}
|
|
908
|
+
await stopSignalMonitorAndDrainDiagnostics();
|
|
909
|
+
// Blocker 2: budget preflight may fire when a prior-attempt candidate is retained.
|
|
910
|
+
const finalPreflightDecision = await restoreRetainedCandidateForTerminalExit(preflightExitDecision);
|
|
911
|
+
const finalizedLoop = await finalizeCurrentLoop(finalPreflightDecision, now());
|
|
912
|
+
await persistLoopRecordIfSupported(input.store, finalizedLoop);
|
|
913
|
+
return {
|
|
914
|
+
loop: finalizedLoop,
|
|
915
|
+
decision: finalPreflightDecision
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
// GATHER → ADMIT: run admission control before executing
|
|
919
|
+
currentPhase = "ADMIT";
|
|
920
|
+
// T05: Context Integrity Pre-gate — blocks authority inversion / injection before reasoning
|
|
921
|
+
//
|
|
922
|
+
// Untrusted "tool output" re-entering the loop is verifier command output from prior
|
|
923
|
+
// attempts (e.g. test runners echoing attacker-controlled strings). Pull that text from
|
|
924
|
+
// already-persisted verification.completed events so the gate scans what actually
|
|
925
|
+
// re-enters subsequent prompts, matching the documented "tool output / test output" scope.
|
|
926
|
+
const priorVerifierOutput = loop.events
|
|
927
|
+
.filter((event) => event.type === "verification.completed")
|
|
928
|
+
.flatMap((event) => event.payload?.steps ?? [])
|
|
929
|
+
.map((step) => step.detail)
|
|
930
|
+
.filter((detail) => Boolean(detail))
|
|
931
|
+
.join("\n---\n");
|
|
932
|
+
const contextPrecheck = await runContextIntegrityPrecheck(loop.loopId, loop.attempts.length + 1, runDir(resolveActiveRunsRoot(input.store), loop.loopId), {
|
|
933
|
+
userPrompt: distilled.focus,
|
|
934
|
+
toolOutput: priorVerifierOutput || undefined,
|
|
935
|
+
history: loop.attempts.map(a => a.summary).join("\n")
|
|
936
|
+
});
|
|
937
|
+
if (contextPrecheck.verdict === "context_poisoning_block") {
|
|
938
|
+
currentPhase = "ABORT";
|
|
939
|
+
const poisoningExitDecision = {
|
|
940
|
+
shouldExit: true,
|
|
941
|
+
lifecycleState: "human_escalation",
|
|
942
|
+
status: "exited",
|
|
943
|
+
reason: "Context Integrity Pre-gate: context poisoning attempt detected.",
|
|
944
|
+
failureClass: "safety_leash_blocked",
|
|
945
|
+
safetySurface: "context_integrity",
|
|
946
|
+
reasonCode: "context_poisoning_blocked"
|
|
947
|
+
};
|
|
948
|
+
if (input.store) {
|
|
949
|
+
await input.store.appendLedger(loop.loopId, makeLedgerEvent({
|
|
950
|
+
kind: "safety.violations_found",
|
|
951
|
+
runId: loop.loopId,
|
|
952
|
+
payload: {
|
|
953
|
+
verdict: contextPrecheck.verdict,
|
|
954
|
+
signals: contextPrecheck.detectedSignals,
|
|
955
|
+
source: "context_integrity_pregate"
|
|
956
|
+
}
|
|
957
|
+
}));
|
|
958
|
+
}
|
|
959
|
+
await stopSignalMonitorAndDrainDiagnostics();
|
|
960
|
+
// Blocker 2: poisoning check may fire between attempts while a candidate is retained.
|
|
961
|
+
const finalPoisoningDecision = await restoreRetainedCandidateForTerminalExit(poisoningExitDecision);
|
|
962
|
+
const terminationEnvelope = await persistGuardStop(finalPoisoningDecision);
|
|
963
|
+
const finalizedLoop = await finalizeCurrentLoop(finalPoisoningDecision, now());
|
|
964
|
+
const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope };
|
|
965
|
+
await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
|
|
966
|
+
return {
|
|
967
|
+
loop: finalizedWithEnvelope,
|
|
968
|
+
decision: finalPoisoningDecision
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
const admissionDecision = evaluateAttemptPolicy({
|
|
972
|
+
request: {
|
|
973
|
+
loopId: loop.loopId,
|
|
974
|
+
workspaceId: loop.workspaceId,
|
|
975
|
+
attemptId,
|
|
976
|
+
context: {
|
|
977
|
+
taskTitle: loop.task.title,
|
|
978
|
+
objective: loop.task.objective,
|
|
979
|
+
verificationPlan: loop.task.verificationPlan,
|
|
980
|
+
...(loop.task.verificationStack ? { verificationStack: loop.task.verificationStack } : {}),
|
|
981
|
+
...(loop.task.definitionOfDonePreSatisfied !== undefined
|
|
982
|
+
? { definitionOfDonePreSatisfied: loop.task.definitionOfDonePreSatisfied }
|
|
983
|
+
: {}),
|
|
984
|
+
...(loop.task.repoRoot ? { repoRoot: loop.task.repoRoot } : {}),
|
|
985
|
+
...(loop.task.allowedPaths ? { allowedPaths: loop.task.allowedPaths } : {}),
|
|
986
|
+
...(loop.task.deniedPaths ? { deniedPaths: loop.task.deniedPaths } : {}),
|
|
987
|
+
...(loop.task.acceptanceCriteria ? { acceptanceCriteria: loop.task.acceptanceCriteria } : {}),
|
|
988
|
+
...(loop.task.executionProfile ? { executionProfile: loop.task.executionProfile } : {}),
|
|
989
|
+
...(loop.task.allowedNetworkDomains
|
|
990
|
+
? { allowedNetworkDomains: loop.task.allowedNetworkDomains }
|
|
991
|
+
: {}),
|
|
992
|
+
...(loop.task.approvalPolicy ? { approvalPolicy: loop.task.approvalPolicy } : {}),
|
|
993
|
+
agentExecutionIntent: loop.task.agentExecutionIntent ?? DEFAULT_AGENT_EXECUTION_INTENT,
|
|
994
|
+
providerExecutionTimeoutMs: normalizeProviderExecutionTimeoutMs(loop.task.providerExecutionTimeoutMs),
|
|
995
|
+
focus: distilled.focus,
|
|
996
|
+
remainingBudgetUsd: distilled.constraints.remainingBudgetUsd,
|
|
997
|
+
remainingIterations: distilled.constraints.remainingIterations,
|
|
998
|
+
remainingTokens: distilled.constraints.remainingTokens
|
|
999
|
+
},
|
|
1000
|
+
previousAttempts: loop.attempts
|
|
1001
|
+
},
|
|
1002
|
+
projectedUsd: budgetPreflight.estimate.estimatedAttemptCostUsd
|
|
1003
|
+
});
|
|
1004
|
+
if (!admissionDecision.allowed) {
|
|
1005
|
+
const exitReason = admissionDecision.reason;
|
|
1006
|
+
// Fix false budget_exit: trajectory circuit-break is diminishing_returns, not budget_exit.
|
|
1007
|
+
// A trajectory/no-progress stop is not a resource exhaustion.
|
|
1008
|
+
const admissionLifecycleState = admissionDecision.recommendedIntervention === "escalate_human"
|
|
1009
|
+
? "human_escalation"
|
|
1010
|
+
: admissionDecision.circuitBreakDecision?.shouldStop
|
|
1011
|
+
? "diminishing_returns"
|
|
1012
|
+
: "budget_exit";
|
|
1013
|
+
const admissionExitDecision = {
|
|
1014
|
+
shouldExit: true,
|
|
1015
|
+
lifecycleState: admissionLifecycleState,
|
|
1016
|
+
status: "exited",
|
|
1017
|
+
reason: exitReason
|
|
1018
|
+
};
|
|
1019
|
+
if (input.store) {
|
|
1020
|
+
await input.store.appendLedger(loop.loopId, makeLedgerEvent({
|
|
1021
|
+
kind: "attempt.rejected",
|
|
1022
|
+
runId: loop.loopId,
|
|
1023
|
+
attemptIndex: loop.attempts.length + 1,
|
|
1024
|
+
payload: { reason: admissionDecision.reason }
|
|
1025
|
+
}));
|
|
1026
|
+
await input.store.appendLedger(loop.loopId, makeLedgerEvent({
|
|
1027
|
+
kind: "run.exited",
|
|
1028
|
+
runId: loop.loopId,
|
|
1029
|
+
payload: createRunExitPayload(admissionExitDecision)
|
|
1030
|
+
}));
|
|
1031
|
+
}
|
|
1032
|
+
await stopSignalMonitorAndDrainDiagnostics();
|
|
1033
|
+
// Blocker 2: admission may be denied between attempts while a candidate is retained.
|
|
1034
|
+
const finalAdmissionDecision = await restoreRetainedCandidateForTerminalExit(admissionExitDecision);
|
|
1035
|
+
const finalizedLoop = await finalizeCurrentLoop(finalAdmissionDecision, now());
|
|
1036
|
+
await persistLoopRecordIfSupported(input.store, finalizedLoop);
|
|
1037
|
+
return {
|
|
1038
|
+
loop: finalizedLoop,
|
|
1039
|
+
decision: finalAdmissionDecision
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
if (input.store) {
|
|
1043
|
+
await input.store.appendLedger(loop.loopId, makeLedgerEvent({
|
|
1044
|
+
kind: "attempt.admitted",
|
|
1045
|
+
runId: loop.loopId,
|
|
1046
|
+
attemptIndex: loop.attempts.length + 1,
|
|
1047
|
+
payload: {
|
|
1048
|
+
attemptId,
|
|
1049
|
+
adapterId: executingAdapter.adapterId,
|
|
1050
|
+
providerId: executingAdapter.metadata.providerId,
|
|
1051
|
+
model: executingAdapter.metadata.model,
|
|
1052
|
+
transport: getAdapterTransport(executingAdapter)
|
|
1053
|
+
}
|
|
1054
|
+
}));
|
|
1055
|
+
}
|
|
1056
|
+
// ADMIT → PATCH
|
|
1057
|
+
currentPhase = "PATCH";
|
|
1058
|
+
loop = appendLoopEvent(loop, {
|
|
1059
|
+
type: "attempt.started",
|
|
1060
|
+
lifecycleState: "running",
|
|
1061
|
+
payload: {
|
|
1062
|
+
attemptId,
|
|
1063
|
+
adapterId: executingAdapter.adapterId,
|
|
1064
|
+
model: executingAdapter.metadata.model,
|
|
1065
|
+
policyPhase: currentPhase
|
|
1066
|
+
}
|
|
1067
|
+
}, { now: attemptStartedAt, idFactory });
|
|
1068
|
+
const request = {
|
|
1069
|
+
loopId: loop.loopId,
|
|
1070
|
+
workspaceId: loop.workspaceId,
|
|
1071
|
+
attemptId,
|
|
898
1072
|
context: {
|
|
899
1073
|
taskTitle: loop.task.title,
|
|
900
1074
|
objective: loop.task.objective,
|
|
901
1075
|
verificationPlan: loop.task.verificationPlan,
|
|
902
1076
|
...(loop.task.verificationStack ? { verificationStack: loop.task.verificationStack } : {}),
|
|
1077
|
+
...(loop.task.mutationMode ? { mutationMode: loop.task.mutationMode } : {}),
|
|
903
1078
|
...(loop.task.definitionOfDonePreSatisfied !== undefined
|
|
904
1079
|
? { definitionOfDonePreSatisfied: loop.task.definitionOfDonePreSatisfied }
|
|
905
1080
|
: {}),
|
|
906
1081
|
...(loop.task.repoRoot ? { repoRoot: loop.task.repoRoot } : {}),
|
|
1082
|
+
...(input.store?.runsRoot ? { runsRoot: input.store.runsRoot } : {}),
|
|
907
1083
|
...(loop.task.allowedPaths ? { allowedPaths: loop.task.allowedPaths } : {}),
|
|
908
1084
|
...(loop.task.deniedPaths ? { deniedPaths: loop.task.deniedPaths } : {}),
|
|
909
1085
|
...(loop.task.acceptanceCriteria ? { acceptanceCriteria: loop.task.acceptanceCriteria } : {}),
|
|
@@ -917,918 +1093,805 @@ export async function runMartin(input) {
|
|
|
917
1093
|
focus: distilled.focus,
|
|
918
1094
|
remainingBudgetUsd: distilled.constraints.remainingBudgetUsd,
|
|
919
1095
|
remainingIterations: distilled.constraints.remainingIterations,
|
|
920
|
-
remainingTokens: distilled.constraints.remainingTokens
|
|
1096
|
+
remainingTokens: distilled.constraints.remainingTokens,
|
|
1097
|
+
...(input.verificationExecutionOwner
|
|
1098
|
+
? { verificationExecutionOwner: input.verificationExecutionOwner }
|
|
1099
|
+
: {}),
|
|
1100
|
+
...(lastVerifierFailure ? { lastVerifierFailure } : {}),
|
|
1101
|
+
// C1: tell the adapter whether the previous candidate was retained so it can
|
|
1102
|
+
// repair rather than rebuild.
|
|
1103
|
+
...(loop.attempts.at(-1)?.candidateRetained
|
|
1104
|
+
? { previousCandidateRetained: true }
|
|
1105
|
+
: {})
|
|
921
1106
|
},
|
|
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"
|
|
1107
|
+
previousAttempts: loop.attempts,
|
|
1108
|
+
...(input.observedUsageGovernor ? { observedUsageGovernor: input.observedUsageGovernor } : {})
|
|
1074
1109
|
};
|
|
1075
|
-
|
|
1110
|
+
const tracksWorkspaceMutations = request.context.repoRoot !== undefined &&
|
|
1111
|
+
executingAdapter.metadata.capabilities?.workspaceMutations !== false;
|
|
1112
|
+
// Pre-attempt: check signals that arrived during guard evaluation
|
|
1113
|
+
const preAttemptTerminal = await evaluateCheckpoint("pre_attempt");
|
|
1114
|
+
if (preAttemptTerminal !== undefined)
|
|
1115
|
+
return await terminateRun(preAttemptTerminal);
|
|
1116
|
+
activeAttemptController = new AbortController();
|
|
1117
|
+
governedTurnsUsed += 1;
|
|
1118
|
+
const rollbackBoundary = tracksWorkspaceMutations
|
|
1119
|
+
? await captureRollbackBoundary({
|
|
1120
|
+
repoRoot: request.context.repoRoot,
|
|
1121
|
+
capturedAt: attemptStartedAt
|
|
1122
|
+
})
|
|
1123
|
+
: undefined;
|
|
1124
|
+
// Persist attempt.started state before blocking on provider execution.
|
|
1125
|
+
// If the MCP client transport times out (default 300 s), a status query against
|
|
1126
|
+
// the stable run ID must see this attempt as active rather than observing
|
|
1127
|
+
// attempts=0 with no in-progress state — which falsely implies nothing happened
|
|
1128
|
+
// and causes callers to start a concurrent recovery run before the backend finishes.
|
|
1129
|
+
await persistLoopRecordIfSupported(input.store, loop);
|
|
1130
|
+
let result;
|
|
1076
1131
|
try {
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
queueControlDiagnostic("terminal_restore_on_provider_exception_failed", {
|
|
1081
|
-
error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
|
|
1132
|
+
result = await executingAdapter.execute({
|
|
1133
|
+
...request,
|
|
1134
|
+
signal: activeAttemptController.signal
|
|
1082
1135
|
});
|
|
1083
1136
|
}
|
|
1084
|
-
|
|
1085
|
-
//
|
|
1086
|
-
//
|
|
1137
|
+
catch (execError) {
|
|
1138
|
+
// Convert an abort (wall-clock or signal-driven) into the correct exit
|
|
1139
|
+
// rather than letting it propagate as an unhandled exception.
|
|
1140
|
+
const abortTerminal = await evaluateCheckpoint("post_attempt");
|
|
1141
|
+
if (abortTerminal !== undefined)
|
|
1142
|
+
return await terminateRun(abortTerminal);
|
|
1143
|
+
// Blocker 2 (exception path): provider threw a genuine non-abort exception.
|
|
1144
|
+
// Restore any retained candidate BEFORE re-throwing so the workspace is
|
|
1145
|
+
// never left with unverified code after an exception escape.
|
|
1146
|
+
const exceptionBaseDecision = {
|
|
1147
|
+
shouldExit: true,
|
|
1148
|
+
lifecycleState: "human_escalation",
|
|
1149
|
+
status: "exited",
|
|
1150
|
+
reason: "Provider threw an unexpected exception.",
|
|
1151
|
+
reasonCode: "provider_exception"
|
|
1152
|
+
};
|
|
1153
|
+
let cleanedExceptionDecision = exceptionBaseDecision;
|
|
1087
1154
|
try {
|
|
1088
|
-
await
|
|
1155
|
+
cleanedExceptionDecision = await restoreRetainedCandidateForTerminalExit(exceptionBaseDecision);
|
|
1089
1156
|
}
|
|
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)
|
|
1157
|
+
catch (cleanupError) {
|
|
1158
|
+
queueControlDiagnostic("terminal_restore_on_provider_exception_failed", {
|
|
1159
|
+
error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
if (cleanedExceptionDecision.reasonCode === "workspace_restore_failed") {
|
|
1163
|
+
// Cleanup failed: persist workspace_restore_failed as the terminal authority
|
|
1164
|
+
// so the operator knows the workspace state is unsafe. Then surface both errors.
|
|
1165
|
+
try {
|
|
1166
|
+
await persistGuardStop(cleanedExceptionDecision);
|
|
1145
1167
|
}
|
|
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;
|
|
1168
|
+
catch { /* persist best-effort */ }
|
|
1169
|
+
const wsError = new Error(cleanedExceptionDecision.reason, { cause: execError instanceof Error ? execError : new Error(String(execError)) });
|
|
1170
|
+
throw wsError;
|
|
1186
1171
|
}
|
|
1172
|
+
// Cleanup succeeded — rethrow the original provider error.
|
|
1173
|
+
throw execError;
|
|
1187
1174
|
}
|
|
1175
|
+
finally {
|
|
1176
|
+
activeAttemptController = undefined;
|
|
1177
|
+
}
|
|
1178
|
+
const attemptCompletedAt = now();
|
|
1179
|
+
const compiledContext = compilePromptPacket(request);
|
|
1180
|
+
const expectedVerifierBinding = resolveExpectedVerifierBinding(request);
|
|
1181
|
+
const verifierConfigured = expectedVerifierBinding.commands.length > 0;
|
|
1182
|
+
const verification = normalizeVerificationOutcome(result, expectedVerifierBinding);
|
|
1183
|
+
// PATCH → VERIFY
|
|
1184
|
+
currentPhase = "VERIFY";
|
|
1185
|
+
let failure = result.status === "failed"
|
|
1186
|
+
? classifyFailure({ attempts: loop.attempts, result })
|
|
1187
|
+
: undefined;
|
|
1188
|
+
const currentAttemptIndex = loop.attempts.length + 1;
|
|
1189
|
+
// Progress evidence is computed below from effectiveVerification (which includes
|
|
1190
|
+
// host-run results). Initialize from the normalized (not raw) verification so
|
|
1191
|
+
// that initial values are already binding-checked. Back-patched with the final
|
|
1192
|
+
// authoritative values after effectiveVerification is available (Blocker 4).
|
|
1193
|
+
let currentVerifierScore = verification.passed ? 1 : 0;
|
|
1194
|
+
let currentFailingVerifierStepCount;
|
|
1195
|
+
const attempt = {
|
|
1196
|
+
attemptId,
|
|
1197
|
+
index: currentAttemptIndex,
|
|
1198
|
+
adapterId: executingAdapter.adapterId,
|
|
1199
|
+
...(executingAdapter.metadata.model ? { model: executingAdapter.metadata.model } : {}),
|
|
1200
|
+
startedAt: attemptStartedAt,
|
|
1201
|
+
completedAt: attemptCompletedAt,
|
|
1202
|
+
summary: result.summary,
|
|
1203
|
+
...(failure?.failureClass ? { failureClass: failure.failureClass } : {}),
|
|
1204
|
+
...(failure?.recommendedIntervention
|
|
1205
|
+
? { intervention: failure.recommendedIntervention }
|
|
1206
|
+
: {}),
|
|
1207
|
+
...(failure?.diagnosticHint ? { diagnosticHint: failure.diagnosticHint } : {}),
|
|
1208
|
+
// C2: real progress signals — back-patched from effectiveVerification below
|
|
1209
|
+
verifierScore: currentVerifierScore
|
|
1210
|
+
// C1: candidateRetained is set below after rollback decision is made
|
|
1211
|
+
};
|
|
1212
|
+
loop = {
|
|
1213
|
+
...loop,
|
|
1214
|
+
attempts: [...loop.attempts, attempt],
|
|
1215
|
+
cost: {
|
|
1216
|
+
actualUsd: roundUsd(loop.cost.actualUsd + getUsageUsd(result.usage)),
|
|
1217
|
+
avoidedUsd: loop.cost.avoidedUsd,
|
|
1218
|
+
tokensIn: loop.cost.tokensIn + result.usage.tokensIn,
|
|
1219
|
+
tokensOut: loop.cost.tokensOut + result.usage.tokensOut,
|
|
1220
|
+
...(result.usage.estimatedUsd !== undefined
|
|
1221
|
+
? {
|
|
1222
|
+
estimatedUsd: roundUsd((loop.cost.estimatedUsd ?? loop.cost.actualUsd) + result.usage.estimatedUsd)
|
|
1223
|
+
}
|
|
1224
|
+
: {}),
|
|
1225
|
+
provenance: mergeCostProvenance(loop.cost.provenance, getUsageProvenance(result.usage)),
|
|
1226
|
+
...(result.usage.providerSettlement
|
|
1227
|
+
? { providerSettlement: result.usage.providerSettlement }
|
|
1228
|
+
: {}),
|
|
1229
|
+
...(loop.cost.savingsBaseline
|
|
1230
|
+
? { savingsBaseline: loop.cost.savingsBaseline }
|
|
1231
|
+
: {})
|
|
1232
|
+
},
|
|
1233
|
+
updatedAt: attemptCompletedAt
|
|
1234
|
+
};
|
|
1235
|
+
// ── Eight-exit state tracking ─────────────────────────────────────────
|
|
1236
|
+
consecutiveErrors = result.status === "failed" ? consecutiveErrors + 1 : 0;
|
|
1237
|
+
const stateHash = hashProgressState({
|
|
1238
|
+
summary: result.summary,
|
|
1239
|
+
verificationPassed: verification.passed,
|
|
1240
|
+
status: result.status
|
|
1241
|
+
});
|
|
1242
|
+
if (recentStateHashes.length >= exitPolicy.progress.windowSize)
|
|
1243
|
+
recentStateHashes.shift();
|
|
1244
|
+
recentStateHashes.push(stateHash);
|
|
1188
1245
|
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",
|
|
1246
|
+
type: "attempt.completed",
|
|
1199
1247
|
lifecycleState: "running",
|
|
1200
|
-
payload: { attemptId,
|
|
1248
|
+
payload: { attemptId, status: result.status, summary: result.summary }
|
|
1201
1249
|
}, { now: attemptCompletedAt, idFactory });
|
|
1202
|
-
|
|
1250
|
+
// post_attempt check is deferred until after verification.completed and all
|
|
1251
|
+
// per-attempt ledger writes — see the check just before before_retry below.
|
|
1252
|
+
const previousVerifierScore = getLastVerifierScore(loop);
|
|
1253
|
+
if (failure) {
|
|
1254
|
+
if (failure.recommendedIntervention === "compress_context") {
|
|
1255
|
+
useCompressedContext = true;
|
|
1256
|
+
}
|
|
1257
|
+
let adapterSwitched = false;
|
|
1258
|
+
if (failure.recommendedIntervention === "switch_adapter") {
|
|
1259
|
+
const nextAdapter = adapterChain[currentAdapterIndex + 1];
|
|
1260
|
+
if (nextAdapter) {
|
|
1261
|
+
currentAdapterIndex += 1;
|
|
1262
|
+
currentAdapter = nextAdapter;
|
|
1263
|
+
adapterSwitched = true;
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1203
1266
|
loop = appendLoopEvent(loop, {
|
|
1204
|
-
type: "
|
|
1267
|
+
type: "failure.classified",
|
|
1205
1268
|
lifecycleState: "running",
|
|
1206
1269
|
payload: {
|
|
1207
1270
|
attemptId,
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
transport: getAdapterTransport(currentAdapter)
|
|
1271
|
+
failureClass: failure.failureClass,
|
|
1272
|
+
rationale: failure.rationale
|
|
1211
1273
|
}
|
|
1212
1274
|
}, { now: attemptCompletedAt, idFactory });
|
|
1275
|
+
loop = appendLoopEvent(loop, {
|
|
1276
|
+
type: "intervention.selected",
|
|
1277
|
+
lifecycleState: "running",
|
|
1278
|
+
payload: { attemptId, intervention: failure.recommendedIntervention }
|
|
1279
|
+
}, { now: attemptCompletedAt, idFactory });
|
|
1280
|
+
if (adapterSwitched) {
|
|
1281
|
+
loop = appendLoopEvent(loop, {
|
|
1282
|
+
type: "intervention.selected",
|
|
1283
|
+
lifecycleState: "running",
|
|
1284
|
+
payload: {
|
|
1285
|
+
attemptId,
|
|
1286
|
+
intervention: "switch_adapter",
|
|
1287
|
+
nextAdapterId: currentAdapter.adapterId,
|
|
1288
|
+
transport: getAdapterTransport(currentAdapter)
|
|
1289
|
+
}
|
|
1290
|
+
}, { now: attemptCompletedAt, idFactory });
|
|
1291
|
+
}
|
|
1213
1292
|
}
|
|
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
|
|
1293
|
+
const costState = evaluateCostGovernor({
|
|
1294
|
+
budget: loop.budget,
|
|
1295
|
+
cost: loop.cost,
|
|
1296
|
+
attemptsUsed: loop.attempts.length
|
|
1237
1297
|
});
|
|
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,
|
|
1298
|
+
loop = appendLoopEvent(loop, {
|
|
1299
|
+
type: "budget.updated",
|
|
1300
|
+
lifecycleState: costState.shouldStop ? "budget_exit" : "running",
|
|
1253
1301
|
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
|
|
1302
|
+
actualUsd: loop.cost.actualUsd,
|
|
1303
|
+
remainingBudgetUsd: costState.remainingBudgetUsd,
|
|
1304
|
+
pressure: costState.pressure,
|
|
1305
|
+
provenance: loop.cost.provenance
|
|
1269
1306
|
}
|
|
1270
|
-
}));
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1307
|
+
}, { now: now(), idFactory });
|
|
1308
|
+
if (input.store) {
|
|
1309
|
+
const settlement = createBudgetSettlement({
|
|
1310
|
+
runId: loop.loopId,
|
|
1311
|
+
attemptIndex: currentAttemptIndex,
|
|
1312
|
+
usage: result.usage,
|
|
1313
|
+
estimate: budgetPreflight.estimate,
|
|
1314
|
+
settledAt: attemptCompletedAt
|
|
1315
|
+
});
|
|
1316
|
+
await input.store.writeAttemptArtifacts(loop.loopId, currentAttemptIndex, {
|
|
1317
|
+
compiledContext,
|
|
1318
|
+
verification,
|
|
1319
|
+
...(rollbackBoundary ? { rollbackBoundary } : {})
|
|
1320
|
+
});
|
|
1321
|
+
await input.store.appendLedger(loop.loopId, makeLedgerEvent({
|
|
1322
|
+
kind: "patch.generated",
|
|
1323
|
+
runId: loop.loopId,
|
|
1324
|
+
attemptIndex: currentAttemptIndex,
|
|
1325
|
+
payload: { status: result.status, summary: result.summary }
|
|
1326
|
+
}));
|
|
1327
|
+
await input.store.appendLedger(loop.loopId, makeLedgerEvent({
|
|
1328
|
+
kind: "budget.settled",
|
|
1329
|
+
runId: loop.loopId,
|
|
1330
|
+
attemptIndex: currentAttemptIndex,
|
|
1331
|
+
payload: {
|
|
1332
|
+
actualUsd: settlement.totalActualUsd,
|
|
1333
|
+
estimatedUsd: result.usage.estimatedUsd,
|
|
1334
|
+
tokensIn: result.usage.tokensIn,
|
|
1335
|
+
tokensOut: result.usage.tokensOut,
|
|
1336
|
+
cachedInputTokens: result.usage.cachedInputTokens,
|
|
1337
|
+
reasoningTokensOut: result.usage.reasoningTokensOut,
|
|
1338
|
+
provenance: getUsageProvenance(result.usage),
|
|
1339
|
+
transport: getAdapterTransport(executingAdapter),
|
|
1340
|
+
providerId: executingAdapter.metadata.providerId,
|
|
1341
|
+
model: executingAdapter.metadata.model,
|
|
1342
|
+
providerSettlement: result.usage.providerSettlement,
|
|
1343
|
+
patchCost: settlement.patchCost,
|
|
1344
|
+
verificationCost: settlement.verificationCost,
|
|
1345
|
+
varianceUsd: settlement.varianceUsd,
|
|
1346
|
+
preflightEstimateUsd: settlement.preflightEstimateUsd
|
|
1347
|
+
}
|
|
1348
|
+
}));
|
|
1349
|
+
}
|
|
1350
|
+
let changedFiles = [];
|
|
1351
|
+
let ignoredChanges = { created: [], modified: [], deleted: [] };
|
|
1352
|
+
let escapingLinks = [];
|
|
1353
|
+
let weakenedTestFiles = [];
|
|
1354
|
+
try {
|
|
1355
|
+
changedFiles = tracksWorkspaceMutations
|
|
1356
|
+
? resolveChangedFiles(result, request.context.repoRoot, rollbackBoundary)
|
|
1286
1357
|
: [];
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1358
|
+
ignoredChanges = tracksWorkspaceMutations
|
|
1359
|
+
? listIgnoredFileChangesSinceBoundary({ repoRoot: request.context.repoRoot, boundary: rollbackBoundary })
|
|
1360
|
+
: ignoredChanges;
|
|
1361
|
+
escapingLinks =
|
|
1362
|
+
tracksWorkspaceMutations && request.context.repoRoot
|
|
1363
|
+
? findEscapingLinks(request.context.repoRoot, [...changedFiles, ...ignoredChanges.created])
|
|
1364
|
+
: [];
|
|
1365
|
+
weakenedTestFiles =
|
|
1366
|
+
tracksWorkspaceMutations && request.context.repoRoot && rollbackBoundary?.headRef
|
|
1367
|
+
? findWeakenedTestFiles({
|
|
1368
|
+
repoRoot: request.context.repoRoot,
|
|
1369
|
+
baseRef: rollbackBoundary.headRef,
|
|
1370
|
+
changedFiles
|
|
1371
|
+
})
|
|
1372
|
+
: [];
|
|
1373
|
+
}
|
|
1374
|
+
catch (error) {
|
|
1375
|
+
for (const filePath of normalizeFileEffectPaths(result.execution?.changedFiles ?? [])) {
|
|
1376
|
+
touchedDuringExecution.add(filePath);
|
|
1377
|
+
}
|
|
1378
|
+
for (const filePath of normalizeFileEffectPaths(changedFiles)) {
|
|
1379
|
+
touchedDuringExecution.add(filePath);
|
|
1380
|
+
}
|
|
1381
|
+
let rollbackOutcome;
|
|
1382
|
+
try {
|
|
1383
|
+
rollbackOutcome = (await restoreRollbackBoundary({
|
|
1290
1384
|
repoRoot: request.context.repoRoot,
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1385
|
+
boundary: rollbackBoundary,
|
|
1386
|
+
restoredAt: attemptCompletedAt,
|
|
1387
|
+
decision: "DISCARD",
|
|
1388
|
+
removePaths: ignoredChanges.created,
|
|
1389
|
+
restoreIgnoredPaths: [...ignoredChanges.modified, ...ignoredChanges.deleted]
|
|
1390
|
+
})) ?? {
|
|
1391
|
+
attempted: false,
|
|
1392
|
+
status: "unavailable",
|
|
1393
|
+
restoredAt: attemptCompletedAt,
|
|
1394
|
+
decision: "DISCARD",
|
|
1395
|
+
before: { trackedDirtyFiles: [], untrackedFiles: [] },
|
|
1396
|
+
after: { trackedDirtyFiles: [], untrackedFiles: [] },
|
|
1397
|
+
restoredFiles: [],
|
|
1398
|
+
deletedFiles: [],
|
|
1399
|
+
error: "Rollback could not run because no repository root was available."
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
catch (rollbackError) {
|
|
1403
|
+
rollbackOutcome = {
|
|
1404
|
+
attempted: true,
|
|
1405
|
+
status: "failed",
|
|
1406
|
+
restoredAt: attemptCompletedAt,
|
|
1407
|
+
decision: "DISCARD",
|
|
1408
|
+
before: { trackedDirtyFiles: [], untrackedFiles: [] },
|
|
1409
|
+
after: { trackedDirtyFiles: [], untrackedFiles: [] },
|
|
1410
|
+
restoredFiles: [],
|
|
1411
|
+
deletedFiles: [],
|
|
1412
|
+
error: `Rollback failed after workspace observation became unavailable: ${fileEffectErrorMessage(rollbackError)}`
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
const observationExitDecision = {
|
|
1416
|
+
shouldExit: true,
|
|
1417
|
+
lifecycleState: "human_escalation",
|
|
1418
|
+
status: "exited",
|
|
1419
|
+
reason: `Workspace change observation became unavailable: ${fileEffectErrorMessage(error)}`,
|
|
1420
|
+
reasonCode: "workspace_observation_unavailable"
|
|
1421
|
+
};
|
|
1422
|
+
if (input.store) {
|
|
1423
|
+
await input.store.writeAttemptArtifacts(loop.loopId, currentAttemptIndex, {
|
|
1424
|
+
compiledContext,
|
|
1425
|
+
...(rollbackBoundary ? { rollbackBoundary } : {}),
|
|
1426
|
+
rollbackOutcome
|
|
1427
|
+
});
|
|
1428
|
+
await input.store.appendLedger(loop.loopId, makeLedgerEvent({
|
|
1429
|
+
kind: "run.exited",
|
|
1430
|
+
runId: loop.loopId,
|
|
1431
|
+
payload: createRunExitPayload(observationExitDecision)
|
|
1432
|
+
}));
|
|
1433
|
+
}
|
|
1434
|
+
await stopSignalMonitorAndDrainDiagnostics();
|
|
1435
|
+
// Blocker 2: observation failure is terminal; restore pre-candidate boundary if set.
|
|
1436
|
+
const finalObservationDecision = await restoreRetainedCandidateForTerminalExit(observationExitDecision);
|
|
1437
|
+
const terminationEnvelope = await persistGuardStop(finalObservationDecision);
|
|
1438
|
+
const finalizedLoop = await finalizeCurrentLoop(finalObservationDecision, now());
|
|
1439
|
+
const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope };
|
|
1440
|
+
await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
|
|
1441
|
+
return { loop: finalizedWithEnvelope, decision: finalObservationDecision };
|
|
1299
1442
|
}
|
|
1300
1443
|
for (const filePath of normalizeFileEffectPaths(changedFiles)) {
|
|
1301
1444
|
touchedDuringExecution.add(filePath);
|
|
1302
1445
|
}
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
}
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1446
|
+
// Routing economics: detect first meaningful workspace delta
|
|
1447
|
+
if (!firstDeltaDetected && changedFiles.length > 0) {
|
|
1448
|
+
const meaningfulFile = changedFiles.find((f) => !isMartinLoopMetadata(f));
|
|
1449
|
+
if (meaningfulFile) {
|
|
1450
|
+
firstDeltaDetected = true;
|
|
1451
|
+
firstDeltaTimestampMs = Date.now();
|
|
1452
|
+
firstDeltaFilePath = meaningfulFile;
|
|
1453
|
+
firstDeltaChangeType = "modify";
|
|
1454
|
+
preDeltaCostUsd = roundUsd(loop.cost.actualUsd - getUsageUsd(result.usage));
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
if (!firstDeltaDetected) {
|
|
1458
|
+
preDeltaCostUsd = loop.cost.actualUsd;
|
|
1459
|
+
}
|
|
1460
|
+
// Evidence is only reliable when the adapter explicitly reported files OR git actually
|
|
1461
|
+
// returned a non-empty list. A repoRoot alone is insufficient — git may fail (e.g. not
|
|
1462
|
+
// a git repo) and silently return [], which would falsely trigger no_code_change.
|
|
1463
|
+
const changedFileEvidenceAvailable = result.execution?.changedFiles !== undefined || changedFiles.length > 0;
|
|
1464
|
+
const filesystemDecision = evaluateFilesystemLeash({
|
|
1465
|
+
repoRoot: request.context.repoRoot,
|
|
1466
|
+
changedFiles,
|
|
1467
|
+
ignoredFiles: [...ignoredChanges.created, ...ignoredChanges.modified, ...ignoredChanges.deleted],
|
|
1468
|
+
escapingLinks,
|
|
1469
|
+
allowedPaths: request.context.allowedPaths,
|
|
1470
|
+
deniedPaths: request.context.deniedPaths,
|
|
1471
|
+
mutationMode: request.context.mutationMode
|
|
1472
|
+
});
|
|
1473
|
+
if (!filesystemDecision.allowed) {
|
|
1474
|
+
const patchDecision = evaluatePatchDecision({
|
|
1475
|
+
verificationPassed: result.verification.passed,
|
|
1476
|
+
previousVerifierScore,
|
|
1477
|
+
verifierScore: result.verification.passed ? 1 : 0,
|
|
1478
|
+
scopeViolationCount: filesystemDecision.violations.length,
|
|
1479
|
+
changedFileCount: changedFiles.length,
|
|
1480
|
+
diffNovelty: changedFiles.length > 0 ? 1 : 0,
|
|
1481
|
+
diffStats: result.execution?.diffStats,
|
|
1482
|
+
costUsd: getUsageUsd(result.usage),
|
|
1483
|
+
summary: result.summary
|
|
1484
|
+
});
|
|
1485
|
+
const filesystemExitDecision = {
|
|
1486
|
+
shouldExit: true,
|
|
1487
|
+
lifecycleState: "human_escalation",
|
|
1488
|
+
status: "exited",
|
|
1489
|
+
reason: filesystemDecision.reason ?? "Safety leash blocked filesystem changes.",
|
|
1490
|
+
...classifySafetyLeashExit(filesystemDecision, "filesystem")
|
|
1491
|
+
};
|
|
1492
|
+
// Wrap per-attempt rollback so that if it throws, preCandidateBoundary
|
|
1493
|
+
// restoration still runs via the cleanup helper below.
|
|
1494
|
+
let rollbackOutcome;
|
|
1495
|
+
try {
|
|
1496
|
+
rollbackOutcome = await restoreRollbackBoundary({
|
|
1497
|
+
repoRoot: request.context.repoRoot,
|
|
1498
|
+
boundary: rollbackBoundary,
|
|
1499
|
+
restoredAt: attemptCompletedAt,
|
|
1500
|
+
decision: patchDecision.decision,
|
|
1501
|
+
removePaths: violatingIgnoredPaths(filesystemDecision.violations, ignoredChanges.created),
|
|
1502
|
+
restoreIgnoredPaths: violatingIgnoredPaths(filesystemDecision.violations, [
|
|
1503
|
+
...ignoredChanges.modified,
|
|
1504
|
+
...ignoredChanges.deleted
|
|
1505
|
+
])
|
|
1506
|
+
}) ?? undefined;
|
|
1507
|
+
}
|
|
1508
|
+
catch (rollbackError) {
|
|
1509
|
+
queueControlDiagnostic("attempt_rollback_failed_on_filesystem_exit", {
|
|
1510
|
+
error: rollbackError instanceof Error ? rollbackError.message : String(rollbackError)
|
|
1511
|
+
});
|
|
1512
|
+
}
|
|
1513
|
+
if (input.store) {
|
|
1514
|
+
await input.store.writeAttemptArtifacts(loop.loopId, currentAttemptIndex, {
|
|
1515
|
+
compiledContext,
|
|
1516
|
+
leash: createLeashArtifact(filesystemDecision, currentAttemptIndex),
|
|
1517
|
+
patchScore: patchDecision.score,
|
|
1518
|
+
patchDecision: toPatchDecisionArtifact(patchDecision),
|
|
1519
|
+
...(rollbackBoundary ? { rollbackBoundary } : {}),
|
|
1520
|
+
...(rollbackOutcome ? { rollbackOutcome } : {})
|
|
1521
|
+
});
|
|
1522
|
+
await input.store.appendLedger(loop.loopId, makeLedgerEvent({
|
|
1523
|
+
kind: "safety.violations_found",
|
|
1524
|
+
runId: loop.loopId,
|
|
1525
|
+
attemptIndex: currentAttemptIndex,
|
|
1526
|
+
payload: {
|
|
1527
|
+
surface: "filesystem",
|
|
1528
|
+
blocked: true,
|
|
1529
|
+
attemptIndex: currentAttemptIndex,
|
|
1530
|
+
violations: filesystemDecision.violations
|
|
1531
|
+
}
|
|
1532
|
+
}));
|
|
1533
|
+
await input.store.appendLedger(loop.loopId, makeLedgerEvent({
|
|
1534
|
+
kind: "attempt.discarded",
|
|
1535
|
+
runId: loop.loopId,
|
|
1536
|
+
attemptIndex: currentAttemptIndex,
|
|
1537
|
+
payload: {
|
|
1538
|
+
decision: patchDecision.decision,
|
|
1539
|
+
reason: patchDecision.summary,
|
|
1540
|
+
reasonCodes: patchDecision.reasonCodes,
|
|
1541
|
+
score: patchDecision.score.score
|
|
1542
|
+
}
|
|
1543
|
+
}));
|
|
1544
|
+
await input.store.appendLedger(loop.loopId, makeLedgerEvent({
|
|
1545
|
+
kind: "run.exited",
|
|
1546
|
+
runId: loop.loopId,
|
|
1547
|
+
payload: createRunExitPayload(filesystemExitDecision)
|
|
1548
|
+
}));
|
|
1549
|
+
}
|
|
1550
|
+
await stopSignalMonitorAndDrainDiagnostics();
|
|
1551
|
+
// Blocker 2: attempt-2 rollback restores to state-with-attempt-1-candidate.
|
|
1552
|
+
// We must also restore preCandidateBoundary to fully remove retained unverified code.
|
|
1553
|
+
const finalFilesystemDecision = await restoreRetainedCandidateForTerminalExit(filesystemExitDecision);
|
|
1554
|
+
const terminationEnvelope = await persistGuardStop(finalFilesystemDecision);
|
|
1555
|
+
const finalizedLoop = await finalizeCurrentLoop(finalFilesystemDecision, now());
|
|
1556
|
+
const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope };
|
|
1557
|
+
await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
|
|
1558
|
+
return {
|
|
1559
|
+
loop: finalizedWithEnvelope,
|
|
1560
|
+
decision: finalFilesystemDecision
|
|
1561
|
+
};
|
|
1562
|
+
}
|
|
1563
|
+
const changeApprovalDecision = evaluateChangeApprovalLeash({
|
|
1564
|
+
changedFiles,
|
|
1565
|
+
weakenedTestFiles,
|
|
1566
|
+
executionProfile: request.context.executionProfile,
|
|
1567
|
+
approvalPolicy: request.context.approvalPolicy
|
|
1568
|
+
});
|
|
1569
|
+
if (!changeApprovalDecision.allowed) {
|
|
1570
|
+
const patchDecision = evaluatePatchDecision({
|
|
1571
|
+
verificationPassed: result.verification.passed,
|
|
1572
|
+
previousVerifierScore,
|
|
1573
|
+
verifierScore: result.verification.passed ? 1 : 0,
|
|
1574
|
+
safetyViolationCount: changeApprovalDecision.violations.length,
|
|
1575
|
+
changedFileCount: changedFiles.length,
|
|
1576
|
+
diffNovelty: changedFiles.length > 0 ? 1 : 0,
|
|
1577
|
+
diffStats: result.execution?.diffStats,
|
|
1578
|
+
costUsd: getUsageUsd(result.usage),
|
|
1579
|
+
humanApprovalRequired: true,
|
|
1580
|
+
summary: result.summary
|
|
1581
|
+
});
|
|
1582
|
+
const approvalExitDecision = {
|
|
1583
|
+
shouldExit: true,
|
|
1584
|
+
lifecycleState: "human_escalation",
|
|
1585
|
+
status: "exited",
|
|
1586
|
+
reason: changeApprovalDecision.reason ??
|
|
1587
|
+
"Safety leash blocked dependency or migration changes that require approval.",
|
|
1588
|
+
...classifySafetyLeashExit(changeApprovalDecision, "dependency")
|
|
1589
|
+
};
|
|
1590
|
+
// Wrap per-attempt rollback so that preCandidateBoundary restore still runs below.
|
|
1591
|
+
let rollbackOutcome;
|
|
1592
|
+
try {
|
|
1593
|
+
rollbackOutcome = await restoreRollbackBoundary({
|
|
1594
|
+
repoRoot: request.context.repoRoot,
|
|
1595
|
+
boundary: rollbackBoundary,
|
|
1596
|
+
restoredAt: attemptCompletedAt,
|
|
1597
|
+
decision: patchDecision.decision
|
|
1598
|
+
}) ?? undefined;
|
|
1599
|
+
}
|
|
1600
|
+
catch (rollbackError) {
|
|
1601
|
+
queueControlDiagnostic("attempt_rollback_failed_on_approval_exit", {
|
|
1602
|
+
error: rollbackError instanceof Error ? rollbackError.message : String(rollbackError)
|
|
1603
|
+
});
|
|
1604
|
+
}
|
|
1605
|
+
if (input.store) {
|
|
1606
|
+
await input.store.writeAttemptArtifacts(loop.loopId, currentAttemptIndex, {
|
|
1607
|
+
compiledContext,
|
|
1608
|
+
leash: createLeashArtifact(changeApprovalDecision, currentAttemptIndex),
|
|
1609
|
+
patchScore: patchDecision.score,
|
|
1610
|
+
patchDecision: toPatchDecisionArtifact(patchDecision),
|
|
1611
|
+
...(rollbackBoundary ? { rollbackBoundary } : {}),
|
|
1612
|
+
...(rollbackOutcome ? { rollbackOutcome } : {})
|
|
1613
|
+
});
|
|
1614
|
+
await input.store.appendLedger(loop.loopId, makeLedgerEvent({
|
|
1615
|
+
kind: "safety.violations_found",
|
|
1616
|
+
runId: loop.loopId,
|
|
1617
|
+
attemptIndex: currentAttemptIndex,
|
|
1618
|
+
payload: {
|
|
1619
|
+
surface: "dependency",
|
|
1620
|
+
blocked: true,
|
|
1621
|
+
profile: changeApprovalDecision.profile ?? executionProfile.name,
|
|
1622
|
+
attemptIndex: currentAttemptIndex,
|
|
1623
|
+
violations: changeApprovalDecision.violations
|
|
1624
|
+
}
|
|
1625
|
+
}));
|
|
1626
|
+
await input.store.appendLedger(loop.loopId, makeLedgerEvent({
|
|
1627
|
+
kind: "attempt.discarded",
|
|
1628
|
+
runId: loop.loopId,
|
|
1629
|
+
attemptIndex: currentAttemptIndex,
|
|
1630
|
+
payload: {
|
|
1631
|
+
decision: patchDecision.decision,
|
|
1632
|
+
reason: patchDecision.summary,
|
|
1633
|
+
reasonCodes: patchDecision.reasonCodes,
|
|
1634
|
+
score: patchDecision.score.score
|
|
1635
|
+
}
|
|
1636
|
+
}));
|
|
1637
|
+
await input.store.appendLedger(loop.loopId, makeLedgerEvent({
|
|
1638
|
+
kind: "run.exited",
|
|
1639
|
+
runId: loop.loopId,
|
|
1640
|
+
payload: createRunExitPayload(approvalExitDecision)
|
|
1641
|
+
}));
|
|
1642
|
+
}
|
|
1643
|
+
await stopSignalMonitorAndDrainDiagnostics();
|
|
1644
|
+
// Blocker 2: per-attempt rollback above only restores to state-with-retained-candidate.
|
|
1645
|
+
const finalApprovalDecision = await restoreRetainedCandidateForTerminalExit(approvalExitDecision);
|
|
1646
|
+
const terminationEnvelope = await persistGuardStop(finalApprovalDecision);
|
|
1647
|
+
const finalizedLoop = await finalizeCurrentLoop(finalApprovalDecision, now());
|
|
1648
|
+
const finalizedWithEnvelope = { ...finalizedLoop, terminationEnvelope };
|
|
1649
|
+
await persistLoopRecordIfSupported(input.store, finalizedWithEnvelope);
|
|
1650
|
+
return {
|
|
1651
|
+
loop: finalizedWithEnvelope,
|
|
1652
|
+
decision: finalApprovalDecision
|
|
1322
1653
|
};
|
|
1323
1654
|
}
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1655
|
+
// VERIFY: Run grounding scan on patch diff if available
|
|
1656
|
+
// Uses the task's repoRoot to build/load the grounding index, then scans any diff
|
|
1657
|
+
let groundingScanResult;
|
|
1658
|
+
const patchDiff = buildPatchDiff(result, changedFiles);
|
|
1659
|
+
if (patchDiff && task.repoRoot) {
|
|
1660
|
+
try {
|
|
1661
|
+
const groundingIndex = await loadOrBuildRepoGroundingIndex(task.repoRoot);
|
|
1662
|
+
groundingScanResult = scanPatchForGroundingViolations(patchDiff, groundingIndex, {
|
|
1663
|
+
allowedPaths: task.allowedPaths
|
|
1664
|
+
});
|
|
1665
|
+
if (input.store && groundingScanResult.violations.length > 0) {
|
|
1666
|
+
await input.store.appendLedger(loop.loopId, makeLedgerEvent({
|
|
1667
|
+
kind: "grounding.violations_found",
|
|
1668
|
+
runId: loop.loopId,
|
|
1669
|
+
attemptIndex: currentAttemptIndex,
|
|
1670
|
+
payload: {
|
|
1671
|
+
violationCount: groundingScanResult.violations.length,
|
|
1672
|
+
resolvedFiles: groundingScanResult.resolvedFiles,
|
|
1673
|
+
contentOnly: groundingScanResult.contentOnly,
|
|
1674
|
+
violations: groundingScanResult.violations.slice(0, 10)
|
|
1675
|
+
}
|
|
1676
|
+
}));
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
catch {
|
|
1680
|
+
// Grounding scan is best-effort — never fail the loop because of a scan error
|
|
1681
|
+
}
|
|
1336
1682
|
}
|
|
1337
|
-
const
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1683
|
+
const shouldEvaluatePatchTruth = result.status === "completed" && tracksWorkspaceMutations && verifierConfigured;
|
|
1684
|
+
let patchDecision;
|
|
1685
|
+
if (shouldEvaluatePatchTruth) {
|
|
1686
|
+
patchDecision = evaluatePatchDecision({
|
|
1687
|
+
verificationPassed: verification.passed,
|
|
1688
|
+
previousVerifierScore,
|
|
1689
|
+
verifierScore: verification.passed ? 1 : 0,
|
|
1690
|
+
groundingViolationCount: groundingScanResult?.violations.length ?? 0,
|
|
1691
|
+
changedFileCount: changedFileEvidenceAvailable ? changedFiles.length : undefined,
|
|
1692
|
+
mutationRequired: request.context.mutationMode === "edit",
|
|
1693
|
+
definitionOfDonePreSatisfied: request.context.definitionOfDonePreSatisfied,
|
|
1694
|
+
diffNovelty: changedFileEvidenceAvailable ? (changedFiles.length > 0 ? 1 : 0) : undefined,
|
|
1695
|
+
diffStats: result.execution?.diffStats,
|
|
1696
|
+
costUsd: getUsageUsd(result.usage),
|
|
1697
|
+
summary: result.summary
|
|
1349
1698
|
});
|
|
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
1699
|
}
|
|
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.
|
|
1700
|
+
// C1 — Safe candidate retention.
|
|
1701
|
+
// Distinguish UNSAFE discards (must roll back) from SAFE_UNVERIFIED discards
|
|
1702
|
+
// (retain candidate for the next repair attempt).
|
|
1703
|
+
//
|
|
1704
|
+
// UNSAFE: scope/safety violations — rollback immediately; hard boundaries.
|
|
1705
|
+
// SAFE_UNVERIFIED: only soft heuristics (grounding, low novelty, verifier
|
|
1706
|
+
// not yet improved) — leave the workspace in the candidate state so the
|
|
1707
|
+
// next agent can repair rather than rebuild from scratch.
|
|
1708
|
+
// provider failure (result.status === "failed"): rollback — the workspace
|
|
1709
|
+
// state is unknown and may be partially corrupt.
|
|
1710
|
+
// When patchDecision is undefined (no patch truth evaluation, e.g. verifierConfigured=false),
|
|
1711
|
+
// preserve original behavior: only rollback on provider failure. Do not treat the absence
|
|
1712
|
+
// of a patch decision as "unsafe" — it simply means patch evaluation was not applicable.
|
|
1713
|
+
const candidateIsUnsafe = patchDecision !== undefined && (patchDecision.decision === "ESCALATE" ||
|
|
1714
|
+
(patchDecision.decision === "DISCARD" &&
|
|
1715
|
+
!isSafeUnverifiedDiscard(patchDecision.reasonCodes)));
|
|
1716
|
+
// Blocker 3: candidate may only be retained if an authoritative rollback boundary
|
|
1717
|
+
// exists to enable exact restoration. Without a boundary, we cannot prove we can
|
|
1718
|
+
// undo the retention — fail closed and discard instead.
|
|
1719
|
+
const candidateRetainedThisAttempt = patchDecision?.decision === "DISCARD" &&
|
|
1720
|
+
isSafeUnverifiedDiscard(patchDecision.reasonCodes) &&
|
|
1721
|
+
result.status !== "failed" &&
|
|
1722
|
+
rollbackBoundary !== undefined;
|
|
1416
1723
|
let rollbackOutcome;
|
|
1417
|
-
|
|
1724
|
+
if (candidateIsUnsafe && result.status !== "failed") {
|
|
1418
1725
|
rollbackOutcome = await restoreRollbackBoundary({
|
|
1419
1726
|
repoRoot: request.context.repoRoot,
|
|
1420
1727
|
boundary: rollbackBoundary,
|
|
1421
1728
|
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 } : {})
|
|
1729
|
+
decision: patchDecision?.decision ?? "DISCARD"
|
|
1443
1730
|
});
|
|
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
1731
|
}
|
|
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 {
|
|
1732
|
+
else if (result.status === "failed") {
|
|
1733
|
+
// Provider failure: workspace state unknown — always rollback.
|
|
1515
1734
|
rollbackOutcome = await restoreRollbackBoundary({
|
|
1516
1735
|
repoRoot: request.context.repoRoot,
|
|
1517
1736
|
boundary: rollbackBoundary,
|
|
1518
1737
|
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)
|
|
1738
|
+
decision: "DISCARD"
|
|
1525
1739
|
});
|
|
1526
1740
|
}
|
|
1741
|
+
// else: candidateRetainedThisAttempt or KEEP — leave workspace as-is.
|
|
1742
|
+
// C1: back-patch candidateRetained onto the attempt that was already appended.
|
|
1743
|
+
if (candidateRetainedThisAttempt) {
|
|
1744
|
+
// First retention: record the boundary BEFORE this candidate was applied,
|
|
1745
|
+
// so terminal cleanup can restore the workspace if the run never reaches VERIFIED.
|
|
1746
|
+
if (!preCandidateBoundary && rollbackBoundary) {
|
|
1747
|
+
preCandidateBoundary = rollbackBoundary;
|
|
1748
|
+
}
|
|
1749
|
+
loop = {
|
|
1750
|
+
...loop,
|
|
1751
|
+
attempts: loop.attempts.map((a) => a.attemptId === attemptId ? { ...a, candidateRetained: true } : a)
|
|
1752
|
+
};
|
|
1753
|
+
}
|
|
1527
1754
|
if (input.store) {
|
|
1528
1755
|
await input.store.writeAttemptArtifacts(loop.loopId, currentAttemptIndex, {
|
|
1529
1756
|
compiledContext,
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
patchDecision:
|
|
1757
|
+
...(patchDiff ? { diff: patchDiff } : {}),
|
|
1758
|
+
...(groundingScanResult ? { groundingScan: groundingScanResult } : {}),
|
|
1759
|
+
...(patchDecision ? { patchScore: patchDecision.score } : {}),
|
|
1760
|
+
...(patchDecision ? { patchDecision: toPatchDecisionArtifact(patchDecision) } : {}),
|
|
1533
1761
|
...(rollbackBoundary ? { rollbackBoundary } : {}),
|
|
1534
1762
|
...(rollbackOutcome ? { rollbackOutcome } : {})
|
|
1535
1763
|
});
|
|
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
1764
|
}
|
|
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) {
|
|
1765
|
+
if (input.store) {
|
|
1766
|
+
if (patchDecision) {
|
|
1588
1767
|
await input.store.appendLedger(loop.loopId, makeLedgerEvent({
|
|
1589
|
-
kind: "
|
|
1768
|
+
kind: patchDecision.decision === "KEEP" ? "attempt.kept" : "attempt.discarded",
|
|
1590
1769
|
runId: loop.loopId,
|
|
1591
1770
|
attemptIndex: currentAttemptIndex,
|
|
1592
1771
|
payload: {
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1772
|
+
decision: patchDecision.decision,
|
|
1773
|
+
reason: patchDecision.summary,
|
|
1774
|
+
reasonCodes: patchDecision.reasonCodes,
|
|
1775
|
+
score: patchDecision.score.score
|
|
1597
1776
|
}
|
|
1598
1777
|
}));
|
|
1599
1778
|
}
|
|
1779
|
+
else {
|
|
1780
|
+
await input.store.appendLedger(loop.loopId, makeLedgerEvent({
|
|
1781
|
+
kind: verification.passed || !verifierConfigured ? "attempt.kept" : "attempt.discarded",
|
|
1782
|
+
runId: loop.loopId,
|
|
1783
|
+
attemptIndex: currentAttemptIndex,
|
|
1784
|
+
payload: { reason: verification.summary }
|
|
1785
|
+
}));
|
|
1786
|
+
}
|
|
1600
1787
|
}
|
|
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,
|
|
1788
|
+
if (patchDecision && patchDecision.decision !== "KEEP" && !failure) {
|
|
1789
|
+
failure = classifyPatchDecisionFailure(patchDecision, result);
|
|
1790
|
+
loop = applyPatchFailureToLoop(loop, {
|
|
1791
|
+
attemptId,
|
|
1792
|
+
summary: patchDecision.summary,
|
|
1793
|
+
failure
|
|
1794
|
+
});
|
|
1795
|
+
if (failure.recommendedIntervention === "compress_context") {
|
|
1796
|
+
useCompressedContext = true;
|
|
1797
|
+
}
|
|
1798
|
+
loop = appendLoopEvent(loop, {
|
|
1799
|
+
type: "failure.classified",
|
|
1800
|
+
lifecycleState: "running",
|
|
1693
1801
|
payload: {
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
score: patchDecision.score.score
|
|
1802
|
+
attemptId,
|
|
1803
|
+
failureClass: failure.failureClass,
|
|
1804
|
+
rationale: failure.rationale
|
|
1698
1805
|
}
|
|
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;
|
|
1806
|
+
}, { now: attemptCompletedAt, idFactory });
|
|
1807
|
+
loop = appendLoopEvent(loop, {
|
|
1808
|
+
type: "intervention.selected",
|
|
1809
|
+
lifecycleState: "running",
|
|
1810
|
+
payload: { attemptId, intervention: failure.recommendedIntervention }
|
|
1811
|
+
}, { now: attemptCompletedAt, idFactory });
|
|
1719
1812
|
}
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1813
|
+
if (patchDecision?.decision === "ESCALATE" || patchDecision?.decision === "HANDOFF") {
|
|
1814
|
+
const patchExitDecision = {
|
|
1815
|
+
shouldExit: true,
|
|
1816
|
+
lifecycleState: "human_escalation",
|
|
1817
|
+
status: "exited",
|
|
1818
|
+
reason: patchDecision.summary
|
|
1819
|
+
};
|
|
1820
|
+
if (input.store) {
|
|
1821
|
+
await input.store.appendLedger(loop.loopId, makeLedgerEvent({
|
|
1822
|
+
kind: "run.exited",
|
|
1823
|
+
runId: loop.loopId,
|
|
1824
|
+
payload: createRunExitPayload(patchExitDecision)
|
|
1825
|
+
}));
|
|
1727
1826
|
}
|
|
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
|
-
}));
|
|
1827
|
+
await stopSignalMonitorAndDrainDiagnostics();
|
|
1828
|
+
// Blocker 2: ESCALATE/HANDOFF exits can occur after a candidate is retained.
|
|
1829
|
+
const finalPatchExitDecision = await restoreRetainedCandidateForTerminalExit(patchExitDecision);
|
|
1830
|
+
const finalizedLoop = await finalizeCurrentLoop(finalPatchExitDecision, now());
|
|
1831
|
+
await persistLoopRecordIfSupported(input.store, finalizedLoop);
|
|
1832
|
+
return {
|
|
1833
|
+
loop: finalizedLoop,
|
|
1834
|
+
decision: finalPatchExitDecision
|
|
1835
|
+
};
|
|
1748
1836
|
}
|
|
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 } : {})
|
|
1837
|
+
const effectiveResult = patchDecision && patchDecision.decision !== "KEEP"
|
|
1838
|
+
? {
|
|
1839
|
+
...result,
|
|
1840
|
+
status: "failed",
|
|
1841
|
+
summary: patchDecision.summary,
|
|
1842
|
+
verification: {
|
|
1843
|
+
...result.verification,
|
|
1844
|
+
passed: false,
|
|
1845
|
+
summary: patchDecision.summary
|
|
1846
|
+
},
|
|
1847
|
+
failure: {
|
|
1848
|
+
message: patchDecision.summary,
|
|
1849
|
+
...(failure?.failureClass ? { classHint: failure.failureClass } : {})
|
|
1850
|
+
}
|
|
1772
1851
|
}
|
|
1852
|
+
: result;
|
|
1853
|
+
// Authoritative verification evidence from effectiveResult (grounding/patch-truth applied).
|
|
1854
|
+
// Back-patch verifierScore/failingVerifierStepCount BEFORE inferExit so that progress
|
|
1855
|
+
// decisions use normalized, binding-checked evidence — never raw adapter-reported values.
|
|
1856
|
+
const effectiveVerification = normalizeVerificationOutcome(effectiveResult, expectedVerifierBinding);
|
|
1857
|
+
{
|
|
1858
|
+
const steps = effectiveVerification.steps ?? [];
|
|
1859
|
+
const launched = steps.filter(s => s.launched);
|
|
1860
|
+
const passing = launched.filter(s => s.exitCode === 0 && !s.timedOut && !s.crashed);
|
|
1861
|
+
const failing = launched.filter(s => s.exitCode !== 0 || s.timedOut || s.crashed);
|
|
1862
|
+
currentVerifierScore = effectiveVerification.passed
|
|
1863
|
+
? 1 : launched.length > 0 ? passing.length / launched.length : 0;
|
|
1864
|
+
currentFailingVerifierStepCount = failing.length > 0 ? failing.length : undefined;
|
|
1865
|
+
loop = {
|
|
1866
|
+
...loop,
|
|
1867
|
+
attempts: loop.attempts.map(a => a.attemptId === attemptId
|
|
1868
|
+
? { ...a, verifierScore: currentVerifierScore, ...(currentFailingVerifierStepCount !== undefined ? { failingVerifierStepCount: currentFailingVerifierStepCount } : {}) }
|
|
1869
|
+
: a)
|
|
1870
|
+
};
|
|
1773
1871
|
}
|
|
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,
|
|
1872
|
+
const decision = inferExit({
|
|
1873
|
+
loop,
|
|
1874
|
+
lastResult: effectiveResult,
|
|
1875
|
+
lastFailure: failure,
|
|
1876
|
+
costState,
|
|
1877
|
+
verificationRequired: verifierConfigured,
|
|
1878
|
+
canSwitchAdapter: failure?.recommendedIntervention === "switch_adapter" &&
|
|
1879
|
+
adapterChain[currentAdapterIndex] !== undefined &&
|
|
1880
|
+
currentAdapter.adapterId !== executingAdapter.adapterId
|
|
1881
|
+
});
|
|
1882
|
+
// Advance phase based on result
|
|
1883
|
+
currentPhase = nextPolicyPhase(currentPhase, effectiveResult, costState, phaseRetryCount);
|
|
1884
|
+
if (failure)
|
|
1885
|
+
phaseRetryCount++;
|
|
1886
|
+
else
|
|
1887
|
+
phaseRetryCount = 0;
|
|
1888
|
+
// Post-attempt: verification.completed event uses the same authoritative effectiveVerification.
|
|
1889
|
+
loop = appendLoopEvent(loop, {
|
|
1890
|
+
type: "verification.completed",
|
|
1891
|
+
lifecycleState: effectiveVerification.passed ? "completed" : "verifying",
|
|
1830
1892
|
payload: {
|
|
1831
1893
|
attemptId,
|
|
1894
|
+
attemptIndex: currentAttemptIndex,
|
|
1832
1895
|
passed: effectiveVerification.passed,
|
|
1833
1896
|
summary: effectiveVerification.summary,
|
|
1834
1897
|
...(effectiveVerification.steps?.length ? { steps: effectiveVerification.steps } : {}),
|
|
@@ -1836,103 +1899,124 @@ export async function runMartin(input) {
|
|
|
1836
1899
|
...(effectiveVerification.binding ? { binding: effectiveVerification.binding } : {}),
|
|
1837
1900
|
changedFiles,
|
|
1838
1901
|
}
|
|
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) {
|
|
1902
|
+
}, { now: attemptCompletedAt, idFactory });
|
|
1883
1903
|
if (input.store) {
|
|
1884
1904
|
await input.store.appendLedger(loop.loopId, makeLedgerEvent({
|
|
1885
|
-
kind: "
|
|
1905
|
+
kind: "verification.completed",
|
|
1886
1906
|
runId: loop.loopId,
|
|
1887
|
-
|
|
1907
|
+
attemptIndex: currentAttemptIndex,
|
|
1908
|
+
payload: {
|
|
1909
|
+
attemptId,
|
|
1910
|
+
passed: effectiveVerification.passed,
|
|
1911
|
+
summary: effectiveVerification.summary,
|
|
1912
|
+
...(effectiveVerification.steps?.length ? { steps: effectiveVerification.steps } : {}),
|
|
1913
|
+
...(effectiveVerification.warnings?.length ? { warnings: effectiveVerification.warnings } : {}),
|
|
1914
|
+
...(effectiveVerification.binding ? { binding: effectiveVerification.binding } : {}),
|
|
1915
|
+
changedFiles,
|
|
1916
|
+
}
|
|
1888
1917
|
}));
|
|
1889
1918
|
}
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1919
|
+
if (!effectiveVerification.passed) {
|
|
1920
|
+
// Find the best failure evidence: prefer a step with a nonzero exit, then a
|
|
1921
|
+
// crashed/timed-out step, then fall back to the verification summary/warnings.
|
|
1922
|
+
const steps = effectiveVerification.steps ?? [];
|
|
1923
|
+
const failedStep = steps.find(s => !s.timedOut && !s.crashed && s.exitCode !== undefined && s.exitCode !== 0) ??
|
|
1924
|
+
steps.find(s => s.timedOut || s.crashed);
|
|
1925
|
+
// Build the failure excerpt: prefer step detail, fall back to verification
|
|
1926
|
+
// summary and warnings so attempt 2 never receives an empty failure block.
|
|
1927
|
+
const rawExcerpt = failedStep?.detail?.trim()
|
|
1928
|
+
? failedStep.detail
|
|
1929
|
+
: [
|
|
1930
|
+
effectiveVerification.summary,
|
|
1931
|
+
...(effectiveVerification.warnings ?? [])
|
|
1932
|
+
].filter(Boolean).join("\n");
|
|
1933
|
+
lastVerifierFailure = {
|
|
1934
|
+
command: failedStep?.command ?? effectiveVerification.binding?.commands?.[0] ?? "unknown",
|
|
1935
|
+
// Only include exitCode for normal (non-crash, non-timeout) exits.
|
|
1936
|
+
// Crash/timeout sentinels like -1 are not meaningful native exit codes.
|
|
1937
|
+
...(!failedStep?.crashed && !failedStep?.timedOut && failedStep?.exitCode !== undefined
|
|
1938
|
+
? { exitCode: failedStep.exitCode }
|
|
1939
|
+
: {}),
|
|
1940
|
+
...(failedStep?.crashed ? { crashed: true } : {}),
|
|
1941
|
+
...(failedStep?.timedOut ? { timedOut: true } : {}),
|
|
1942
|
+
failureExcerpt: sanitizeVerifierExcerpt(rawExcerpt ?? ""),
|
|
1943
|
+
attemptId,
|
|
1944
|
+
attemptIndex: currentAttemptIndex,
|
|
1945
|
+
};
|
|
1946
|
+
}
|
|
1947
|
+
else {
|
|
1948
|
+
lastVerifierFailure = undefined;
|
|
1949
|
+
}
|
|
1950
|
+
const postAttemptTerminal = await evaluateCheckpoint("post_attempt", {
|
|
1951
|
+
status: effectiveResult.status === "completed" ? "completed" : "failed",
|
|
1952
|
+
verificationPassed: effectiveVerification.passed
|
|
1953
|
+
});
|
|
1954
|
+
if (postAttemptTerminal !== undefined)
|
|
1955
|
+
return await terminateRun(postAttemptTerminal);
|
|
1956
|
+
// Before-retry: check signals before scheduling the next attempt
|
|
1957
|
+
const preRetryTerminal = await evaluateCheckpoint("before_retry");
|
|
1958
|
+
if (preRetryTerminal !== undefined)
|
|
1959
|
+
return await terminateRun(preRetryTerminal);
|
|
1960
|
+
if (decision.shouldExit) {
|
|
1961
|
+
if (input.store) {
|
|
1962
|
+
await input.store.appendLedger(loop.loopId, makeLedgerEvent({
|
|
1963
|
+
kind: "run.exited",
|
|
1964
|
+
runId: loop.loopId,
|
|
1965
|
+
payload: createRunExitPayload(decision)
|
|
1966
|
+
}));
|
|
1967
|
+
}
|
|
1968
|
+
// Drain pending diagnostic ledger writes before receipt signing.
|
|
1969
|
+
await stopSignalMonitorAndDrainDiagnostics();
|
|
1970
|
+
// Blocker 2: inferExit can return shouldExit=true (e.g. diminishing_returns) while
|
|
1971
|
+
// a prior-attempt candidate is retained. No-op when lifecycleState="completed".
|
|
1972
|
+
const finalInferDecision = await restoreRetainedCandidateForTerminalExit(decision);
|
|
1973
|
+
const routingInput = { runStartMs, firstDeltaDetected, firstDeltaTimestampMs, firstDeltaFilePath, firstDeltaChangeType, preDeltaCostUsd };
|
|
1974
|
+
const finalizedLoop = await finalizeCurrentLoop(finalInferDecision, now(), routingInput);
|
|
1975
|
+
await persistLoopRecordIfSupported(input.store, finalizedLoop);
|
|
1976
|
+
return {
|
|
1977
|
+
loop: finalizedLoop,
|
|
1978
|
+
decision: finalInferDecision
|
|
1979
|
+
};
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
const exhaustedTerminal = await evaluateCheckpoint("before_retry");
|
|
1983
|
+
if (!exhaustedTerminal) {
|
|
1984
|
+
throw new Error("Loop exhausted its configured iterations without an exit-policy decision.");
|
|
1902
1985
|
}
|
|
1986
|
+
return await terminateRun(exhaustedTerminal);
|
|
1903
1987
|
}
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1988
|
+
finally {
|
|
1989
|
+
if (wallTimer !== undefined)
|
|
1990
|
+
clearTimeout(wallTimer);
|
|
1991
|
+
disposeSignalMonitor();
|
|
1992
|
+
runController.abort();
|
|
1993
|
+
input.signal?.removeEventListener("abort", observeParentAbort);
|
|
1994
|
+
activeAttemptController?.abort();
|
|
1995
|
+
// Flush any diagnostic ledger writes queued from signal callbacks
|
|
1996
|
+
await Promise.all(pendingControlWrites);
|
|
1997
|
+
// Last-resort: if an unhandled exception escaped the main loop and preCandidateBoundary
|
|
1998
|
+
// is still set, attempt to restore the workspace so unverified code is not left behind.
|
|
1999
|
+
// This path is reached only when none of the normal terminal paths ran successfully.
|
|
2000
|
+
if (preCandidateBoundary) {
|
|
2001
|
+
try {
|
|
2002
|
+
await restoreRollbackBoundary({
|
|
2003
|
+
repoRoot: task.repoRoot,
|
|
2004
|
+
boundary: preCandidateBoundary,
|
|
2005
|
+
restoredAt: now(),
|
|
2006
|
+
decision: "DISCARD"
|
|
2007
|
+
});
|
|
2008
|
+
preCandidateBoundary = undefined;
|
|
2009
|
+
}
|
|
2010
|
+
catch {
|
|
2011
|
+
// Cannot safely write to ledger here. The workspace state is unknown.
|
|
2012
|
+
// The exception that caused this path will propagate to the caller.
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
1907
2015
|
}
|
|
1908
|
-
return await terminateRun(exhaustedTerminal);
|
|
1909
2016
|
}
|
|
1910
2017
|
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
|
-
}
|
|
2018
|
+
if (executionLease) {
|
|
2019
|
+
await executionLease.release().catch(() => undefined);
|
|
1936
2020
|
}
|
|
1937
2021
|
}
|
|
1938
2022
|
}
|