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.
Files changed (164) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +55 -80
  3. package/dist/agentconfig.d.ts +11 -36
  4. package/dist/agentconfig.js +3 -62
  5. package/dist/approval-policy.d.ts +1 -1
  6. package/dist/approval-policy.js +1 -1
  7. package/dist/backup.d.ts +8 -0
  8. package/dist/backup.js +10 -1
  9. package/dist/board.d.ts +3 -19
  10. package/dist/board.js +4 -42
  11. package/dist/browser/workspace.css +1 -1
  12. package/dist/browser/workspace.js +18 -18
  13. package/dist/browser-workspace.d.ts +3 -1
  14. package/dist/builder.d.ts +9 -40
  15. package/dist/builder.js +50 -257
  16. package/dist/chat-actions.js +3 -14
  17. package/dist/chat-controls.d.ts +0 -4
  18. package/dist/chat-controls.js +0 -1
  19. package/dist/chat-decide.js +1 -3
  20. package/dist/chat-delivery.d.ts +0 -2
  21. package/dist/chat-delivery.js +0 -1
  22. package/dist/chat-quiet.js +2 -2
  23. package/dist/chat-task-actions.d.ts +1 -1
  24. package/dist/chat-task-actions.js +0 -2
  25. package/dist/claim.d.ts +79 -244
  26. package/dist/claim.js +688 -1023
  27. package/dist/cli.d.ts +2 -0
  28. package/dist/cli.js +10 -2
  29. package/dist/contracts/cli.d.ts +0 -13
  30. package/dist/contracts/cli.js +0 -2
  31. package/dist/contracts/console-api.d.ts +5 -10
  32. package/dist/contracts/console-api.js +4 -10
  33. package/dist/contracts/flow.d.ts +50 -0
  34. package/dist/contracts/flow.js +26 -1
  35. package/dist/contracts/lead-tools.d.ts +0 -13
  36. package/dist/contracts/lead-tools.js +3 -3
  37. package/dist/contracts/store-json.d.ts +1 -25
  38. package/dist/contracts/store-json.js +3 -13
  39. package/dist/converse.d.ts +1 -11
  40. package/dist/converse.js +6 -38
  41. package/dist/coordinator.d.ts +0 -1
  42. package/dist/coordinator.js +3 -5
  43. package/dist/database-reclaim.d.ts +4 -0
  44. package/dist/database-reclaim.js +13 -0
  45. package/dist/demo.d.ts +1 -1
  46. package/dist/demo.js +17 -27
  47. package/dist/desktop-update-gate.js +0 -1
  48. package/dist/desktop-update.d.ts +1 -1
  49. package/dist/desktop-update.js +1 -3
  50. package/dist/dispatch.d.ts +1 -1
  51. package/dist/dispatch.js +1 -1
  52. package/dist/dispose.d.ts +2 -3
  53. package/dist/dispose.js +17 -92
  54. package/dist/envelope.js +0 -3
  55. package/dist/exec.d.ts +0 -64
  56. package/dist/exec.js +3 -356
  57. package/dist/exhaustion.d.ts +22 -76
  58. package/dist/exhaustion.js +14 -140
  59. package/dist/export.js +4 -4
  60. package/dist/flow-engine.js +1 -1
  61. package/dist/flow-schedule.d.ts +154 -0
  62. package/dist/flow-schedule.js +321 -0
  63. package/dist/flow-share.js +1 -1
  64. package/dist/flow-triggers.d.ts +6 -1
  65. package/dist/flow-triggers.js +65 -10
  66. package/dist/flows-cli.js +8 -4
  67. package/dist/flows-ui.js +2 -0
  68. package/dist/flows.js +1 -1
  69. package/dist/guides.js +9 -29
  70. package/dist/invoke.d.ts +1 -24
  71. package/dist/invoke.js +17 -259
  72. package/dist/lead-status.js +2 -4
  73. package/dist/lead-voice.js +1 -1
  74. package/dist/live-bus.d.ts +3 -3
  75. package/dist/live-bus.js +3 -3
  76. package/dist/mate-doors.d.ts +1 -2
  77. package/dist/mate-doors.js +12 -25
  78. package/dist/mate-tools.d.ts +2 -2
  79. package/dist/mate-tools.js +10 -22
  80. package/dist/modes.d.ts +6 -7
  81. package/dist/modes.js +7 -13
  82. package/dist/monitoring.js +32 -8
  83. package/dist/notification-delivery.d.ts +19 -0
  84. package/dist/notification-delivery.js +37 -0
  85. package/dist/operate-remote-arguments.js +2 -6
  86. package/dist/operate-remote.js +6 -7
  87. package/dist/operate.d.ts +0 -6
  88. package/dist/operate.js +283 -2378
  89. package/dist/phase-routing.d.ts +13 -23
  90. package/dist/phase-routing.js +13 -35
  91. package/dist/plan-auto.js +2 -2
  92. package/dist/planner-source.js +2 -1
  93. package/dist/policy.d.ts +1 -4
  94. package/dist/policy.js +0 -8
  95. package/dist/process-recovery-eligibility.js +2 -3
  96. package/dist/process-recovery-settlement.js +1 -0
  97. package/dist/project-delete.js +3 -15
  98. package/dist/proposal.d.ts +4 -24
  99. package/dist/proposal.js +3 -88
  100. package/dist/provider-auth.d.ts +3 -3
  101. package/dist/provider-auth.js +4 -6
  102. package/dist/provider.d.ts +11 -25
  103. package/dist/provider.js +4 -36
  104. package/dist/push.js +1 -1
  105. package/dist/recipe-ui.js +6 -6
  106. package/dist/recipes.d.ts +3 -3
  107. package/dist/recipes.js +27 -23
  108. package/dist/retention.js +6 -5
  109. package/dist/run-closeout.d.ts +1 -1
  110. package/dist/run-closeout.js +1 -4
  111. package/dist/scope.d.ts +11 -65
  112. package/dist/scope.js +17 -82
  113. package/dist/serve.js +16 -25
  114. package/dist/server/chat.js +7 -30
  115. package/dist/server/flows.js +11 -10
  116. package/dist/server/guards.js +0 -1
  117. package/dist/server/pages-assets.js +1 -1
  118. package/dist/server/pages.js +0 -2
  119. package/dist/server/people-tokens.js +0 -4
  120. package/dist/server/route-table.d.ts +1 -1
  121. package/dist/server/route-table.js +2 -17
  122. package/dist/server/settings.js +4 -9
  123. package/dist/server/shared.d.ts +29 -212
  124. package/dist/server/shared.js +73 -780
  125. package/dist/server/task-views.js +9 -76
  126. package/dist/server/tasks.d.ts +1 -50
  127. package/dist/server/tasks.js +36 -946
  128. package/dist/setup-guide.d.ts +2 -2
  129. package/dist/slack-chat.d.ts +0 -2
  130. package/dist/slack-chat.js +0 -1
  131. package/dist/store.d.ts +142 -1382
  132. package/dist/store.js +900 -4083
  133. package/dist/surface.js +10 -25
  134. package/dist/task-composer.d.ts +1 -1
  135. package/dist/task-composer.js +1 -1
  136. package/dist/task-control.d.ts +3 -10
  137. package/dist/task-control.js +2 -7
  138. package/dist/teammate-desk.js +1 -1
  139. package/dist/teammates-ui.js +1 -1
  140. package/dist/telegram-mate.d.ts +0 -3
  141. package/dist/telegram-mate.js +0 -1
  142. package/dist/templates.d.ts +11 -7
  143. package/dist/templates.js +12 -9
  144. package/dist/toolroll-update.d.ts +24 -3
  145. package/dist/toolroll-update.js +159 -16
  146. package/dist/webhooks.d.ts +10 -22
  147. package/dist/webhooks.js +43 -81
  148. package/dist/work-index.js +3 -7
  149. package/dist/workspace-revision.d.ts +28 -1
  150. package/dist/workspace-revision.js +276 -49
  151. package/dist/workspace-ui.js +1 -1
  152. package/package.json +1 -3
  153. package/scripts/recovery-canary.mjs +3 -3
  154. package/dist/contest.d.ts +0 -318
  155. package/dist/contest.js +0 -753
  156. package/dist/held.d.ts +0 -150
  157. package/dist/held.js +0 -799
  158. package/dist/liveness.d.ts +0 -30
  159. package/dist/liveness.js +0 -42
  160. package/dist/pricing.d.ts +0 -45
  161. package/dist/pricing.js +0 -77
  162. package/dist/routine.d.ts +0 -228
  163. package/dist/routine.js +0 -764
  164. 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 an attended
