@superjackfruitlabs/salaam 0.0.3 → 0.0.5

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 (172) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +12 -11
  3. package/dist/src/acp-desk.js +140 -0
  4. package/dist/src/acp-outcome.js +182 -18
  5. package/dist/src/acp.js +296 -15
  6. package/dist/src/agent-profiles.js +45 -0
  7. package/dist/src/agentpod-authority.js +169 -11
  8. package/dist/src/agentpod-default-config.js +12 -0
  9. package/dist/src/agentpod-work-decision-protocol.js +40 -6
  10. package/dist/src/agentpod-work-decisions.js +108 -4
  11. package/dist/src/agentpod-work.js +1214 -127
  12. package/dist/src/approval-rules.js +287 -0
  13. package/dist/src/attach.js +3 -1
  14. package/dist/src/autonomy.js +80 -9
  15. package/dist/src/board-reads.js +106 -0
  16. package/dist/src/children.js +83 -0
  17. package/dist/src/cli-help.js +61 -6
  18. package/dist/src/cli.js +140 -4
  19. package/dist/src/command-danger.js +797 -0
  20. package/dist/src/command-network.js +200 -0
  21. package/dist/src/command-sandbox.js +327 -0
  22. package/dist/src/custom-commands.js +159 -0
  23. package/dist/src/danger-classifier.js +286 -0
  24. package/dist/src/execution-evidence.js +33 -1
  25. package/dist/src/git-helpers.js +160 -0
  26. package/dist/src/hooks.js +351 -0
  27. package/dist/src/host-bind.js +111 -0
  28. package/dist/src/host-breaker.js +88 -0
  29. package/dist/src/host-start.js +41 -1
  30. package/dist/src/host.js +284 -6
  31. package/dist/src/house-extension.js +292 -61
  32. package/dist/src/local-coding.js +726 -35
  33. package/dist/src/local-edit-helper.js +25 -0
  34. package/dist/src/local-edits.js +168 -6
  35. package/dist/src/local-undo.js +25 -16
  36. package/dist/src/managed.js +1 -1
  37. package/dist/src/mentions.js +277 -0
  38. package/dist/src/operator-mcp-cli.js +107 -0
  39. package/dist/src/operator-mcp.js +342 -0
  40. package/dist/src/persona.js +31 -0
  41. package/dist/src/pi-sessions.js +5 -1
  42. package/dist/src/pi.js +22 -1
  43. package/dist/src/project-instructions.js +195 -0
  44. package/dist/src/protocol.js +9 -3
  45. package/dist/src/rate-limit-retries.js +62 -0
  46. package/dist/src/recurring-work.js +1 -0
  47. package/dist/src/repository-snapshots.js +5 -2
  48. package/dist/src/run-grants.js +62 -0
  49. package/dist/src/service.js +482 -10
  50. package/dist/src/session-pick.js +77 -0
  51. package/dist/src/shell-passthrough.js +39 -0
  52. package/dist/src/station-checkpoints.js +139 -0
  53. package/dist/src/station-config.js +8 -1
  54. package/dist/src/station-files.js +20 -1
  55. package/dist/src/station-task-verification.js +6 -3
  56. package/dist/src/station-work.js +315 -22
  57. package/dist/src/store.js +70 -4
  58. package/dist/src/supervisor.js +408 -25
  59. package/dist/src/task-budget.js +39 -3
  60. package/dist/src/task-context.js +8 -2
  61. package/dist/src/task-critic.js +115 -0
  62. package/dist/src/task-driver.js +46 -2
  63. package/dist/src/task-policy.js +44 -12
  64. package/dist/src/task-verification.js +42 -9
  65. package/dist/src/task-work.js +47 -0
  66. package/dist/src/terminal-status.js +88 -0
  67. package/dist/src/tool-identity.js +69 -0
  68. package/dist/src/tui-activity.js +99 -0
  69. package/dist/src/tui-classifier.js +25 -0
  70. package/dist/src/tui-client.js +186 -5
  71. package/dist/src/tui-commands.js +23 -5
  72. package/dist/src/tui-composer.js +113 -0
  73. package/dist/src/tui-keymap.js +116 -0
  74. package/dist/src/tui-mentions.js +38 -0
  75. package/dist/src/tui-modals.js +157 -34
  76. package/dist/src/tui-notify.js +62 -0
  77. package/dist/src/tui-review.js +55 -0
  78. package/dist/src/tui-turn-keys.js +52 -0
  79. package/dist/src/tui-view.js +243 -69
  80. package/dist/src/tui.js +875 -118
  81. package/dist/src/turn-checkpoints.js +221 -0
  82. package/dist/src/turn-rewind.js +168 -0
  83. package/dist/src/usage.js +1 -1
  84. package/dist/src/web-fetch.js +114 -0
  85. package/dist/src/web-guard.js +163 -0
  86. package/dist/src/web-search.js +302 -0
  87. package/npm-shrinkwrap.json +2 -2
  88. package/package.json +1 -1
  89. package/src/acp-desk.ts +155 -0
  90. package/src/acp-outcome.ts +174 -20
  91. package/src/acp.ts +386 -30
  92. package/src/agent-profiles.ts +48 -0
  93. package/src/agentpod-authority.ts +234 -15
  94. package/src/agentpod-default-config.ts +16 -0
  95. package/src/agentpod-work-decision-protocol.ts +69 -8
  96. package/src/agentpod-work-decisions.ts +123 -5
  97. package/src/agentpod-work.ts +1455 -152
  98. package/src/approval-rules.ts +360 -0
  99. package/src/attach.ts +4 -1
  100. package/src/autonomy.ts +111 -10
  101. package/src/board-reads.ts +148 -0
  102. package/src/children.ts +112 -0
  103. package/src/cli-help.ts +60 -6
  104. package/src/cli.ts +174 -4
  105. package/src/command-danger.ts +886 -0
  106. package/src/command-network.ts +215 -0
  107. package/src/command-sandbox.ts +393 -0
  108. package/src/custom-commands.ts +175 -0
  109. package/src/danger-classifier.ts +321 -0
  110. package/src/execution-evidence.ts +31 -1
  111. package/src/git-helpers.ts +177 -0
  112. package/src/hooks.ts +425 -0
  113. package/src/host-bind.ts +156 -0
  114. package/src/host-breaker.ts +89 -0
  115. package/src/host-start.ts +51 -1
  116. package/src/host.ts +365 -6
  117. package/src/house-extension.ts +313 -60
  118. package/src/local-coding.ts +894 -43
  119. package/src/local-edit-helper.ts +26 -0
  120. package/src/local-edits.ts +191 -8
  121. package/src/local-undo.ts +29 -14
  122. package/src/managed.ts +2 -2
  123. package/src/mentions.ts +304 -0
  124. package/src/operator-mcp-cli.ts +108 -0
  125. package/src/operator-mcp.ts +420 -0
  126. package/src/persona.ts +37 -0
  127. package/src/pi-sessions.ts +9 -1
  128. package/src/pi.ts +23 -1
  129. package/src/project-instructions.ts +229 -0
  130. package/src/protocol.ts +25 -3
  131. package/src/rate-limit-retries.ts +69 -0
  132. package/src/recurring-work.ts +1 -0
  133. package/src/repository-snapshots.ts +5 -2
  134. package/src/run-grants.ts +107 -0
  135. package/src/service.ts +709 -10
  136. package/src/session-pick.ts +102 -0
  137. package/src/shell-passthrough.ts +38 -0
  138. package/src/station-checkpoints.ts +183 -0
  139. package/src/station-config.ts +10 -1
  140. package/src/station-files.ts +20 -1
  141. package/src/station-task-verification.ts +6 -3
  142. package/src/station-work.ts +369 -20
  143. package/src/store.ts +70 -3
  144. package/src/supervisor.ts +463 -26
  145. package/src/task-budget.ts +48 -3
  146. package/src/task-context.ts +9 -2
  147. package/src/task-critic.ts +171 -0
  148. package/src/task-driver.ts +49 -2
  149. package/src/task-policy.ts +65 -13
  150. package/src/task-verification.ts +53 -9
  151. package/src/task-work.ts +75 -0
  152. package/src/terminal-status.ts +103 -0
  153. package/src/tool-identity.ts +110 -0
  154. package/src/tui-activity.ts +133 -0
  155. package/src/tui-classifier.ts +38 -0
  156. package/src/tui-client.ts +239 -8
  157. package/src/tui-commands.ts +49 -10
  158. package/src/tui-composer.ts +124 -0
  159. package/src/tui-keymap.ts +152 -0
  160. package/src/tui-mentions.ts +43 -0
  161. package/src/tui-modals.ts +173 -43
  162. package/src/tui-notify.ts +77 -0
  163. package/src/tui-review.ts +76 -0
  164. package/src/tui-turn-keys.ts +77 -0
  165. package/src/tui-view.ts +341 -88
  166. package/src/tui.ts +1047 -137
  167. package/src/turn-checkpoints.ts +253 -0
  168. package/src/turn-rewind.ts +258 -0
  169. package/src/usage.ts +1 -1
  170. package/src/web-fetch.ts +140 -0
  171. package/src/web-guard.ts +195 -0
  172. package/src/web-search.ts +402 -0
@@ -1,3 +1,8 @@
1
+ import { BoardReads } from './board-reads.js';
2
+ import { StationCheckpoints } from './station-checkpoints.js';
3
+ import { requiredChecks } from './task-policy.js';
4
+ import { RATE_LIMIT_STOP, RateLimitRetries } from './rate-limit-retries.js';
5
+ import { planUpdate, toolMetadata, toolTitle } from './acp-outcome.js';
1
6
  import { canonical, digest, textDigest, ServiceError } from './protocol.js';
2
7
  import { DIAGNOSTIC_PROFILE, NATIVE_CAPABILITIES } from './worker-boundary.js';
3
8
  import { POLICY_DIGEST } from './config.js';
@@ -10,6 +15,8 @@ import { agentpodWorkAdmission, checkAgentPodWorkAdmission, } from './agentpod-w
10
15
  * adapter must supply its own session and independently admitted work identity.
11
16
  * This receipt records task admission, never successful work or permission to run.
12
17
  */
