@superjackfruitlabs/salaam 0.0.3 → 0.0.4

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 (102) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +12 -11
  3. package/dist/src/acp-outcome.js +177 -17
  4. package/dist/src/acp.js +41 -7
  5. package/dist/src/agent-profiles.js +45 -0
  6. package/dist/src/agentpod-authority.js +169 -11
  7. package/dist/src/agentpod-default-config.js +12 -0
  8. package/dist/src/agentpod-work-decision-protocol.js +40 -6
  9. package/dist/src/agentpod-work-decisions.js +108 -4
  10. package/dist/src/agentpod-work.js +1214 -127
  11. package/dist/src/board-reads.js +106 -0
  12. package/dist/src/children.js +83 -0
  13. package/dist/src/cli-help.js +27 -2
  14. package/dist/src/cli.js +87 -3
  15. package/dist/src/command-network.js +200 -0
  16. package/dist/src/command-sandbox.js +217 -0
  17. package/dist/src/execution-evidence.js +33 -1
  18. package/dist/src/host-bind.js +111 -0
  19. package/dist/src/host-breaker.js +88 -0
  20. package/dist/src/host-start.js +41 -1
  21. package/dist/src/host.js +192 -6
  22. package/dist/src/house-extension.js +162 -57
  23. package/dist/src/local-coding.js +214 -18
  24. package/dist/src/local-edit-helper.js +25 -0
  25. package/dist/src/local-undo.js +25 -16
  26. package/dist/src/managed.js +1 -1
  27. package/dist/src/operator-mcp-cli.js +107 -0
  28. package/dist/src/operator-mcp.js +342 -0
  29. package/dist/src/persona.js +31 -0
  30. package/dist/src/pi.js +22 -1
  31. package/dist/src/protocol.js +5 -2
  32. package/dist/src/rate-limit-retries.js +62 -0
  33. package/dist/src/recurring-work.js +1 -0
  34. package/dist/src/repository-snapshots.js +5 -2
  35. package/dist/src/run-grants.js +62 -0
  36. package/dist/src/service.js +141 -6
  37. package/dist/src/station-checkpoints.js +139 -0
  38. package/dist/src/station-config.js +8 -1
  39. package/dist/src/station-files.js +20 -1
  40. package/dist/src/station-task-verification.js +6 -3
  41. package/dist/src/station-work.js +315 -22
  42. package/dist/src/store.js +19 -0
  43. package/dist/src/supervisor.js +177 -5
  44. package/dist/src/task-budget.js +39 -3
  45. package/dist/src/task-critic.js +115 -0
  46. package/dist/src/task-driver.js +46 -2
  47. package/dist/src/task-policy.js +44 -12
  48. package/dist/src/task-verification.js +42 -9
  49. package/dist/src/task-work.js +47 -0
  50. package/dist/src/tool-identity.js +69 -0
  51. package/dist/src/usage.js +1 -1
  52. package/npm-shrinkwrap.json +2 -2
  53. package/package.json +1 -1
  54. package/src/acp-outcome.ts +169 -19
  55. package/src/acp.ts +53 -7
  56. package/src/agent-profiles.ts +48 -0
  57. package/src/agentpod-authority.ts +234 -15
  58. package/src/agentpod-default-config.ts +16 -0
  59. package/src/agentpod-work-decision-protocol.ts +69 -8
  60. package/src/agentpod-work-decisions.ts +123 -5
  61. package/src/agentpod-work.ts +1455 -152
  62. package/src/board-reads.ts +148 -0
  63. package/src/children.ts +112 -0
  64. package/src/cli-help.ts +27 -2
  65. package/src/cli.ts +109 -3
  66. package/src/command-network.ts +215 -0
  67. package/src/command-sandbox.ts +273 -0
  68. package/src/execution-evidence.ts +31 -1
  69. package/src/host-bind.ts +156 -0
  70. package/src/host-breaker.ts +89 -0
  71. package/src/host-start.ts +51 -1
  72. package/src/host.ts +244 -6
  73. package/src/house-extension.ts +174 -56
  74. package/src/local-coding.ts +270 -18
  75. package/src/local-edit-helper.ts +26 -0
  76. package/src/local-undo.ts +29 -14
  77. package/src/managed.ts +2 -2
  78. package/src/operator-mcp-cli.ts +108 -0
  79. package/src/operator-mcp.ts +420 -0
  80. package/src/persona.ts +37 -0
  81. package/src/pi.ts +23 -1
  82. package/src/protocol.ts +6 -1
  83. package/src/rate-limit-retries.ts +69 -0
  84. package/src/recurring-work.ts +1 -0
  85. package/src/repository-snapshots.ts +5 -2
  86. package/src/run-grants.ts +107 -0
  87. package/src/service.ts +222 -6
  88. package/src/station-checkpoints.ts +183 -0
  89. package/src/station-config.ts +10 -1
  90. package/src/station-files.ts +20 -1
  91. package/src/station-task-verification.ts +6 -3
  92. package/src/station-work.ts +369 -20
  93. package/src/store.ts +18 -0
  94. package/src/supervisor.ts +203 -4
  95. package/src/task-budget.ts +48 -3
  96. package/src/task-critic.ts +171 -0
  97. package/src/task-driver.ts +49 -2
  98. package/src/task-policy.ts +65 -13
  99. package/src/task-verification.ts +53 -9
  100. package/src/task-work.ts +75 -0
  101. package/src/tool-identity.ts +110 -0
  102. package/src/usage.ts +1 -1