104
- // authorization, not a raw CLI claim. One SQL predicate, so roads
105
- // cannot diverge. After the seal it is ordinary in every respect.
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, the raw CLI claim, and the tournament resume all
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
- // The authority union (v6 W1): an unapproved scope still dispatches
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 && !attendedAuthority) {
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
- const { changes } = db
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
- export function completeFenced(store, leaseId, state, now, mutation = {}) {
496
+ function sealComplete(store, leaseId, args) {
497
+ const { state, now, mutation = {} } = args;
617
498
  const db = store.handle;
618
- return inTransaction(store, () => {
619
- 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);
620
- if (stoppedRun !== undefined) {
621
- const runId = Number(stoppedRun["id"]);
622
- const run = store.getRun(runId);
623
- const task = store.refForId(run.taskRef);
624
- if (task !== null)
625
- interruptIfStopped(store, { leaseId, runId, taskId: task.externalId, now });
626
- return { ok: false, reason: "stopped" };
627
- }
628
- const { changes } = db
629
- .prepare(`UPDATE claim SET released_at = ?, released_by = 'completed'
630
- WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
631
- .run(now.toISOString(), leaseId);
632
- if (Number(changes) === 0) {
633
- // Only a completion this same lease already made reads as a duplicate.
634
- // The DURABLE disposition (released_by) reproduces the original arm —
635
- // whatever the task or the tracker did since (finding 33). A lease
636
- // the reaper or recovery released was never *accepted*; a late
637
- // completion after that is exactly the stale commit the fence keeps
638
- // out.
639
- const released = db
640
- .prepare("SELECT * FROM claim WHERE lease_id = ? AND released_at IS NOT NULL")
641
- .get(leaseId);
642
- if (released !== undefined) {
643
- const by = String(released["released_by"] ?? "");
644
- if (by === "completed" || by === "disowned") {
645
- return {
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 refusal(db, leaseId);
528
+ return { ok: false, reason: "fenced" };
655
529
  }
656
- const row = db.prepare("SELECT * FROM claim WHERE lease_id = ?").get(leaseId);
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
- if (store.activeTournamentTerms(live.taskRef) !== null) {
726
- return { ok: false, reason: "contest-open", message: "a tournament raced onto this task — continuation waits" };
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
- const blocked = store.continuationBlockOf(live.parentRun);
729
- if (blocked !== null) {
730
- return { ok: false, reason: "continuation-blocked", message: blocked };
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
- return acquire(store, live.taskRef, runner, options);
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
- export function finalizeParkFenced(store, args) {
828
- const { leaseId, runId, taskId, decision, artifactIds, now } = args;
829
- const db = store.handle;
830
- return inTransaction(store, () => {
831
- const run = store.getRun(runId);
832
- if (run === null || run.leaseId !== leaseId || run.outcome !== null) {
833
- // A run that names another lease or is already finished cannot be
834
- // sealed by this call — that is a caller defect, not a race to absorb.
835
- throw new Error(`run ${runId} is not ${leaseId}'s open attempt — a park seals exactly one`);
836
- }
837
- const repairRun = plannerRepairChild(store, run, args.repairRunId ?? null);
838
- const stopped = interruptIfStopped(store, { leaseId, runId, taskId, now });
839
- if (stopped !== null)
840
- return { ok: false, reason: "stopped" };
841
- const { changes } = db
842
- .prepare(`UPDATE claim SET released_at = ?, released_by = 'parked'
843
- WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
844
- .run(now.toISOString(), leaseId);
845
- if (Number(changes) === 0) {
846
- if (repairRun !== null)
847
- store.finishRun(repairRun.id, { outcome: "refused", reason: "fenced", now });
848
- store.finishRun(runId, { outcome: "refused", reason: "fenced", now });
849
- return refusal(db, leaseId);
850
- }
851
- const decisionId = store.saveDecision({
852
- run: runId,
853
- urgency: decision.urgency,
854
- recap: decision.recap,
855
- question: decision.question,
856
- options: decision.options,
857
- recommendation: decision.recommendation,
858
- ...(decision.assignee === null ? {} : { assignee: decision.assignee }),
859
- ...(decision.deadline === null ? {} : { deadline: decision.deadline }),
860
- }, now);
861
- for (const artifact of artifactIds)
862
- store.linkEvidence(decisionId, artifact);
863
- // The hold is indefinite by construction. The decision's deadline is
864
- // attention metadata; wiring it into `until` would dispatch the task,
865
- // unanswered, the moment the deadline passed — expiry never chooses.
866
- store.holdOwned({
867
- taskRef: run.taskRef,
868
- ownerKind: "decision",
869
- ownerId: String(decisionId),
870
- reason: `decision:${decisionId} — ${oneLine(decision.question, 80)}`,
871
- until: null,
872
- }, now);
873
- store.finishRun(runId, { outcome: "parked", reason: `decision:${decisionId}`, now });
874
- if (repairRun !== null) {
875
- store.finishRun(repairRun.id, { outcome: "no-change", reason: "structured planner output repaired", now });
876
- }
877
- store.enqueueNotification({
878
- source: { run: runId },
879
- dedupeKey: `decision:${decisionId}`,
880
- kind: "decision",
881
- subject: `${taskId} parked a decision`,
882
- body: `${oneLine(decision.question, 200)}\n\`toolroll decide ${decisionId}\``,
883
- // The push stamp (arc 3): class + machine-minted link, at enqueue
884
- // or never. Fixed phrases ride the push service; this subject does not.
885
- pushClass: "decision",
886
- link: `/d/${decisionId}`,
887
- }, now);
888
- return { ok: true, decisionId };
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
- export function finalizeMalformedFenced(store, args) {
900
- const { leaseId, runId, taskId, problems, now } = args;
901
- const db = store.handle;
902
- return inTransaction(store, () => {
903
- const run = store.getRun(runId);
904
- if (run === null || run.leaseId !== leaseId || run.outcome !== null) {
905
- throw new Error(`run ${runId} is not ${leaseId}'s open attempt — a park seals exactly one`);
906
- }
907
- const stopped = interruptIfStopped(store, { leaseId, runId, taskId, now });
908
- if (stopped !== null)
909
- return { ok: false, reason: "stopped" };
910
- const { changes } = db
911
- .prepare(`UPDATE claim SET released_at = ?, released_by = 'parked'
912
- WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
913
- .run(now.toISOString(), leaseId);
914
- if (Number(changes) === 0) {
915
- store.finishRun(runId, { outcome: "refused", reason: "fenced", now });
916
- return refusal(db, leaseId);
917
- }
918
- const incidentId = store.createIncident({ run: runId, kind: "malformed-decision" }, now);
919
- store.holdOwned({
920
- taskRef: run.taskRef,
921
- ownerKind: "incident",
922
- ownerId: String(incidentId),
923
- reason: `malformed-decision — the agent tried to park ${taskId} and could not say what`,
924
- until: null,
925
- }, now);
926
- store.finishRun(runId, { outcome: "failed", reason: "malformed-decision", now });
927
- store.enqueueNotification({
928
- source: { run: runId },
929
- dedupeKey: `malformed:${runId}`,
930
- kind: "malformed-decision",
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
- export function finalizeInterruptedFenced(store, args) {
962
- const { leaseId, runId, taskId, stopRun, now } = args;
730
+ function sealInterrupted(store, leaseId, args) {
731
+ const { runId, taskId, stopRun, now } = args;
963
732
  const db = store.handle;
964
- return inTransaction(store, () => {
965
- const run = store.getRun(runId);
966
- if (run === null || run.leaseId !== leaseId || run.outcome !== null) {
967
- throw new Error(`run ${runId} is not ${leaseId}'s open attempt — an interruption seals exactly one`);
968
- }
969
- const { changes } = db
970
- .prepare(`UPDATE claim SET released_at = ?, released_by = 'interrupted'
971
- WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
972
- .run(now.toISOString(), leaseId);
973
- const fenced = Number(changes) === 0;
974
- if (args.message !== undefined && args.message.trim() !== "") {
975
- store.recordOutcomeFacts(runId, { handoff: args.message });
976
- }
977
- // Owned descendants first (a repair turn, a correction) — the parent's
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
- store.finishRun(runId, {
988
- outcome: "failed",
989
- reason: "interrupted",
990
- ...(args.committed === undefined ? {} : { committed: args.committed }),
991
- now,
992
- stopSettlement: "interrupted",
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
- export function finalizeHandBackFenced(store, args) {
1018
- const { leaseId, runId, taskId, now } = args;
778
+ function sealHandBack(store, leaseId, args) {
779
+ const { runId, taskId, now } = args;
1019
780
  const db = store.handle;
1020
- return inTransaction(store, () => {
1021
- const run = store.getRun(runId);
1022
- if (run === null || run.leaseId !== leaseId || run.outcome !== null) {
1023
- throw new Error(`run ${runId} is not ${leaseId}'s open attempt — a hand-back seals exactly one`);
1024
- }
1025
- const { changes } = db
1026
- .prepare(`UPDATE claim SET released_at = ?, released_by = 'interrupted'
1027
- WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
1028
- .run(now.toISOString(), leaseId);
1029
- const fenced = Number(changes) === 0;
1030
- if (args.message.trim() !== "")
1031
- store.recordOutcomeFacts(runId, { handoff: args.message });
1032
- for (const owned of store.ownedRunsOf(runId)) {
1033
- if (owned === runId)
1034
- continue;
1035
- const child = store.getRun(owned);
1036
- if (child !== null && child.outcome === null)
1037
- store.finishRun(owned, { outcome: "failed", reason: "interrupted", now });
1038
- }
1039
- store.finishRun(runId, { outcome: "failed", reason: "interrupted", now });
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
- return finalizeInterruptedFenced(store, {
1064
- ...args,
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
- export function finalizeFailureFenced(store, args) {
1085
- const { leaseId, runId, taskId, failureClass, message, now } = args;
838
+ function sealFailure(store, leaseId, args) {
839
+ const { runId, taskId, failureClass, message, now } = args;
1086
840
  const db = store.handle;
1087
- return inTransaction(store, () => {
1088
- const run = store.getRun(runId);
1089
- if (run === null || run.leaseId !== leaseId || run.outcome !== null) {
1090
- throw new Error(`run ${runId} is not ${leaseId}'s open attempt — a failure seals exactly one`);
1091
- }
1092
- if (run.role !== "builder") {
1093
- throw new Error(`run ${runId} is a ${run.role} run — only top-level builder attempts take strikes`);
1094
- }
1095
- const stopped = interruptIfStopped(store, { leaseId, runId, taskId, now });
1096
- if (stopped !== null)
1097
- return { ok: false, reason: "stopped" };
1098
- const { changes } = db
1099
- .prepare(`UPDATE claim SET released_at = ?, released_by = 'released'
1100
- WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
1101
- .run(now.toISOString(), leaseId);
1102
- if (Number(changes) === 0) {
1103
- store.finishRun(runId, { outcome: "refused", reason: "fenced", now });
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: "backoff",
1164
- ownerId: String(run.taskRef),
1165
- // Run 2085: this failure is named in plain words wherever the hold shows.
1166
- reason: failureClass === "no-handoff"
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: `run:${runId}:failed`,
1174
- kind: "build-failed",
1175
- subject: failureClass === "no-handoff"
1176
- ? `${taskId}: the agent stopped before handing off; its work was kept, retry ${strikes}/${MAX_STRIKES}`
1177
- : `${taskId}: attempt failed (${failureClass}), retry ${strikes}/${MAX_STRIKES}`,
1178
- body: `${oneLine(message, 200)}\nNext attempt no earlier than ${until.toISOString()}.`,
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: "backoff", strikes, until: until.toISOString() };
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
- export function finalizePlanFenced(store, args) {
1216
- const { leaseId, runId, taskId, plan, now } = args;
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
- return inTransaction(store, () => {
1237
- const run = store.getRun(runId);
1238
- if (run === null || run.leaseId !== leaseId || run.outcome !== null) {
1239
- throw new Error(`run ${runId} is not ${leaseId}'s open attempt — a plan seals exactly one`);
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 (run.role !== "planner") {
1242
- throw new Error(`run ${runId} is a ${run.role} run — only planner runs draft plans`);
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
- const repairRun = plannerRepairChild(store, run, args.repairRunId ?? null);
1245
- if (store.lookupRef(taskId)?.id !== run.taskRef)
1246
- throw new Error("a planner can finalize only its own task");
1247
- const stopped = interruptIfStopped(store, { leaseId, runId, taskId, now });
1248
- if (stopped !== null)
1249
- return { ok: false, reason: "stopped" };
1250
- const invalidSource = (detail, kind = "malformed") => {
1251
- const failed = finalizePlanFailureFenced(store, { leaseId, runId, taskId, kind, message: detail, now });
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: failed.ok ? "source-invalid" : "fenced", now });
1254
- return failed.ok ? { ok: false, reason: "source-invalid", detail } : failed;
1255
- };
1256
- const sources = store.artifactsFor(runId).filter(one => one.kind === "plan-contract" && one.key.endsWith("/planner-source.json"));
1257
- // Legacy rows have no input stamp or artifact. Every current dispatch
1258
- // stamps even an empty scope; dropping a caller argument cannot bypass custody.
1259
- if (args.source !== undefined || sources.length > 0 || run.scopeDigest !== null) {
1260
- if (args.source === undefined || args.evidenceRoot === undefined || sources.length !== 1 || sources[0].id !== args.sourceArtifact) {
1261
- return invalidSource("the planner's admitted source inventory is missing, ambiguous, or was not presented");
1262
- }
1263
- const artifact = sources[0];
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
- // THE SOURCE RECHECK (contract handoff, task 1): the filed request is
1282
- // re-derived from durable state inside this very transaction and must
1283
- // still be the one the planner read. A scope rewritten, approved, or
1284
- // re-termed while the planner ran is the NEWER source; an old draft
1285
- // never overwrites it. The attempt ends refused in words — no strike,
1286
- // the planner did nothing wrong — and the task stays requested. The
1287
- // claim releases under the same fence the ingestion would have used.
1288
- if (args.source !== undefined) {
1289
- const current = plannerContractOf(store, taskId);
1290
- const currentDigest = current.ok ? plannerSourceDigest(current.contract) : null;
1291
- const currentAnswers = store.answeredDecisionsFor(taskId, PLANNER_SOURCE_LIMITS.answers + 1).map(one => ({ question: one.question, choice: one.choice ?? "", note: one.note }));
1292
- if (currentDigest !== args.source.sourceDigest || canonicalContractJson(currentAnswers) !== canonicalContractJson(args.source.answers)) {
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
- if (record !== null && args.evidenceRoot !== undefined) {
1321
- const recordBytes = encodePlanContractRecord(record);
1322
- if (recordBytes.length > EVIDENCE_CAPS["plan-contract"])
1323
- return invalidSource("the required plan amendment record exceeds its explicit evidence cap");
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 recordId = storeEvidence(store, args.evidenceRoot, runId, "plan-contract", "plan-contract.json", recordBytes, record.changes.length === 0
1326
- ? "plan ingestion: filed contract reproduced exactly (verified tree)"
1327
- : `plan ingestion: ${record.changes.length} contract change(s), amendment ${record.amendment === null ? "absent" : "stated"} (verified tree)`, now, { captureStatus: "ok" });
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
- return invalidSource("the required plan contract record could not be saved", "failure");
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
- store.setPlanState(run.taskRef, "drafted");
1383
- const autoApproved = sealUnchangedPlanUnderMode(store, taskId, args.source?.sourceDigest ?? "", runId, args.source !== undefined && filed !== null && contractChanges.length === 0 && amendment === null && verifiedPlan, now);
1384
- store.resetPlanStrikes(run.taskRef);
1385
- store.finishRun(runId, { outcome: "built", reason: "plan-drafted", now });
1386
- if (repairRun !== null) {
1387
- store.finishRun(repairRun.id, { outcome: "no-change", reason: "structured planner output repaired", now });
1388
- }
1389
- store.enqueueNotification({
1390
- source: { run: runId },
1391
- dedupeKey: `plan:${run.taskRef}:${runId}`,
1392
- kind: "plan-ready",
1393
- 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`}`,
1394
- body: `The planner proposes: ${oneLine(plan.goal, 200)}\n` +
1395
- (contractChanges.length === 0
1396
- ? filed === null
1397
- ? ""
1398
- : "The filed goal, exclusions, touches, and acceptance criteria are reproduced exactly.\n"
1399
- : `It AMENDS the filed contract (${oneLine(describeContractChanges(contractChanges).join("; "), 300)})${amendment === null ? "" : ` — because: ${oneLine(amendment, 200)}`}\n`) +
1400
- (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."),
1401
- }, now);
1402
- return { ok: true, changes: contractChanges.length, amendment };
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 `finalizePlanFenced`'s twin: the same
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
- export function finalizeRevisionFenced(store, args) {
1464
- const { leaseId, runId, taskId, taskRef, revision, now } = args;
1465
- const db = store.handle;
1466
- return inTransaction(store, () => {
1467
- const run = store.getRun(runId);
1468
- if (run === null || run.leaseId !== leaseId || run.outcome !== null) {
1469
- throw new Error(`run ${runId} is not ${leaseId}'s open attempt — a plan revision seals exactly one`);
1470
- }
1471
- if (run.role !== "builder") {
1472
- throw new Error(`run ${runId} is a ${run.role} run — only builder runs file plan revisions`);
1473
- }
1474
- const stopped = interruptIfStopped(store, { leaseId, runId, taskId, now });
1475
- if (stopped !== null)
1476
- return { ok: false, reason: "stopped" };
1477
- const { changes } = db
1478
- .prepare(`UPDATE claim SET released_at = ?, released_by = 'released'
1479
- WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
1480
- .run(now.toISOString(), leaseId);
1481
- if (Number(changes) === 0) {
1482
- store.finishRun(runId, { outcome: "refused", reason: "fenced", now });
1483
- return refusal(db, leaseId);
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
- return { ok: true, revisionId, authorityKind: revision.authorityKind };
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
- export function finalizePlanFailureFenced(store, args) {
1528
- const { leaseId, runId, taskId, kind, message, now } = args;
1231
+ function sealPlanFailure(store, leaseId, args) {
1232
+ const { runId, taskId, failure: kind, message, now } = args;
1529
1233
  const db = store.handle;
1530
- return inTransaction(store, () => {
1531
- const run = store.getRun(runId);
1532
- if (run === null || run.leaseId !== leaseId || run.outcome !== null) {
1533
- throw new Error(`run ${runId} is not ${leaseId}'s open attempt — a failure seals exactly one`);
1534
- }
1535
- if (run.role !== "planner") {
1536
- throw new Error(`run ${runId} is a ${run.role} run — this finalizer seals planner attempts only`);
1537
- }
1538
- const stopped = interruptIfStopped(store, { leaseId, runId, taskId, now });
1539
- if (stopped !== null)
1540
- return { ok: false, reason: "stopped" };
1541
- const { changes } = db
1542
- .prepare(`UPDATE claim SET released_at = ?, released_by = 'released'
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
- // Its own owner id: a planning pause must never displace the
1599
- // builder's backoff for the same task, or vice versa.
1600
- ownerKind: "backoff",
1601
- ownerId: `plan:${run.taskRef}`,
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: `plan-run:${runId}:failed`,
1608
- kind: "plan-failed",
1609
- subject: `${taskId}: planning attempt failed, retry ${strikes}/${MAX_PLAN_STRIKES}`,
1610
- body: `${oneLine(message, 200)}\nNext attempt no earlier than ${until.toISOString()}.`,
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: "backoff", strikes };
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
- export function finalizeScoutFenced(store, args) {
1623
- const { leaseId, runId, taskId, report, now } = args;
1313
+ function sealScout(store, leaseId, args) {
1314
+ const { runId, taskId, report, now } = args;
1624
1315
  const db = store.handle;
1625
- return inTransaction(store, () => {
1626
- const run = store.getRun(runId);
1627
- if (run === null || run.leaseId !== leaseId || run.outcome !== null) {
1628
- throw new Error(`run ${runId} is not ${leaseId}'s open attempt — a report seals exactly one`);
1629
- }
1630
- if (run.role !== "scout") {
1631
- throw new Error(`run ${runId} is a ${run.role} run — only scout runs deliver reports`);
1632
- }
1633
- const stopped = interruptIfStopped(store, { leaseId, runId, taskId, now });
1634
- if (stopped !== null)
1635
- return { ok: false, reason: "stopped" };
1636
- const { changes } = db
1637
- .prepare(`UPDATE claim SET released_at = ?, released_by = 'completed'
1638
- WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
1639
- .run(now.toISOString(), leaseId);
1640
- if (Number(changes) === 0) {
1641
- store.finishRun(runId, { outcome: "refused", reason: "fenced", now });
1642
- return refusal(db, leaseId);
1643
- }
1644
- store.saveArtifact({ run: runId, kind: "report", ...args.artifact }, now);
1645
- store.resetStrikes(run.taskRef);
1646
- store.finishRun(runId, { outcome: "built", reason: "report-delivered", now });
1647
- const done = store.setTaskState(taskId, "done", now);
1648
- if (!done.ok) {
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
- export function finalizeScoutFailureFenced(store, args) {
1671
- const { leaseId, runId, taskId, kind, message, now } = args;
1348
+ function sealScoutFailure(store, leaseId, args) {
1349
+ const { runId, taskId, failure: kind, message, now } = args;
1672
1350
  const db = store.handle;
1673
- return inTransaction(store, () => {
1674
- const run = store.getRun(runId);
1675
- if (run === null || run.leaseId !== leaseId || run.outcome !== null) {
1676
- throw new Error(`run ${runId} is not ${leaseId}'s open attempt — a failure seals exactly one`);
1677
- }
1678
- if (run.role !== "scout") {
1679
- throw new Error(`run ${runId} is a ${run.role} run — this finalizer seals scout attempts only`);
1680
- }
1681
- const stopped = interruptIfStopped(store, { leaseId, runId, taskId, now });
1682
- if (stopped !== null)
1683
- return { ok: false, reason: "stopped" };
1684
- const { changes } = db
1685
- .prepare(`UPDATE claim SET released_at = ?, released_by = 'released'
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: "backoff",
1743
- ownerId: String(run.taskRef),
1744
- reason: `scout retry ${strikes}/${MAX_STRIKES} — backing off ${Math.round(wait / 60_000)}m`,
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: `run:${runId}:failed`,
1750
- kind: "scout-failed",
1751
- subject: `${taskId}: scouting attempt failed, retry ${strikes}/${MAX_STRIKES}`,
1752
- body: `${oneLine(message, 200)}\nNext attempt no earlier than ${until.toISOString()}.`,
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: "backoff", strikes };
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);