18
+ /** Extra progression steps a strict grant may carry, only ever spent on re-running a step. */
19
+ export const RERUN_ALLOWANCE = 3;
13
20
  export class AgentPodWork {
14
21
  store;
15
22
  authority;
@@ -38,6 +45,7 @@ export class AgentPodWork {
38
45
  objective: task.objective,
39
46
  constraints: task.constraints,
40
47
  acceptance: task.acceptance,
48
+ ...(task.completion_gate ? { completion_gate: task.completion_gate } : {}),
41
49
  deliverables: task.deliverables,
42
50
  limits: task.limits,
43
51
  steps: task.steps.map(({ key, title, instruction, depends_on, checks }) => ({
@@ -253,9 +261,12 @@ export class AgentPodWork {
253
261
  offered,
254
262
  })
255
263
  : null;
256
- if (!simple && !history)
264
+ const multi = !simple && !history
265
+ ? this.multiOfferReviewExpiry(root.session_id, task, runtime, offered)
266
+ : null;
267
+ if (!simple && !history && !multi)
257
268
  throw new ServiceError('work_settlement_unsafe', 'Only exact expired unanswered unapplied work with closed historical evidence can be settled');
258
- if (history)
269
+ if (history || multi)
259
270
  this.store.event(root.session_id, 'agentpod.work_expiry_evidence', {
260
271
  version: 1,
261
272
  task_id: task.id,
@@ -265,7 +276,7 @@ export class AgentPodWork {
265
276
  request_id: request,
266
277
  work_request_id: workRequest,
267
278
  producer_terminal_digest: terminalDigest,
268
- history,
279
+ ...(history ? { history } : { several_review_offers: multi }),
269
280
  }),
270
281
  }, request);
271
282
  if (task.state !== 'cancelled')
@@ -289,6 +300,530 @@ export class AgentPodWork {
289
300
  return receipt;
290
301
  });
291
302
  }
303
+ /** Several unanswered review offers in one attempt: each one expired, or was invalidated by the
304
+ * owner's revocation (a hub restart), before anyone answered it. Every operation was cancelled
305
+ * before dispatch, the only station calls are closed reads, and nothing ran after the revoke. */
306
+ multiOfferReviewExpiry(session, task, runtime, offered) {
307
+ if (task.state !== 'blocked' ||
308
+ task.steps.some((s) => !['pending', 'cancelled'].includes(s.state)) ||
309
+ !runtime ||
310
+ runtime.auto_enabled ||
311
+ runtime.reserved_requests ||
312
+ runtime.reserved_tokens ||
313
+ runtime.verification_runs)
314
+ return null;
315
+ const attempts = this.store.db
316
+ .prepare('SELECT * FROM attempts WHERE session_id=?')
317
+ .all(session);
318
+ const attempt = attempts[0];
319
+ if (attempts.length !== 1 ||
320
+ !attempt ||
321
+ attempt.state !== 'cancelled' ||
322
+ attempt.resolved_by !== null ||
323
+ attempt.result !== null)
324
+ return null;
325
+ // Cancelled by the node's revocation of this session, which names exactly this attempt.
326
+ const cancellations = this.store.db
327
+ .prepare('SELECT * FROM cancellations WHERE attempt_id=?')
328
+ .all(String(attempt.id));
329
+ const revocations = this.store.db
330
+ .prepare('SELECT * FROM session_revocations WHERE session_id=?')
331
+ .all(session);
332
+ if (cancellations.length !== 1 ||
333
+ cancellations[0].requested_by !== 'agentpod-node' ||
334
+ revocations.length !== 1)
335
+ return null;
336
+ const revocation = JSON.parse(String(revocations[0].receipt));
337
+ if (revocation.revoked !== true ||
338
+ canonical(revocation.attempt_ids) !== canonical([attempt.id]) ||
339
+ canonical(revocation.unresolved_operations) !== canonical([]))
340
+ return null;
341
+ const events = this.store.db
342
+ .prepare('SELECT seq,kind,payload,causation_id FROM events WHERE session_id=? ORDER BY seq')
343
+ .all(session);
344
+ const one = (kind) => {
345
+ const found = events.filter((e) => e.kind === kind);
346
+ return found.length === 1 ? found[0] : null;
347
+ };
348
+ const requested = one('attempt.cancel_requested'), revoked = one('execution.revoked'), cancelled = one('attempt.cancelled');
349
+ if (!requested ||
350
+ !revoked ||
351
+ !cancelled ||
352
+ requested.causation_id !== 'agentpod-revocation' ||
353
+ !(Number(requested.seq) < Number(revoked.seq) && Number(revoked.seq) < Number(cancelled.seq)) ||
354
+ events.some((e) => e.kind === 'tool.started' && Number(e.seq) > Number(requested.seq)))
355
+ return null;
356
+ // Two or more review operations, all on this attempt, all cancelled before dispatch.
357
+ const ops = this.store.db
358
+ .prepare('SELECT * FROM operations WHERE session_id=? ORDER BY rowid')
359
+ .all(session);
360
+ if (ops.length < 2 ||
361
+ !ops.some((op) => op.id === offered.operation_id) ||
362
+ ops.some((op) => op.kind !== 'review' ||
363
+ op.state !== 'cancelled_before_dispatch' ||
364
+ op.attempt_id !== attempt.id))
365
+ return null;
366
+ const of = (e) => JSON.parse(String(e.payload));
367
+ // The invalidations the revocation made: the contiguous block of invalidated approvals and
368
+ // their cancelled operations that ends at the revocation's own cancel request.
369
+ const block = new Set();
370
+ for (let n = events.indexOf(requested) - 1; n >= 0; n--) {
371
+ const e = events[n];
372
+ if (e.kind === 'operation.cancelled_before_dispatch')
373
+ continue;
374
+ if (e.kind !== 'approval.invalidated')
375
+ break;
376
+ block.add(String(of(e).approval_id));
377
+ }
378
+ const offers = [];
379
+ for (const op of ops) {
380
+ const approvals = this.store.db
381
+ .prepare('SELECT * FROM approvals WHERE operation_id=?')
382
+ .all(String(op.id));
383
+ const approval = approvals[0];
384
+ if (approvals.length !== 1 ||
385
+ !approval ||
386
+ approval.session_id !== session ||
387
+ approval.decision !== null ||
388
+ approval.operation_digest !== op.operation_digest)
389
+ return null;
390
+ if (approval.state === 'expired') {
391
+ const expired = events.findIndex((e) => e.kind === 'approval.expired' && of(e).approval_id === approval.id);
392
+ if (expired < 0 ||
393
+ Number(approval.expires_at) > Date.now() ||
394
+ events[expired + 1]?.kind !== 'operation.cancelled_before_dispatch' ||
395
+ of(events[expired + 1]).operation_id !== op.id)
396
+ return null;
397
+ }
398
+ else if (approval.state !== 'invalidated' || !block.has(String(approval.id)))
399
+ return null;
400
+ offers.push({ operation_id: op.id, approval_id: approval.id, state: approval.state });
401
+ }
402
+ // The decision being settled is one that expired unanswered, exactly as it was offered.
403
+ const settled = offers.find((o) => o.approval_id === offered.approval_id);
404
+ const approval = this.store.db
405
+ .prepare('SELECT * FROM approvals WHERE id=?')
406
+ .get(String(offered.approval_id));
407
+ if (settled?.state !== 'expired' ||
408
+ !approval ||
409
+ approval.operation_digest !== offered.operation_digest ||
410
+ approval.expires_at !== offered.expires_at)
411
+ return null;
412
+ const units = this.noLedgeredEffects(session, task, { operations: true, stationReads: true });
413
+ if (!units)
414
+ return null;
415
+ return {
416
+ revocation,
417
+ offers,
418
+ reads: this.store.db
419
+ .prepare('SELECT id,input_digest FROM station_calls WHERE session_id=? ORDER BY id')
420
+ .all(session),
421
+ unbound_stopped_units: units,
422
+ };
423
+ }
424
+ /** An attempt the request-budget control interrupted after its operator MCP calls kept failing:
425
+ * every call is journaled as failed or tool_error, the only station calls are closed reads, and
426
+ * no tool ran after the stop. The failed calls are preserved as the known effects. */
427
+ requestBudgetStopFailure(session, task, runtime, attempts) {
428
+ const reason = 'Required control failed: No model request budget remains',
429
+ // Picked by state, never by row order; the count below rejects any other attempt.
430
+ attempt = attempts.find((a) => a.state === 'interrupted');
431
+ if (task.state !== 'blocked' ||
432
+ !runtime ||
433
+ runtime.auto_enabled ||
434
+ runtime.reserved_requests ||
435
+ runtime.reserved_tokens ||
436
+ runtime.verification_runs ||
437
+ attempts.length !== 1 ||
438
+ !attempt ||
439
+ attempt.error !== reason ||
440
+ attempt.resolved_by !== null ||
441
+ attempt.continues_attempt_id !== null ||
442
+ attempt.result !== null)
443
+ return null;
444
+ const steps = task.steps.filter((s) => s.attempt_id === attempt.id);
445
+ if (steps.length !== 1 ||
446
+ steps[0].state !== 'interrupted' ||
447
+ task.steps.some((s) => s !== steps[0] && (s.state !== 'pending' || s.attempt_id !== null)))
448
+ return null;
449
+ // Stopped by the budget control, not cancelled by anyone.
450
+ if (this.store.db
451
+ .prepare('SELECT 1 FROM cancellations WHERE attempt_id=?')
452
+ .get(String(attempt.id)))
453
+ return null;
454
+ const events = this.store.db
455
+ .prepare('SELECT seq,kind,payload FROM events WHERE session_id=? ORDER BY seq')
456
+ .all(session);
457
+ const of = (e) => JSON.parse(String(e.payload));
458
+ const unhealthy = events.filter((e) => e.kind === 'worker.unhealthy'), interrupted = events.filter((e) => e.kind === 'attempt.interrupted');
459
+ if (unhealthy.length !== 1 ||
460
+ interrupted.length !== 1 ||
461
+ of(unhealthy[0]).error !== reason ||
462
+ of(interrupted[0]).attempt_id !== attempt.id ||
463
+ of(interrupted[0]).error !== reason ||
464
+ Number(unhealthy[0].seq) >= Number(interrupted[0].seq))
465
+ return null;
466
+ // Tools: operator MCP (catalog reads, or a journaled failed call) and station reads, every one
467
+ // closed before the stop. Tools may run in parallel, and a call does not name its tool, so each
468
+ // call takes an open operator MCP tool that has none yet; a call with no such tool is refused.
469
+ const calls = [];
470
+ const open = new Map();
471
+ for (const e of events) {
472
+ if (!['tool.started', 'tool.finished', 'operator_mcp.call'].includes(String(e.kind)))
473
+ continue;
474
+ const payload = of(e);
475
+ if (Number(e.seq) >= Number(unhealthy[0].seq) || payload.attempt_id !== attempt.id)
476
+ return null;
477
+ if (e.kind === 'tool.started') {
478
+ if (!['salaam_operator_mcp', 'salaam_station', 'salaam_board'].includes(String(payload.tool)) ||
479
+ typeof payload.tool_call_id !== 'string' ||
480
+ !payload.tool_call_id ||
481
+ payload.parent_tool_call_id)
482
+ return null;
483
+ open.set(payload.tool_call_id, { tool: String(payload.tool), calls: 0 });
484
+ }
485
+ else if (e.kind === 'tool.finished') {
486
+ const started = open.get(String(payload.tool_call_id));
487
+ if (!started || payload.tool !== started.tool)
488
+ return null;
489
+ open.delete(String(payload.tool_call_id));
490
+ }
491
+ else {
492
+ const free = [...open.values()].find((t) => t.tool === 'salaam_operator_mcp' && t.calls === 0);
493
+ if (!free || !['failed', 'tool_error'].includes(String(payload.outcome)))
494
+ return null;
495
+ free.calls++;
496
+ calls.push({ seq: e.seq, ...payload });
497
+ }
498
+ }
499
+ if (open.size)
500
+ return null;
501
+ const units = this.noLedgeredEffects(session, task, { stationReads: true });
502
+ if (!units)
503
+ return null;
504
+ return {
505
+ unhealthy: unhealthy[0],
506
+ interrupted: interrupted[0],
507
+ operator_mcp_calls: calls,
508
+ reads: this.store.db
509
+ .prepare('SELECT id,input_digest FROM station_calls WHERE session_id=? ORDER BY id')
510
+ .all(session),
511
+ unbound_stopped_units: units,
512
+ };
513
+ }
514
+ /** An attempt the task runtime limit stopped while its approved workspace writes were all
515
+ * refused by the protected filesystem before their mutation boundary. Each write is journaled
516
+ * failed_definite: refused by the station helper after its approval, or (the one still waiting
517
+ * when the stop came) cancelled before dispatch with its approval invalidated. Every other
518
+ * station call is a closed read, every operator MCP call is closed inside its own tool, and no
519
+ * tool started after the stop. The refused writes and the reads are the known effects. */
520
+ runtimeStopRefusedWritesFailure(session, task, runtime, attempts) {
521
+ const reason = 'Task runtime limit reached', refused = 'Protected filesystem refused before its mutation boundary', notSent = 'Workspace apply was cancelled before dispatch', attempt = attempts[0];
522
+ if (task.state !== 'blocked' ||
523
+ !runtime ||
524
+ runtime.blocked_reason !== reason ||
525
+ runtime.started_at === null ||
526
+ !Number.isFinite(Number(runtime.started_at)) ||
527
+ Date.now() - Number(runtime.started_at) < task.limits.runtime_ms ||
528
+ runtime.auto_enabled ||
529
+ runtime.reserved_requests ||
530
+ runtime.reserved_tokens ||
531
+ runtime.verification_runs ||
532
+ attempts.length !== 1 ||
533
+ !attempt ||
534
+ attempt.state !== 'cancelled' ||
535
+ attempt.error !== 'Attempt aborted' ||
536
+ attempt.resolved_by !== null ||
537
+ attempt.continues_attempt_id !== null ||
538
+ attempt.result !== null)
539
+ return null;
540
+ const steps = task.steps.filter((s) => s.attempt_id === attempt.id);
541
+ if (steps.length !== 1 ||
542
+ steps[0].state !== 'cancelled' ||
543
+ task.steps.some((s) => s !== steps[0] && (s.state !== 'pending' || s.attempt_id !== null)))
544
+ return null;
545
+ // Stopped by the runtime limit's own task stop, not by anyone.
546
+ const cancellations = this.store.db
547
+ .prepare('SELECT * FROM cancellations WHERE attempt_id=?')
548
+ .all(String(attempt.id));
549
+ if (cancellations.length !== 1 || cancellations[0].requested_by !== 'task-stop')
550
+ return null;
551
+ const events = this.store.db
552
+ .prepare('SELECT seq,kind,payload FROM events WHERE session_id=? ORDER BY seq')
553
+ .all(session);
554
+ const of = (e) => JSON.parse(String(e.payload));
555
+ const blocks = events.filter((e) => e.kind === 'work_task.budget_blocked' && of(e).reason === reason), requested = events.filter((e) => e.kind === 'attempt.cancel_requested'), cancelled = events.filter((e) => e.kind === 'attempt.cancelled');
556
+ if (blocks.length !== 1 ||
557
+ requested.length !== 1 ||
558
+ cancelled.length !== 1 ||
559
+ of(requested[0]).attempt_id !== attempt.id ||
560
+ of(cancelled[0]).attempt_id !== attempt.id ||
561
+ !(Number(blocks[0].seq) < Number(requested[0].seq) &&
562
+ Number(requested[0].seq) < Number(cancelled[0].seq)))
563
+ return null;
564
+ // Every operation is a workspace write on this attempt that never crossed its boundary.
565
+ const ops = this.store.db
566
+ .prepare('SELECT * FROM operations WHERE session_id=? ORDER BY rowid')
567
+ .all(session);
568
+ const calls = this.store.db
569
+ .prepare('SELECT * FROM station_calls WHERE session_id=? ORDER BY rowid')
570
+ .all(session);
571
+ if (!ops.length)
572
+ return null;
573
+ const writes = [];
574
+ for (const op of ops) {
575
+ const approval = this.store.db
576
+ .prepare('SELECT * FROM approvals WHERE operation_id=?')
577
+ .get(String(op.id));
578
+ const linked = calls.filter((c) => c.operation_id === op.id);
579
+ const call = linked[0];
580
+ if (op.kind !== 'workspace' ||
581
+ op.attempt_id !== attempt.id ||
582
+ op.state !== 'failed_definite' ||
583
+ linked.length !== 1 ||
584
+ !call ||
585
+ call.kind !== 'patch' ||
586
+ call.attempt_id !== attempt.id ||
587
+ !approval ||
588
+ approval.operation_digest !== op.operation_digest)
589
+ return null;
590
+ if (op.error === refused) {
591
+ // Approved, sent, and refused by the helper before it could change anything.
592
+ if (approval.state !== 'decided' ||
593
+ approval.decision !== 'approve' ||
594
+ call.state !== 'failed' ||
595
+ call.helper_state !== 'stopped' ||
596
+ !call.result_json ||
597
+ canonical(JSON.parse(String(call.result_json))) !==
598
+ canonical({ code: 'station_files_changed' }))
599
+ return null;
600
+ }
601
+ else if (op.error === notSent) {
602
+ // Still waiting when the stop came: never sent to the helper at all.
603
+ if (approval.state !== 'invalidated' ||
604
+ call.state !== 'cancelled' ||
605
+ call.helper_state !== 'none' ||
606
+ call.result_json !== null)
607
+ return null;
608
+ }
609
+ else
610
+ return null;
611
+ writes.push({
612
+ operation_id: op.id,
613
+ operation_digest: op.operation_digest,
614
+ error: op.error,
615
+ approval: approval.state,
616
+ call_id: call.id,
617
+ call_state: call.state,
618
+ });
619
+ }
620
+ // The remaining station calls are closed reads.
621
+ const reads = calls.filter((c) => c.operation_id === null);
622
+ if (reads.some((c) => c.kind !== 'read' || c.state !== 'returned'))
623
+ return null;
624
+ // Tools: station calls and operator MCP tools, every one closed, none started after the stop,
625
+ // each operator MCP call inside an open operator MCP tool that has no call yet.
626
+ const mcp = [];
627
+ const open = new Map();
628
+ for (const e of events) {
629
+ if (!['tool.started', 'tool.finished', 'operator_mcp.call'].includes(String(e.kind)))
630
+ continue;
631
+ const payload = of(e);
632
+ if (Number(e.seq) >= Number(cancelled[0].seq) || payload.attempt_id !== attempt.id)
633
+ return null;
634
+ if (e.kind === 'tool.started') {
635
+ if (Number(e.seq) >= Number(requested[0].seq) ||
636
+ !['salaam_operator_mcp', 'salaam_station', 'salaam_board'].includes(String(payload.tool)) ||
637
+ typeof payload.tool_call_id !== 'string' ||
638
+ !payload.tool_call_id ||
639
+ payload.parent_tool_call_id)
640
+ return null;
641
+ open.set(payload.tool_call_id, { tool: String(payload.tool), calls: 0 });
642
+ }
643
+ else if (e.kind === 'tool.finished') {
644
+ const started = open.get(String(payload.tool_call_id));
645
+ if (!started || payload.tool !== started.tool)
646
+ return null;
647
+ open.delete(String(payload.tool_call_id));
648
+ }
649
+ else {
650
+ const free = [...open.values()].find((t) => t.tool === 'salaam_operator_mcp' && t.calls === 0);
651
+ if (!free || !['returned', 'failed', 'tool_error'].includes(String(payload.outcome)))
652
+ return null;
653
+ free.calls++;
654
+ mcp.push({ seq: e.seq, ...payload });
655
+ }
656
+ }
657
+ if (open.size)
658
+ return null;
659
+ const units = this.noLedgeredEffects(session, task, { operations: true, stationCalls: true });
660
+ if (!units)
661
+ return null;
662
+ return {
663
+ blocked: blocks[0],
664
+ cancel_requested: requested[0],
665
+ cancelled: cancelled[0],
666
+ refused_writes: writes,
667
+ reads: reads.map((c) => ({ id: c.id, input_digest: c.input_digest })),
668
+ operator_mcp_calls: mcp,
669
+ unbound_stopped_units: units,
670
+ };
671
+ }
672
+ /** An attempt the request-budget control interrupted after its approved work had succeeded:
673
+ * each workspace write approved, applied through exactly one returned patch call and recorded
674
+ * with that call's own receipt, and any review approved and read-only. Every other station call
675
+ * is a closed read or a returned check whose unit stopped with a complete proof, and no tool ran
676
+ * after the stop. The applied writes and the review are the known effects. */
677
+ requestBudgetStopKnownEffectsFailure(session, binding, task, runtime, attempts) {
678
+ const reason = 'Required control failed: No model request budget remains', attempt = attempts.find((a) => a.state === 'interrupted');
679
+ if (task.state !== 'blocked' ||
680
+ !runtime ||
681
+ runtime.auto_enabled ||
682
+ runtime.reserved_requests ||
683
+ runtime.reserved_tokens ||
684
+ runtime.verification_runs ||
685
+ attempts.length !== 1 ||
686
+ !attempt ||
687
+ attempt.error !== reason ||
688
+ attempt.resolved_by !== null ||
689
+ attempt.continues_attempt_id !== null ||
690
+ attempt.result !== null)
691
+ return null;
692
+ const steps = task.steps.filter((s) => s.attempt_id === attempt.id);
693
+ if (steps.length !== 1 ||
694
+ steps[0].state !== 'interrupted' ||
695
+ task.steps.some((s) => s !== steps[0] && (s.state !== 'pending' || s.attempt_id !== null)))
696
+ return null;
697
+ if (this.store.db
698
+ .prepare('SELECT 1 FROM cancellations WHERE attempt_id=?')
699
+ .get(String(attempt.id)))
700
+ return null;
701
+ const events = this.store.db
702
+ .prepare('SELECT seq,kind,payload FROM events WHERE session_id=? ORDER BY seq')
703
+ .all(session);
704
+ const of = (e) => JSON.parse(String(e.payload));
705
+ const unhealthy = events.filter((e) => e.kind === 'worker.unhealthy'), interrupted = events.filter((e) => e.kind === 'attempt.interrupted');
706
+ if (unhealthy.length !== 1 ||
707
+ interrupted.length !== 1 ||
708
+ of(unhealthy[0]).error !== reason ||
709
+ of(interrupted[0]).attempt_id !== attempt.id ||
710
+ of(interrupted[0]).error !== reason ||
711
+ Number(unhealthy[0].seq) >= Number(interrupted[0].seq))
712
+ return null;
713
+ const ops = this.store.db
714
+ .prepare('SELECT * FROM operations WHERE session_id=? ORDER BY rowid')
715
+ .all(session);
716
+ const calls = this.store.db
717
+ .prepare('SELECT * FROM station_calls WHERE session_id=? ORDER BY rowid')
718
+ .all(session);
719
+ if (!ops.length)
720
+ return null;
721
+ const root = digest(binding), effects = [];
722
+ for (const op of ops) {
723
+ const approval = this.store.db
724
+ .prepare('SELECT * FROM approvals WHERE operation_id=?')
725
+ .get(String(op.id));
726
+ const linked = calls.filter((c) => c.operation_id === op.id);
727
+ if (op.attempt_id !== attempt.id ||
728
+ op.state !== 'succeeded' ||
729
+ !approval ||
730
+ approval.state !== 'decided' ||
731
+ approval.decision !== 'approve' ||
732
+ approval.operation_digest !== op.operation_digest ||
733
+ !op.receipt)
734
+ return null;
735
+ if (op.kind === 'workspace') {
736
+ const call = linked[0], effect = JSON.parse(String(op.receipt));
737
+ if (op.resolution !== 'workspace-content-receipt-v1' ||
738
+ // station_calls.operation_id is unique, so a write has at most one call.
739
+ !call ||
740
+ call.kind !== 'patch' ||
741
+ call.state !== 'returned' ||
742
+ call.helper_state !== 'stopped' ||
743
+ call.attempt_id !== attempt.id ||
744
+ !call.result_json ||
745
+ effect.version !== 1 ||
746
+ effect.scope !== 'station-workspace' ||
747
+ effect.call_id !== call.id ||
748
+ effect.root_digest !== root ||
749
+ effect.root_digest !== call.root_digest ||
750
+ effect.configuration_digest !== call.configuration_digest ||
751
+ effect.input_digest !== call.input_digest ||
752
+ canonical(effect.result) !== canonical(JSON.parse(String(call.result_json))) ||
753
+ effect.result?.ok !== true)
754
+ return null;
755
+ effects.push({
756
+ operation_id: op.id,
757
+ operation_digest: op.operation_digest,
758
+ call_id: call.id,
759
+ effect,
760
+ });
761
+ }
762
+ else if (op.kind === 'review') {
763
+ if (op.connector !== 'local-review-v1' ||
764
+ op.replay !== 'read-only' ||
765
+ op.resolution !== 'local-review-receipt' ||
766
+ linked.length)
767
+ return null;
768
+ effects.push({ operation_id: op.id, operation_digest: op.operation_digest, review: true });
769
+ }
770
+ else
771
+ return null;
772
+ }
773
+ // Other station calls: closed reads, or returned checks whose own unit stopped with proof.
774
+ const others = calls.filter((c) => c.operation_id === null);
775
+ if (others.some((c) => c.state !== 'returned' ||
776
+ !(c.kind === 'read' || (c.kind === 'check' && c.helper_state === 'stopped'))))
777
+ return null;
778
+ const checks = others.filter((c) => c.kind === 'check').map((c) => String(c.id));
779
+ const mcp = [];
780
+ const open = new Map();
781
+ for (const e of events) {
782
+ if (!['tool.started', 'tool.finished', 'operator_mcp.call'].includes(String(e.kind)))
783
+ continue;
784
+ const payload = of(e);
785
+ if (Number(e.seq) >= Number(unhealthy[0].seq) || payload.attempt_id !== attempt.id)
786
+ return null;
787
+ if (e.kind === 'tool.started') {
788
+ if (!['salaam_operator_mcp', 'salaam_station', 'salaam_board', 'salaam_review'].includes(String(payload.tool)) ||
789
+ typeof payload.tool_call_id !== 'string' ||
790
+ !payload.tool_call_id ||
791
+ payload.parent_tool_call_id)
792
+ return null;
793
+ open.set(payload.tool_call_id, { tool: String(payload.tool), calls: 0 });
794
+ }
795
+ else if (e.kind === 'tool.finished') {
796
+ const started = open.get(String(payload.tool_call_id));
797
+ if (!started || payload.tool !== started.tool)
798
+ return null;
799
+ open.delete(String(payload.tool_call_id));
800
+ }
801
+ else {
802
+ const free = [...open.values()].find((t) => t.tool === 'salaam_operator_mcp' && t.calls === 0);
803
+ if (!free || !['returned', 'failed', 'tool_error'].includes(String(payload.outcome)))
804
+ return null;
805
+ free.calls++;
806
+ mcp.push({ seq: e.seq, ...payload });
807
+ }
808
+ }
809
+ if (open.size)
810
+ return null;
811
+ const units = this.noLedgeredEffects(session, task, {
812
+ operations: true,
813
+ stationCalls: true,
814
+ checkUnits: checks,
815
+ });
816
+ if (!units)
817
+ return null;
818
+ return {
819
+ unhealthy: unhealthy[0],
820
+ interrupted: interrupted[0],
821
+ known_effects: effects,
822
+ station_calls: others.map((c) => ({ id: c.id, kind: c.kind, input_digest: c.input_digest })),
823
+ operator_mcp_calls: mcp,
824
+ unbound_stopped_units: units,
825
+ };
826
+ }
292
827
  /** A disjoint failure profile: the native child was denied before any effect
293
828
  * ledger was created. Stopped resources alone never establish zero effects. */
294
829
  zeroEffectConcurrencyFailure(session, task, runtime, attempts) {
@@ -369,6 +904,21 @@ export class AgentPodWork {
369
904
  links[0].plan_revision !== task.plan_revision ||
370
905
  links[0].step_id !== task.steps.find((s) => s.attempt_id === attempt.id)?.id)
371
906
  return null;
907
+ const units = this.noLedgeredEffects(session, task);
908
+ if (!units)
909
+ return null;
910
+ return {
911
+ block: blocks[0],
912
+ activity,
913
+ model: models[0],
914
+ policy: policies[0],
915
+ link: links[0],
916
+ unbound_stopped_units: units,
917
+ };
918
+ }
919
+ /** Every execution/effect ledger, including read calls, is empty for this session and task,
920
+ * and any station unit is unbound with a complete durable stop proof. Returns those units. */
921
+ noLedgeredEffects(session, task, checked = {}) {
372
922
  // These are all independent execution/effect ledgers, including read calls.
373
923
  for (const table of [
374
924
  'operations',
@@ -379,6 +929,17 @@ export class AgentPodWork {
379
929
  'local_calls',
380
930
  'command_runs',
381
931
  ]) {
932
+ // A caller that proves its own operations or closed reads checks those itself.
933
+ if (table === 'operations' && checked.operations)
934
+ continue;
935
+ if (table === 'station_calls' && checked.stationCalls)
936
+ continue;
937
+ if (table === 'station_calls' &&
938
+ checked.stationReads &&
939
+ !this.store.db
940
+ .prepare("SELECT 1 FROM station_calls WHERE session_id=? AND NOT (kind='read' AND state='returned')")
941
+ .get(session))
942
+ continue;
382
943
  if (this.store.db.prepare(`SELECT 1 FROM ${table} WHERE session_id=?`).get(session))
383
944
  return null;
384
945
  }
@@ -420,8 +981,11 @@ export class AgentPodWork {
420
981
  .prepare('SELECT * FROM station_units WHERE session_id=? OR session_id IS NULL')
421
982
  .all(session);
422
983
  for (const unit of units) {
423
- if (unit.session_id !== null ||
424
- unit.call_id !== null ||
984
+ // A proven returned check's own unit belongs to this session; it still needs its stop proof.
985
+ const ownCheck = unit.session_id === session &&
986
+ typeof unit.call_id === 'string' &&
987
+ !!checked.checkUnits?.includes(unit.call_id);
988
+ if ((!ownCheck && (unit.session_id !== null || unit.call_id !== null)) ||
425
989
  unit.state !== 'stopped' ||
426
990
  !unit.stop_json)
427
991
  return null;
@@ -434,11 +998,139 @@ export class AgentPodWork {
434
998
  !/^[a-f0-9]{32}$/.test(stop.invocation))
435
999
  return null;
436
1000
  }
1001
+ return units;
1002
+ }
1003
+ /** An attempt the owner's authority revocation cancelled (for example a hub restart) before
1004
+ * any ledgered effect. The only tool activity allowed is closed operator MCP calls, each
1005
+ * journaled with argument and result digests; they are preserved as the known effects. */
1006
+ revokeCancelledFailure(session, task, runtime, attempts) {
1007
+ const attempt = attempts[0];
1008
+ if (task.state !== 'blocked' ||
1009
+ !runtime ||
1010
+ runtime.auto_enabled ||
1011
+ runtime.reserved_requests ||
1012
+ runtime.reserved_tokens ||
1013
+ runtime.verification_runs ||
1014
+ attempts.length !== 1 ||
1015
+ !attempt ||
1016
+ attempt.state !== 'cancelled' ||
1017
+ attempt.resolved_by !== null ||
1018
+ attempt.continues_attempt_id !== null ||
1019
+ attempt.result !== null ||
1020
+ !attempt.outcome)
1021
+ return null;
1022
+ const outcome = JSON.parse(String(attempt.outcome));
1023
+ if (outcome.version !== 1 ||
1024
+ outcome.stopReason !== 'cancelled' ||
1025
+ outcome.failure?.kind !== 'cancelled' ||
1026
+ outcome.failure.code !== 'attempt_cancelled' ||
1027
+ outcome.failure.retryable !== false ||
1028
+ outcome.failure.sessionId !== session ||
1029
+ outcome.failure.attemptId !== attempt.id)
1030
+ return null;
1031
+ const steps = task.steps.filter((s) => s.attempt_id === attempt.id);
1032
+ if (steps.length !== 1 ||
1033
+ steps[0].state !== 'cancelled' ||
1034
+ task.steps.some((s) => s !== steps[0] && (s.state !== 'pending' || s.attempt_id !== null)))
1035
+ return null;
1036
+ const links = this.store.db
1037
+ .prepare('SELECT b.* FROM task_step_attempts b JOIN task_steps s ON s.id=b.step_id WHERE s.task_id=?')
1038
+ .all(task.id);
1039
+ if (links.length !== 1 ||
1040
+ links[0].attempt_id !== attempt.id ||
1041
+ links[0].plan_revision !== task.plan_revision ||
1042
+ links[0].step_id !== steps[0].id)
1043
+ return null;
1044
+ // The cancellation came from the node's revocation of this session, not a local operator.
1045
+ const cancellations = this.store.db
1046
+ .prepare('SELECT * FROM cancellations WHERE attempt_id=?')
1047
+ .all(String(attempt.id));
1048
+ const revocations = this.store.db
1049
+ .prepare('SELECT * FROM session_revocations WHERE session_id=?')
1050
+ .all(session);
1051
+ if (cancellations.length !== 1 ||
1052
+ cancellations[0].requested_by !== 'agentpod-node' ||
1053
+ revocations.length !== 1)
1054
+ return null;
1055
+ const revocation = JSON.parse(String(revocations[0].receipt));
1056
+ if (revocation.revoked !== true ||
1057
+ revocation.session_id !== session ||
1058
+ canonical(revocation.session_ids) !== canonical([session]) ||
1059
+ canonical(revocation.attempt_ids) !== canonical([attempt.id]) ||
1060
+ canonical(revocation.unresolved_operations) !== canonical([]) ||
1061
+ typeof revocation.request_id !== 'string' ||
1062
+ !revocation.request_id)
1063
+ return null;
1064
+ const marks = this.store.db
1065
+ .prepare("SELECT seq,kind,payload,causation_id FROM events WHERE session_id=? AND kind IN ('attempt.cancel_requested','execution.revoked','attempt.cancelled') ORDER BY seq")
1066
+ .all(session);
1067
+ const only = (kind) => {
1068
+ const found = marks.filter((e) => e.kind === kind);
1069
+ return found.length === 1 ? found[0] : null;
1070
+ };
1071
+ const requested = only('attempt.cancel_requested'), revoked = only('execution.revoked'), cancelled = only('attempt.cancelled');
1072
+ if (marks.length !== 3 ||
1073
+ !requested ||
1074
+ !revoked ||
1075
+ !cancelled ||
1076
+ requested.causation_id !== 'agentpod-revocation' ||
1077
+ JSON.parse(String(requested.payload)).attempt_id !== attempt.id ||
1078
+ revoked.causation_id !== revocation.request_id ||
1079
+ JSON.parse(String(revoked.payload)).owner_session !== session ||
1080
+ JSON.parse(String(cancelled.payload)).attempt_id !== attempt.id ||
1081
+ !(Number(requested.seq) < Number(revoked.seq) && Number(revoked.seq) < Number(cancelled.seq)))
1082
+ return null;
1083
+ // Tool activity: only operator MCP tools, each closed before the revocation, each holding
1084
+ // at most one journaled call (none for a catalog read), and no call outside a tool.
1085
+ const activity = this.store.db
1086
+ .prepare("SELECT seq,kind,payload FROM events WHERE session_id=? AND kind IN ('tool.started','tool.finished','operator_mcp.call','policy.allowed','policy.denied') ORDER BY seq")
1087
+ .all(session);
1088
+ const calls = [];
1089
+ let open = null;
1090
+ for (const event of activity) {
1091
+ const payload = JSON.parse(String(event.payload));
1092
+ if (Number(event.seq) >= Number(requested.seq))
1093
+ return null;
1094
+ if (payload.attempt_id !== attempt.id)
1095
+ return null;
1096
+ if (event.kind === 'tool.started') {
1097
+ if (open ||
1098
+ payload.tool !== 'salaam_operator_mcp' ||
1099
+ typeof payload.tool_call_id !== 'string' ||
1100
+ !payload.tool_call_id ||
1101
+ payload.parent_tool_call_id)
1102
+ return null;
1103
+ open = { id: payload.tool_call_id, calls: 0 };
1104
+ }
1105
+ else if (event.kind === 'tool.finished') {
1106
+ if (!open || payload.tool_call_id !== open.id || payload.tool !== 'salaam_operator_mcp')
1107
+ return null;
1108
+ open = null;
1109
+ }
1110
+ else if (event.kind === 'operator_mcp.call') {
1111
+ if (!open ||
1112
+ ++open.calls > 1 ||
1113
+ !['returned', 'tool_error', 'failed'].includes(String(payload.outcome)))
1114
+ return null;
1115
+ calls.push({ seq: event.seq, ...payload });
1116
+ }
1117
+ else if (event.kind === 'policy.allowed') {
1118
+ if (!open || payload.tool !== 'salaam_operator_mcp')
1119
+ return null;
1120
+ }
1121
+ else
1122
+ return null;
1123
+ }
1124
+ if (open)
1125
+ return null;
1126
+ const units = this.noLedgeredEffects(session, task);
1127
+ if (!units)
1128
+ return null;
437
1129
  return {
438
- block: blocks[0],
439
- activity,
440
- model: models[0],
441
- policy: policies[0],
1130
+ revocation,
1131
+ cancellation: cancellations[0],
1132
+ events: marks,
1133
+ operator_mcp_calls: calls,
442
1134
  link: links[0],
443
1135
  unbound_stopped_units: units,
444
1136
  };
@@ -1093,6 +1785,7 @@ export class AgentPodWork {
1093
1785
  objective: task.objective,
1094
1786
  constraints: task.constraints,
1095
1787
  acceptance: task.acceptance,
1788
+ ...(task.completion_gate ? { completion_gate: task.completion_gate } : {}),
1096
1789
  deliverables: task.deliverables,
1097
1790
  limits: task.limits,
1098
1791
  steps: task.steps.map(({ key, title, instruction, depends_on, checks }) => ({
@@ -1121,7 +1814,7 @@ export class AgentPodWork {
1121
1814
  .get(String(check.id));
1122
1815
  require(saved);
1123
1816
  const bindingArtifact = JSON.parse(String(saved.response)), bound = artifact(bindingArtifact, session);
1124
- const call = calls.find((c) => c.id === bound.call_id), configured = task.acceptance.find((c) => c.key === check.check_key);
1817
+ const call = calls.find((c) => c.id === bound.call_id), configured = requiredChecks(task).find((c) => c.key === check.check_key);
1125
1818
  require(check.state === 'passed' &&
1126
1819
  check.plan_revision === task.plan_revision &&
1127
1820
  check.engine_id === 'linux-systemd-station-v1' &&
@@ -1431,15 +2124,7 @@ export class AgentPodWork {
1431
2124
  const { root, row, task } = this.owned(binding);
1432
2125
  if (workRequest !== row.request_id || !/^[a-f0-9]{64}$/.test(terminalDigest))
1433
2126
  throw new ServiceError('work_settlement_conflict', 'Exact original work and terminal digest required');
1434
- const stopped = () => {
1435
- const current = this.authority.inspect(binding);
1436
- if (!current.revoked ||
1437
- current.authorized ||
1438
- !current.owned_execution_stopped ||
1439
- current.unresolved_operations.length ||
1440
- current.active_attempts.length)
1441
- throw new ServiceError('work_settlement_unsafe', 'Revoked stopped root with known effects required');
1442
- };
2127
+ const stopped = () => this.assertKnownEffectsStop(binding);
1443
2128
  stopped();
1444
2129
  return this.store.request('agentpod-work-failure', workRequest, {
1445
2130
  binding: root.binding,
@@ -1449,112 +2134,7 @@ export class AgentPodWork {
1449
2134
  }, () => {
1450
2135
  stopped();
1451
2136
  this.assertPlan(task, JSON.parse(String(row.admission_json)));
1452
- const runtime = this.store.db
1453
- .prepare('SELECT * FROM task_runtime WHERE task_id=?')
1454
- .get(task.id);
1455
- const attempts = this.store.db
1456
- .prepare('SELECT * FROM attempts WHERE session_id=?')
1457
- .all(root.session_id);
1458
- const ops = this.store.db
1459
- .prepare('SELECT * FROM operations WHERE session_id=?')
1460
- .all(root.session_id);
1461
- const calls = this.store.db
1462
- .prepare("SELECT * FROM station_calls WHERE session_id=? AND kind!='read'")
1463
- .all(root.session_id);
1464
- const op = ops[0], attempt = attempts[0], call = calls[0];
1465
- const approval = op
1466
- ? this.store.db.prepare('SELECT * FROM approvals WHERE operation_id=?').get(String(op.id))
1467
- : undefined;
1468
- const effect = op?.receipt ? JSON.parse(String(op.receipt)) : null;
1469
- const bound = 'Provider payload exceeds declared per-request token bounds';
1470
- const originalBlock = this.store.db
1471
- .prepare("SELECT seq,payload FROM events WHERE session_id=? AND kind='work_task.budget_blocked' AND json_extract(payload,'$.task_id')=? AND json_extract(payload,'$.reason')=? ORDER BY seq LIMIT 1")
1472
- .get(root.session_id, task.id, bound);
1473
- const runtimeExpired = runtime?.blocked_reason === 'Task runtime limit reached' &&
1474
- runtime.started_at !== null &&
1475
- Number.isFinite(Number(runtime.started_at)) &&
1476
- Date.now() - Number(runtime.started_at) >= task.limits.runtime_ms;
1477
- const savedInvalidatedOffer = approval?.state === 'invalidated'
1478
- ? this.store.db
1479
- .prepare("SELECT response FROM requests WHERE actor='agentpod-work-decision-offer' AND id=?")
1480
- .get(String(approval.id))
1481
- : undefined;
1482
- const knownReadOnly = this.historicalKnownReadOnlyFailure(root.binding, task, runtime) ??
1483
- (ops.length === 1 && op && approval && savedInvalidatedOffer
1484
- ? this.historicalKnownReadOnlyFailure(root.binding, task, runtime, {
1485
- operation: op,
1486
- approval,
1487
- offered: JSON.parse(String(savedInvalidatedOffer.response)),
1488
- invalidated: true,
1489
- })
1490
- : null) ??
1491
- (ops.length === 1 && op && approval
1492
- ? this.historicalKnownReadOnlyFailure(root.binding, task, runtime, undefined, {
1493
- operation: op,
1494
- approval,
1495
- })
1496
- : null);
1497
- const zeroEffect = this.zeroEffectConcurrencyFailure(root.session_id, task, runtime, attempts);
1498
- if (!zeroEffect &&
1499
- !knownReadOnly &&
1500
- (task.state !== 'blocked' ||
1501
- !runtime ||
1502
- !originalBlock ||
1503
- (runtime.blocked_reason !== bound && !runtimeExpired) ||
1504
- attempt?.error !== 'Required control failed: ' + bound ||
1505
- runtime.auto_enabled ||
1506
- runtime.reserved_requests ||
1507
- runtime.reserved_tokens ||
1508
- runtime.verification_runs ||
1509
- attempts.length !== 1 ||
1510
- attempt?.state !== 'interrupted' ||
1511
- attempt.resolved_by !== null ||
1512
- ops.length !== 1 ||
1513
- op?.kind !== 'workspace' ||
1514
- op.state !== 'succeeded' ||
1515
- op.attempt_id !== attempt.id ||
1516
- op.resolution !== 'workspace-content-receipt-v1' ||
1517
- calls.length !== 1 ||
1518
- call?.kind !== 'patch' ||
1519
- call.state !== 'returned' ||
1520
- call.helper_state !== 'stopped' ||
1521
- call.operation_id !== op.id ||
1522
- call.attempt_id !== attempt.id ||
1523
- !call.result_json ||
1524
- !approval ||
1525
- approval.state !== 'decided' ||
1526
- approval.decision !== 'approve' ||
1527
- approval.operation_digest !== op.operation_digest ||
1528
- !effect ||
1529
- effect.version !== 1 ||
1530
- effect.scope !== 'station-workspace' ||
1531
- effect.call_id !== call.id ||
1532
- effect.root_digest !== digest(root.binding) ||
1533
- effect.root_digest !== call.root_digest ||
1534
- effect.configuration_digest !== call.configuration_digest ||
1535
- effect.input_digest !== call.input_digest ||
1536
- canonical(effect.result) !== canonical(JSON.parse(String(call.result_json))) ||
1537
- effect.result?.ok !== true ||
1538
- task.steps.filter((s) => s.attempt_id === attempt.id && s.state === 'interrupted')
1539
- .length !== 1 ||
1540
- task.steps.some((s) => !['pending', 'interrupted'].includes(s.state)) ||
1541
- this.store.db.prepare('SELECT 1 FROM task_checks WHERE task_id=?').get(task.id) ||
1542
- this.store.db
1543
- .prepare('SELECT 1 FROM child_tasks WHERE parent_session=?')
1544
- .get(root.session_id)))
1545
- throw new ServiceError('work_settlement_unsafe', 'Only exact known approved-patch, zero-effect child denial, or closed historical read-only evidence can retire');
1546
- const evidence_digest = digest({
1547
- binding: root.binding,
1548
- task_id: task.id,
1549
- plan_revision: task.plan_revision,
1550
- blocked_reason: runtime.blocked_reason,
1551
- attempt,
1552
- operation: op ?? null,
1553
- approval: approval ?? null,
1554
- call: call ?? null,
1555
- ...(zeroEffect ? { zero_effect_concurrency: zeroEffect } : {}),
1556
- ...(knownReadOnly ? { historical_known_readonly: knownReadOnly } : {}),
1557
- });
2137
+ const evidence_digest = this.failureEvidence(root, task);
1558
2138
  driver.cancelInTransaction(task.id, task.revision, stopped);
1559
2139
  const receipt = {
1560
2140
  version: 1,
@@ -1573,6 +2153,368 @@ export class AgentPodWork {
1573
2153
  return receipt;
1574
2154
  });
1575
2155
  }
2156
+ /** A revoked root whose execution stopped with nothing unresolved or still running. */
2157
+ assertKnownEffectsStop(binding) {
2158
+ const current = this.authority.inspect(binding);
2159
+ if (!current.revoked ||
2160
+ current.authorized ||
2161
+ !current.owned_execution_stopped ||
2162
+ current.unresolved_operations.length ||
2163
+ current.active_attempts.length)
2164
+ throw new ServiceError('work_settlement_unsafe', 'Revoked stopped root with known effects required');
2165
+ }
2166
+ /** The evidence a known-effects retirement records, or work_settlement_unsafe when the stopped
2167
+ * run is not one of the exact shapes Salaam can prove. Reads only; fail() and status share it. */
2168
+ /** A task the provider rate limit stopped after its retries (RATE_LIMIT_STOP). Everything is
2169
+ * closed and definite: every attempt completed or failed, the one failed step's last attempt
2170
+ * rate-limited and recorded by the driver, every operation succeeded or failed definitely with
2171
+ * no approval pending, every station call settled with its helper stopped, no children. */
2172
+ rateLimitStopFailure(session, task, runtime, attempts) {
2173
+ if (!['blocked', 'partially_completed'].includes(task.state) ||
2174
+ !runtime ||
2175
+ runtime.blocked_reason !== RATE_LIMIT_STOP ||
2176
+ runtime.auto_enabled ||
2177
+ runtime.reserved_requests ||
2178
+ runtime.reserved_tokens ||
2179
+ attempts.some((a) => !['completed', 'failed'].includes(String(a.state))))
2180
+ return null;
2181
+ const failed = task.steps.filter((s) => s.state === 'failed');
2182
+ if (failed.length !== 1 ||
2183
+ task.steps.some((s) => !['completed', 'failed', 'pending'].includes(s.state)))
2184
+ return null;
2185
+ const last = String(failed[0].attempt_id ?? '');
2186
+ const limits = new RateLimitRetries(this.store);
2187
+ const recorded = this.store.db
2188
+ .prepare('SELECT attempt_id,failures FROM task_rate_limits WHERE task_id=? AND step_id=? ORDER BY failures')
2189
+ .all(task.id, failed[0].id);
2190
+ if (!limits.rateLimited(last) || !recorded.some((r) => r.attempt_id === last))
2191
+ return null;
2192
+ const db = this.store.db;
2193
+ const operations = db
2194
+ .prepare('SELECT id,state FROM operations WHERE session_id=? ORDER BY rowid')
2195
+ .all(session);
2196
+ const calls = db
2197
+ .prepare('SELECT id,kind,state,helper_state FROM station_calls WHERE session_id=? ORDER BY rowid')
2198
+ .all(session);
2199
+ if (operations.some((o) => !['succeeded', 'failed_definite'].includes(String(o.state))) ||
2200
+ db.prepare("SELECT 1 FROM approvals WHERE session_id=? AND state='pending'").get(session) ||
2201
+ calls.some((c) => ['prepared', 'dispatching', 'outcome_unknown'].includes(String(c.state)) ||
2202
+ !['none', 'stopped'].includes(String(c.helper_state))) ||
2203
+ db.prepare('SELECT 1 FROM child_tasks WHERE parent_session=?').get(session))
2204
+ return null;
2205
+ return {
2206
+ step_id: failed[0].id,
2207
+ attempts: attempts.map((a) => ({ id: a.id, state: a.state })),
2208
+ rate_limited: recorded,
2209
+ operations,
2210
+ calls,
2211
+ };
2212
+ }
2213
+ /** A task its verification budget stopped after every step finished (staging run_54648a07).
2214
+ * Everything is closed and definite: every attempt completed or failed, every step completed or
2215
+ * failed, every operation succeeded or failed definitely with no approval pending, every station
2216
+ * call settled with its helper stopped, nothing reserved, no children. */
2217
+ /** Which part of the verification-budget stop shape a run misses: a closed set of codes, so a
2218
+ * refusal can say why without carrying any data. */
2219
+ static VERIFICATION_LIMIT_MISSES = [
2220
+ 'vl:task_state',
2221
+ 'vl:reason',
2222
+ 'vl:auto',
2223
+ 'vl:reserved',
2224
+ 'vl:attempts',
2225
+ 'vl:steps',
2226
+ 'vl:operations',
2227
+ 'vl:approvals',
2228
+ 'vl:calls',
2229
+ 'vl:children',
2230
+ ];
2231
+ verificationLimitMiss = null;
2232
+ verificationLimitStopFailure(session, task, runtime, attempts) {
2233
+ const miss = (code) => {
2234
+ this.verificationLimitMiss = code;
2235
+ return null;
2236
+ };
2237
+ this.verificationLimitMiss = null;
2238
+ // The stop is the verification budget's: the current reason, or the run's first budget block
2239
+ // when its runtime limit overwrote the reason later while it sat parked (staging run_54648a07).
2240
+ const firstBlock = this.store.db
2241
+ .prepare("SELECT json_extract(payload,'$.reason') AS reason FROM events WHERE session_id=? AND kind='work_task.budget_blocked' AND json_extract(payload,'$.task_id')=? ORDER BY seq LIMIT 1")
2242
+ .get(session, task.id);
2243
+ if (!runtime ||
2244
+ !(runtime.blocked_reason === 'Task verification run limit reached' ||
2245
+ (runtime.blocked_reason === 'Task runtime limit reached' &&
2246
+ firstBlock?.reason === 'Task verification run limit reached')))
2247
+ return miss('vl:reason');
2248
+ if (!['blocked', 'partially_completed'].includes(task.state))
2249
+ return miss('vl:task_state');
2250
+ if (runtime.auto_enabled)
2251
+ return miss('vl:auto');
2252
+ if (runtime.reserved_requests || runtime.reserved_tokens)
2253
+ return miss('vl:reserved');
2254
+ if (!attempts.length ||
2255
+ attempts.some((a) => !['completed', 'failed'].includes(String(a.state))))
2256
+ return miss('vl:attempts');
2257
+ if (task.steps.some((s) => !['completed', 'failed'].includes(s.state)))
2258
+ return miss('vl:steps');
2259
+ const db = this.store.db;
2260
+ const operations = db
2261
+ .prepare('SELECT id,state FROM operations WHERE session_id=? ORDER BY rowid')
2262
+ .all(session);
2263
+ const calls = db
2264
+ .prepare('SELECT id,kind,state,helper_state FROM station_calls WHERE session_id=? ORDER BY rowid')
2265
+ .all(session);
2266
+ if (operations.some((o) => !['succeeded', 'failed_definite'].includes(String(o.state))))
2267
+ return miss('vl:operations');
2268
+ if (db.prepare("SELECT 1 FROM approvals WHERE session_id=? AND state='pending'").get(session))
2269
+ return miss('vl:approvals');
2270
+ if (calls.some((c) => ['prepared', 'dispatching', 'outcome_unknown'].includes(String(c.state)) ||
2271
+ !['none', 'stopped'].includes(String(c.helper_state))))
2272
+ return miss('vl:calls');
2273
+ if (db.prepare('SELECT 1 FROM child_tasks WHERE parent_session=?').get(session))
2274
+ return miss('vl:children');
2275
+ return {
2276
+ steps: task.steps.map((s) => ({ id: s.id, state: s.state })),
2277
+ attempts: attempts.map((a) => ({ id: a.id, state: a.state })),
2278
+ operations,
2279
+ calls,
2280
+ };
2281
+ }
2282
+ /** A task its own acceptance check stopped (staging card_d74eac4509f1453c): the latest receipt
2283
+ * for the check the stop names failed definitely, and the run is as closed as a
2284
+ * verification-budget stop. The board then gets a failed run with that reason, not a park. */
2285
+ static CHECK_FAILED_MISSES = [
2286
+ 'cf:reason',
2287
+ 'cf:check',
2288
+ 'cf:task_state',
2289
+ 'cf:auto',
2290
+ 'cf:reserved',
2291
+ 'cf:attempts',
2292
+ 'cf:steps',
2293
+ 'cf:operations',
2294
+ 'cf:approvals',
2295
+ 'cf:calls',
2296
+ 'cf:children',
2297
+ ];
2298
+ checkFailedMiss = null;
2299
+ checkFailedStopFailure(session, task, runtime, attempts) {
2300
+ const miss = (code) => {
2301
+ this.checkFailedMiss = code;
2302
+ return null;
2303
+ };
2304
+ this.checkFailedMiss = null;
2305
+ const prefix = 'Verification failed or became stale: ';
2306
+ const reason = String(runtime?.auto_error ?? '');
2307
+ if (!runtime || runtime.blocked_reason || !reason.startsWith(prefix))
2308
+ return miss('cf:reason');
2309
+ const db = this.store.db;
2310
+ const check = db
2311
+ .prepare('SELECT id,state FROM task_checks WHERE task_id=? AND check_key=? AND plan_revision=? ORDER BY rowid DESC LIMIT 1')
2312
+ .get(task.id, reason.slice(prefix.length), task.plan_revision);
2313
+ if (check?.state !== 'failed')
2314
+ return miss('cf:check');
2315
+ if (!['blocked', 'partially_completed'].includes(task.state))
2316
+ return miss('cf:task_state');
2317
+ if (runtime.auto_enabled)
2318
+ return miss('cf:auto');
2319
+ if (runtime.reserved_requests || runtime.reserved_tokens)
2320
+ return miss('cf:reserved');
2321
+ if (!attempts.length ||
2322
+ attempts.some((a) => !['completed', 'failed'].includes(String(a.state))))
2323
+ return miss('cf:attempts');
2324
+ if (task.steps.some((s) => !['completed', 'failed'].includes(s.state)))
2325
+ return miss('cf:steps');
2326
+ const operations = db
2327
+ .prepare('SELECT id,state FROM operations WHERE session_id=? ORDER BY rowid')
2328
+ .all(session);
2329
+ const calls = db
2330
+ .prepare('SELECT id,kind,state,helper_state FROM station_calls WHERE session_id=? ORDER BY rowid')
2331
+ .all(session);
2332
+ if (operations.some((o) => !['succeeded', 'failed_definite'].includes(String(o.state))))
2333
+ return miss('cf:operations');
2334
+ if (db.prepare("SELECT 1 FROM approvals WHERE session_id=? AND state='pending'").get(session))
2335
+ return miss('cf:approvals');
2336
+ if (calls.some((c) => ['prepared', 'dispatching', 'outcome_unknown'].includes(String(c.state)) ||
2337
+ !['none', 'stopped'].includes(String(c.helper_state))))
2338
+ return miss('cf:calls');
2339
+ if (db.prepare('SELECT 1 FROM child_tasks WHERE parent_session=?').get(session))
2340
+ return miss('cf:children');
2341
+ return {
2342
+ reason,
2343
+ check: check.id,
2344
+ steps: task.steps.map((s) => ({ id: s.id, state: s.state })),
2345
+ attempts: attempts.map((a) => ({ id: a.id, state: a.state })),
2346
+ operations,
2347
+ calls,
2348
+ };
2349
+ }
2350
+ failureEvidence(root, task) {
2351
+ const runtime = this.store.db
2352
+ .prepare('SELECT * FROM task_runtime WHERE task_id=?')
2353
+ .get(task.id);
2354
+ const attempts = this.store.db
2355
+ .prepare('SELECT * FROM attempts WHERE session_id=?')
2356
+ .all(root.session_id);
2357
+ const ops = this.store.db
2358
+ .prepare('SELECT * FROM operations WHERE session_id=?')
2359
+ .all(root.session_id);
2360
+ const calls = this.store.db
2361
+ .prepare("SELECT * FROM station_calls WHERE session_id=? AND kind!='read'")
2362
+ .all(root.session_id);
2363
+ const op = ops[0], attempt = attempts[0], call = calls[0];
2364
+ const approval = op
2365
+ ? this.store.db.prepare('SELECT * FROM approvals WHERE operation_id=?').get(String(op.id))
2366
+ : undefined;
2367
+ const effect = op?.receipt ? JSON.parse(String(op.receipt)) : null;
2368
+ const bound = 'Provider payload exceeds declared per-request token bounds';
2369
+ const originalBlock = this.store.db
2370
+ .prepare("SELECT seq,payload FROM events WHERE session_id=? AND kind='work_task.budget_blocked' AND json_extract(payload,'$.task_id')=? AND json_extract(payload,'$.reason')=? ORDER BY seq LIMIT 1")
2371
+ .get(root.session_id, task.id, bound);
2372
+ const runtimeExpired = runtime?.blocked_reason === 'Task runtime limit reached' &&
2373
+ runtime.started_at !== null &&
2374
+ Number.isFinite(Number(runtime.started_at)) &&
2375
+ Date.now() - Number(runtime.started_at) >= task.limits.runtime_ms;
2376
+ const savedInvalidatedOffer = approval?.state === 'invalidated'
2377
+ ? this.store.db
2378
+ .prepare("SELECT response FROM requests WHERE actor='agentpod-work-decision-offer' AND id=?")
2379
+ .get(String(approval.id))
2380
+ : undefined;
2381
+ const knownReadOnly = this.historicalKnownReadOnlyFailure(root.binding, task, runtime) ??
2382
+ (ops.length === 1 && op && approval && savedInvalidatedOffer
2383
+ ? this.historicalKnownReadOnlyFailure(root.binding, task, runtime, {
2384
+ operation: op,
2385
+ approval,
2386
+ offered: JSON.parse(String(savedInvalidatedOffer.response)),
2387
+ invalidated: true,
2388
+ })
2389
+ : null) ??
2390
+ (ops.length === 1 && op && approval
2391
+ ? this.historicalKnownReadOnlyFailure(root.binding, task, runtime, undefined, {
2392
+ operation: op,
2393
+ approval,
2394
+ })
2395
+ : null);
2396
+ const zeroEffect = this.zeroEffectConcurrencyFailure(root.session_id, task, runtime, attempts);
2397
+ const revokeCancelled = zeroEffect
2398
+ ? null
2399
+ : this.revokeCancelledFailure(root.session_id, task, runtime, attempts);
2400
+ const budgetStop = zeroEffect || revokeCancelled
2401
+ ? null
2402
+ : this.requestBudgetStopFailure(root.session_id, task, runtime, attempts);
2403
+ const refusedWrites = zeroEffect || revokeCancelled || budgetStop
2404
+ ? null
2405
+ : this.runtimeStopRefusedWritesFailure(root.session_id, task, runtime, attempts);
2406
+ const budgetKnown = zeroEffect || revokeCancelled || budgetStop || refusedWrites
2407
+ ? null
2408
+ : this.requestBudgetStopKnownEffectsFailure(root.session_id, root.binding, task, runtime, attempts);
2409
+ const rateLimit = zeroEffect || revokeCancelled || budgetStop || refusedWrites || budgetKnown
2410
+ ? null
2411
+ : this.rateLimitStopFailure(root.session_id, task, runtime, attempts);
2412
+ const verificationLimit = zeroEffect || revokeCancelled || budgetStop || refusedWrites || budgetKnown || rateLimit
2413
+ ? null
2414
+ : this.verificationLimitStopFailure(root.session_id, task, runtime, attempts);
2415
+ const checkFailed = zeroEffect ||
2416
+ revokeCancelled ||
2417
+ budgetStop ||
2418
+ refusedWrites ||
2419
+ budgetKnown ||
2420
+ rateLimit ||
2421
+ verificationLimit
2422
+ ? null
2423
+ : this.checkFailedStopFailure(root.session_id, task, runtime, attempts);
2424
+ if (!zeroEffect &&
2425
+ !revokeCancelled &&
2426
+ !budgetStop &&
2427
+ !refusedWrites &&
2428
+ !rateLimit &&
2429
+ !verificationLimit &&
2430
+ !checkFailed &&
2431
+ !budgetKnown &&
2432
+ !knownReadOnly &&
2433
+ (task.state !== 'blocked' ||
2434
+ !runtime ||
2435
+ !originalBlock ||
2436
+ (runtime.blocked_reason !== bound && !runtimeExpired) ||
2437
+ attempt?.error !== 'Required control failed: ' + bound ||
2438
+ runtime.auto_enabled ||
2439
+ runtime.reserved_requests ||
2440
+ runtime.reserved_tokens ||
2441
+ runtime.verification_runs ||
2442
+ attempts.length !== 1 ||
2443
+ attempt?.state !== 'interrupted' ||
2444
+ attempt.resolved_by !== null ||
2445
+ ops.length !== 1 ||
2446
+ op?.kind !== 'workspace' ||
2447
+ op.state !== 'succeeded' ||
2448
+ op.attempt_id !== attempt.id ||
2449
+ op.resolution !== 'workspace-content-receipt-v1' ||
2450
+ calls.length !== 1 ||
2451
+ call?.kind !== 'patch' ||
2452
+ call.state !== 'returned' ||
2453
+ call.helper_state !== 'stopped' ||
2454
+ call.operation_id !== op.id ||
2455
+ call.attempt_id !== attempt.id ||
2456
+ !call.result_json ||
2457
+ !approval ||
2458
+ approval.state !== 'decided' ||
2459
+ approval.decision !== 'approve' ||
2460
+ approval.operation_digest !== op.operation_digest ||
2461
+ !effect ||
2462
+ effect.version !== 1 ||
2463
+ effect.scope !== 'station-workspace' ||
2464
+ effect.call_id !== call.id ||
2465
+ effect.root_digest !== digest(root.binding) ||
2466
+ effect.root_digest !== call.root_digest ||
2467
+ effect.configuration_digest !== call.configuration_digest ||
2468
+ effect.input_digest !== call.input_digest ||
2469
+ canonical(effect.result) !== canonical(JSON.parse(String(call.result_json))) ||
2470
+ effect.result?.ok !== true ||
2471
+ task.steps.filter((s) => s.attempt_id === attempt.id && s.state === 'interrupted')
2472
+ .length !== 1 ||
2473
+ task.steps.some((s) => !['pending', 'interrupted'].includes(s.state)) ||
2474
+ this.store.db.prepare('SELECT 1 FROM task_checks WHERE task_id=?').get(task.id) ||
2475
+ this.store.db
2476
+ .prepare('SELECT 1 FROM child_tasks WHERE parent_session=?')
2477
+ .get(root.session_id)))
2478
+ throw new ServiceError('work_settlement_unsafe', this.verificationLimitMiss && this.verificationLimitMiss !== 'vl:reason'
2479
+ ? 'Verification-budget stop not provable: ' + this.verificationLimitMiss
2480
+ : this.checkFailedMiss && this.checkFailedMiss !== 'cf:reason'
2481
+ ? 'Check-failed stop not provable: ' + this.checkFailedMiss
2482
+ : 'Only exact known approved-patch, zero-effect child denial, revoke-cancelled operator MCP reads, request budget stopped operator MCP reads, runtime-stopped refused workspace writes, request budget stopped known effects, a closed rate-limit, verification-budget or check-failed stop, or closed historical read-only evidence can retire');
2483
+ const evidence_digest = digest({
2484
+ binding: root.binding,
2485
+ task_id: task.id,
2486
+ plan_revision: task.plan_revision,
2487
+ blocked_reason: runtime.blocked_reason,
2488
+ attempt,
2489
+ operation: op ?? null,
2490
+ approval: approval ?? null,
2491
+ call: call ?? null,
2492
+ ...(zeroEffect ? { zero_effect_concurrency: zeroEffect } : {}),
2493
+ ...(revokeCancelled ? { revoke_cancelled: revokeCancelled } : {}),
2494
+ ...(budgetStop ? { request_budget_stop: budgetStop } : {}),
2495
+ ...(refusedWrites ? { runtime_stop_refused_writes: refusedWrites } : {}),
2496
+ ...(budgetKnown ? { request_budget_stop_known_effects: budgetKnown } : {}),
2497
+ ...(rateLimit ? { rate_limit_stop: rateLimit } : {}),
2498
+ ...(verificationLimit ? { verification_limit_stop: verificationLimit } : {}),
2499
+ ...(checkFailed ? { check_failed_stop: checkFailed } : {}),
2500
+ ...(knownReadOnly ? { historical_known_readonly: knownReadOnly } : {}),
2501
+ });
2502
+ return evidence_digest;
2503
+ }
2504
+ /** Status's read-only verdict: "known_effects_failure" when fail() would retire this run now. */
2505
+ settlementReady(binding, root, row, task) {
2506
+ try {
2507
+ this.assertKnownEffectsStop(binding);
2508
+ this.assertPlan(task, JSON.parse(String(row.admission_json)));
2509
+ this.failureEvidence(root, task);
2510
+ return 'known_effects_failure';
2511
+ }
2512
+ catch (error) {
2513
+ return {
2514
+ refused: error instanceof ServiceError ? error.code + ': ' + error.message : 'settlement_unproven',
2515
+ };
2516
+ }
2517
+ }
1576
2518
  abandon(binding, request, workRequest, terminalDigest) {
1577
2519
  const { root, row, task } = this.owned(binding);
1578
2520
  if (workRequest !== row.request_id || !/^[a-f0-9]{64}$/.test(terminalDigest))
@@ -1715,7 +2657,7 @@ export class AgentPodWork {
1715
2657
  return e;
1716
2658
  });
1717
2659
  if (evidence.checks.some((id) => !checks.some((c) => c.id === id)) ||
1718
- task.acceptance.some((a) => !checks.some((c) => c.step_id === null && c.check_key === a.key)) ||
2660
+ requiredChecks(task).some((a) => !checks.some((c) => c.step_id === null && c.check_key === a.key)) ||
1719
2661
  calls.some((c) => c.kind === 'check' && !checkEvidence.some((e) => e.call_id === c.id)))
1720
2662
  fail();
1721
2663
  // Historical evidence only: do not observe a fresh tree, renew authority,
@@ -1746,6 +2688,64 @@ export class AgentPodWork {
1746
2688
  return receipt;
1747
2689
  });
1748
2690
  }
2691
+ /** Item 11b: restore a checkpoint's files, operator-approved, only for a run that has ended (its
2692
+ * execution revoked and stopped with nothing unresolved), never while another run is active on
2693
+ * the station, and only exactly as reviewed: the restore digest is recomputed and must match. */
2694
+ async restore(binding, request, checkpoint, restoreDigest, approvedBy) {
2695
+ const { root, task } = this.owned(binding);
2696
+ const current = this.authority.inspect(binding);
2697
+ if (!current.revoked ||
2698
+ current.authorized ||
2699
+ !current.owned_execution_stopped ||
2700
+ current.unresolved_operations.length ||
2701
+ current.active_attempts.length)
2702
+ throw new ServiceError('restore_unsafe', 'Restore is only for a run that has ended and stopped');
2703
+ if (this.store.db
2704
+ .prepare("SELECT 1 FROM agentpod_admissions WHERE state!='revoked' AND session_id<>?")
2705
+ .get(root.session_id))
2706
+ throw new ServiceError('restore_busy', 'Another run is active on this station');
2707
+ const station = this.stationBoundary();
2708
+ if (!station)
2709
+ throw new ServiceError('restore_unavailable', 'Restore needs the station boundary');
2710
+ // A replay of a restore already journaled returns its receipt: the live tree it moved is no
2711
+ // reason to refuse the very approval that moved it.
2712
+ const replay = station.replayed?.(root.session_id, request, restoreDigest) ?? null;
2713
+ if (replay !== null)
2714
+ return replay;
2715
+ const plan = await new StationCheckpoints(this.store).restorePlan(task, checkpoint, await station.live());
2716
+ if (plan.restore_digest !== restoreDigest)
2717
+ throw new ServiceError('restore_changed', 'This restore no longer matches what was reviewed');
2718
+ return station.restore(root.session_id, digest(root.binding), plan, request, approvedBy);
2719
+ }
2720
+ /** Item 11: the run's checkpoints, from its own recorded station writes. Read-only. */
2721
+ checkpoints(binding) {
2722
+ const { task } = this.owned(binding);
2723
+ this.authority.inspect(binding);
2724
+ return {
2725
+ version: 1,
2726
+ task_id: task.id,
2727
+ checkpoints: new StationCheckpoints(this.store).list(task),
2728
+ };
2729
+ }
2730
+ /** Item 11: what restoring a checkpoint would do to each file written after it. Read-only. */
2731
+ async checkpointDiff(binding, checkpoint) {
2732
+ const { task } = this.owned(binding);
2733
+ this.authority.inspect(binding);
2734
+ const checkpoints = new StationCheckpoints(this.store);
2735
+ checkpoints.get(task, checkpoint);
2736
+ const station = this.stationBoundary();
2737
+ if (!station)
2738
+ throw new ServiceError('restore_unavailable', 'A checkpoint diff needs the station boundary');
2739
+ return {
2740
+ version: 1,
2741
+ task_id: task.id,
2742
+ ...(await checkpoints.diff(task, checkpoint, await station.live())),
2743
+ };
2744
+ }
2745
+ /** The station boundary, which reads the live workspace and writes restores. */
2746
+ stationBoundary() {
2747
+ return this.verification.station?.station;
2748
+ }
1749
2749
  async status(binding, driver) {
1750
2750
  const { root, row, task } = this.owned(binding);
1751
2751
  const view = await driver.viewAsync(task.id);
@@ -1758,8 +2758,91 @@ export class AgentPodWork {
1758
2758
  plan_revision: this.tasks.get(task.id).plan_revision,
1759
2759
  binding_digest: digest(root.binding),
1760
2760
  auto_enabled: !!view.runtime.auto_enabled,
2761
+ activity: this.activity(root.session_id),
2762
+ plan: planUpdate(view),
2763
+ // Board reads the agent is waiting on; the owner serves them (operator ruling 2026-10-28).
2764
+ ...(() => {
2765
+ const reads = new BoardReads(this.store).pending(root.session_id);
2766
+ return reads.length ? { board_reads: reads } : {};
2767
+ })(),
2768
+ // The owner may settle a stopped run automatically only on this verdict.
2769
+ ...(() => {
2770
+ const ready = this.settlementReady(binding, root, row, task);
2771
+ if (ready === 'known_effects_failure')
2772
+ return { settlement_ready: ready };
2773
+ // Which guard refused, for a stopped run only: the owner logs it, so an unsettled run
2774
+ // says why instead of "cannot prove". Codes and fixed messages only; no data.
2775
+ return ready && this.authority.inspect(binding).revoked
2776
+ ? { settlement_refused: ready.refused }
2777
+ : {};
2778
+ })(),
1761
2779
  };
1762
2780
  }
2781
+ /** The owner's answer to one of this run's board reads. */
2782
+ answerBoardRead(binding, answer) {
2783
+ const { root } = this.owned(binding);
2784
+ return new BoardReads(this.store).answer(root.session_id, answer);
2785
+ }
2786
+ /** The latest tool steps, oldest first, for the owner to show as card activity while the work
2787
+ * runs. Observation only: it carries no authority and changes nothing. Input is clipped. */
2788
+ activity(session) {
2789
+ const startedOf = this.store.db.prepare("SELECT json_extract(payload,'$.input') AS input FROM events WHERE session_id=? AND kind='tool.started' AND json_extract(payload,'$.tool_call_id')=? ORDER BY seq DESC LIMIT 1");
2790
+ const startedInput = (id) => typeof id === 'string'
2791
+ ? startedOf.get(session, id)?.input
2792
+ : undefined;
2793
+ return this.store.db
2794
+ .prepare("SELECT seq,kind,payload FROM events WHERE session_id=? AND kind IN ('tool.started','tool.finished','permission.auto-approved','fetch.host_paused') ORDER BY seq DESC LIMIT 64")
2795
+ .all(session)
2796
+ .reverse()
2797
+ .map((row) => {
2798
+ const p = JSON.parse(String(row.payload));
2799
+ // Approval scopes: a write the run grant answered says so on the card, with the grant.
2800
+ // A fetch host paused by the breaker: one line, and the run carries on.
2801
+ if (row.kind === 'fetch.host_paused')
2802
+ return {
2803
+ seq: Number(row.seq),
2804
+ phase: 'finished',
2805
+ tool_call_id: 'fetch-pause:' + String(row.seq),
2806
+ title: `Paused requests to ${String(p.host)} for ${Math.round(Number(p.pause_ms) / 1000)}s after ${Number(p.failures)} failures`,
2807
+ kind: null,
2808
+ input: null,
2809
+ failed: false,
2810
+ };
2811
+ if (row.kind === 'permission.auto-approved')
2812
+ return {
2813
+ seq: Number(row.seq),
2814
+ phase: 'finished',
2815
+ tool_call_id: 'approval:' + String(p.operation_id ?? ''),
2816
+ title: p.rule === 'run-grant'
2817
+ ? 'Approved by the run grant' +
2818
+ (typeof p.granted_by === 'string' ? ` (granted by ${p.granted_by})` : '')
2819
+ : p.rule === 'agent-rule'
2820
+ ? "Allowed by the agent's standing rule" +
2821
+ (typeof p.set_by === 'string' ? ` (set by ${p.set_by})` : '')
2822
+ : 'Approved by a standing policy',
2823
+ kind: null,
2824
+ input: null,
2825
+ failed: false,
2826
+ };
2827
+ const started = row.kind === 'tool.started', tool = typeof p.tool === 'string' ? p.tool : '';
2828
+ const input = typeof p.input === 'string' ? p.input.slice(0, 2048) : null;
2829
+ // A finished step carries no input, so it is titled from the call it finishes: the card
2830
+ // read a bare "salaam_station" under every station step (staging card_3a97983751784bf9).
2831
+ const callInput = started ? p.input : startedInput(p.tool_call_id);
2832
+ return {
2833
+ seq: Number(row.seq),
2834
+ phase: started ? 'started' : 'finished',
2835
+ tool_call_id: typeof p.tool_call_id === 'string' ? p.tool_call_id : '',
2836
+ title: tool ? toolTitle(tool, callInput) : 'Tool',
2837
+ kind: started ? String(p.kind ?? toolMetadata(tool, p.input).kind) : null,
2838
+ input: started ? input : null,
2839
+ failed: !started && p.failed === true,
2840
+ // The tool's text output, already redacted and clipped by the worker, so the card shows
2841
+ // what the step returned as Hermes does.
2842
+ ...(!started && typeof p.output === 'string' ? { output: p.output.slice(0, 2048) } : {}),
2843
+ };
2844
+ });
2845
+ }
1763
2846
  progress(binding, request, workRequest, steps, driver) {
1764
2847
  const { root, row, task } = this.owned(binding);
1765
2848
  if (workRequest !== row.request_id)
@@ -1773,7 +2856,11 @@ export class AgentPodWork {
1773
2856
  }, () => {
1774
2857
  this.store.execution.assert(root.session_id);
1775
2858
  this.assertPlan(task, JSON.parse(String(row.admission_json)));
1776
- if (steps !== task.steps.length || steps < 1 || steps > 32)
2859
+ // Exactly the plan's steps, or those plus the capped re-run allowance that funds a
2860
+ // rate-limited retry or a reopened step; steps complete once, so it never adds work.
2861
+ if ((steps !== task.steps.length && steps !== task.steps.length + RERUN_ALLOWANCE) ||
2862
+ steps < 1 ||
2863
+ steps > 32 + RERUN_ALLOWANCE)
1777
2864
  throw new ServiceError('invalid_limit', 'Progression must bind every admitted step within32');
1778
2865
  if (this.store.controlView(root.session_id).expires_at > Date.now())
1779
2866
  throw new ServiceError('control_busy', 'Local controller currently owns this session');