package/src/supervisor.ts CHANGED
@@ -1,7 +1,9 @@
1
+ import { BoardReads } from './board-reads.js';
2
+ import { HostBreaker, callThroughBreaker } from './host-breaker.js';
1
3
  import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
2
4
  import { execFileSync } from 'node:child_process';
3
5
  import { EventEmitter } from 'node:events';
4
- import { mkdirSync, existsSync, writeFileSync, readFileSync, readlinkSync } from 'node:fs';
6
+ import { mkdirSync, existsSync, writeFileSync, readFileSync, readlinkSync, rmSync } from 'node:fs';
5
7
  import { join, resolve } from 'node:path';
6
8
  import {
7
9
  CAPABILITIES,
@@ -32,6 +34,7 @@ import type { ManagedWork } from './managed.js';
32
34
  import type { McpWork, McpWorkerBinding } from './mcp-work.js';
33
35
  import type { StationWork, StationBinding } from './station-work.js';
34
36
  import type { ToolkitWork, ToolkitWorkerBinding } from './toolkit-work.js';
37
+ import { mergeMcpProvenance, type McpServerProvenance, type OperatorMcp } from './operator-mcp.js';
35
38
  import type { ChildWork } from './children.js';
36
39
  import {
37
40
  OFFLINE_ROUTE,
@@ -81,6 +84,8 @@ export interface SupervisorOptions {
81
84
  }
82
85
  interface Worker {
83
86
  mcp?: McpWorkerBinding;
87
+ operatorMcp?: McpServerProvenance[];
88
+ mcpServers?: McpServerProvenance[];
84
89
  toolkit?: ToolkitWorkerBinding;
85
90
  station?: StationBinding;
86
91
  secrets?: string[];
@@ -126,7 +131,15 @@ export function processIdentity(pid: number): string | null {
126
131
  };
127
132
  const before = stat(),
128
133
  boot = readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim(),
129
- executable = readlinkSync(path + '/exe'),
134
+ // A tools-user launcher (sudo) is root-owned; its exe link is unreadable to the service.
135
+ executable = (() => {
136
+ try {
137
+ return readlinkSync(path + '/exe');
138
+ } catch (error) {
139
+ if ((error as NodeJS.ErrnoException).code === 'EACCES') return 'unreadable';
140
+ throw error;
141
+ }
142
+ })(),
130
143
  argv = readFileSync(path + '/cmdline');
131
144
  if (
132
145
  !/^[a-f0-9-]{36}$/.test(boot) ||
@@ -166,8 +179,12 @@ export class Supervisor extends EventEmitter {
166
179
  localCoding?: import('./local-coding.js').LocalCoding;
167
180
  tasks!: TaskContext;
168
181
  private compactionJobs = new Set<Promise<void>>();
182
+ private readonly fetchBreaker = new HostBreaker();
169
183
  managed?: ManagedWork;
170
184
  mcp?: McpWork;
185
+ operatorMcp?: OperatorMcp;
186
+ /** Item 9: the host's pinned persona, given to every worker on this host. */
187
+ persona?: import('./persona.js').Persona;
171
188
  toolkit?: ToolkitWork;
172
189
  station?: StationWork;
173
190
  children?: ChildWork;
@@ -222,8 +239,26 @@ export class Supervisor extends EventEmitter {
222
239
  !!this.station,
223
240
  this.store.localWorkspace(id),
224
241
  this.store.localCoding(id),
242
+ this.declaredMcp(id),
225
243
  );
226
244
  }
245
+ /** Operator-declared MCP reaches root sessions only; children get no MCP authority. */
246
+ private declaredMcp(id: string): boolean {
247
+ return !!this.operatorMcp && !this.operatorMcp.empty && !this.children?.owned(id);
248
+ }
249
+ /** Every MCP server the session got, from both sources; persisted beside the worker. */
250
+ mcpServers(id: string): McpServerProvenance[] | undefined {
251
+ const live = this.workers.get(id)?.mcpServers;
252
+ if (live) return structuredClone(live);
253
+ try {
254
+ const path = join(this.options.stateDir, 'workers', id, 'mcp-servers.json');
255
+ return existsSync(path)
256
+ ? (JSON.parse(readFileSync(path, 'utf8')) as McpServerProvenance[])
257
+ : undefined;
258
+ } catch {
259
+ return undefined;
260
+ }
261
+ }
227
262
  private childBoundary(id: string, epoch: number) {
228
263
  const child = this.children?.owned(id);
229
264
  return child ? this.store.workerBoundary.assert(child, epoch, this.routeFor(id)) : undefined;
@@ -255,6 +290,10 @@ export class Supervisor extends EventEmitter {
255
290
  }
256
291
  readonly bootId = randomUUID();
257
292
  private workers = new Map<string, Worker>();
293
+ private boardReadsStore?: BoardReads;
294
+ get boardReads(): BoardReads {
295
+ return (this.boardReadsStore ??= new BoardReads(this.store));
296
+ }
258
297
  private monitor: NodeJS.Timeout;
259
298
  readonly bundle: string;
260
299
  constructor(
@@ -406,6 +445,20 @@ export class Supervisor extends EventEmitter {
406
445
  const child = this.children?.owned(id);
407
446
  if (this.mcp && !child) worker.mcp = await this.mcp.ready(id);
408
447
  if (this.toolkit && !child) worker.toolkit = await this.toolkit.ready(id);
448
+ if (this.declaredMcp(id)) worker.operatorMcp = this.operatorMcp!.provenance();
449
+ if (worker.operatorMcp || worker.mcp) {
450
+ // Collisions are refused at admission; nothing is silently shadowed.
451
+ worker.mcpServers = mergeMcpProvenance(
452
+ worker.operatorMcp ?? [],
453
+ (worker.mcp?.servers ?? []).map((server) => ({
454
+ name: server.name,
455
+ source: 'agentpod' as const,
456
+ transport: 'http' as const,
457
+ endpoint_digest: server.endpoint_digest,
458
+ declaration_digest: digest(server),
459
+ })),
460
+ );
461
+ }
409
462
  if (this.station && !child) worker.station = this.station.ready(id);
410
463
  this.store.execution.assert(id);
411
464
  if (worker.stopping || worker.invalid) throw new Error('Worker startup cancelled');
@@ -421,6 +474,10 @@ export class Supervisor extends EventEmitter {
421
474
  );
422
475
  const sessionDir = join(workerDir, 'sessions');
423
476
  mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
477
+ const provenancePath = join(workerDir, 'mcp-servers.json');
478
+ if (worker.mcpServers)
479
+ writeFileSync(provenancePath, JSON.stringify(worker.mcpServers, null, 2), { mode: 0o600 });
480
+ else rmSync(provenancePath, { force: true });
424
481
  const config = {
425
482
  socket: this.options.bridgeSocket,
426
483
  token: worker.token,
@@ -437,8 +494,11 @@ export class Supervisor extends EventEmitter {
437
494
  local: this.store.localWorkspace(id),
438
495
  localCoding: this.store.localCoding(id),
439
496
  ...(worker.mcp ? { mcp: worker.mcp } : {}),
497
+ ...(worker.operatorMcp ? { operator_mcp: worker.operatorMcp } : {}),
498
+ ...(worker.mcpServers ? { mcp_servers: worker.mcpServers } : {}),
440
499
  ...(worker.toolkit ? { toolkit: worker.toolkit } : {}),
441
500
  ...(worker.station ? { station: worker.station } : {}),
501
+ ...(this.persona ? { persona: this.persona } : {}),
442
502
  };
443
503
  const args = [
444
504
  PI_CLI,
@@ -689,6 +749,43 @@ export class Supervisor extends EventEmitter {
689
749
  }
690
750
  return w;
691
751
  }
752
+ /** Is this attempt a task step that has acceptance checks to decide it? */
753
+ private checkedStepAttempt(attempt: string): boolean {
754
+ const row = this.store.db
755
+ .prepare('SELECT checks FROM task_steps WHERE attempt_id=?')
756
+ .get(attempt) as { checks?: string } | undefined;
757
+ try {
758
+ const checks = row?.checks ? JSON.parse(String(row.checks)) : [];
759
+ return Array.isArray(checks) && checks.length > 0;
760
+ } catch {
761
+ return false;
762
+ }
763
+ }
764
+ /** A station call refused before it runs leaves no station_calls row, so record its
765
+ * method, requested action and refusal code: never its path, arguments or content. */
766
+ private recordStationRefusal(
767
+ id: string,
768
+ method: string,
769
+ body: Record<string, unknown>,
770
+ error: unknown,
771
+ ): void {
772
+ const args =
773
+ body.args && typeof body.args === 'object' ? (body.args as Record<string, unknown>) : {};
774
+ const ident = (v: unknown) =>
775
+ typeof v === 'string' && /^[A-Za-z0-9_.:-]{1,64}$/.test(v) ? v : null;
776
+ try {
777
+ this.store.transaction(() =>
778
+ this.store.event(id, 'station.call_refused', {
779
+ attempt_id: this.workers.get(id)?.attempt ?? null,
780
+ method: ident(method),
781
+ action: ident(args.action),
782
+ code: error instanceof ServiceError ? ident(error.code) : 'error',
783
+ }),
784
+ );
785
+ } catch {
786
+ // Recording a refusal never changes the refusal itself.
787
+ }
788
+ }
692
789
  async bridge(
693
790
  id: string,
694
791
  method: string,
@@ -732,8 +829,14 @@ export class Supervisor extends EventEmitter {
732
829
  context_digest: textDigest(w.frame),
733
830
  ...(boundary ? { worker_binding: boundary } : {}),
734
831
  ...(w.mcp ? { mcp: w.mcp } : {}),
832
+ ...(w.operatorMcp ? { operator_mcp: w.operatorMcp } : {}),
833
+ ...(w.mcpServers
834
+ ? { mcp_servers: w.mcpServers, mcp_servers_digest: digest(w.mcpServers) }
835
+ : {}),
735
836
  ...(w.toolkit ? { toolkit: w.toolkit } : {}),
736
837
  ...(w.station ? { station: w.station } : {}),
838
+ // Item 9: the worker proves it runs with the host's pinned persona.
839
+ ...(this.persona ? { persona_digest: this.persona.digest } : {}),
737
840
  };
738
841
  for (const [key, value] of Object.entries(expected))
739
842
  if (canonical(body[key]) !== canonical(value))
@@ -839,6 +942,58 @@ export class Supervisor extends EventEmitter {
839
942
  w.secrets = [...new Set([...(w.secrets ?? []), ...credentialStrings(auth)])];
840
943
  return auth;
841
944
  }
945
+ if (method.startsWith('operator_mcp.')) {
946
+ if (!this.declaredMcp(id) || !w.operatorMcp || !w.attempt)
947
+ throw new ServiceError('mcp_disabled', 'Worker has no declared MCP servers', 403);
948
+ this.assertActive(id, w.epoch, w.attempt);
949
+ if (method === 'operator_mcp.catalog')
950
+ return await this.operatorMcp!.catalog(id, body.server, body.tool);
951
+ if (method !== 'operator_mcp.call')
952
+ throw new ServiceError('invalid_field', 'Unknown declared MCP method', 400);
953
+ const attempt = w.attempt;
954
+ const args = body.arguments ?? {};
955
+ const server = String(body.server ?? ''),
956
+ tool = String(body.tool ?? '');
957
+ // A host failing again and again is paused briefly (never called, so no operator MCP call
958
+ // is recorded); the agent gets a tool error and the run carries on.
959
+ return await callThroughBreaker(
960
+ this.fetchBreaker,
961
+ id,
962
+ args,
963
+ (paused) =>
964
+ this.store.transaction(() =>
965
+ this.store.event(id, 'fetch.host_paused', { attempt_id: attempt, ...paused }),
966
+ ),
967
+ async () => {
968
+ try {
969
+ const result = await this.operatorMcp!.call(id, body.server, body.tool, args);
970
+ this.store.transaction(() =>
971
+ this.store.event(id, 'operator_mcp.call', {
972
+ attempt_id: attempt,
973
+ server,
974
+ tool,
975
+ argument_digest: digest(args),
976
+ outcome: result.is_error ? 'tool_error' : 'returned',
977
+ result_digest: digest(result),
978
+ }),
979
+ );
980
+ return result;
981
+ } catch (error) {
982
+ this.store.transaction(() =>
983
+ this.store.event(id, 'operator_mcp.call', {
984
+ attempt_id: attempt,
985
+ server,
986
+ tool,
987
+ argument_digest: digest(args),
988
+ outcome: 'failed',
989
+ error: (error as ServiceError).code ?? 'mcp_server_failed',
990
+ }),
991
+ );
992
+ throw error;
993
+ }
994
+ },
995
+ );
996
+ }
842
997
  if (method.startsWith('toolkit.')) {
843
998
  if (!this.toolkit || !w.toolkit || !w.attempt || this.children?.owned(id))
844
999
  throw new ServiceError('toolkit_disabled', 'Worker has no native Toolkit authority', 403);
@@ -923,6 +1078,19 @@ export class Supervisor extends EventEmitter {
923
1078
  : {}),
924
1079
  };
925
1080
  }
1081
+ if (method === 'board.read' || method === 'board.status') {
1082
+ // Read-only board reads, served by the hub (operator ruling 2026-10-28, option iii).
1083
+ if (!this.station || !w.station || !w.attempt || this.children?.owned(id))
1084
+ throw new ServiceError('station_disabled', 'Board reads need protected station work');
1085
+ this.assertActive(id, w.epoch, w.attempt);
1086
+ return method === 'board.read'
1087
+ ? this.boardReads.request(
1088
+ id,
1089
+ string(body.tool_call_id, 'tool_call_id', 512),
1090
+ object(body.args),
1091
+ )
1092
+ : this.boardReads.get(id, string(body.read_id, 'read_id', 64));
1093
+ }
926
1094
  if (method.startsWith('station.')) {
927
1095
  if (!this.station || !w.station || !w.attempt || this.children?.owned(id))
928
1096
  throw new ServiceError('station_disabled', 'Station execution is unavailable');
@@ -1211,6 +1379,7 @@ export class Supervisor extends EventEmitter {
1211
1379
  return { ok: true };
1212
1380
  } catch (error) {
1213
1381
  this.store.taskBudgetDenied?.(id, error);
1382
+ if (method.startsWith('station.')) this.recordStationRefusal(id, method, body, error);
1214
1383
  if (
1215
1384
  method.startsWith('station.') &&
1216
1385
  error instanceof ServiceError &&
@@ -1225,6 +1394,18 @@ export class Supervisor extends EventEmitter {
1225
1394
  ].includes(error.code)
1226
1395
  )
1227
1396
  throw error;
1397
+ if (
1398
+ method.startsWith('operator_mcp.') &&
1399
+ error instanceof ServiceError &&
1400
+ [
1401
+ 'invalid_field',
1402
+ 'invalid_mcp_server',
1403
+ 'invalid_mcp_tool',
1404
+ 'invalid_mcp_arguments',
1405
+ 'mcp_server_failed',
1406
+ ].includes(error.code)
1407
+ )
1408
+ throw error;
1228
1409
  if (
1229
1410
  (method.startsWith('mcp.') || method.startsWith('toolkit.')) &&
1230
1411
  error instanceof ServiceError &&
@@ -1339,6 +1520,7 @@ export class Supervisor extends EventEmitter {
1339
1520
  ? null
1340
1521
  : 'No provider-enforced output cap; tasks must explicitly disable token budgeting',
1341
1522
  },
1523
+ ...(this.mcpServers(id) ? { mcp_servers: this.mcpServers(id) } : {}),
1342
1524
  };
1343
1525
  const w = this.workers.get(id);
1344
1526
  if (w?.invalid) return { ...state, state: 'unhealthy', capabilities: [], error: w.invalid };
@@ -1608,11 +1790,19 @@ export class Supervisor extends EventEmitter {
1608
1790
  if (candidate.stopReason !== 'end_turn') outcome = candidate;
1609
1791
  }
1610
1792
  }
1611
- if (value.type === 'tool_execution_end' && value.isError === true)
1793
+ // A checked task step is decided by its checks, not by a tool error the model went on
1794
+ // to recover from (operator decision, 2026-10-23). The error stays in the journal
1795
+ // (tool.finished failed=true). A delegated child turn, whose denied probes are its
1796
+ // boundary proof, and any attempt outside a checked step still fail on it.
1797
+ if (
1798
+ value.type === 'tool_execution_end' &&
1799
+ value.isError === true &&
1800
+ (this.children?.owned(id) || !this.checkedStepAttempt(work.attempt.id))
1801
+ )
1612
1802
  outcome ??= {
1613
1803
  version: 1,
1614
1804
  stopReason: 'error',
1615
- failure: acpFailure(new ServiceError('tool_failed', 'Tool execution failed')),
1805
+ failure: acpFailure(new ServiceError('tool_failed', toolFailureMessage(value.toolName))),
1616
1806
  };
1617
1807
  };
1618
1808
  w.pi.on('record', record);
@@ -1684,6 +1874,7 @@ export class Supervisor extends EventEmitter {
1684
1874
  try {
1685
1875
  this.managed?.invalidateWorker(w.id, w.epoch);
1686
1876
  this.cancelMcpWorker(w);
1877
+ void this.operatorMcp?.stopSession(w.id).catch(() => {});
1687
1878
  this.children?.workerLost(w.id, 'Worker stopped before child dispatch');
1688
1879
  } catch {
1689
1880
  /* shutdown still owns and stops the worker */
@@ -1711,3 +1902,11 @@ export class Supervisor extends EventEmitter {
1711
1902
  throw new AggregateError(errors, 'Worker shutdown could not persist all final states');
1712
1903
  }
1713
1904
  }
1905
+
1906
+ /** Names the failing tool (never its arguments or output), so a failed step says which call
1907
+ * failed it. Only a plain tool identifier is echoed. */
1908
+ export function toolFailureMessage(toolName: unknown): string {
1909
+ return typeof toolName === 'string' && /^[A-Za-z0-9_.:-]{1,64}$/.test(toolName)
1910
+ ? `Tool execution failed (tool ${toolName})`
1911
+ : 'Tool execution failed';
1912
+ }
@@ -11,6 +11,13 @@ export class TaskBudgetError extends ServiceError {
11
11
  }
12
12
  }
13
13
 
14
+ /** Tools a critic review child may hold and still be left out of the verification busy check. */
15
+ export const READ_ONLY_REVIEW_TOOLS = [
16
+ 'salaam_findings',
17
+ 'salaam_probe',
18
+ 'salaam_repository',
19
+ ] as const;
20
+
14
21
  export class TaskBudget {
15
22
  constructor(
16
23
  readonly store: Store,
@@ -29,6 +36,8 @@ export class TaskBudget {
29
36
  store.taskChildFinished = (attempt) => this.releaseChild(attempt);
30
37
  store.taskCommandAdmission = (session) => this.command(session);
31
38
  store.taskRemainingRuntime = (session) => this.remaining(session);
39
+ // Only the task's own session: a child's attempt keeps the budget its reservation set.
40
+ store.taskAttemptRequestLimit = (session) => this.tasks.forSession(session)?.limits.requests;
32
41
  }
33
42
  task(session: string): WorkTask | null {
34
43
  const direct = this.tasks.forSession(session);
@@ -46,16 +55,52 @@ export class TaskBudget {
46
55
  );
47
56
  return { ...runtime, invocations_spent: n, external_model_usage: n ? 'unknown' : 'none' };
48
57
  }
49
- active(task: WorkTask): number {
58
+ /** The child attempt of each running critic review of this task revision whose recorded
59
+ * tools are all read-only (operator ruling 2026-10-30, option A). Only verification reads may
60
+ * leave these out of the busy count: the reviewer reads a digest-bound snapshot and cannot
61
+ * write the workspace, run a command or reach the station (staging card_019a647a169744ff). */
62
+ private readOnlyReviews(task: WorkTask): string[] {
63
+ if (
64
+ !this.store.db
65
+ .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='task_critics'")
66
+ .get()
67
+ )
68
+ return [];
69
+ const rows = this.store.db
70
+ .prepare(
71
+ "SELECT c.child_attempt, c.authority FROM task_critics r JOIN child_tasks c ON c.id=r.child_id WHERE r.task_id=? AND r.plan_revision=? AND r.state='running' AND c.parent_session=?",
72
+ )
73
+ .all(task.id, task.plan_revision, task.session_id) as {
74
+ child_attempt: string;
75
+ authority: string;
76
+ }[];
77
+ return rows
78
+ .filter((r) => {
79
+ try {
80
+ const tools = JSON.parse(r.authority) as unknown;
81
+ return (
82
+ Array.isArray(tools) &&
83
+ tools.every((t) => (READ_ONLY_REVIEW_TOOLS as readonly unknown[]).includes(t))
84
+ );
85
+ } catch {
86
+ return false;
87
+ }
88
+ })
89
+ .map((r) => r.child_attempt);
90
+ }
91
+ /** Live work in the task's session tree. `verification` leaves out a running read-only review's
92
+ * child attempt, for verification reads only; admission and concurrency count everything. */
93
+ active(task: WorkTask, opts: { verification?: boolean } = {}): number {
94
+ const excluded = JSON.stringify(opts.verification ? this.readOnlyReviews(task) : []);
50
95
  const row = this.store.db
51
96
  .prepare(
52
97
  `WITH tree(id) AS (SELECT ? UNION ALL SELECT child_session FROM child_tasks WHERE parent_session=?) SELECT
53
- (SELECT count(*) FROM attempts WHERE session_id IN (SELECT id FROM tree) AND state IN ('accepted','dispatching','running')) +
98
+ (SELECT count(*) FROM attempts WHERE session_id IN (SELECT id FROM tree) AND state IN ('accepted','dispatching','running') AND id NOT IN (SELECT value FROM json_each(?))) +
54
99
  (SELECT count(*) FROM station_calls WHERE session_id IN (SELECT id FROM tree) AND kind='check' AND attempt_id IS NULL AND state IN ('prepared','dispatching')) +
55
100
  (SELECT count(*) FROM command_runs WHERE session_id IN (SELECT id FROM tree) AND state NOT IN ('succeeded','failed','cancelled','timed_out','output_limited','interrupted')) +
56
101
  (SELECT count(*) FROM external_runs e JOIN child_tasks c ON c.id=e.child_id JOIN attempts a ON a.id=c.child_attempt WHERE c.parent_session=? AND e.stopped=0 AND a.state NOT IN ('accepted','dispatching','running')) AS n`,
57
102
  )
58
- .get(task.session_id, task.session_id, task.session_id)!;
103
+ .get(task.session_id, task.session_id, excluded, task.session_id)!;
59
104
  return Number(row.n);
60
105
  }
61
106
  private deny(session: string, message: string): never {
@@ -0,0 +1,171 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type { Store } from './store.js';
3
+ import type { Artifacts } from './artifacts.js';
4
+ import type { AgentGrant } from './agent-profiles.js';
5
+ import type { WorkTask } from './task-work.js';
6
+ import type { WorkspaceTree } from './workspaces.js';
7
+ import type { ChildTask } from './protocol.js';
8
+
9
+ /**
10
+ * The completion critic (item 8). When the admitted plan declares a critic, the built-in reviewer
11
+ * reads an immutable snapshot of the verified deliverables against the plan's rubric before the
12
+ * task may complete. Its verdict is bound to the exact verified evidence (checks and tree): any
13
+ * later change makes it stale. A critical or high finding means changes are needed; a reviewer
14
+ * that reports nothing at all is a failure, never a pass.
15
+ */
16
+ export const CRITIC_BUDGET = 4;
17
+ export const CRITIC_REOPENS = 2;
18
+ export type CriticVerdict =
19
+ | { state: 'pass' }
20
+ | { state: 'pending' }
21
+ | { state: 'changes_needed'; findings: CriticFinding[] }
22
+ | { state: 'failed'; reason: string };
23
+ export interface CriticFinding {
24
+ path: string;
25
+ line: number;
26
+ severity: string;
27
+ message: string;
28
+ }
29
+ export interface CriticDeps {
30
+ admit(task: WorkTask, request: string): Promise<AgentGrant>;
31
+ spawn(
32
+ task: WorkTask,
33
+ parentAttempt: string,
34
+ step: string,
35
+ prompt: string,
36
+ budget: number,
37
+ grant: AgentGrant,
38
+ snapshot: WorkspaceTree,
39
+ ): ChildTask;
40
+ workerId(childId: string): string;
41
+ }
42
+
43
+ export class TaskCritic {
44
+ constructor(
45
+ private readonly store: Store,
46
+ private readonly artifacts: Artifacts,
47
+ private readonly deps: CriticDeps,
48
+ ) {
49
+ store.db.exec(`CREATE TABLE IF NOT EXISTS task_critics(
50
+ id TEXT PRIMARY KEY NOT NULL,
51
+ task_id TEXT NOT NULL,
52
+ plan_revision INTEGER NOT NULL,
53
+ evidence_digest TEXT NOT NULL,
54
+ round INTEGER NOT NULL,
55
+ child_id TEXT NOT NULL,
56
+ state TEXT NOT NULL,
57
+ findings TEXT,
58
+ reason TEXT,
59
+ UNIQUE(task_id, plan_revision, evidence_digest))`);
60
+ }
61
+ /** Whether this exact verified evidence has a passing critic verdict. */
62
+ passed(task: WorkTask, evidenceDigest: string): boolean {
63
+ return (
64
+ this.store.db
65
+ .prepare(
66
+ "SELECT 1 FROM task_critics WHERE task_id=? AND plan_revision=? AND evidence_digest=? AND state='pass'",
67
+ )
68
+ .get(task.id, task.plan_revision, evidenceDigest) !== undefined
69
+ );
70
+ }
71
+ /** How many times the critic has sent this plan revision back for changes. */
72
+ reopens(task: WorkTask): number {
73
+ return Number(
74
+ this.store.db
75
+ .prepare(
76
+ "SELECT count(*) AS n FROM task_critics WHERE task_id=? AND plan_revision=? AND state='changes_needed'",
77
+ )
78
+ .get(task.id, task.plan_revision)!.n,
79
+ );
80
+ }
81
+ /** The verdict for this verified evidence, starting the reviewer the first time it is asked. */
82
+ async gate(
83
+ task: WorkTask,
84
+ evidenceDigest: string,
85
+ deliverables: { path: string; content: string }[],
86
+ ): Promise<CriticVerdict> {
87
+ const critic = task.completion_gate?.critic;
88
+ if (!critic) return { state: 'pass' };
89
+ const row = this.store.db
90
+ .prepare(
91
+ 'SELECT * FROM task_critics WHERE task_id=? AND plan_revision=? AND evidence_digest=?',
92
+ )
93
+ .get(task.id, task.plan_revision, evidenceDigest);
94
+ if (row) return this.settle(row);
95
+ const last = this.store.db
96
+ .prepare(
97
+ 'SELECT b.attempt_id FROM task_step_attempts b JOIN task_steps s ON s.id=b.step_id WHERE s.task_id=? ORDER BY b.rowid DESC LIMIT 1',
98
+ )
99
+ .get(task.id);
100
+ if (!last) return { state: 'failed', reason: 'No step attempt for the critic to follow' };
101
+ if (!deliverables.length)
102
+ return { state: 'failed', reason: 'No verified deliverables to review' };
103
+ const round =
104
+ Number(
105
+ this.store.db
106
+ .prepare('SELECT count(*) AS n FROM task_critics WHERE task_id=? AND plan_revision=?')
107
+ .get(task.id, task.plan_revision)!.n,
108
+ ) + 1;
109
+ const snapshot: WorkspaceTree = Object.fromEntries(
110
+ deliverables.map((d) => [
111
+ d.path,
112
+ { content: Buffer.from(d.content, 'utf8').toString('base64'), executable: false },
113
+ ]),
114
+ );
115
+ const grant = await this.deps.admit(task, `critic:${task.id}:${task.plan_revision}`);
116
+ const child = this.deps.spawn(
117
+ task,
118
+ String(last.attempt_id),
119
+ `critic-${task.plan_revision}-${round}`,
120
+ [
121
+ 'Judge the delivered work against this rubric before it is accepted:',
122
+ critic.rubric,
123
+ '',
124
+ 'Objective: ' + task.objective,
125
+ 'Deliverables (the snapshot): ' + deliverables.map((d) => d.path).join(', '),
126
+ '',
127
+ 'Report findings with salaam_findings, citing the file and line. Use critical or high only',
128
+ 'for what must change before the work is accepted; medium or low for suggestions. If the',
129
+ 'work meets the rubric, report an empty list.',
130
+ ].join('\n'),
131
+ CRITIC_BUDGET,
132
+ grant,
133
+ snapshot,
134
+ );
135
+ this.store.db
136
+ .prepare("INSERT INTO task_critics VALUES (?,?,?,?,?,?,'running',NULL,NULL)")
137
+ .run(randomUUID(), task.id, task.plan_revision, evidenceDigest, round, child.id);
138
+ return { state: 'pending' };
139
+ }
140
+ private settle(row: Record<string, unknown>): CriticVerdict {
141
+ if (row.state === 'pass') return { state: 'pass' };
142
+ if (row.state === 'changes_needed')
143
+ return { state: 'changes_needed', findings: JSON.parse(String(row.findings)) };
144
+ if (row.state === 'failed') return { state: 'failed', reason: String(row.reason) };
145
+ const child = this.store.db
146
+ .prepare('SELECT state FROM child_tasks WHERE id=?')
147
+ .get(String(row.child_id));
148
+ if (!child || child.state === 'accepted') return { state: 'pending' };
149
+ const report = this.store.db
150
+ .prepare('SELECT artifact_id FROM specialist_reports WHERE worker_id=?')
151
+ .get(this.deps.workerId(String(row.child_id)));
152
+ const set = (state: string, findings: CriticFinding[] | null, reason: string | null) =>
153
+ this.store.db
154
+ .prepare('UPDATE task_critics SET state=?,findings=?,reason=? WHERE id=?')
155
+ .run(state, findings ? JSON.stringify(findings) : null, reason, String(row.id));
156
+ if (!report) {
157
+ const reason = 'The critic returned no findings report';
158
+ set('failed', null, reason);
159
+ return { state: 'failed', reason };
160
+ }
161
+ const findings = (
162
+ JSON.parse(this.artifacts.read(String(report.artifact_id))).findings as CriticFinding[]
163
+ ).map(({ path, line, severity, message }) => ({ path, line, severity, message }));
164
+ if (findings.some((f) => f.severity === 'critical' || f.severity === 'high')) {
165
+ set('changes_needed', findings, null);
166
+ return { state: 'changes_needed', findings };
167
+ }
168
+ set('pass', findings, null);
169
+ return { state: 'pass' };
170
+ }
171
+ }
@@ -1,3 +1,6 @@
1
+ import { CRITIC_REOPENS } from './task-critic.js';
2
+ import { requiredChecks } from './task-policy.js';
3
+ import { RATE_LIMIT_STOP, RateLimitRetries } from './rate-limit-retries.js';
1
4
  import { randomUUID } from 'node:crypto';
2
5
  import type { Store } from './store.js';
3
6
  import type { TaskWork, WorkTask } from './task-work.js';
@@ -15,6 +18,7 @@ export class TaskDriver {
15
18
  awaitingAuthorityRenewal?: (session: string) => boolean;
16
19
  private active?: Promise<void>;
17
20
  private stopped = true;
21
+ readonly rateLimits: RateLimitRetries;
18
22
  constructor(
19
23
  readonly store: Store,
20
24
  readonly tasks: TaskWork,
@@ -24,6 +28,7 @@ export class TaskDriver {
24
28
  readonly children: ChildWork,
25
29
  readonly coding: CodingWork,
26
30
  ) {
31
+ this.rateLimits = new RateLimitRetries(store);
27
32
  tasks.assertProgressionBinding = (task, binding) => {
28
33
  if (canonical(this.verification.binding(task)) !== binding)
29
34
  throw new ServiceError(
@@ -374,6 +379,20 @@ export class TaskDriver {
374
379
  return;
375
380
  }
376
381
  if (task.steps.some((s) => ['interrupted', 'failed', 'cancelled'].includes(s.state))) {
382
+ // A provider 429 is retried as a fresh attempt of the step after a backoff; past the limit,
383
+ // or with no progression step left, the task stops with a block recovery can settle.
384
+ const retry = this.rateLimits.decide(task);
385
+ if (retry.action === 'wait') return;
386
+ if (retry.action === 'retry' && Number(r.remaining_steps) >= 1) {
387
+ if (!(await this.worker(task)) || this.stopped) return;
388
+ this.tasks.retryStep(task.id, retry.step, retry.n, Number(r.auto_epoch));
389
+ return;
390
+ }
391
+ if (retry.action !== 'none') {
392
+ this.budget.block(task.session_id, RATE_LIMIT_STOP);
393
+ this.stop(task, RATE_LIMIT_STOP);
394
+ return;
395
+ }
377
396
  this.stop(task, 'An interrupted or failed step requires explicit operator review');
378
397
  return;
379
398
  }
@@ -420,13 +439,41 @@ export class TaskDriver {
420
439
  // the native workspace. Wait for the whole set before testing freshness;
421
440
  // finishAsync still performs the final transactional evidence validation.
422
441
  if (
423
- task.acceptance.some((check) => {
442
+ requiredChecks(task).some((check) => {
424
443
  const receipt = this.verification.latest(task, check.key, null);
425
444
  return receipt?.state === 'accepted' || receipt?.state === 'running';
426
445
  })
427
446
  )
428
447
  return;
429
- for (const check of task.acceptance) if (!(await this.check(task, check.key, null))) return;
448
+ for (const check of requiredChecks(task))
449
+ if (!(await this.check(task, check.key, null))) return;
450
+ // Item 8: a declared critic reviews the verified deliverables before completion.
451
+ if (task.completion_gate?.critic) {
452
+ const verdict = await this.verification.criticGateAsync(task);
453
+ if (verdict.state === 'pending') return;
454
+ if (verdict.state === 'failed') {
455
+ this.stop(task, 'Critic review failed: ' + verdict.reason);
456
+ return;
457
+ }
458
+ if (verdict.state === 'changes_needed') {
459
+ const round = this.verification.critic!.reopens(task);
460
+ if (round > CRITIC_REOPENS || Number(r.remaining_steps) < 1) {
461
+ this.stop(task, 'Critic requested changes');
462
+ return;
463
+ }
464
+ if (!(await this.worker(task)) || this.stopped) return;
465
+ this.tasks.reopenForCritic(
466
+ task.id,
467
+ task.steps.at(-1)!.id,
468
+ round,
469
+ Number(r.auto_epoch),
470
+ verdict.findings
471
+ .map((f) => `- ${f.path}:${f.line} (${f.severity}) ${f.message}`)
472
+ .join('\n'),
473
+ );
474
+ return;
475
+ }
476
+ }
430
477
  await this.verification.finishAsync(
431
478
  task.id,
432
479
  'auto-finish:' + task.id + ':' + task.plan_revision,