toolroll 0.9.51 → 0.9.53
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/CHANGELOG.md +20 -0
- package/README.md +55 -80
- package/dist/agentconfig.d.ts +11 -36
- package/dist/agentconfig.js +3 -62
- package/dist/approval-policy.d.ts +1 -1
- package/dist/approval-policy.js +1 -1
- package/dist/backup.d.ts +8 -0
- package/dist/backup.js +10 -1
- package/dist/board.d.ts +3 -19
- package/dist/board.js +4 -42
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +18 -18
- package/dist/browser-workspace.d.ts +3 -1
- package/dist/builder.d.ts +9 -40
- package/dist/builder.js +50 -257
- package/dist/chat-actions.js +3 -14
- package/dist/chat-controls.d.ts +0 -4
- package/dist/chat-controls.js +0 -1
- package/dist/chat-decide.js +1 -3
- package/dist/chat-delivery.d.ts +0 -2
- package/dist/chat-delivery.js +0 -1
- package/dist/chat-quiet.js +2 -2
- package/dist/chat-task-actions.d.ts +1 -1
- package/dist/chat-task-actions.js +0 -2
- package/dist/claim.d.ts +79 -244
- package/dist/claim.js +688 -1023
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +10 -2
- package/dist/contracts/cli.d.ts +0 -13
- package/dist/contracts/cli.js +0 -2
- package/dist/contracts/console-api.d.ts +5 -10
- package/dist/contracts/console-api.js +4 -10
- package/dist/contracts/flow.d.ts +50 -0
- package/dist/contracts/flow.js +26 -1
- package/dist/contracts/lead-tools.d.ts +0 -13
- package/dist/contracts/lead-tools.js +3 -3
- package/dist/contracts/store-json.d.ts +1 -25
- package/dist/contracts/store-json.js +3 -13
- package/dist/converse.d.ts +1 -11
- package/dist/converse.js +6 -38
- package/dist/coordinator.d.ts +0 -1
- package/dist/coordinator.js +3 -5
- package/dist/database-reclaim.d.ts +4 -0
- package/dist/database-reclaim.js +13 -0
- package/dist/demo.d.ts +1 -1
- package/dist/demo.js +17 -27
- package/dist/desktop-update-gate.js +0 -1
- package/dist/desktop-update.d.ts +1 -1
- package/dist/desktop-update.js +1 -3
- package/dist/dispatch.d.ts +1 -1
- package/dist/dispatch.js +1 -1
- package/dist/dispose.d.ts +2 -3
- package/dist/dispose.js +17 -92
- package/dist/envelope.js +0 -3
- package/dist/exec.d.ts +0 -64
- package/dist/exec.js +3 -356
- package/dist/exhaustion.d.ts +22 -76
- package/dist/exhaustion.js +14 -140
- package/dist/export.js +4 -4
- package/dist/flow-engine.js +1 -1
- package/dist/flow-schedule.d.ts +154 -0
- package/dist/flow-schedule.js +321 -0
- package/dist/flow-share.js +1 -1
- package/dist/flow-triggers.d.ts +6 -1
- package/dist/flow-triggers.js +65 -10
- package/dist/flows-cli.js +8 -4
- package/dist/flows-ui.js +2 -0
- package/dist/flows.js +1 -1
- package/dist/guides.js +9 -29
- package/dist/invoke.d.ts +1 -24
- package/dist/invoke.js +17 -259
- package/dist/lead-status.js +2 -4
- package/dist/lead-voice.js +1 -1
- package/dist/live-bus.d.ts +3 -3
- package/dist/live-bus.js +3 -3
- package/dist/mate-doors.d.ts +1 -2
- package/dist/mate-doors.js +12 -25
- package/dist/mate-tools.d.ts +2 -2
- package/dist/mate-tools.js +10 -22
- package/dist/modes.d.ts +6 -7
- package/dist/modes.js +7 -13
- package/dist/monitoring.js +32 -8
- package/dist/notification-delivery.d.ts +19 -0
- package/dist/notification-delivery.js +37 -0
- package/dist/operate-remote-arguments.js +2 -6
- package/dist/operate-remote.js +6 -7
- package/dist/operate.d.ts +0 -6
- package/dist/operate.js +283 -2378
- package/dist/phase-routing.d.ts +13 -23
- package/dist/phase-routing.js +13 -35
- package/dist/plan-auto.js +2 -2
- package/dist/planner-source.js +2 -1
- package/dist/policy.d.ts +1 -4
- package/dist/policy.js +0 -8
- package/dist/process-recovery-eligibility.js +2 -3
- package/dist/process-recovery-settlement.js +1 -0
- package/dist/project-delete.js +3 -15
- package/dist/proposal.d.ts +4 -24
- package/dist/proposal.js +3 -88
- package/dist/provider-auth.d.ts +3 -3
- package/dist/provider-auth.js +4 -6
- package/dist/provider.d.ts +11 -25
- package/dist/provider.js +4 -36
- package/dist/push.js +1 -1
- package/dist/recipe-ui.js +6 -6
- package/dist/recipes.d.ts +3 -3
- package/dist/recipes.js +27 -23
- package/dist/retention.js +6 -5
- package/dist/run-closeout.d.ts +1 -1
- package/dist/run-closeout.js +1 -4
- package/dist/scope.d.ts +11 -65
- package/dist/scope.js +17 -82
- package/dist/serve.js +16 -25
- package/dist/server/chat.js +7 -30
- package/dist/server/flows.js +11 -10
- package/dist/server/guards.js +0 -1
- package/dist/server/pages-assets.js +1 -1
- package/dist/server/pages.js +0 -2
- package/dist/server/people-tokens.js +0 -4
- package/dist/server/route-table.d.ts +1 -1
- package/dist/server/route-table.js +2 -17
- package/dist/server/settings.js +4 -9
- package/dist/server/shared.d.ts +29 -212
- package/dist/server/shared.js +73 -780
- package/dist/server/task-views.js +9 -76
- package/dist/server/tasks.d.ts +1 -50
- package/dist/server/tasks.js +36 -946
- package/dist/setup-guide.d.ts +2 -2
- package/dist/slack-chat.d.ts +0 -2
- package/dist/slack-chat.js +0 -1
- package/dist/store.d.ts +142 -1382
- package/dist/store.js +900 -4083
- package/dist/surface.js +10 -25
- package/dist/task-composer.d.ts +1 -1
- package/dist/task-composer.js +1 -1
- package/dist/task-control.d.ts +3 -10
- package/dist/task-control.js +2 -7
- package/dist/teammate-desk.js +1 -1
- package/dist/teammates-ui.js +1 -1
- package/dist/telegram-mate.d.ts +0 -3
- package/dist/telegram-mate.js +0 -1
- package/dist/templates.d.ts +11 -7
- package/dist/templates.js +12 -9
- package/dist/toolroll-update.d.ts +24 -3
- package/dist/toolroll-update.js +159 -16
- package/dist/webhooks.d.ts +10 -22
- package/dist/webhooks.js +43 -81
- package/dist/work-index.js +3 -7
- package/dist/workspace-revision.d.ts +28 -1
- package/dist/workspace-revision.js +276 -49
- package/dist/workspace-ui.js +1 -1
- package/package.json +1 -3
- package/scripts/recovery-canary.mjs +3 -3
- package/dist/contest.d.ts +0 -318
- package/dist/contest.js +0 -753
- package/dist/held.d.ts +0 -150
- package/dist/held.js +0 -799
- package/dist/liveness.d.ts +0 -30
- package/dist/liveness.js +0 -42
- package/dist/pricing.d.ts +0 -45
- package/dist/pricing.js +0 -77
- package/dist/routine.d.ts +0 -228
- package/dist/routine.js +0 -764
- package/dist/supervisor.mjs +0 -297
package/dist/claim.js
CHANGED
|
@@ -28,14 +28,9 @@
|
|
|
28
28
|
* rather than probable.
|
|
29
29
|
*/
|
|
30
30
|
import { randomUUID } from "node:crypto";
|
|
31
|
-
import { attendedLivenessState } from "./liveness.js";
|
|
32
31
|
import { authenticate } from "./runner.js";
|
|
33
32
|
import { pauseForAuth } from "./provider-auth.js";
|
|
34
33
|
import { DEFAULT_MAX_OPEN_DECISIONS, missingCapability, scopeApprovedForDispatch, taskReadinessBlocker, } from "./dispatch.js";
|
|
35
|
-
/** attendedLivenessState over the store's ISO columns. */
|
|
36
|
-
function attendedWatchState(lastBeatAt, now, absoluteExpiry) {
|
|
37
|
-
return attendedLivenessState(lastBeatAt === null ? null : Date.parse(lastBeatAt), now.getTime(), Date.parse(absoluteExpiry));
|
|
38
|
-
}
|
|
39
34
|
import { BUILT_IN, } from "./store.js";
|
|
40
35
|
import { digestOf } from "./scope.js";
|
|
41
36
|
import { parseExecutionPlanDocument } from "./plan.js";
|
|
@@ -100,15 +95,15 @@ function acquireLocked(store, taskRef, runner, options) {
|
|
|
100
95
|
// THE COORDINATOR QUARANTINE in the one primitive every claim road
|
|
101
96
|
// shares (MCP spec v6, round-3 f1): a coordinator-filed task whose
|
|
102
97
|
// scope has not been sealed by the password ceremony claims for
|
|
103
|
-
// NOBODY — not the pre-approval planner road, not
|
|
104
|
-
//
|
|
105
|
-
//
|
|
98
|
+
// NOBODY — not the pre-approval planner road, not a raw CLI claim.
|
|
99
|
+
// One SQL predicate, so roads cannot diverge. After the seal it is
|
|
100
|
+
// ordinary in every respect.
|
|
106
101
|
if (placedRef !== null && placedRef.coordinatorCid !== null && !store.scopeSealed(placedRef.externalId)) {
|
|
107
102
|
return { ok: false, reason: "coordinator-filed" };
|
|
108
103
|
}
|
|
109
104
|
// The reservation gate lives HERE, in the one primitive every
|
|
110
105
|
// acquisition path shares (queue-columns review, finding 1): tick's
|
|
111
|
-
// acquireIfReady
|
|
106
|
+
// acquireIfReady and the raw CLI claim both
|
|
112
107
|
// pass through this line, so a task reserved for one worker can
|
|
113
108
|
// never be taken by another, whatever the caller's snapshot said.
|
|
114
109
|
// Scheduling, not authority: nothing about WHAT may build changes.
|
|
@@ -124,15 +119,6 @@ function acquireLocked(store, taskRef, runner, options) {
|
|
|
124
119
|
if (mirrorWhy !== null && mirrorWhy !== "not-a-mirror") {
|
|
125
120
|
return { ok: false, reason: "external", detail: mirrorWhy };
|
|
126
121
|
}
|
|
127
|
-
// The attended gate (Phase 2, v6 W10/Q4), in the one primitive every
|
|
128
|
-
// acquisition path shares — raw CLI claims included: a task with an
|
|
129
|
-
// OPEN attended authorization dispatches only to its named runner,
|
|
130
|
-
// and a task whose ONLY authority is attended dispatches only while
|
|
131
|
-
// the operator is watching, with the one attempt unspent.
|
|
132
|
-
// An authorization past its absolute expiry gates NOTHING (round-6
|
|
133
|
-
// finding 8): it is a corpse the sweep will close, and letting it
|
|
134
|
-
// keep refusing other runners on an approved task would be a
|
|
135
|
-
// permanent lock nobody signed.
|
|
136
122
|
// THE MODE BELT on the one primitive every claim road shares (Codex
|
|
137
123
|
// people round 2, finding 2): a mode-sealed approval whose signature
|
|
138
124
|
// no longer stands — revoked, expired by clock, or a dead signer —
|
|
@@ -145,29 +131,6 @@ function acquireLocked(store, taskRef, runner, options) {
|
|
|
145
131
|
message: "the operating mode that approved this has ended — the approval falls back to a person",
|
|
146
132
|
};
|
|
147
133
|
}
|
|
148
|
-
const attendedRow = store.openAuthorizationFor(taskRef);
|
|
149
|
-
const attendedOpen = attendedRow !== null && Date.parse(attendedRow.absoluteExpiry) > now.getTime() ? attendedRow : null;
|
|
150
|
-
if (attendedOpen !== null) {
|
|
151
|
-
if (attendedOpen.runner !== runner) {
|
|
152
|
-
return { ok: false, reason: "attended-held", runner: attendedOpen.runner };
|
|
153
|
-
}
|
|
154
|
-
const scopeApproved = db
|
|
155
|
-
.prepare(`SELECT 1 AS hit FROM task_scope
|
|
156
|
-
JOIN task_ref ON task_ref.id = ? AND task_scope.task_id = task_ref.external_id
|
|
157
|
-
WHERE task_scope.approved_digest = task_scope.digest AND task_scope.approved_at IS NOT NULL AND (COALESCE(task_scope.approval_basis, 'password') <> 'mode'
|
|
158
|
-
OR EXISTS (SELECT 1 FROM operating_mode om
|
|
159
|
-
JOIN approver signer ON signer.name = om.signed_by
|
|
160
|
-
WHERE om.repo = task_ref.repo AND om.revoked_at IS NULL
|
|
161
|
-
AND om.absolute_expiry > ? AND om.digest = task_scope.mode_digest
|
|
162
|
-
AND signer.revoked_at IS NULL AND signer.role = 'approver'))`)
|
|
163
|
-
.get(taskRef, now.toISOString());
|
|
164
|
-
if (scopeApproved === undefined) {
|
|
165
|
-
const watching = attendedWatchState(attendedOpen.lastBeatAt, now, attendedOpen.absoluteExpiry);
|
|
166
|
-
if ((watching !== "live" && watching !== "grace") || attendedOpen.attemptRun !== null) {
|
|
167
|
-
return { ok: false, reason: "attended-only" };
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
134
|
const existing = latest(db, taskRef);
|
|
172
135
|
if (existing !== undefined && isLive(existing, stamp)) {
|
|
173
136
|
return {
|
|
@@ -274,24 +237,8 @@ export function acquireIfReady(store, taskRef, runner, options) {
|
|
|
274
237
|
return { ok: false, reason: "not-ready", message: "the scope is not approved" };
|
|
275
238
|
}
|
|
276
239
|
}
|
|
277
|
-
let attendedAuthority = false;
|
|
278
240
|
if (role !== "planner" && !approvedScope) {
|
|
279
|
-
|
|
280
|
-
// when a LIVE attended authorization names THIS runner and its one
|
|
281
|
-
// attempt is unspent — the authorization IS the authority. Everything
|
|
282
|
-
// else about readiness still gates below and in acquireLocked.
|
|
283
|
-
const authorization = store.openAuthorizationFor(taskRef);
|
|
284
|
-
const watching = authorization === null
|
|
285
|
-
? null
|
|
286
|
-
: attendedWatchState(authorization.lastBeatAt, options.now, authorization.absoluteExpiry);
|
|
287
|
-
attendedAuthority =
|
|
288
|
-
authorization !== null &&
|
|
289
|
-
authorization.runner === runner &&
|
|
290
|
-
(watching === "live" || watching === "grace") &&
|
|
291
|
-
authorization.attemptRun === null;
|
|
292
|
-
if (!attendedAuthority) {
|
|
293
|
-
return { ok: false, reason: "not-ready", message: "the scope is not approved" };
|
|
294
|
-
}
|
|
241
|
+
return { ok: false, reason: "not-ready", message: "the scope is not approved" };
|
|
295
242
|
}
|
|
296
243
|
// Capabilities are re-read inside the same transaction as the CAS, like
|
|
297
244
|
// every other readiness fact: a key that expired between the survey and
|
|
@@ -304,10 +251,7 @@ export function acquireIfReady(store, taskRef, runner, options) {
|
|
|
304
251
|
// registered runners; a runner the store has never met is a test driving
|
|
305
252
|
// the API directly, and the CLI always registers.
|
|
306
253
|
const registered = store.getRunner(runner);
|
|
307
|
-
if (registered !== null
|
|
308
|
-
// Capacity governs UNATTENDED work (v6 W2 + round-6 finding 7): an
|
|
309
|
-
// attended session's bound is one-held-per-runner, so a full
|
|
310
|
-
// unattended ledger must not refuse the operator who is watching.
|
|
254
|
+
if (registered !== null) {
|
|
311
255
|
const held = store.liveClaimCount(runner, options.now);
|
|
312
256
|
if (held >= registered.runner.capacity) {
|
|
313
257
|
return {
|
|
@@ -380,124 +324,6 @@ export function acquireIfReady(store, taskRef, runner, options) {
|
|
|
380
324
|
return taken;
|
|
381
325
|
});
|
|
382
326
|
}
|
|
383
|
-
/**
|
|
384
|
-
* The FALLBACK-ADMISSION claim (E3d, review finding 4): the one gate a
|
|
385
|
-
* pending-admission chain cycle dispatches through. It enforces everything
|
|
386
|
-
* `acquireIfReady` enforces — task state, holds, blockers, the approved
|
|
387
|
-
* scope + live-mode belt, capability, capacity, and quota — with EXACTLY
|
|
388
|
-
* ONE deliberate difference: a BACKOFF hold does not gate, because the
|
|
389
|
-
* pending admission IS the system's answer to the predecessor's failure
|
|
390
|
-
* (waiting out the exhausted entry's backoff to run a different credential
|
|
391
|
-
* would be waiting for nothing). Quota is keyed by the PINNED entry's
|
|
392
|
-
* provider, model, and auth mode — the credential that would actually
|
|
393
|
-
* spend — so an exhausted fallback credential refuses here, before any
|
|
394
|
-
* claim exists.
|
|
395
|
-
*/
|
|
396
|
-
export function acquireFallback(store, taskRef, runner, options) {
|
|
397
|
-
return inTransaction(store, () => {
|
|
398
|
-
const db = store.handle;
|
|
399
|
-
const stamp = options.now.toISOString();
|
|
400
|
-
const task = db
|
|
401
|
-
.prepare(`SELECT task.id, task.state FROM task
|
|
402
|
-
JOIN task_ref ON task_ref.external_id = task.id AND task_ref.backend = ?
|
|
403
|
-
WHERE task_ref.id = ?`)
|
|
404
|
-
.get(BUILT_IN, taskRef);
|
|
405
|
-
if (task === undefined)
|
|
406
|
-
return { ok: false, reason: "not-ready", message: "no such task" };
|
|
407
|
-
// A task the strikes already ended (state failed) or that concluded is
|
|
408
|
-
// TERMINAL: its pending cycle never spends (the reviewer's third-strike
|
|
409
|
-
// scenario) — the cycle simply waits out as an inert record.
|
|
410
|
-
if (String(task["state"]) !== "queued") {
|
|
411
|
-
return { ok: false, reason: "not-ready", message: `state is ${String(task["state"])}, not queued` };
|
|
412
|
-
}
|
|
413
|
-
// Every hold EXCEPT backoff gates: an operator hold, a decision hold,
|
|
414
|
-
// an incident hold all still mean "do not spend".
|
|
415
|
-
const hold = db
|
|
416
|
-
.prepare(`SELECT reason FROM hold
|
|
417
|
-
WHERE task_ref = ? AND owner_kind <> 'backoff' AND (until IS NULL OR until > ?) LIMIT 1`)
|
|
418
|
-
.get(taskRef, stamp);
|
|
419
|
-
if (hold !== undefined)
|
|
420
|
-
return { ok: false, reason: "not-ready", message: `held: ${String(hold["reason"])}` };
|
|
421
|
-
const blocker = db
|
|
422
|
-
.prepare(`SELECT blocker.id FROM task_edge
|
|
423
|
-
JOIN task AS blocker ON blocker.id = task_edge.blocker
|
|
424
|
-
WHERE task_edge.blocked = ? AND blocker.state <> 'done'
|
|
425
|
-
ORDER BY blocker.id LIMIT 1`)
|
|
426
|
-
.get(String(task["id"]));
|
|
427
|
-
if (blocker !== undefined)
|
|
428
|
-
return { ok: false, reason: "not-ready", message: `waiting on ${String(blocker["id"])}` };
|
|
429
|
-
// The approved scope + live-mode belt, verbatim from acquireIfReady: a
|
|
430
|
-
// chain approval IS a scope approval, and a mode-sealed one must still
|
|
431
|
-
// stand.
|
|
432
|
-
const approvedScope = db
|
|
433
|
-
.prepare(`SELECT 1 AS hit FROM task_scope
|
|
434
|
-
JOIN task_ref ON task_ref.id = ? AND task_scope.task_id = task_ref.external_id
|
|
435
|
-
WHERE task_scope.approved_digest = task_scope.digest AND task_scope.approved_at IS NOT NULL AND (COALESCE(task_scope.approval_basis, 'password') <> 'mode'
|
|
436
|
-
OR EXISTS (SELECT 1 FROM operating_mode om
|
|
437
|
-
JOIN approver signer ON signer.name = om.signed_by
|
|
438
|
-
WHERE om.repo = task_ref.repo AND om.revoked_at IS NULL
|
|
439
|
-
AND om.absolute_expiry > ? AND om.digest = task_scope.mode_digest
|
|
440
|
-
AND signer.revoked_at IS NULL AND signer.role = 'approver'))`)
|
|
441
|
-
.get(taskRef, stamp);
|
|
442
|
-
if (approvedScope === undefined) {
|
|
443
|
-
return { ok: false, reason: "not-ready", message: "the scope is not approved" };
|
|
444
|
-
}
|
|
445
|
-
const gap = missingCapability(store, taskRef, options.repo ?? null, options.now);
|
|
446
|
-
if (gap !== null)
|
|
447
|
-
return { ok: false, reason: "capability", message: gap };
|
|
448
|
-
// The pinned entry's provider must not be one this runner reports
|
|
449
|
-
// unavailable (v47): the approved chain is the ONLY substitution road,
|
|
450
|
-
// and it still never dispatches onto a provider known to be missing.
|
|
451
|
-
const entryReadiness = store.runnerReadinessOf(runner, options.provider);
|
|
452
|
-
if (entryReadiness !== null && entryReadiness.state === "unavailable") {
|
|
453
|
-
return {
|
|
454
|
-
ok: false,
|
|
455
|
-
reason: "provider-unavailable",
|
|
456
|
-
message: `${entryReadiness.provider} is reported unavailable on ${runner} (${entryReadiness.reason}; observed ${entryReadiness.observedAt}) — the fallback entry cannot run here`,
|
|
457
|
-
};
|
|
458
|
-
}
|
|
459
|
-
const registered = store.getRunner(runner);
|
|
460
|
-
if (registered !== null) {
|
|
461
|
-
const held = store.liveClaimCount(runner, options.now);
|
|
462
|
-
if (held >= registered.runner.capacity) {
|
|
463
|
-
return {
|
|
464
|
-
ok: false,
|
|
465
|
-
reason: "capacity",
|
|
466
|
-
message: `${runner} holds ${held} of ${registered.runner.capacity} slot(s) — a free CPU against a full ledger is not capacity`,
|
|
467
|
-
};
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
// Quota, keyed by the PINNED credential that would spend.
|
|
471
|
-
const quota = store.quotaState(runner, options.provider, options.model, options.now, options.authMode);
|
|
472
|
-
if (quota !== null && quota.state === "exhausted") {
|
|
473
|
-
return {
|
|
474
|
-
ok: false,
|
|
475
|
-
reason: "quota",
|
|
476
|
-
message: `${runner}'s ${options.provider} quota is exhausted (${quota.reason})${quota.resetAt === null ? "" : ` until ${quota.resetAt}`} — the fallback entry's own credential is spent`,
|
|
477
|
-
};
|
|
478
|
-
}
|
|
479
|
-
// The attention budget, exactly as the ordinary road holds it (E3d
|
|
480
|
-
// verify, R4): a fallback attempt can park and add a decision like any
|
|
481
|
-
// other, so above the budget a task with a measured parking habit steps
|
|
482
|
-
// aside here too — backoff is the ONE exemption, not this.
|
|
483
|
-
const budget = options.maxOpenDecisions ?? DEFAULT_MAX_OPEN_DECISIONS;
|
|
484
|
-
if (store.countUnanswered() >= budget) {
|
|
485
|
-
const rate = store.refForId(taskRef)?.parkRate ?? 0;
|
|
486
|
-
if (rate > 0) {
|
|
487
|
-
return {
|
|
488
|
-
ok: false,
|
|
489
|
-
reason: "attention-budget",
|
|
490
|
-
message: `${store.countUnanswered()} decisions already wait and this task parks ${Math.round(rate * 100)}% of its attempts — answer some before it may add more`,
|
|
491
|
-
};
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
const taken = acquireLocked(store, taskRef, runner, options);
|
|
495
|
-
if (taken.ok && quota !== null) {
|
|
496
|
-
store.consumeHalfOpen(runner, options.provider, options.model, options.now, options.authMode);
|
|
497
|
-
}
|
|
498
|
-
return taken;
|
|
499
|
-
});
|
|
500
|
-
}
|
|
501
327
|
/**
|
|
502
328
|
* "I am still here." Extends the lease, and tells a superseded runner that it
|
|
503
329
|
* has been superseded — which is the cheapest moment for it to find out, well
|
|
@@ -541,6 +367,35 @@ function refusal(db, leaseId) {
|
|
|
541
367
|
const row = db.prepare("SELECT 1 AS hit FROM claim WHERE lease_id = ?").get(leaseId);
|
|
542
368
|
return { ok: false, reason: row === undefined ? "unknown" : "fenced" };
|
|
543
369
|
}
|
|
370
|
+
/** The release every ending makes: a compare-and-swap that only the live lease nobody superseded wins. */
|
|
371
|
+
function releaseAs(store, leaseId, as, now) {
|
|
372
|
+
const { changes } = store.handle
|
|
373
|
+
.prepare(`UPDATE claim SET released_at = ?, released_by = ? WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
|
|
374
|
+
.run(now.toISOString(), as, leaseId);
|
|
375
|
+
return Number(changes) > 0;
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* The open attempt an ending seals, proved first inside finalize's transaction: the run must be this lease's open
|
|
379
|
+
* attempt of the role the ending is for (anything else is a caller defect, thrown — not a race to absorb).
|
|
380
|
+
*/
|
|
381
|
+
function openAttempt(store, leaseId, runId, what, role) {
|
|
382
|
+
const run = store.getRun(runId);
|
|
383
|
+
if (run === null || run.leaseId !== leaseId || run.outcome !== null)
|
|
384
|
+
throw new Error(`run ${runId} is not ${leaseId}'s open attempt — ${what} seals exactly one`);
|
|
385
|
+
if (role !== undefined && run.role !== role)
|
|
386
|
+
throw new Error(`run ${runId} is a ${run.role} run — only ${role} runs can be sealed as ${what}`);
|
|
387
|
+
return run;
|
|
388
|
+
}
|
|
389
|
+
/** Release the claim as `as`, or — for a lease the world moved past — finish the run (and its open repair child) as a
|
|
390
|
+
* fenced refusal and say why. Null when the release won. */
|
|
391
|
+
function releaseOrRefuse(store, leaseId, as, runId, now, repairRun = null) {
|
|
392
|
+
if (releaseAs(store, leaseId, as, now))
|
|
393
|
+
return null;
|
|
394
|
+
if (repairRun !== null)
|
|
395
|
+
store.finishRun(repairRun.id, { outcome: "refused", reason: "fenced", now });
|
|
396
|
+
store.finishRun(runId, { outcome: "refused", reason: "fenced", now });
|
|
397
|
+
return refusal(store.handle, leaseId);
|
|
398
|
+
}
|
|
544
399
|
/**
|
|
545
400
|
* Hand the task back.
|
|
546
401
|
*
|
|
@@ -553,11 +408,7 @@ function refusal(db, leaseId) {
|
|
|
553
408
|
export function release(store, leaseId, now) {
|
|
554
409
|
const db = store.handle;
|
|
555
410
|
return inTransaction(store, () => {
|
|
556
|
-
|
|
557
|
-
.prepare(`UPDATE claim SET released_at = ?, released_by = 'released'
|
|
558
|
-
WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
|
|
559
|
-
.run(now.toISOString(), leaseId);
|
|
560
|
-
if (Number(changes) === 0) {
|
|
411
|
+
if (!releaseAs(store, leaseId, "released", now)) {
|
|
561
412
|
// A repeat of a hand-back this same lease already made is not a fence —
|
|
562
413
|
// nobody took the task away, the runner simply said so twice because its
|
|
563
414
|
// first acknowledgement was lost. M1 asks for duplicate completion to be
|
|
@@ -599,6 +450,35 @@ function duplicateOrRefusal(db, leaseId, own) {
|
|
|
599
450
|
return { ok: false, reason: "fenced" };
|
|
600
451
|
return refusal(db, leaseId);
|
|
601
452
|
}
|
|
453
|
+
/**
|
|
454
|
+
* THE ONE WAY AN ATTEMPT ENDS: seal `outcome` for `leaseId` in one fenced transaction.
|
|
455
|
+
*
|
|
456
|
+
* Every ending passes the same fence, in the same order: the run is this lease's open attempt of the right role (a
|
|
457
|
+
* caller defect otherwise, thrown); a stop that applies to it wins — the attempt is sealed as interrupted instead
|
|
458
|
+
* (except the endings that ARE a stop or a service hand-back); then the claim is released by a compare-and-swap only the
|
|
459
|
+
* live, unsuperseded lease wins. A lease the world moved past writes nothing but its own run's fenced refusal (the
|
|
460
|
+
* stop and hand-back endings still end their run, touching no task state), and a retry of an ending the same lease
|
|
461
|
+
* already made answers as a duplicate where that ending allows it. Only then does the ending's own effect run —
|
|
462
|
+
* decision and hold, incident, strike and backoff, scope, report, revision, task state, the disowned mirror — inside
|
|
463
|
+
* the same transaction, so nothing of it exists without the release, and a crash rolls all of it back together.
|
|
464
|
+
*/
|
|
465
|
+
export function finalize(store, leaseId, outcome) {
|
|
466
|
+
return inTransaction(store, () => {
|
|
467
|
+
switch (outcome.kind) {
|
|
468
|
+
case "complete": return sealComplete(store, leaseId, outcome);
|
|
469
|
+
case "park": return sealPark(store, leaseId, outcome);
|
|
470
|
+
case "malformed": return sealMalformed(store, leaseId, outcome);
|
|
471
|
+
case "interrupted": return sealInterrupted(store, leaseId, outcome);
|
|
472
|
+
case "hand-back": return sealHandBack(store, leaseId, outcome);
|
|
473
|
+
case "failure": return sealFailure(store, leaseId, outcome);
|
|
474
|
+
case "plan": return sealPlan(store, leaseId, outcome);
|
|
475
|
+
case "revision": return sealRevision(store, leaseId, outcome);
|
|
476
|
+
case "plan-failure": return sealPlanFailure(store, leaseId, outcome);
|
|
477
|
+
case "scout": return sealScout(store, leaseId, outcome);
|
|
478
|
+
case "scout-failure": return sealScoutFailure(store, leaseId, outcome);
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
}
|
|
602
482
|
/**
|
|
603
483
|
* Release the lease and write the task's terminal state, as one step.
|
|
604
484
|
*
|
|
@@ -613,172 +493,71 @@ function duplicateOrRefusal(db, leaseId, own) {
|
|
|
613
493
|
* `duplicate` and also writes nothing — the first completion already said,
|
|
614
494
|
* and a retry changing the answer would make "done" negotiable.
|
|
615
495
|
*/
|
|
616
|
-
|
|
496
|
+
function sealComplete(store, leaseId, args) {
|
|
497
|
+
const { state, now, mutation = {} } = args;
|
|
617
498
|
const db = store.handle;
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
ok: true,
|
|
647
|
-
arm: by === "disowned" ? "disowned" : "completed",
|
|
648
|
-
claim: readClaim(released),
|
|
649
|
-
duplicate: true,
|
|
650
|
-
};
|
|
651
|
-
}
|
|
652
|
-
return { ok: false, reason: "fenced" };
|
|
499
|
+
const stoppedRun = db.prepare("SELECT r.id FROM run r JOIN run_stop s ON s.run = r.id WHERE r.lease_id = ? AND r.outcome IS NULL LIMIT 1").get(leaseId);
|
|
500
|
+
if (stoppedRun !== undefined) {
|
|
501
|
+
const runId = Number(stoppedRun["id"]);
|
|
502
|
+
const run = store.getRun(runId);
|
|
503
|
+
const task = store.refForId(run.taskRef);
|
|
504
|
+
if (task !== null)
|
|
505
|
+
interruptIfStopped(store, { leaseId, runId, taskId: task.externalId, now });
|
|
506
|
+
return { ok: false, reason: "stopped" };
|
|
507
|
+
}
|
|
508
|
+
if (!releaseAs(store, leaseId, "completed", now)) {
|
|
509
|
+
// Only a completion this same lease already made reads as a duplicate.
|
|
510
|
+
// The DURABLE disposition (released_by) reproduces the original arm —
|
|
511
|
+
// whatever the task or the tracker did since (finding 33). A lease
|
|
512
|
+
// the reaper or recovery released was never *accepted*; a late
|
|
513
|
+
// completion after that is exactly the stale commit the fence keeps
|
|
514
|
+
// out.
|
|
515
|
+
const released = db
|
|
516
|
+
.prepare("SELECT * FROM claim WHERE lease_id = ? AND released_at IS NOT NULL")
|
|
517
|
+
.get(leaseId);
|
|
518
|
+
if (released !== undefined) {
|
|
519
|
+
const by = String(released["released_by"] ?? "");
|
|
520
|
+
if (by === "completed" || by === "disowned") {
|
|
521
|
+
return {
|
|
522
|
+
ok: true,
|
|
523
|
+
arm: by === "disowned" ? "disowned" : "completed",
|
|
524
|
+
claim: readClaim(released),
|
|
525
|
+
duplicate: true,
|
|
526
|
+
};
|
|
653
527
|
}
|
|
654
|
-
return
|
|
528
|
+
return { ok: false, reason: "fenced" };
|
|
655
529
|
}
|
|
656
|
-
|
|
657
|
-
const claim = readClaim(row);
|
|
658
|
-
const taskId = db
|
|
659
|
-
.prepare("SELECT external_id FROM task_ref WHERE id = ? AND backend = ?")
|
|
660
|
-
.get(claim.taskRef, BUILT_IN);
|
|
661
|
-
// The completion gate (external dispatch, v4 §24): the latch is read
|
|
662
|
-
// INSIDE this transaction, on a FRESH win only. A latched mirror's
|
|
663
|
-
// completion is DISOWNED — the task is cancelled, the disposition is
|
|
664
|
-
// written durably, and the caller never sees the completed arm, so
|
|
665
|
-
// publication is impossible by construction.
|
|
666
|
-
const disowned = taskId !== undefined && !store.mirrorAllowsCompletion(String(taskId["external_id"]));
|
|
667
|
-
if (disowned) {
|
|
668
|
-
db.prepare("UPDATE claim SET released_by = 'disowned' WHERE lease_id = ?").run(leaseId);
|
|
669
|
-
}
|
|
670
|
-
if (taskId !== undefined) {
|
|
671
|
-
store.replay(mutation, "completeFenced", () => {
|
|
672
|
-
if (disowned) {
|
|
673
|
-
// Cancellation goes through the one floor — a disowned
|
|
674
|
-
// completion is a typed machine reason, not a bare state write.
|
|
675
|
-
store.applyCancellation(String(taskId["external_id"]), { kind: "machine", code: "disowned-completion" }, now, null);
|
|
676
|
-
}
|
|
677
|
-
else {
|
|
678
|
-
db.prepare("UPDATE task SET state = ?, updated_at = ? WHERE id = ?").run(state, now.toISOString(), String(taskId["external_id"]));
|
|
679
|
-
}
|
|
680
|
-
return true;
|
|
681
|
-
});
|
|
682
|
-
}
|
|
683
|
-
store.bumpWake();
|
|
684
|
-
return { ok: true, arm: disowned ? "disowned" : "completed", claim };
|
|
685
|
-
});
|
|
686
|
-
}
|
|
687
|
-
/**
|
|
688
|
-
* Seal a park, fenced at the write (§7).
|
|
689
|
-
*
|
|
690
|
-
* The builder proved its lease after the agent ran; this transaction proves
|
|
691
|
-
* it again *in the same statement that releases it*, because everything a
|
|
692
|
-
* park creates — the decision, its hold, the outbox row — is only true if
|
|
693
|
-
* this lease still spoke for the task at the moment of sealing. A superseded
|
|
694
|
-
* lease creates none of it: no decision, no hold, no notification, and the
|
|
695
|
-
* run is finalized as the fenced refusal it was. A crash anywhere inside
|
|
696
|
-
* rolls all of it back together; there is no instant at which a decision
|
|
697
|
-
* exists without its hold.
|
|
698
|
-
*
|
|
699
|
-
* `released_by = 'parked'` so a late retry from this lease is answered with
|
|
700
|
-
* the fence, not "duplicate": a park hands the task to a person, and nothing
|
|
701
|
-
* the runner says afterwards is that person's answer.
|
|
702
|
-
*/
|
|
703
|
-
/**
|
|
704
|
-
* Continuation admission (Phase 2E, A4 / v3 R7): the AUTHORIZATION is the
|
|
705
|
-
* claimable unit — the finished parent task is never re-queued, its state,
|
|
706
|
-
* strikes, holds, and dependents untouched. Every shared gate still runs
|
|
707
|
-
* (acquireLocked: reservation, external mirror, the attended gates, the
|
|
708
|
-
* lease CAS); the queued-state readiness gate alone is replaced by
|
|
709
|
-
* construction. Liveness, the named runner, and the unspent attempt are
|
|
710
|
-
* re-proved HERE at claim time and again at the final dispatch proof.
|
|
711
|
-
*/
|
|
712
|
-
export function acquireContinuation(store, authorization, runner, options) {
|
|
713
|
-
const live = store.readAuthorization(authorization.id);
|
|
714
|
-
if (live === null ||
|
|
715
|
-
live.closedAt !== null ||
|
|
716
|
-
live.attemptRun !== null ||
|
|
717
|
-
live.parentRun === null ||
|
|
718
|
-
live.runner !== runner) {
|
|
719
|
-
return { ok: false, reason: "attended-only", message: "the authorization is not an open continuation for this runner" };
|
|
720
|
-
}
|
|
721
|
-
const watching = attendedWatchState(live.lastBeatAt, options.now, live.absoluteExpiry);
|
|
722
|
-
if (watching !== "live" && watching !== "grace") {
|
|
723
|
-
return { ok: false, reason: "attended-only", message: "the operator is not watching" };
|
|
530
|
+
return refusal(db, leaseId);
|
|
724
531
|
}
|
|
725
|
-
|
|
726
|
-
|
|
532
|
+
const row = db.prepare("SELECT * FROM claim WHERE lease_id = ?").get(leaseId);
|
|
533
|
+
const claim = readClaim(row);
|
|
534
|
+
const taskId = db
|
|
535
|
+
.prepare("SELECT external_id FROM task_ref WHERE id = ? AND backend = ?")
|
|
536
|
+
.get(claim.taskRef, BUILT_IN);
|
|
537
|
+
// The completion gate (external dispatch, v4 §24): the latch is read
|
|
538
|
+
// INSIDE this transaction, on a FRESH win only. A latched mirror's
|
|
539
|
+
// completion is DISOWNED — the task is cancelled, the disposition is
|
|
540
|
+
// written durably, and the caller never sees the completed arm, so
|
|
541
|
+
// publication is impossible by construction.
|
|
542
|
+
const disowned = taskId !== undefined && !store.mirrorAllowsCompletion(String(taskId["external_id"]));
|
|
543
|
+
if (disowned) {
|
|
544
|
+
db.prepare("UPDATE claim SET released_by = 'disowned' WHERE lease_id = ?").run(leaseId);
|
|
727
545
|
}
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
546
|
+
if (taskId !== undefined) {
|
|
547
|
+
store.replay(mutation, "completeFenced", () => {
|
|
548
|
+
if (disowned) {
|
|
549
|
+
// Cancellation goes through the one floor — a disowned
|
|
550
|
+
// completion is a typed machine reason, not a bare state write.
|
|
551
|
+
store.applyCancellation(String(taskId["external_id"]), { kind: "machine", code: "disowned-completion" }, now, null);
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
db.prepare("UPDATE task SET state = ?, updated_at = ? WHERE id = ?").run(state, now.toISOString(), String(taskId["external_id"]));
|
|
555
|
+
}
|
|
556
|
+
return true;
|
|
557
|
+
});
|
|
731
558
|
}
|
|
732
|
-
|
|
733
|
-
}
|
|
734
|
-
/**
|
|
735
|
-
* The HELD park (Phase 2E, v2 S1f): the decision is recorded, paged, and
|
|
736
|
-
* causally linked to the session turn that produced it — and NOTHING else
|
|
737
|
-
* ends. The lease stays; the run stays open; the process stays held. The
|
|
738
|
-
* conversation continues when the answer is injected as the next turn.
|
|
739
|
-
* The one-unresolved-per-run partial unique refuses a second open park.
|
|
740
|
-
*/
|
|
741
|
-
export function finalizeParkHeld(store, args) {
|
|
742
|
-
const { runId, taskId, decision, artifactIds, sessionTurn, now } = args;
|
|
743
|
-
return inTransaction(store, () => {
|
|
744
|
-
const held = store.heldSessionOf(runId);
|
|
745
|
-
if (held === null || held.endedAt !== null || held.state !== "open") {
|
|
746
|
-
return { ok: false, reason: "not-held" };
|
|
747
|
-
}
|
|
748
|
-
const run = store.getRun(runId);
|
|
749
|
-
if (run === null || run.outcome !== null)
|
|
750
|
-
return { ok: false, reason: "not-held" };
|
|
751
|
-
let decisionId;
|
|
752
|
-
try {
|
|
753
|
-
decisionId = store.saveDecision({
|
|
754
|
-
run: runId,
|
|
755
|
-
urgency: decision.urgency,
|
|
756
|
-
recap: decision.recap,
|
|
757
|
-
question: decision.question,
|
|
758
|
-
options: decision.options,
|
|
759
|
-
recommendation: decision.recommendation,
|
|
760
|
-
...(decision.assignee === null ? {} : { assignee: decision.assignee }),
|
|
761
|
-
...(decision.deadline === null ? {} : { deadline: decision.deadline }),
|
|
762
|
-
}, now);
|
|
763
|
-
}
|
|
764
|
-
catch {
|
|
765
|
-
// The partial unique: one unresolved question per run at a time.
|
|
766
|
-
return { ok: false, reason: "decision-open" };
|
|
767
|
-
}
|
|
768
|
-
store.handle.prepare("UPDATE decision SET session_turn = ? WHERE id = ?").run(sessionTurn, decisionId);
|
|
769
|
-
for (const artifact of artifactIds)
|
|
770
|
-
store.linkEvidence(decisionId, artifact);
|
|
771
|
-
store.enqueueNotification({
|
|
772
|
-
source: { run: runId },
|
|
773
|
-
dedupeKey: `decision:${decisionId}`,
|
|
774
|
-
kind: "decision",
|
|
775
|
-
subject: `${taskId} asked a question mid-session`,
|
|
776
|
-
body: `${oneLine(decision.question, 200)}\n\`toolroll decide ${decisionId}\``,
|
|
777
|
-
pushClass: "decision",
|
|
778
|
-
link: `/d/${decisionId}`,
|
|
779
|
-
}, now);
|
|
780
|
-
return { ok: true, decisionId };
|
|
781
|
-
});
|
|
559
|
+
store.bumpWake();
|
|
560
|
+
return { ok: true, arm: disowned ? "disowned" : "completed", claim };
|
|
782
561
|
}
|
|
783
562
|
/**
|
|
784
563
|
* Validate the optional accepted formatting child against the open planner
|
|
@@ -824,69 +603,70 @@ function plannerRepairChild(store, root, repairRunId) {
|
|
|
824
603
|
}
|
|
825
604
|
throw new Error(`run ${repairRunId} is outside planner ${root.id}'s bounded linear correction chain`);
|
|
826
605
|
}
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
},
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
store.
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
});
|
|
606
|
+
/**
|
|
607
|
+
* Seal a park, fenced at the write (§7).
|
|
608
|
+
*
|
|
609
|
+
* The builder proved its lease after the agent ran; this transaction proves
|
|
610
|
+
* it again *in the same statement that releases it*, because everything a
|
|
611
|
+
* park creates — the decision, its hold, the outbox row — is only true if
|
|
612
|
+
* this lease still spoke for the task at the moment of sealing. A superseded
|
|
613
|
+
* lease creates none of it: no decision, no hold, no notification, and the
|
|
614
|
+
* run is finalized as the fenced refusal it was. A crash anywhere inside
|
|
615
|
+
* rolls all of it back together; there is no instant at which a decision
|
|
616
|
+
* exists without its hold.
|
|
617
|
+
*
|
|
618
|
+
* `released_by = 'parked'` so a late retry from this lease is answered with
|
|
619
|
+
* the fence, not "duplicate": a park hands the task to a person, and nothing
|
|
620
|
+
* the runner says afterwards is that person's answer.
|
|
621
|
+
*/
|
|
622
|
+
function sealPark(store, leaseId, args) {
|
|
623
|
+
const { runId, taskId, decision, artifactIds, now } = args;
|
|
624
|
+
const run = openAttempt(store, leaseId, runId, "a park");
|
|
625
|
+
const repairRun = plannerRepairChild(store, run, args.repairRunId ?? null);
|
|
626
|
+
const stopped = interruptIfStopped(store, { leaseId, runId, taskId, now });
|
|
627
|
+
if (stopped !== null)
|
|
628
|
+
return { ok: false, reason: "stopped" };
|
|
629
|
+
const fenced = releaseOrRefuse(store, leaseId, "parked", runId, now, repairRun);
|
|
630
|
+
if (fenced !== null)
|
|
631
|
+
return fenced;
|
|
632
|
+
const decisionId = store.saveDecision({
|
|
633
|
+
run: runId,
|
|
634
|
+
urgency: decision.urgency,
|
|
635
|
+
recap: decision.recap,
|
|
636
|
+
question: decision.question,
|
|
637
|
+
options: decision.options,
|
|
638
|
+
recommendation: decision.recommendation,
|
|
639
|
+
...(decision.assignee === null ? {} : { assignee: decision.assignee }),
|
|
640
|
+
...(decision.deadline === null ? {} : { deadline: decision.deadline }),
|
|
641
|
+
}, now);
|
|
642
|
+
for (const artifact of artifactIds)
|
|
643
|
+
store.linkEvidence(decisionId, artifact);
|
|
644
|
+
// The hold is indefinite by construction. The decision's deadline is
|
|
645
|
+
// attention metadata; wiring it into `until` would dispatch the task,
|
|
646
|
+
// unanswered, the moment the deadline passed — expiry never chooses.
|
|
647
|
+
store.holdOwned({
|
|
648
|
+
taskRef: run.taskRef,
|
|
649
|
+
ownerKind: "decision",
|
|
650
|
+
ownerId: String(decisionId),
|
|
651
|
+
reason: `decision:${decisionId} — ${oneLine(decision.question, 80)}`,
|
|
652
|
+
until: null,
|
|
653
|
+
}, now);
|
|
654
|
+
store.finishRun(runId, { outcome: "parked", reason: `decision:${decisionId}`, now });
|
|
655
|
+
if (repairRun !== null) {
|
|
656
|
+
store.finishRun(repairRun.id, { outcome: "no-change", reason: "structured planner output repaired", now });
|
|
657
|
+
}
|
|
658
|
+
store.enqueueNotification({
|
|
659
|
+
source: { run: runId },
|
|
660
|
+
dedupeKey: `decision:${decisionId}`,
|
|
661
|
+
kind: "decision",
|
|
662
|
+
subject: `${taskId} parked a decision`,
|
|
663
|
+
body: `${oneLine(decision.question, 200)}\n\`toolroll decide ${decisionId}\``,
|
|
664
|
+
// The push stamp (arc 3): class + machine-minted link, at enqueue
|
|
665
|
+
// or never. Fixed phrases ride the push service; this subject does not.
|
|
666
|
+
pushClass: "decision",
|
|
667
|
+
link: `/d/${decisionId}`,
|
|
668
|
+
}, now);
|
|
669
|
+
return { ok: true, decisionId };
|
|
890
670
|
}
|
|
891
671
|
/**
|
|
892
672
|
* Seal a park whose payload never became a decision (§6's bounded repair ran
|
|
@@ -896,49 +676,38 @@ export function finalizeParkFenced(store, args) {
|
|
|
896
676
|
* malformed payload is preserved as evidence — a person may still be able to
|
|
897
677
|
* read what the agent meant.
|
|
898
678
|
*/
|
|
899
|
-
|
|
900
|
-
const {
|
|
901
|
-
const
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
pushClass: "attention",
|
|
932
|
-
link: `/r/${runId}`,
|
|
933
|
-
subject: `${taskId}: the agent parked but could not say what`,
|
|
934
|
-
body: [
|
|
935
|
-
`Repair ran out. The task is held until somebody looks.`,
|
|
936
|
-
...problems.slice(0, 5).map(problem => `- ${oneLine(problem.message, 120)}`),
|
|
937
|
-
`The raw payload is preserved in run ${runId}'s evidence.`,
|
|
938
|
-
].join("\n"),
|
|
939
|
-
}, now);
|
|
940
|
-
return { ok: true, incidentId };
|
|
941
|
-
});
|
|
679
|
+
function sealMalformed(store, leaseId, args) {
|
|
680
|
+
const { runId, taskId, problems, now } = args;
|
|
681
|
+
const run = openAttempt(store, leaseId, runId, "a park");
|
|
682
|
+
const stopped = interruptIfStopped(store, { leaseId, runId, taskId, now });
|
|
683
|
+
if (stopped !== null)
|
|
684
|
+
return { ok: false, reason: "stopped" };
|
|
685
|
+
const fenced = releaseOrRefuse(store, leaseId, "parked", runId, now);
|
|
686
|
+
if (fenced !== null)
|
|
687
|
+
return fenced;
|
|
688
|
+
const incidentId = store.createIncident({ run: runId, kind: "malformed-decision" }, now);
|
|
689
|
+
store.holdOwned({
|
|
690
|
+
taskRef: run.taskRef,
|
|
691
|
+
ownerKind: "incident",
|
|
692
|
+
ownerId: String(incidentId),
|
|
693
|
+
reason: `malformed-decision — the agent tried to park ${taskId} and could not say what`,
|
|
694
|
+
until: null,
|
|
695
|
+
}, now);
|
|
696
|
+
store.finishRun(runId, { outcome: "failed", reason: "malformed-decision", now });
|
|
697
|
+
store.enqueueNotification({
|
|
698
|
+
source: { run: runId },
|
|
699
|
+
dedupeKey: `malformed:${runId}`,
|
|
700
|
+
kind: "malformed-decision",
|
|
701
|
+
pushClass: "attention",
|
|
702
|
+
link: `/r/${runId}`,
|
|
703
|
+
subject: `${taskId}: the agent parked but could not say what`,
|
|
704
|
+
body: [
|
|
705
|
+
`Repair ran out. The task is held until somebody looks.`,
|
|
706
|
+
...problems.slice(0, 5).map(problem => `- ${oneLine(problem.message, 120)}`),
|
|
707
|
+
`The raw payload is preserved in run ${runId}'s evidence.`,
|
|
708
|
+
].join("\n"),
|
|
709
|
+
}, now);
|
|
710
|
+
return { ok: true, incidentId };
|
|
942
711
|
}
|
|
943
712
|
/** 1m, 2m, 4m, 8m, 16m — doubling, capped. Indexed by strikes-1. */
|
|
944
713
|
const BACKOFF_MS = [60_000, 120_000, 240_000, 480_000, 960_000];
|
|
@@ -958,50 +727,42 @@ export const MAX_STRIKES = 3;
|
|
|
958
727
|
* ends the run as interrupted — that IS the honest ending — but touches
|
|
959
728
|
* no task state: the task belongs to whoever holds it now.
|
|
960
729
|
*/
|
|
961
|
-
|
|
962
|
-
const {
|
|
730
|
+
function sealInterrupted(store, leaseId, args) {
|
|
731
|
+
const { runId, taskId, stopRun, now } = args;
|
|
963
732
|
const db = store.handle;
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
// ending settles the stop, and every owned row ends in the same write.
|
|
979
|
-
for (const owned of store.ownedRunsOf(runId)) {
|
|
980
|
-
if (owned === runId)
|
|
981
|
-
continue;
|
|
982
|
-
const child = store.getRun(owned);
|
|
983
|
-
if (child !== null && child.outcome === null) {
|
|
984
|
-
store.finishRun(owned, { outcome: "failed", reason: "interrupted", now, stopSettlement: "interrupted" });
|
|
985
|
-
}
|
|
733
|
+
const run = openAttempt(store, leaseId, runId, "an interruption");
|
|
734
|
+
// A lease the world moved past still ends its run as interrupted — that IS the honest ending — touching no task state.
|
|
735
|
+
const fenced = !releaseAs(store, leaseId, "interrupted", now);
|
|
736
|
+
if (args.message !== undefined && args.message.trim() !== "") {
|
|
737
|
+
store.recordOutcomeFacts(runId, { handoff: args.message });
|
|
738
|
+
}
|
|
739
|
+
// Owned descendants first (a repair turn, a correction) — the parent's
|
|
740
|
+
// ending settles the stop, and every owned row ends in the same write.
|
|
741
|
+
for (const owned of store.ownedRunsOf(runId)) {
|
|
742
|
+
if (owned === runId)
|
|
743
|
+
continue;
|
|
744
|
+
const child = store.getRun(owned);
|
|
745
|
+
if (child !== null && child.outcome === null) {
|
|
746
|
+
store.finishRun(owned, { outcome: "failed", reason: "interrupted", now, stopSettlement: "interrupted" });
|
|
986
747
|
}
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
store.settleRunStop(stopRun, "interrupted", now);
|
|
995
|
-
if (fenced)
|
|
996
|
-
return { ok: true, stopRun, requeued: false, fenced: true };
|
|
997
|
-
// Back to the queue, where the stop's hold keeps it — a paused task
|
|
998
|
-
// reads as queued-and-held, exactly like an operator pause.
|
|
999
|
-
const requeued = db
|
|
1000
|
-
.prepare("UPDATE task SET state = 'queued', updated_at = ? WHERE id = ? AND state = 'running'")
|
|
1001
|
-
.run(now.toISOString(), taskId);
|
|
1002
|
-
store.bumpWake();
|
|
1003
|
-
return { ok: true, stopRun, requeued: Number(requeued.changes) > 0, fenced: false };
|
|
748
|
+
}
|
|
749
|
+
store.finishRun(runId, {
|
|
750
|
+
outcome: "failed",
|
|
751
|
+
reason: "interrupted",
|
|
752
|
+
...(args.committed === undefined ? {} : { committed: args.committed }),
|
|
753
|
+
now,
|
|
754
|
+
stopSettlement: "interrupted",
|
|
1004
755
|
});
|
|
756
|
+
store.settleRunStop(stopRun, "interrupted", now);
|
|
757
|
+
if (fenced)
|
|
758
|
+
return { ok: true, stopRun, requeued: false, fenced: true };
|
|
759
|
+
// Back to the queue, where the stop's hold keeps it — a paused task
|
|
760
|
+
// reads as queued-and-held, exactly like an operator pause.
|
|
761
|
+
const requeued = db
|
|
762
|
+
.prepare("UPDATE task SET state = 'queued', updated_at = ? WHERE id = ? AND state = 'running'")
|
|
763
|
+
.run(now.toISOString(), taskId);
|
|
764
|
+
store.bumpWake();
|
|
765
|
+
return { ok: true, stopRun, requeued: Number(requeued.changes) > 0, fenced: false };
|
|
1005
766
|
}
|
|
1006
767
|
/**
|
|
1007
768
|
* Hand an attempt back because the SERVICE stopped (its builder is
|
|
@@ -1014,37 +775,29 @@ export function finalizeInterruptedFenced(store, args) {
|
|
|
1014
775
|
* resumes it. A lease the world moved past still ends the run, touching no
|
|
1015
776
|
* task state.
|
|
1016
777
|
*/
|
|
1017
|
-
|
|
1018
|
-
const {
|
|
778
|
+
function sealHandBack(store, leaseId, args) {
|
|
779
|
+
const { runId, taskId, now } = args;
|
|
1019
780
|
const db = store.handle;
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
if (fenced)
|
|
1041
|
-
return { ok: true, requeued: false, fenced: true };
|
|
1042
|
-
const requeued = db
|
|
1043
|
-
.prepare("UPDATE task SET state = 'queued', updated_at = ? WHERE id = ? AND state = 'running'")
|
|
1044
|
-
.run(now.toISOString(), taskId);
|
|
1045
|
-
store.bumpWake();
|
|
1046
|
-
return { ok: true, requeued: Number(requeued.changes) > 0, fenced: false };
|
|
1047
|
-
});
|
|
781
|
+
const run = openAttempt(store, leaseId, runId, "a hand-back");
|
|
782
|
+
// A lease the world moved past still ends its run as interrupted — that IS the honest ending — touching no task state.
|
|
783
|
+
const fenced = !releaseAs(store, leaseId, "interrupted", now);
|
|
784
|
+
if (args.message.trim() !== "")
|
|
785
|
+
store.recordOutcomeFacts(runId, { handoff: args.message });
|
|
786
|
+
for (const owned of store.ownedRunsOf(runId)) {
|
|
787
|
+
if (owned === runId)
|
|
788
|
+
continue;
|
|
789
|
+
const child = store.getRun(owned);
|
|
790
|
+
if (child !== null && child.outcome === null)
|
|
791
|
+
store.finishRun(owned, { outcome: "failed", reason: "interrupted", now });
|
|
792
|
+
}
|
|
793
|
+
store.finishRun(runId, { outcome: "failed", reason: "interrupted", now });
|
|
794
|
+
if (fenced)
|
|
795
|
+
return { ok: true, requeued: false, fenced: true };
|
|
796
|
+
const requeued = db
|
|
797
|
+
.prepare("UPDATE task SET state = 'queued', updated_at = ? WHERE id = ? AND state = 'running'")
|
|
798
|
+
.run(now.toISOString(), taskId);
|
|
799
|
+
store.bumpWake();
|
|
800
|
+
return { ok: true, requeued: Number(requeued.changes) > 0, fenced: false };
|
|
1048
801
|
}
|
|
1049
802
|
/**
|
|
1050
803
|
* THE STOP FENCE at settlement (v52): when a stop applies to the run —
|
|
@@ -1060,11 +813,12 @@ export function interruptIfStopped(store, args) {
|
|
|
1060
813
|
return null;
|
|
1061
814
|
const run = store.getRun(args.runId);
|
|
1062
815
|
const where = run?.worktree ?? null;
|
|
1063
|
-
|
|
1064
|
-
|
|
816
|
+
const { leaseId, ...rest } = args;
|
|
817
|
+
return inTransaction(store, () => sealInterrupted(store, leaseId, {
|
|
818
|
+
...rest,
|
|
1065
819
|
stopRun: stop.run,
|
|
1066
820
|
message: args.message ?? `stopped by ${stop.requestedBy} (run #${stop.run}) — ${where === null ? "the attempt's evidence is on record" : `the work is preserved in ${where}`}`,
|
|
1067
|
-
});
|
|
821
|
+
}));
|
|
1068
822
|
}
|
|
1069
823
|
/**
|
|
1070
824
|
* Seal a failed attempt: one fenced transaction for the release, the run,
|
|
@@ -1081,104 +835,91 @@ export function interruptIfStopped(store, args) {
|
|
|
1081
835
|
* worktree holds unpreserved work, ordinary re-pooling would refuse it as
|
|
1082
836
|
* dirty forever, so it is an immediate incident naming the checkout.
|
|
1083
837
|
*/
|
|
1084
|
-
|
|
1085
|
-
const {
|
|
838
|
+
function sealFailure(store, leaseId, args) {
|
|
839
|
+
const { runId, taskId, failureClass, message, now } = args;
|
|
1086
840
|
const db = store.handle;
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
return refusal(db, leaseId);
|
|
1105
|
-
}
|
|
1106
|
-
// A sign-in that no longer works is not the attempt's fault: no strike,
|
|
1107
|
-
// no retry, the task back in the queue, and its provider paused.
|
|
1108
|
-
if (failureClass !== "commit-failure" && run.terminalClass === "auth-expired") {
|
|
1109
|
-
return { ok: true, ...sealAuthExpired(store, run, taskId, now) };
|
|
1110
|
-
}
|
|
1111
|
-
store.finishRun(runId, { outcome: "failed", reason: failureClass, now });
|
|
1112
|
-
if (failureClass === "commit-failure") {
|
|
1113
|
-
// The work exists and is uncommitted; nothing times its way out of
|
|
1114
|
-
// that. A person (or an explicit repair) proves the checkout.
|
|
1115
|
-
const incidentId = store.createIncident({ run: runId, kind: "commit-failure" }, now);
|
|
1116
|
-
store.holdOwned({
|
|
1117
|
-
taskRef: run.taskRef,
|
|
1118
|
-
ownerKind: "incident",
|
|
1119
|
-
ownerId: String(incidentId),
|
|
1120
|
-
reason: `commit-failure — uncommitted work preserved in ${args.worktree}`,
|
|
1121
|
-
until: null,
|
|
1122
|
-
}, now);
|
|
1123
|
-
store.enqueueNotification({
|
|
1124
|
-
source: { run: runId },
|
|
1125
|
-
dedupeKey: `commit-failure:${runId}`,
|
|
1126
|
-
kind: "commit-failure",
|
|
1127
|
-
pushClass: "attention",
|
|
1128
|
-
link: `/r/${runId}`,
|
|
1129
|
-
subject: `${taskId}: the commit itself failed`,
|
|
1130
|
-
body: `${oneLine(message, 200)}\nThe work is preserved, uncommitted, in ${args.worktree}. Prove it and \`toolroll task requeue ${taskId}\`.`,
|
|
1131
|
-
}, now);
|
|
1132
|
-
return { ok: true, disposition: "commit-incident", incidentId };
|
|
1133
|
-
}
|
|
1134
|
-
const strikes = store.addStrike(run.taskRef);
|
|
1135
|
-
if (strikes >= MAX_STRIKES) {
|
|
1136
|
-
const incidentId = store.createIncident({ run: runId, kind: "attempts-exhausted" }, now);
|
|
1137
|
-
store.holdOwned({
|
|
1138
|
-
taskRef: run.taskRef,
|
|
1139
|
-
ownerKind: "incident",
|
|
1140
|
-
ownerId: String(incidentId),
|
|
1141
|
-
reason: `attempts-exhausted — ${strikes} consecutive failures, last: ${failureClass}`,
|
|
1142
|
-
until: null,
|
|
1143
|
-
}, now);
|
|
1144
|
-
db.prepare("UPDATE task SET state = 'failed', updated_at = ? WHERE id = ?").run(now.toISOString(), taskId);
|
|
1145
|
-
store.enqueueNotification({
|
|
1146
|
-
source: { run: runId },
|
|
1147
|
-
dedupeKey: `stalled:${run.taskRef}`,
|
|
1148
|
-
kind: "attempts-exhausted",
|
|
1149
|
-
pushClass: "attention",
|
|
1150
|
-
link: `/r/${runId}`,
|
|
1151
|
-
subject: `${taskId} stalled after ${strikes} straight failures`,
|
|
1152
|
-
body: `Last failure (${failureClass}): ${oneLine(message, 200)}\nIt will not be retried. Read the runs, then \`toolroll task requeue ${taskId}\`.`,
|
|
1153
|
-
}, now);
|
|
1154
|
-
return { ok: true, disposition: "stalled", strikes, incidentId };
|
|
1155
|
-
}
|
|
1156
|
-
// Under the limit: requeue behind a doubling pause the failure owns.
|
|
1157
|
-
// Replacing the previous backoff hold is correct — this attempt's strike
|
|
1158
|
-
// already reflects the whole streak.
|
|
1159
|
-
const wait = BACKOFF_MS[Math.min(strikes, BACKOFF_MS.length) - 1];
|
|
1160
|
-
const until = new Date(now.getTime() + wait);
|
|
841
|
+
const run = openAttempt(store, leaseId, runId, "a failure", "builder");
|
|
842
|
+
const stopped = interruptIfStopped(store, { leaseId, runId, taskId, now });
|
|
843
|
+
if (stopped !== null)
|
|
844
|
+
return { ok: false, reason: "stopped" };
|
|
845
|
+
const fenced = releaseOrRefuse(store, leaseId, "released", runId, now);
|
|
846
|
+
if (fenced !== null)
|
|
847
|
+
return fenced;
|
|
848
|
+
// A sign-in that no longer works is not the attempt's fault: no strike,
|
|
849
|
+
// no retry, the task back in the queue, and its provider paused.
|
|
850
|
+
if (failureClass !== "commit-failure" && run.terminalClass === "auth-expired") {
|
|
851
|
+
return { ok: true, ...sealAuthExpired(store, run, taskId, now) };
|
|
852
|
+
}
|
|
853
|
+
store.finishRun(runId, { outcome: "failed", reason: failureClass, now });
|
|
854
|
+
if (failureClass === "commit-failure") {
|
|
855
|
+
// The work exists and is uncommitted; nothing times its way out of
|
|
856
|
+
// that. A person (or an explicit repair) proves the checkout.
|
|
857
|
+
const incidentId = store.createIncident({ run: runId, kind: "commit-failure" }, now);
|
|
1161
858
|
store.holdOwned({
|
|
1162
859
|
taskRef: run.taskRef,
|
|
1163
|
-
ownerKind: "
|
|
1164
|
-
ownerId: String(
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
? `The agent stopped before handing off; its work was kept and it is being resumed (retry ${strikes}/${MAX_STRIKES} in ${Math.round(wait / 60_000)}m)`
|
|
1168
|
-
: `retry ${strikes}/${MAX_STRIKES} after ${failureClass} — backing off ${Math.round(wait / 60_000)}m`,
|
|
1169
|
-
until,
|
|
860
|
+
ownerKind: "incident",
|
|
861
|
+
ownerId: String(incidentId),
|
|
862
|
+
reason: `commit-failure — uncommitted work preserved in ${args.worktree}`,
|
|
863
|
+
until: null,
|
|
1170
864
|
}, now);
|
|
1171
865
|
store.enqueueNotification({
|
|
1172
866
|
source: { run: runId },
|
|
1173
|
-
dedupeKey: `
|
|
1174
|
-
kind: "
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
body: `${oneLine(message, 200)}\
|
|
867
|
+
dedupeKey: `commit-failure:${runId}`,
|
|
868
|
+
kind: "commit-failure",
|
|
869
|
+
pushClass: "attention",
|
|
870
|
+
link: `/r/${runId}`,
|
|
871
|
+
subject: `${taskId}: the commit itself failed`,
|
|
872
|
+
body: `${oneLine(message, 200)}\nThe work is preserved, uncommitted, in ${args.worktree}. Prove it and \`toolroll task requeue ${taskId}\`.`,
|
|
1179
873
|
}, now);
|
|
1180
|
-
return { ok: true, disposition: "
|
|
1181
|
-
}
|
|
874
|
+
return { ok: true, disposition: "commit-incident", incidentId };
|
|
875
|
+
}
|
|
876
|
+
const strikes = store.addStrike(run.taskRef);
|
|
877
|
+
if (strikes >= MAX_STRIKES) {
|
|
878
|
+
const incidentId = store.createIncident({ run: runId, kind: "attempts-exhausted" }, now);
|
|
879
|
+
store.holdOwned({
|
|
880
|
+
taskRef: run.taskRef,
|
|
881
|
+
ownerKind: "incident",
|
|
882
|
+
ownerId: String(incidentId),
|
|
883
|
+
reason: `attempts-exhausted — ${strikes} consecutive failures, last: ${failureClass}`,
|
|
884
|
+
until: null,
|
|
885
|
+
}, now);
|
|
886
|
+
db.prepare("UPDATE task SET state = 'failed', updated_at = ? WHERE id = ?").run(now.toISOString(), taskId);
|
|
887
|
+
store.enqueueNotification({
|
|
888
|
+
source: { run: runId },
|
|
889
|
+
dedupeKey: `stalled:${run.taskRef}`,
|
|
890
|
+
kind: "attempts-exhausted",
|
|
891
|
+
pushClass: "attention",
|
|
892
|
+
link: `/r/${runId}`,
|
|
893
|
+
subject: `${taskId} stalled after ${strikes} straight failures`,
|
|
894
|
+
body: `Last failure (${failureClass}): ${oneLine(message, 200)}\nIt will not be retried. Read the runs, then \`toolroll task requeue ${taskId}\`.`,
|
|
895
|
+
}, now);
|
|
896
|
+
return { ok: true, disposition: "stalled", strikes, incidentId };
|
|
897
|
+
}
|
|
898
|
+
// Under the limit: requeue behind a doubling pause the failure owns.
|
|
899
|
+
// Replacing the previous backoff hold is correct — this attempt's strike
|
|
900
|
+
// already reflects the whole streak.
|
|
901
|
+
const wait = BACKOFF_MS[Math.min(strikes, BACKOFF_MS.length) - 1];
|
|
902
|
+
const until = new Date(now.getTime() + wait);
|
|
903
|
+
store.holdOwned({
|
|
904
|
+
taskRef: run.taskRef,
|
|
905
|
+
ownerKind: "backoff",
|
|
906
|
+
ownerId: String(run.taskRef),
|
|
907
|
+
// Run 2085: this failure is named in plain words wherever the hold shows.
|
|
908
|
+
reason: failureClass === "no-handoff"
|
|
909
|
+
? `The agent stopped before handing off; its work was kept and it is being resumed (retry ${strikes}/${MAX_STRIKES} in ${Math.round(wait / 60_000)}m)`
|
|
910
|
+
: `retry ${strikes}/${MAX_STRIKES} after ${failureClass} — backing off ${Math.round(wait / 60_000)}m`,
|
|
911
|
+
until,
|
|
912
|
+
}, now);
|
|
913
|
+
store.enqueueNotification({
|
|
914
|
+
source: { run: runId },
|
|
915
|
+
dedupeKey: `run:${runId}:failed`,
|
|
916
|
+
kind: "build-failed",
|
|
917
|
+
subject: failureClass === "no-handoff"
|
|
918
|
+
? `${taskId}: the agent stopped before handing off; its work was kept, retry ${strikes}/${MAX_STRIKES}`
|
|
919
|
+
: `${taskId}: attempt failed (${failureClass}), retry ${strikes}/${MAX_STRIKES}`,
|
|
920
|
+
body: `${oneLine(message, 200)}\nNext attempt no earlier than ${until.toISOString()}.`,
|
|
921
|
+
}, now);
|
|
922
|
+
return { ok: true, disposition: "backoff", strikes, until: until.toISOString() };
|
|
1182
923
|
}
|
|
1183
924
|
/**
|
|
1184
925
|
* The auth-expired ending, inside the caller's fenced transaction (claim
|
|
@@ -1212,8 +953,8 @@ const PLAN_BACKOFF_MS = [60_000, 2 * 60_000, 4 * 60_000];
|
|
|
1212
953
|
* will restate), which is why this is the park discipline, not the DONE
|
|
1213
954
|
* one (Codex planning review, question 2).
|
|
1214
955
|
*/
|
|
1215
|
-
|
|
1216
|
-
const {
|
|
956
|
+
function sealPlan(store, leaseId, args) {
|
|
957
|
+
const { runId, taskId, plan, now } = args;
|
|
1217
958
|
const db = store.handle;
|
|
1218
959
|
// The ingestion record (contract handoff, task 1) is composed here from
|
|
1219
960
|
// the filed terms and the drafted plan; its file and row land inside the
|
|
@@ -1233,174 +974,151 @@ export function finalizePlanFenced(store, args) {
|
|
|
1233
974
|
amendment,
|
|
1234
975
|
changes: contractChanges,
|
|
1235
976
|
};
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
977
|
+
const run = openAttempt(store, leaseId, runId, "a plan", "planner");
|
|
978
|
+
const repairRun = plannerRepairChild(store, run, args.repairRunId ?? null);
|
|
979
|
+
if (store.lookupRef(taskId)?.id !== run.taskRef)
|
|
980
|
+
throw new Error("a planner can finalize only its own task");
|
|
981
|
+
const stopped = interruptIfStopped(store, { leaseId, runId, taskId, now });
|
|
982
|
+
if (stopped !== null)
|
|
983
|
+
return { ok: false, reason: "stopped" };
|
|
984
|
+
const invalidSource = (detail, kind = "malformed") => {
|
|
985
|
+
const failed = sealPlanFailure(store, leaseId, { runId, taskId, failure: kind, message: detail, now });
|
|
986
|
+
if (repairRun !== null)
|
|
987
|
+
store.finishRun(repairRun.id, { outcome: "refused", reason: failed.ok ? "source-invalid" : "fenced", now });
|
|
988
|
+
return failed.ok ? { ok: false, reason: "source-invalid", detail } : failed;
|
|
989
|
+
};
|
|
990
|
+
const sources = store.artifactsFor(runId).filter(one => one.kind === "plan-contract" && one.key.endsWith("/planner-source.json"));
|
|
991
|
+
// Legacy rows have no input stamp or artifact. Every current dispatch
|
|
992
|
+
// stamps even an empty scope; dropping a caller argument cannot bypass custody.
|
|
993
|
+
if (args.source !== undefined || sources.length > 0 || run.scopeDigest !== null) {
|
|
994
|
+
if (args.source === undefined || args.evidenceRoot === undefined || sources.length !== 1 || sources[0].id !== args.sourceArtifact) {
|
|
995
|
+
return invalidSource("the planner's admitted source inventory is missing, ambiguous, or was not presented");
|
|
996
|
+
}
|
|
997
|
+
const artifact = sources[0];
|
|
998
|
+
let verified;
|
|
999
|
+
try {
|
|
1000
|
+
verified = readVerifiedArtifact(args.evidenceRoot, artifact);
|
|
1001
|
+
}
|
|
1002
|
+
catch {
|
|
1003
|
+
return invalidSource("the planner source file cannot be read");
|
|
1240
1004
|
}
|
|
1241
|
-
if (
|
|
1242
|
-
|
|
1005
|
+
if (!verified.ok || artifact.truncated)
|
|
1006
|
+
return invalidSource("the planner source bytes no longer verify");
|
|
1007
|
+
const recorded = decodePlannerSource(verified.content);
|
|
1008
|
+
if (recorded === null || recorded.taskId !== taskId || !verified.content.equals(encodePlannerSource(args.source)) || run.scopeDigest !== (recorded.contract.scope?.digest ?? "")) {
|
|
1009
|
+
return invalidSource("the supplied planning source is not the source recorded for this run");
|
|
1243
1010
|
}
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1011
|
+
if (contractChanges.length > 0 && (typeof amendment !== "string" || !amendment.trim() || amendment.length > PLANNER_SOURCE_LIMITS.amendment)) {
|
|
1012
|
+
return invalidSource("a planner changed the filed contract without a bounded, explicit amendment");
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
// THE SOURCE RECHECK (contract handoff, task 1): the filed request is
|
|
1016
|
+
// re-derived from durable state inside this very transaction and must
|
|
1017
|
+
// still be the one the planner read. A scope rewritten, approved, or
|
|
1018
|
+
// re-termed while the planner ran is the NEWER source; an old draft
|
|
1019
|
+
// never overwrites it. The attempt ends refused in words — no strike,
|
|
1020
|
+
// the planner did nothing wrong — and the task stays requested. The
|
|
1021
|
+
// claim releases under the same fence the ingestion would have used.
|
|
1022
|
+
if (args.source !== undefined) {
|
|
1023
|
+
const current = plannerContractOf(store, taskId);
|
|
1024
|
+
const currentDigest = current.ok ? plannerSourceDigest(current.contract) : null;
|
|
1025
|
+
const currentAnswers = store.answeredDecisionsFor(taskId, PLANNER_SOURCE_LIMITS.answers + 1).map(one => ({ question: one.question, choice: one.choice ?? "", note: one.note }));
|
|
1026
|
+
if (currentDigest !== args.source.sourceDigest || canonicalContractJson(currentAnswers) !== canonicalContractJson(args.source.answers)) {
|
|
1027
|
+
const detail = current.ok
|
|
1028
|
+
? describeSourceDrift(args.source, current.contract)
|
|
1029
|
+
: `the filed request can no longer be read: ${current.message}`;
|
|
1030
|
+
const fenced = releaseOrRefuse(store, leaseId, "released", runId, now, repairRun);
|
|
1031
|
+
if (fenced !== null)
|
|
1032
|
+
return fenced;
|
|
1252
1033
|
if (repairRun !== null)
|
|
1253
|
-
store.finishRun(repairRun.id, { outcome: "refused", reason:
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
}
|
|
1263
|
-
|
|
1264
|
-
let verified;
|
|
1265
|
-
try {
|
|
1266
|
-
verified = readVerifiedArtifact(args.evidenceRoot, artifact);
|
|
1267
|
-
}
|
|
1268
|
-
catch {
|
|
1269
|
-
return invalidSource("the planner source file cannot be read");
|
|
1270
|
-
}
|
|
1271
|
-
if (!verified.ok || artifact.truncated)
|
|
1272
|
-
return invalidSource("the planner source bytes no longer verify");
|
|
1273
|
-
const recorded = decodePlannerSource(verified.content);
|
|
1274
|
-
if (recorded === null || recorded.taskId !== taskId || !verified.content.equals(encodePlannerSource(args.source)) || run.scopeDigest !== (recorded.contract.scope?.digest ?? "")) {
|
|
1275
|
-
return invalidSource("the supplied planning source is not the source recorded for this run");
|
|
1276
|
-
}
|
|
1277
|
-
if (contractChanges.length > 0 && (typeof amendment !== "string" || !amendment.trim() || amendment.length > PLANNER_SOURCE_LIMITS.amendment)) {
|
|
1278
|
-
return invalidSource("a planner changed the filed contract without a bounded, explicit amendment");
|
|
1279
|
-
}
|
|
1034
|
+
store.finishRun(repairRun.id, { outcome: "refused", reason: "stale-source", now });
|
|
1035
|
+
store.finishRun(runId, { outcome: "refused", reason: "stale-source", now });
|
|
1036
|
+
store.enqueueNotification({
|
|
1037
|
+
source: { run: runId },
|
|
1038
|
+
dedupeKey: `plan-stale-source:${run.taskRef}:${runId}`,
|
|
1039
|
+
kind: "plan-stale-source",
|
|
1040
|
+
link: `/t/${encodeURIComponent(taskId)}`,
|
|
1041
|
+
subject: `${taskId}: the filed request changed while the planner ran`,
|
|
1042
|
+
body: `${oneLine(detail, 300)}\nNothing from that draft was ingested; the current terms stand and the next pass plans against them.`,
|
|
1043
|
+
}, now);
|
|
1044
|
+
return { ok: false, reason: "stale-source", detail };
|
|
1280
1045
|
}
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
const
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
const detail = current.ok
|
|
1294
|
-
? describeSourceDrift(args.source, current.contract)
|
|
1295
|
-
: `the filed request can no longer be read: ${current.message}`;
|
|
1296
|
-
const released = db
|
|
1297
|
-
.prepare(`UPDATE claim SET released_at = ?, released_by = 'released'
|
|
1298
|
-
WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
|
|
1299
|
-
.run(now.toISOString(), leaseId);
|
|
1300
|
-
if (Number(released.changes) === 0) {
|
|
1301
|
-
if (repairRun !== null)
|
|
1302
|
-
store.finishRun(repairRun.id, { outcome: "refused", reason: "fenced", now });
|
|
1303
|
-
store.finishRun(runId, { outcome: "refused", reason: "fenced", now });
|
|
1304
|
-
return refusal(db, leaseId);
|
|
1305
|
-
}
|
|
1306
|
-
if (repairRun !== null)
|
|
1307
|
-
store.finishRun(repairRun.id, { outcome: "refused", reason: "stale-source", now });
|
|
1308
|
-
store.finishRun(runId, { outcome: "refused", reason: "stale-source", now });
|
|
1309
|
-
store.enqueueNotification({
|
|
1310
|
-
source: { run: runId },
|
|
1311
|
-
dedupeKey: `plan-stale-source:${run.taskRef}:${runId}`,
|
|
1312
|
-
kind: "plan-stale-source",
|
|
1313
|
-
link: `/t/${encodeURIComponent(taskId)}`,
|
|
1314
|
-
subject: `${taskId}: the filed request changed while the planner ran`,
|
|
1315
|
-
body: `${oneLine(detail, 300)}\nNothing from that draft was ingested; the current terms stand and the next pass plans against them.`,
|
|
1316
|
-
}, now);
|
|
1317
|
-
return { ok: false, reason: "stale-source", detail };
|
|
1046
|
+
}
|
|
1047
|
+
if (record !== null && args.evidenceRoot !== undefined) {
|
|
1048
|
+
const recordBytes = encodePlanContractRecord(record);
|
|
1049
|
+
if (recordBytes.length > EVIDENCE_CAPS["plan-contract"])
|
|
1050
|
+
return invalidSource("the required plan amendment record exceeds its explicit evidence cap");
|
|
1051
|
+
try {
|
|
1052
|
+
const recordId = storeEvidence(store, args.evidenceRoot, runId, "plan-contract", "plan-contract.json", recordBytes, record.changes.length === 0
|
|
1053
|
+
? "plan ingestion: filed contract reproduced exactly (verified tree)"
|
|
1054
|
+
: `plan ingestion: ${record.changes.length} contract change(s), amendment ${record.amendment === null ? "absent" : "stated"} (verified tree)`, now, { captureStatus: "ok" });
|
|
1055
|
+
const captured = store.getArtifact(recordId);
|
|
1056
|
+
if (captured === null || captured.truncated || !readVerifiedArtifact(args.evidenceRoot, captured).ok) {
|
|
1057
|
+
return invalidSource("the required plan contract record did not seal completely", "failure");
|
|
1318
1058
|
}
|
|
1319
1059
|
}
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1060
|
+
catch {
|
|
1061
|
+
return invalidSource("the required plan contract record could not be saved", "failure");
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
const fenced = releaseOrRefuse(store, leaseId, "completed", runId, now, repairRun);
|
|
1065
|
+
if (fenced !== null)
|
|
1066
|
+
return fenced;
|
|
1067
|
+
store.saveScope({
|
|
1068
|
+
taskId,
|
|
1069
|
+
goal: plan.goal,
|
|
1070
|
+
outOfScope: plan.outOfScope,
|
|
1071
|
+
touches: plan.touches,
|
|
1072
|
+
acceptance: plan.acceptance,
|
|
1073
|
+
proposedAt: now.toISOString(),
|
|
1074
|
+
digest: digestOf({ goal: plan.goal, outOfScope: plan.outOfScope, touches: plan.touches, acceptance: plan.acceptance }),
|
|
1075
|
+
budgetMicrousd: filed?.terms.budgetMicrousd ?? null,
|
|
1076
|
+
approvedAt: null,
|
|
1077
|
+
approvedBy: null,
|
|
1078
|
+
approvedDigest: null,
|
|
1079
|
+
}, {}, filed === null ? {} : {
|
|
1080
|
+
proposedVia: filed.proposedVia,
|
|
1081
|
+
qualityMode: filed.terms.qualityMode,
|
|
1082
|
+
...(filed.terms.profile == null ? {} : { profile: filed.terms.profile }),
|
|
1083
|
+
...(filed.terms.profile != null && routeFromJson(filed.terms.routeJson) !== null &&
|
|
1084
|
+
canonicalContractJson([...new Set(filed.acceptance.flatMap(one => one.evidence))].sort()) === canonicalContractJson([...new Set(plan.acceptance.flatMap(one => one.evidence))].sort())
|
|
1085
|
+
? { route: routeFromJson(filed.terms.routeJson) } : {}),
|
|
1086
|
+
});
|
|
1087
|
+
let verifiedPlan = false;
|
|
1088
|
+
if (args.artifact !== null) {
|
|
1089
|
+
const artifactId = store.saveArtifact({ run: runId, kind: "plan", ...args.artifact }, now);
|
|
1090
|
+
if (args.evidenceRoot !== undefined && !args.artifact.truncated) {
|
|
1324
1091
|
try {
|
|
1325
|
-
const
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
const captured = store.getArtifact(recordId);
|
|
1329
|
-
if (captured === null || captured.truncated || !readVerifiedArtifact(args.evidenceRoot, captured).ok) {
|
|
1330
|
-
return invalidSource("the required plan contract record did not seal completely", "failure");
|
|
1331
|
-
}
|
|
1092
|
+
const captured = store.getArtifact(artifactId);
|
|
1093
|
+
const verified = captured === null ? null : readVerifiedArtifact(args.evidenceRoot, captured);
|
|
1094
|
+
verifiedPlan = verified?.ok === true && verified.content.equals(Buffer.from(plan.plan, "utf8")) && parseExecutionPlanDocument(plan.plan).ok;
|
|
1332
1095
|
}
|
|
1333
1096
|
catch {
|
|
1334
|
-
|
|
1335
|
-
}
|
|
1336
|
-
}
|
|
1337
|
-
const { changes } = db
|
|
1338
|
-
.prepare(`UPDATE claim SET released_at = ?, released_by = 'completed'
|
|
1339
|
-
WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
|
|
1340
|
-
.run(now.toISOString(), leaseId);
|
|
1341
|
-
if (Number(changes) === 0) {
|
|
1342
|
-
if (repairRun !== null)
|
|
1343
|
-
store.finishRun(repairRun.id, { outcome: "refused", reason: "fenced", now });
|
|
1344
|
-
store.finishRun(runId, { outcome: "refused", reason: "fenced", now });
|
|
1345
|
-
return refusal(db, leaseId);
|
|
1346
|
-
}
|
|
1347
|
-
store.saveScope({
|
|
1348
|
-
taskId,
|
|
1349
|
-
goal: plan.goal,
|
|
1350
|
-
outOfScope: plan.outOfScope,
|
|
1351
|
-
touches: plan.touches,
|
|
1352
|
-
acceptance: plan.acceptance,
|
|
1353
|
-
proposedAt: now.toISOString(),
|
|
1354
|
-
digest: digestOf({ goal: plan.goal, outOfScope: plan.outOfScope, touches: plan.touches, acceptance: plan.acceptance }),
|
|
1355
|
-
budgetMicrousd: filed?.terms.budgetMicrousd ?? null,
|
|
1356
|
-
approvedAt: null,
|
|
1357
|
-
approvedBy: null,
|
|
1358
|
-
approvedDigest: null,
|
|
1359
|
-
}, {}, filed === null ? {} : {
|
|
1360
|
-
proposedVia: filed.proposedVia,
|
|
1361
|
-
riskLevel: filed.terms.riskLevel,
|
|
1362
|
-
qualityMode: filed.terms.qualityMode,
|
|
1363
|
-
...(filed.terms.profile == null ? {} : { profile: filed.terms.profile }),
|
|
1364
|
-
...(filed.terms.profile != null && routeFromJson(filed.terms.routeJson) !== null &&
|
|
1365
|
-
canonicalContractJson([...new Set(filed.acceptance.flatMap(one => one.evidence))].sort()) === canonicalContractJson([...new Set(plan.acceptance.flatMap(one => one.evidence))].sort())
|
|
1366
|
-
? { route: routeFromJson(filed.terms.routeJson) } : {}),
|
|
1367
|
-
});
|
|
1368
|
-
let verifiedPlan = false;
|
|
1369
|
-
if (args.artifact !== null) {
|
|
1370
|
-
const artifactId = store.saveArtifact({ run: runId, kind: "plan", ...args.artifact }, now);
|
|
1371
|
-
if (args.evidenceRoot !== undefined && !args.artifact.truncated) {
|
|
1372
|
-
try {
|
|
1373
|
-
const captured = store.getArtifact(artifactId);
|
|
1374
|
-
const verified = captured === null ? null : readVerifiedArtifact(args.evidenceRoot, captured);
|
|
1375
|
-
verifiedPlan = verified?.ok === true && verified.content.equals(Buffer.from(plan.plan, "utf8")) && parseExecutionPlanDocument(plan.plan).ok;
|
|
1376
|
-
}
|
|
1377
|
-
catch {
|
|
1378
|
-
verifiedPlan = false;
|
|
1379
|
-
}
|
|
1097
|
+
verifiedPlan = false;
|
|
1380
1098
|
}
|
|
1381
1099
|
}
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
}
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
:
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
}
|
|
1100
|
+
}
|
|
1101
|
+
store.setPlanState(run.taskRef, "drafted");
|
|
1102
|
+
const autoApproved = sealUnchangedPlanUnderMode(store, taskId, args.source?.sourceDigest ?? "", runId, args.source !== undefined && filed !== null && contractChanges.length === 0 && amendment === null && verifiedPlan, now);
|
|
1103
|
+
store.resetPlanStrikes(run.taskRef);
|
|
1104
|
+
store.finishRun(runId, { outcome: "built", reason: "plan-drafted", now });
|
|
1105
|
+
if (repairRun !== null) {
|
|
1106
|
+
store.finishRun(repairRun.id, { outcome: "no-change", reason: "structured planner output repaired", now });
|
|
1107
|
+
}
|
|
1108
|
+
store.enqueueNotification({
|
|
1109
|
+
source: { run: runId },
|
|
1110
|
+
dedupeKey: `plan:${run.taskRef}:${runId}`,
|
|
1111
|
+
kind: "plan-ready",
|
|
1112
|
+
subject: autoApproved ? `${taskId}: unchanged plan auto-approved` : `${taskId}: plan ready for review${contractChanges.length === 0 ? "" : ` — ${contractChanges.length} contract change${contractChanges.length === 1 ? "" : "s"} to check`}`,
|
|
1113
|
+
body: `The planner proposes: ${oneLine(plan.goal, 200)}\n` +
|
|
1114
|
+
(contractChanges.length === 0
|
|
1115
|
+
? filed === null
|
|
1116
|
+
? ""
|
|
1117
|
+
: "The filed goal, exclusions, touches, and acceptance criteria are reproduced exactly.\n"
|
|
1118
|
+
: `It AMENDS the filed contract (${oneLine(describeContractChanges(contractChanges).join("; "), 300)})${amendment === null ? "" : ` — because: ${oneLine(amendment, 200)}`}\n`) +
|
|
1119
|
+
(autoApproved ? "Approved under the signed operating mode. The next build may proceed with the original terms and required quality checks." : "Review, edit, and approve the scope — nothing builds until you do."),
|
|
1120
|
+
}, now);
|
|
1121
|
+
return { ok: true, changes: contractChanges.length, amendment };
|
|
1404
1122
|
}
|
|
1405
1123
|
/** Which part of the filed request moved, in words — for the refusal,
|
|
1406
1124
|
* the page, and the notification. */
|
|
@@ -1434,7 +1152,7 @@ function describeSourceDrift(source, current) {
|
|
|
1434
1152
|
* without committing — the park discipline, applied to the road rather than
|
|
1435
1153
|
* to a question. The ledger row, the hold when one is owed, the run's
|
|
1436
1154
|
* outcome, and the page exist together or, if the lease was superseded,
|
|
1437
|
-
* not at all. Structurally
|
|
1155
|
+
* not at all. Structurally the plan ending's twin: the same
|
|
1438
1156
|
* open-attempt assertion, the same fenced release, the same all-or-nothing
|
|
1439
1157
|
* transaction.
|
|
1440
1158
|
*
|
|
@@ -1460,56 +1178,42 @@ function describeSourceDrift(source, current) {
|
|
|
1460
1178
|
* finished as `refused`, reusing the existing outcome vocabulary rather
|
|
1461
1179
|
* than growing it: no work was committed, and nothing failed.
|
|
1462
1180
|
*/
|
|
1463
|
-
|
|
1464
|
-
const {
|
|
1465
|
-
const
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
}
|
|
1485
|
-
const applied = revision.authorityKind === "plan-only";
|
|
1486
|
-
const revisionId = store.insertPlanRevision({ taskRef, ...revision, kind: "builder-proposal", status: applied ? "applied" : "blocked" }, now);
|
|
1487
|
-
const moved = revision.changedFields
|
|
1488
|
-
.map(field => (field === "signed-scope" ? "the signed scope" : "publication authority"))
|
|
1489
|
-
.join(" and ");
|
|
1490
|
-
if (!applied) {
|
|
1491
|
-
store.holdOwned({
|
|
1492
|
-
taskRef,
|
|
1493
|
-
ownerKind: "revision",
|
|
1494
|
-
ownerId: String(revisionId),
|
|
1495
|
-
reason: `plan-revision-blocked — ${moved} changed while this build ran, so revision ${revision.revision} waits for a person`,
|
|
1496
|
-
until: null,
|
|
1497
|
-
}, now);
|
|
1498
|
-
}
|
|
1499
|
-
store.finishRun(runId, { outcome: "refused", reason: applied ? "plan-revised" : "plan-revision-blocked", now });
|
|
1500
|
-
store.enqueueNotification({
|
|
1501
|
-
source: { run: runId },
|
|
1502
|
-
dedupeKey: `plan-revision:${taskRef}:${revisionId}`,
|
|
1503
|
-
kind: applied ? "plan-revised" : "plan-revision-blocked",
|
|
1504
|
-
...(applied ? {} : { pushClass: "attention" }),
|
|
1505
|
-
link: `/t/${encodeURIComponent(taskId)}`,
|
|
1506
|
-
subject: `${taskId}: plan revision ${revision.revision} ${applied ? "applied — resuming" : "awaiting your approval"}`,
|
|
1507
|
-
body: applied
|
|
1508
|
-
? `The build found the plan wrong and rewrote it: ${oneLine(revision.reason, 200)}\nEvidence: ${oneLine(revision.evidenceLink ?? "none given", 200)}\nNothing was committed. The next attempt builds against the new plan.`
|
|
1509
|
-
: `The build proposed a new plan, but ${moved} changed while it ran — so nothing was applied: ${oneLine(revision.reason, 200)}\nEvidence: ${oneLine(revision.evidenceLink ?? "none given", 200)}\nNothing runs on this task until you accept or reject the revision.`,
|
|
1181
|
+
function sealRevision(store, leaseId, args) {
|
|
1182
|
+
const { runId, taskId, taskRef, revision, now } = args;
|
|
1183
|
+
const run = openAttempt(store, leaseId, runId, "a plan revision", "builder");
|
|
1184
|
+
const stopped = interruptIfStopped(store, { leaseId, runId, taskId, now });
|
|
1185
|
+
if (stopped !== null)
|
|
1186
|
+
return { ok: false, reason: "stopped" };
|
|
1187
|
+
const fenced = releaseOrRefuse(store, leaseId, "released", runId, now);
|
|
1188
|
+
if (fenced !== null)
|
|
1189
|
+
return fenced;
|
|
1190
|
+
const applied = revision.authorityKind === "plan-only";
|
|
1191
|
+
const revisionId = store.insertPlanRevision({ taskRef, ...revision, kind: "builder-proposal", status: applied ? "applied" : "blocked" }, now);
|
|
1192
|
+
const moved = revision.changedFields
|
|
1193
|
+
.map(field => (field === "signed-scope" ? "the signed scope" : "publication authority"))
|
|
1194
|
+
.join(" and ");
|
|
1195
|
+
if (!applied) {
|
|
1196
|
+
store.holdOwned({
|
|
1197
|
+
taskRef,
|
|
1198
|
+
ownerKind: "revision",
|
|
1199
|
+
ownerId: String(revisionId),
|
|
1200
|
+
reason: `plan-revision-blocked — ${moved} changed while this build ran, so revision ${revision.revision} waits for a person`,
|
|
1201
|
+
until: null,
|
|
1510
1202
|
}, now);
|
|
1511
|
-
|
|
1512
|
-
});
|
|
1203
|
+
}
|
|
1204
|
+
store.finishRun(runId, { outcome: "refused", reason: applied ? "plan-revised" : "plan-revision-blocked", now });
|
|
1205
|
+
store.enqueueNotification({
|
|
1206
|
+
source: { run: runId },
|
|
1207
|
+
dedupeKey: `plan-revision:${taskRef}:${revisionId}`,
|
|
1208
|
+
kind: applied ? "plan-revised" : "plan-revision-blocked",
|
|
1209
|
+
...(applied ? {} : { pushClass: "attention" }),
|
|
1210
|
+
link: `/t/${encodeURIComponent(taskId)}`,
|
|
1211
|
+
subject: `${taskId}: plan revision ${revision.revision} ${applied ? "applied — resuming" : "awaiting your approval"}`,
|
|
1212
|
+
body: applied
|
|
1213
|
+
? `The build found the plan wrong and rewrote it: ${oneLine(revision.reason, 200)}\nEvidence: ${oneLine(revision.evidenceLink ?? "none given", 200)}\nNothing was committed. The next attempt builds against the new plan.`
|
|
1214
|
+
: `The build proposed a new plan, but ${moved} changed while it ran — so nothing was applied: ${oneLine(revision.reason, 200)}\nEvidence: ${oneLine(revision.evidenceLink ?? "none given", 200)}\nNothing runs on this task until you accept or reject the revision.`,
|
|
1215
|
+
}, now);
|
|
1216
|
+
return { ok: true, revisionId, authorityKind: revision.authorityKind };
|
|
1513
1217
|
}
|
|
1514
1218
|
/**
|
|
1515
1219
|
* The planner's own fenced failure finalizer (Codex planning review,
|
|
@@ -1524,93 +1228,80 @@ export function finalizeRevisionFenced(store, args) {
|
|
|
1524
1228
|
* nothing without the repair machinery (deliberately not wired for
|
|
1525
1229
|
* planners in v1).
|
|
1526
1230
|
*/
|
|
1527
|
-
|
|
1528
|
-
const {
|
|
1231
|
+
function sealPlanFailure(store, leaseId, args) {
|
|
1232
|
+
const { runId, taskId, failure: kind, message, now } = args;
|
|
1529
1233
|
const db = store.handle;
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
|
|
1544
|
-
.run(now.toISOString(), leaseId);
|
|
1545
|
-
if (Number(changes) === 0) {
|
|
1546
|
-
store.finishRun(runId, { outcome: "refused", reason: "fenced", now });
|
|
1547
|
-
return refusal(db, leaseId);
|
|
1548
|
-
}
|
|
1549
|
-
if (kind === "failure" && run.terminalClass === "auth-expired")
|
|
1550
|
-
return { ok: true, ...sealAuthExpired(store, run, taskId, now) };
|
|
1551
|
-
const malformedKind = args.malformed === "decision" ? "malformed-decision" : "malformed-plan";
|
|
1552
|
-
store.finishRun(runId, { outcome: "failed", reason: kind === "malformed" ? malformedKind : oneLine(message, 120), now });
|
|
1553
|
-
if (kind === "malformed") {
|
|
1554
|
-
const incidentId = store.createIncident({ run: runId, kind: malformedKind }, now);
|
|
1555
|
-
store.holdOwned({
|
|
1556
|
-
taskRef: run.taskRef,
|
|
1557
|
-
ownerKind: "incident",
|
|
1558
|
-
ownerId: String(incidentId),
|
|
1559
|
-
reason: `${malformedKind} — the planner's ${args.malformed === "decision" ? "question" : "plan"} failed validation`,
|
|
1560
|
-
until: null,
|
|
1561
|
-
}, now);
|
|
1562
|
-
store.enqueueNotification({
|
|
1563
|
-
source: { run: runId },
|
|
1564
|
-
dedupeKey: `${malformedKind}:${runId}`,
|
|
1565
|
-
kind: malformedKind,
|
|
1566
|
-
pushClass: "attention",
|
|
1567
|
-
link: `/r/${runId}`,
|
|
1568
|
-
subject: `${taskId}: the planner's ${args.malformed === "decision" ? "question" : "plan"} failed validation`,
|
|
1569
|
-
body: `${oneLine(message, 300)}\nResolve the incident to let planning retry.`,
|
|
1570
|
-
}, now);
|
|
1571
|
-
return { ok: true, disposition: "malformed-incident", incidentId };
|
|
1572
|
-
}
|
|
1573
|
-
const strikes = store.addPlanStrike(run.taskRef);
|
|
1574
|
-
if (strikes >= MAX_PLAN_STRIKES) {
|
|
1575
|
-
const incidentId = store.createIncident({ run: runId, kind: "plan-attempts-exhausted" }, now);
|
|
1576
|
-
store.holdOwned({
|
|
1577
|
-
taskRef: run.taskRef,
|
|
1578
|
-
ownerKind: "incident",
|
|
1579
|
-
ownerId: String(incidentId),
|
|
1580
|
-
reason: `plan-attempts-exhausted — ${strikes} straight planning failures`,
|
|
1581
|
-
until: null,
|
|
1582
|
-
}, now);
|
|
1583
|
-
store.enqueueNotification({
|
|
1584
|
-
source: { run: runId },
|
|
1585
|
-
dedupeKey: `plan-stalled:${run.taskRef}`,
|
|
1586
|
-
kind: "plan-attempts-exhausted",
|
|
1587
|
-
pushClass: "attention",
|
|
1588
|
-
link: `/r/${runId}`,
|
|
1589
|
-
subject: `${taskId}: planning stalled after ${strikes} straight failures`,
|
|
1590
|
-
body: `Last failure: ${oneLine(message, 200)}\nResolve the incident to let planning retry, or write the scope yourself.`,
|
|
1591
|
-
}, now);
|
|
1592
|
-
return { ok: true, disposition: "exhausted", incidentId, strikes };
|
|
1593
|
-
}
|
|
1594
|
-
const wait = PLAN_BACKOFF_MS[Math.min(strikes, PLAN_BACKOFF_MS.length) - 1];
|
|
1595
|
-
const until = new Date(now.getTime() + wait);
|
|
1234
|
+
const run = openAttempt(store, leaseId, runId, "a failure", "planner");
|
|
1235
|
+
const stopped = interruptIfStopped(store, { leaseId, runId, taskId, now });
|
|
1236
|
+
if (stopped !== null)
|
|
1237
|
+
return { ok: false, reason: "stopped" };
|
|
1238
|
+
const fenced = releaseOrRefuse(store, leaseId, "released", runId, now);
|
|
1239
|
+
if (fenced !== null)
|
|
1240
|
+
return fenced;
|
|
1241
|
+
if (kind === "failure" && run.terminalClass === "auth-expired")
|
|
1242
|
+
return { ok: true, ...sealAuthExpired(store, run, taskId, now) };
|
|
1243
|
+
const malformedKind = args.malformed === "decision" ? "malformed-decision" : "malformed-plan";
|
|
1244
|
+
store.finishRun(runId, { outcome: "failed", reason: kind === "malformed" ? malformedKind : oneLine(message, 120), now });
|
|
1245
|
+
if (kind === "malformed") {
|
|
1246
|
+
const incidentId = store.createIncident({ run: runId, kind: malformedKind }, now);
|
|
1596
1247
|
store.holdOwned({
|
|
1597
1248
|
taskRef: run.taskRef,
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
reason: `plan retry ${strikes}/${MAX_PLAN_STRIKES} — backing off ${Math.round(wait / 60_000)}m`,
|
|
1603
|
-
until,
|
|
1249
|
+
ownerKind: "incident",
|
|
1250
|
+
ownerId: String(incidentId),
|
|
1251
|
+
reason: `${malformedKind} — the planner's ${args.malformed === "decision" ? "question" : "plan"} failed validation`,
|
|
1252
|
+
until: null,
|
|
1604
1253
|
}, now);
|
|
1605
1254
|
store.enqueueNotification({
|
|
1606
1255
|
source: { run: runId },
|
|
1607
|
-
dedupeKey:
|
|
1608
|
-
kind:
|
|
1609
|
-
|
|
1610
|
-
|
|
1256
|
+
dedupeKey: `${malformedKind}:${runId}`,
|
|
1257
|
+
kind: malformedKind,
|
|
1258
|
+
pushClass: "attention",
|
|
1259
|
+
link: `/r/${runId}`,
|
|
1260
|
+
subject: `${taskId}: the planner's ${args.malformed === "decision" ? "question" : "plan"} failed validation`,
|
|
1261
|
+
body: `${oneLine(message, 300)}\nResolve the incident to let planning retry.`,
|
|
1611
1262
|
}, now);
|
|
1612
|
-
return { ok: true, disposition: "
|
|
1613
|
-
}
|
|
1263
|
+
return { ok: true, disposition: "malformed-incident", incidentId };
|
|
1264
|
+
}
|
|
1265
|
+
const strikes = store.addPlanStrike(run.taskRef);
|
|
1266
|
+
if (strikes >= MAX_PLAN_STRIKES) {
|
|
1267
|
+
const incidentId = store.createIncident({ run: runId, kind: "plan-attempts-exhausted" }, now);
|
|
1268
|
+
store.holdOwned({
|
|
1269
|
+
taskRef: run.taskRef,
|
|
1270
|
+
ownerKind: "incident",
|
|
1271
|
+
ownerId: String(incidentId),
|
|
1272
|
+
reason: `plan-attempts-exhausted — ${strikes} straight planning failures`,
|
|
1273
|
+
until: null,
|
|
1274
|
+
}, now);
|
|
1275
|
+
store.enqueueNotification({
|
|
1276
|
+
source: { run: runId },
|
|
1277
|
+
dedupeKey: `plan-stalled:${run.taskRef}`,
|
|
1278
|
+
kind: "plan-attempts-exhausted",
|
|
1279
|
+
pushClass: "attention",
|
|
1280
|
+
link: `/r/${runId}`,
|
|
1281
|
+
subject: `${taskId}: planning stalled after ${strikes} straight failures`,
|
|
1282
|
+
body: `Last failure: ${oneLine(message, 200)}\nResolve the incident to let planning retry, or write the scope yourself.`,
|
|
1283
|
+
}, now);
|
|
1284
|
+
return { ok: true, disposition: "exhausted", incidentId, strikes };
|
|
1285
|
+
}
|
|
1286
|
+
const wait = PLAN_BACKOFF_MS[Math.min(strikes, PLAN_BACKOFF_MS.length) - 1];
|
|
1287
|
+
const until = new Date(now.getTime() + wait);
|
|
1288
|
+
store.holdOwned({
|
|
1289
|
+
taskRef: run.taskRef,
|
|
1290
|
+
// Its own owner id: a planning pause must never displace the
|
|
1291
|
+
// builder's backoff for the same task, or vice versa.
|
|
1292
|
+
ownerKind: "backoff",
|
|
1293
|
+
ownerId: `plan:${run.taskRef}`,
|
|
1294
|
+
reason: `plan retry ${strikes}/${MAX_PLAN_STRIKES} — backing off ${Math.round(wait / 60_000)}m`,
|
|
1295
|
+
until,
|
|
1296
|
+
}, now);
|
|
1297
|
+
store.enqueueNotification({
|
|
1298
|
+
source: { run: runId },
|
|
1299
|
+
dedupeKey: `plan-run:${runId}:failed`,
|
|
1300
|
+
kind: "plan-failed",
|
|
1301
|
+
subject: `${taskId}: planning attempt failed, retry ${strikes}/${MAX_PLAN_STRIKES}`,
|
|
1302
|
+
body: `${oneLine(message, 200)}\nNext attempt no earlier than ${until.toISOString()}.`,
|
|
1303
|
+
}, now);
|
|
1304
|
+
return { ok: true, disposition: "backoff", strikes };
|
|
1614
1305
|
}
|
|
1615
1306
|
/**
|
|
1616
1307
|
* Seal a successful scouting run (mate arc §10): the report artifact, the
|
|
@@ -1619,46 +1310,33 @@ export function finalizePlanFailureFenced(store, args) {
|
|
|
1619
1310
|
* here touches a branch, a publication, or a scope: the deliverable is
|
|
1620
1311
|
* the artifact, and the task is done the moment it verifies.
|
|
1621
1312
|
*/
|
|
1622
|
-
|
|
1623
|
-
const {
|
|
1313
|
+
function sealScout(store, leaseId, args) {
|
|
1314
|
+
const { runId, taskId, report, now } = args;
|
|
1624
1315
|
const db = store.handle;
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
}
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
// A mirror the tracker closed meanwhile: the report stands as evidence
|
|
1650
|
-
// on the run; the task keeps the tracker's word for its state.
|
|
1651
|
-
store.finishRun(runId, { outcome: "built", reason: `report-delivered (task ${done.reason})`, now });
|
|
1652
|
-
}
|
|
1653
|
-
store.enqueueNotification({
|
|
1654
|
-
source: { run: runId },
|
|
1655
|
-
dedupeKey: `report:${run.taskRef}:${runId}`,
|
|
1656
|
-
kind: "report-ready",
|
|
1657
|
-
subject: `${taskId}: report ready — ${oneLine(report.title, 120)}`,
|
|
1658
|
-
body: `${oneLine(report.summary, 300)}\nRead it on the task page${report.followUps.length > 0 ? `; ${report.followUps.length} follow-up(s) file with a tap` : ""}.`,
|
|
1659
|
-
}, now);
|
|
1660
|
-
return { ok: true };
|
|
1661
|
-
});
|
|
1316
|
+
const run = openAttempt(store, leaseId, runId, "a report", "scout");
|
|
1317
|
+
const stopped = interruptIfStopped(store, { leaseId, runId, taskId, now });
|
|
1318
|
+
if (stopped !== null)
|
|
1319
|
+
return { ok: false, reason: "stopped" };
|
|
1320
|
+
const fenced = releaseOrRefuse(store, leaseId, "completed", runId, now);
|
|
1321
|
+
if (fenced !== null)
|
|
1322
|
+
return fenced;
|
|
1323
|
+
store.saveArtifact({ run: runId, kind: "report", ...args.artifact }, now);
|
|
1324
|
+
store.resetStrikes(run.taskRef);
|
|
1325
|
+
store.finishRun(runId, { outcome: "built", reason: "report-delivered", now });
|
|
1326
|
+
const done = store.setTaskState(taskId, "done", now);
|
|
1327
|
+
if (!done.ok) {
|
|
1328
|
+
// A mirror the tracker closed meanwhile: the report stands as evidence
|
|
1329
|
+
// on the run; the task keeps the tracker's word for its state.
|
|
1330
|
+
store.finishRun(runId, { outcome: "built", reason: `report-delivered (task ${done.reason})`, now });
|
|
1331
|
+
}
|
|
1332
|
+
store.enqueueNotification({
|
|
1333
|
+
source: { run: runId },
|
|
1334
|
+
dedupeKey: `report:${run.taskRef}:${runId}`,
|
|
1335
|
+
kind: "report-ready",
|
|
1336
|
+
subject: `${taskId}: report ready — ${oneLine(report.title, 120)}`,
|
|
1337
|
+
body: `${oneLine(report.summary, 300)}\nRead it on the task page${report.followUps.length > 0 ? `; ${report.followUps.length} follow-up(s) file with a tap` : ""}.`,
|
|
1338
|
+
}, now);
|
|
1339
|
+
return { ok: true };
|
|
1662
1340
|
}
|
|
1663
1341
|
/**
|
|
1664
1342
|
* The scout's fenced failure finalizer: the BUILDER's discipline, not the
|
|
@@ -1667,92 +1345,79 @@ export function finalizeScoutFenced(store, args) {
|
|
|
1667
1345
|
* report is a straight incident with no strike. Never a branch, never a
|
|
1668
1346
|
* repair session, never a commit.
|
|
1669
1347
|
*/
|
|
1670
|
-
|
|
1671
|
-
const {
|
|
1348
|
+
function sealScoutFailure(store, leaseId, args) {
|
|
1349
|
+
const { runId, taskId, failure: kind, message, now } = args;
|
|
1672
1350
|
const db = store.handle;
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
|
|
1687
|
-
.run(now.toISOString(), leaseId);
|
|
1688
|
-
if (Number(changes) === 0) {
|
|
1689
|
-
store.finishRun(runId, { outcome: "refused", reason: "fenced", now });
|
|
1690
|
-
return refusal(db, leaseId);
|
|
1691
|
-
}
|
|
1692
|
-
if (kind === "failure" && run.terminalClass === "auth-expired")
|
|
1693
|
-
return { ok: true, ...sealAuthExpired(store, run, taskId, now) };
|
|
1694
|
-
const malformedKind = args.malformed === "decision" ? "malformed-decision" : "malformed-report";
|
|
1695
|
-
store.finishRun(runId, { outcome: "failed", reason: kind === "malformed" ? malformedKind : oneLine(message, 120), now });
|
|
1696
|
-
if (kind === "malformed") {
|
|
1697
|
-
const incidentId = store.createIncident({ run: runId, kind: malformedKind }, now);
|
|
1698
|
-
store.holdOwned({
|
|
1699
|
-
taskRef: run.taskRef,
|
|
1700
|
-
ownerKind: "incident",
|
|
1701
|
-
ownerId: String(incidentId),
|
|
1702
|
-
reason: `${malformedKind} — the scout's ${args.malformed === "decision" ? "question" : "report"} failed validation`,
|
|
1703
|
-
until: null,
|
|
1704
|
-
}, now);
|
|
1705
|
-
store.enqueueNotification({
|
|
1706
|
-
source: { run: runId },
|
|
1707
|
-
dedupeKey: `${malformedKind}:${runId}`,
|
|
1708
|
-
kind: malformedKind,
|
|
1709
|
-
pushClass: "attention",
|
|
1710
|
-
link: `/r/${runId}`,
|
|
1711
|
-
subject: `${taskId}: the scout's ${args.malformed === "decision" ? "question" : "report"} failed validation`,
|
|
1712
|
-
body: `${oneLine(message, 300)}\nResolve the incident to let scouting retry.`,
|
|
1713
|
-
}, now);
|
|
1714
|
-
return { ok: true, disposition: "malformed-incident", incidentId };
|
|
1715
|
-
}
|
|
1716
|
-
const strikes = store.addStrike(run.taskRef);
|
|
1717
|
-
if (strikes >= MAX_STRIKES) {
|
|
1718
|
-
const incidentId = store.createIncident({ run: runId, kind: "attempts-exhausted" }, now);
|
|
1719
|
-
store.holdOwned({
|
|
1720
|
-
taskRef: run.taskRef,
|
|
1721
|
-
ownerKind: "incident",
|
|
1722
|
-
ownerId: String(incidentId),
|
|
1723
|
-
reason: `attempts-exhausted — ${strikes} consecutive scouting failures`,
|
|
1724
|
-
until: null,
|
|
1725
|
-
}, now);
|
|
1726
|
-
db.prepare("UPDATE task SET state = 'failed', updated_at = ? WHERE id = ?").run(now.toISOString(), taskId);
|
|
1727
|
-
store.enqueueNotification({
|
|
1728
|
-
source: { run: runId },
|
|
1729
|
-
dedupeKey: `stalled:${run.taskRef}`,
|
|
1730
|
-
kind: "attempts-exhausted",
|
|
1731
|
-
pushClass: "attention",
|
|
1732
|
-
link: `/r/${runId}`,
|
|
1733
|
-
subject: `${taskId} stalled after ${strikes} straight scouting failures`,
|
|
1734
|
-
body: `Last failure: ${oneLine(message, 200)}\nIt will not be retried. Read the runs, then \`toolroll task requeue ${taskId}\`.`,
|
|
1735
|
-
}, now);
|
|
1736
|
-
return { ok: true, disposition: "stalled", incidentId, strikes };
|
|
1737
|
-
}
|
|
1738
|
-
const wait = BACKOFF_MS[Math.min(strikes, BACKOFF_MS.length) - 1];
|
|
1739
|
-
const until = new Date(now.getTime() + wait);
|
|
1351
|
+
const run = openAttempt(store, leaseId, runId, "a failure", "scout");
|
|
1352
|
+
const stopped = interruptIfStopped(store, { leaseId, runId, taskId, now });
|
|
1353
|
+
if (stopped !== null)
|
|
1354
|
+
return { ok: false, reason: "stopped" };
|
|
1355
|
+
const fenced = releaseOrRefuse(store, leaseId, "released", runId, now);
|
|
1356
|
+
if (fenced !== null)
|
|
1357
|
+
return fenced;
|
|
1358
|
+
if (kind === "failure" && run.terminalClass === "auth-expired")
|
|
1359
|
+
return { ok: true, ...sealAuthExpired(store, run, taskId, now) };
|
|
1360
|
+
const malformedKind = args.malformed === "decision" ? "malformed-decision" : "malformed-report";
|
|
1361
|
+
store.finishRun(runId, { outcome: "failed", reason: kind === "malformed" ? malformedKind : oneLine(message, 120), now });
|
|
1362
|
+
if (kind === "malformed") {
|
|
1363
|
+
const incidentId = store.createIncident({ run: runId, kind: malformedKind }, now);
|
|
1740
1364
|
store.holdOwned({
|
|
1741
1365
|
taskRef: run.taskRef,
|
|
1742
|
-
ownerKind: "
|
|
1743
|
-
ownerId: String(
|
|
1744
|
-
reason:
|
|
1745
|
-
until,
|
|
1366
|
+
ownerKind: "incident",
|
|
1367
|
+
ownerId: String(incidentId),
|
|
1368
|
+
reason: `${malformedKind} — the scout's ${args.malformed === "decision" ? "question" : "report"} failed validation`,
|
|
1369
|
+
until: null,
|
|
1746
1370
|
}, now);
|
|
1747
1371
|
store.enqueueNotification({
|
|
1748
1372
|
source: { run: runId },
|
|
1749
|
-
dedupeKey:
|
|
1750
|
-
kind:
|
|
1751
|
-
|
|
1752
|
-
|
|
1373
|
+
dedupeKey: `${malformedKind}:${runId}`,
|
|
1374
|
+
kind: malformedKind,
|
|
1375
|
+
pushClass: "attention",
|
|
1376
|
+
link: `/r/${runId}`,
|
|
1377
|
+
subject: `${taskId}: the scout's ${args.malformed === "decision" ? "question" : "report"} failed validation`,
|
|
1378
|
+
body: `${oneLine(message, 300)}\nResolve the incident to let scouting retry.`,
|
|
1753
1379
|
}, now);
|
|
1754
|
-
return { ok: true, disposition: "
|
|
1755
|
-
}
|
|
1380
|
+
return { ok: true, disposition: "malformed-incident", incidentId };
|
|
1381
|
+
}
|
|
1382
|
+
const strikes = store.addStrike(run.taskRef);
|
|
1383
|
+
if (strikes >= MAX_STRIKES) {
|
|
1384
|
+
const incidentId = store.createIncident({ run: runId, kind: "attempts-exhausted" }, now);
|
|
1385
|
+
store.holdOwned({
|
|
1386
|
+
taskRef: run.taskRef,
|
|
1387
|
+
ownerKind: "incident",
|
|
1388
|
+
ownerId: String(incidentId),
|
|
1389
|
+
reason: `attempts-exhausted — ${strikes} consecutive scouting failures`,
|
|
1390
|
+
until: null,
|
|
1391
|
+
}, now);
|
|
1392
|
+
db.prepare("UPDATE task SET state = 'failed', updated_at = ? WHERE id = ?").run(now.toISOString(), taskId);
|
|
1393
|
+
store.enqueueNotification({
|
|
1394
|
+
source: { run: runId },
|
|
1395
|
+
dedupeKey: `stalled:${run.taskRef}`,
|
|
1396
|
+
kind: "attempts-exhausted",
|
|
1397
|
+
pushClass: "attention",
|
|
1398
|
+
link: `/r/${runId}`,
|
|
1399
|
+
subject: `${taskId} stalled after ${strikes} straight scouting failures`,
|
|
1400
|
+
body: `Last failure: ${oneLine(message, 200)}\nIt will not be retried. Read the runs, then \`toolroll task requeue ${taskId}\`.`,
|
|
1401
|
+
}, now);
|
|
1402
|
+
return { ok: true, disposition: "stalled", incidentId, strikes };
|
|
1403
|
+
}
|
|
1404
|
+
const wait = BACKOFF_MS[Math.min(strikes, BACKOFF_MS.length) - 1];
|
|
1405
|
+
const until = new Date(now.getTime() + wait);
|
|
1406
|
+
store.holdOwned({
|
|
1407
|
+
taskRef: run.taskRef,
|
|
1408
|
+
ownerKind: "backoff",
|
|
1409
|
+
ownerId: String(run.taskRef),
|
|
1410
|
+
reason: `scout retry ${strikes}/${MAX_STRIKES} — backing off ${Math.round(wait / 60_000)}m`,
|
|
1411
|
+
until,
|
|
1412
|
+
}, now);
|
|
1413
|
+
store.enqueueNotification({
|
|
1414
|
+
source: { run: runId },
|
|
1415
|
+
dedupeKey: `run:${runId}:failed`,
|
|
1416
|
+
kind: "scout-failed",
|
|
1417
|
+
subject: `${taskId}: scouting attempt failed, retry ${strikes}/${MAX_STRIKES}`,
|
|
1418
|
+
body: `${oneLine(message, 200)}\nNext attempt no earlier than ${until.toISOString()}.`,
|
|
1419
|
+
}, now);
|
|
1420
|
+
return { ok: true, disposition: "backoff", strikes };
|
|
1756
1421
|
}
|
|
1757
1422
|
export function currentClaim(store, taskRef, now) {
|
|
1758
1423
|
const row = latest(store.handle, taskRef);
|