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