@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
@@ -1,12 +1,15 @@
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 { PROTOCOL_VERSION, canonical, object, ServiceError, textDigest, workerTools, workerCapabilities, string, } from './protocol.js';
7
9
  import { PI_CLI, HOUSE_EXTENSION, POLICY_DIGEST, bundleDigest, VERSION, PI_VERSION, } from './config.js';
8
10
  import { readinessFingerprint } from './readiness-fingerprint.js';
9
11
  import { PiProcess, PiCommandError } from './pi.js';
12
+ import { mergeMcpProvenance } from './operator-mcp.js';
10
13
  import { OFFLINE_ROUTE, providerRoute, providerHasOutputCap, } from './provider-config.js';
11
14
  import { validateApiKey } from './credentials.js';
12
15
  import { resolveProviderAuth } from './provider-auth.js';
@@ -37,7 +40,18 @@ export function processIdentity(pid) {
37
40
  throw Error('Invalid process start identity');
38
41
  return { start: fields[19], group: fields[2], session: fields[3] };
39
42
  };
40
- const before = stat(), boot = readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim(), executable = readlinkSync(path + '/exe'), argv = readFileSync(path + '/cmdline');
43
+ const before = stat(), boot = readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim(),
44
+ // A tools-user launcher (sudo) is root-owned; its exe link is unreadable to the service.
45
+ executable = (() => {
46
+ try {
47
+ return readlinkSync(path + '/exe');
48
+ }
49
+ catch (error) {
50
+ if (error.code === 'EACCES')
51
+ return 'unreadable';
52
+ throw error;
53
+ }
54
+ })(), argv = readFileSync(path + '/cmdline');
41
55
  if (!/^[a-f0-9-]{36}$/.test(boot) ||
42
56
  !argv.length ||
43
57
  argv.length > 16384 ||
@@ -75,8 +89,12 @@ export class Supervisor extends EventEmitter {
75
89
  localCoding;
76
90
  tasks;
77
91
  compactionJobs = new Set();
92
+ fetchBreaker = new HostBreaker();
78
93
  managed;
79
94
  mcp;
95
+ operatorMcp;
96
+ /** Item 9: the host's pinned persona, given to every worker on this host. */
97
+ persona;
80
98
  toolkit;
81
99
  station;
82
100
  children;
@@ -118,7 +136,26 @@ export class Supervisor extends EventEmitter {
118
136
  const child = this.children?.owned(id);
119
137
  if (child)
120
138
  return JSON.parse(child.authority);
121
- return workerTools(!!this.options.effectsEnabled, !!this.children?.owned(id), this.codingBinding(id), undefined, !!this.mcp, !!this.toolkit, !!this.station, this.store.localWorkspace(id), this.store.localCoding(id));
139
+ return workerTools(!!this.options.effectsEnabled, !!this.children?.owned(id), this.codingBinding(id), undefined, !!this.mcp, !!this.toolkit, !!this.station, this.store.localWorkspace(id), this.store.localCoding(id), this.declaredMcp(id));
140
+ }
141
+ /** Operator-declared MCP reaches root sessions only; children get no MCP authority. */
142
+ declaredMcp(id) {
143
+ return !!this.operatorMcp && !this.operatorMcp.empty && !this.children?.owned(id);
144
+ }
145
+ /** Every MCP server the session got, from both sources; persisted beside the worker. */
146
+ mcpServers(id) {
147
+ const live = this.workers.get(id)?.mcpServers;
148
+ if (live)
149
+ return structuredClone(live);
150
+ try {
151
+ const path = join(this.options.stateDir, 'workers', id, 'mcp-servers.json');
152
+ return existsSync(path)
153
+ ? JSON.parse(readFileSync(path, 'utf8'))
154
+ : undefined;
155
+ }
156
+ catch {
157
+ return undefined;
158
+ }
122
159
  }
123
160
  childBoundary(id, epoch) {
124
161
  const child = this.children?.owned(id);
@@ -149,6 +186,10 @@ export class Supervisor extends EventEmitter {
149
186
  }
150
187
  bootId = randomUUID();
151
188
  workers = new Map();
189
+ boardReadsStore;
190
+ get boardReads() {
191
+ return (this.boardReadsStore ??= new BoardReads(this.store));
192
+ }
152
193
  monitor;
153
194
  bundle;
154
195
  constructor(store, options) {
@@ -276,6 +317,18 @@ export class Supervisor extends EventEmitter {
276
317
  worker.mcp = await this.mcp.ready(id);
277
318
  if (this.toolkit && !child)
278
319
  worker.toolkit = await this.toolkit.ready(id);
320
+ if (this.declaredMcp(id))
321
+ worker.operatorMcp = this.operatorMcp.provenance();
322
+ if (worker.operatorMcp || worker.mcp) {
323
+ // Collisions are refused at admission; nothing is silently shadowed.
324
+ worker.mcpServers = mergeMcpProvenance(worker.operatorMcp ?? [], (worker.mcp?.servers ?? []).map((server) => ({
325
+ name: server.name,
326
+ source: 'agentpod',
327
+ transport: 'http',
328
+ endpoint_digest: server.endpoint_digest,
329
+ declaration_digest: digest(server),
330
+ })));
331
+ }
279
332
  if (this.station && !child)
280
333
  worker.station = this.station.ready(id);
281
334
  this.store.execution.assert(id);
@@ -290,6 +343,11 @@ export class Supervisor extends EventEmitter {
290
343
  }), { mode: 0o600 });
291
344
  const sessionDir = join(workerDir, 'sessions');
292
345
  mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
346
+ const provenancePath = join(workerDir, 'mcp-servers.json');
347
+ if (worker.mcpServers)
348
+ writeFileSync(provenancePath, JSON.stringify(worker.mcpServers, null, 2), { mode: 0o600 });
349
+ else
350
+ rmSync(provenancePath, { force: true });
293
351
  const config = {
294
352
  socket: this.options.bridgeSocket,
295
353
  token: worker.token,
@@ -306,8 +364,11 @@ export class Supervisor extends EventEmitter {
306
364
  local: this.store.localWorkspace(id),
307
365
  localCoding: this.store.localCoding(id),
308
366
  ...(worker.mcp ? { mcp: worker.mcp } : {}),
367
+ ...(worker.operatorMcp ? { operator_mcp: worker.operatorMcp } : {}),
368
+ ...(worker.mcpServers ? { mcp_servers: worker.mcpServers } : {}),
309
369
  ...(worker.toolkit ? { toolkit: worker.toolkit } : {}),
310
370
  ...(worker.station ? { station: worker.station } : {}),
371
+ ...(this.persona ? { persona: this.persona } : {}),
311
372
  };
312
373
  const args = [
313
374
  PI_CLI,
@@ -543,6 +604,36 @@ export class Supervisor extends EventEmitter {
543
604
  }
544
605
  return w;
545
606
  }
607
+ /** Is this attempt a task step that has acceptance checks to decide it? */
608
+ checkedStepAttempt(attempt) {
609
+ const row = this.store.db
610
+ .prepare('SELECT checks FROM task_steps WHERE attempt_id=?')
611
+ .get(attempt);
612
+ try {
613
+ const checks = row?.checks ? JSON.parse(String(row.checks)) : [];
614
+ return Array.isArray(checks) && checks.length > 0;
615
+ }
616
+ catch {
617
+ return false;
618
+ }
619
+ }
620
+ /** A station call refused before it runs leaves no station_calls row, so record its
621
+ * method, requested action and refusal code: never its path, arguments or content. */
622
+ recordStationRefusal(id, method, body, error) {
623
+ const args = body.args && typeof body.args === 'object' ? body.args : {};
624
+ const ident = (v) => typeof v === 'string' && /^[A-Za-z0-9_.:-]{1,64}$/.test(v) ? v : null;
625
+ try {
626
+ this.store.transaction(() => this.store.event(id, 'station.call_refused', {
627
+ attempt_id: this.workers.get(id)?.attempt ?? null,
628
+ method: ident(method),
629
+ action: ident(args.action),
630
+ code: error instanceof ServiceError ? ident(error.code) : 'error',
631
+ }));
632
+ }
633
+ catch {
634
+ // Recording a refusal never changes the refusal itself.
635
+ }
636
+ }
546
637
  async bridge(id, method, token, body) {
547
638
  const w = this.authenticate(id, token, body);
548
639
  const route = w.route;
@@ -587,8 +678,14 @@ export class Supervisor extends EventEmitter {
587
678
  context_digest: textDigest(w.frame),
588
679
  ...(boundary ? { worker_binding: boundary } : {}),
589
680
  ...(w.mcp ? { mcp: w.mcp } : {}),
681
+ ...(w.operatorMcp ? { operator_mcp: w.operatorMcp } : {}),
682
+ ...(w.mcpServers
683
+ ? { mcp_servers: w.mcpServers, mcp_servers_digest: digest(w.mcpServers) }
684
+ : {}),
590
685
  ...(w.toolkit ? { toolkit: w.toolkit } : {}),
591
686
  ...(w.station ? { station: w.station } : {}),
687
+ // Item 9: the worker proves it runs with the host's pinned persona.
688
+ ...(this.persona ? { persona_digest: this.persona.digest } : {}),
592
689
  };
593
690
  for (const [key, value] of Object.entries(expected))
594
691
  if (canonical(body[key]) !== canonical(value))
@@ -680,6 +777,45 @@ export class Supervisor extends EventEmitter {
680
777
  w.secrets = [...new Set([...(w.secrets ?? []), ...credentialStrings(auth)])];
681
778
  return auth;
682
779
  }
780
+ if (method.startsWith('operator_mcp.')) {
781
+ if (!this.declaredMcp(id) || !w.operatorMcp || !w.attempt)
782
+ throw new ServiceError('mcp_disabled', 'Worker has no declared MCP servers', 403);
783
+ this.assertActive(id, w.epoch, w.attempt);
784
+ if (method === 'operator_mcp.catalog')
785
+ return await this.operatorMcp.catalog(id, body.server, body.tool);
786
+ if (method !== 'operator_mcp.call')
787
+ throw new ServiceError('invalid_field', 'Unknown declared MCP method', 400);
788
+ const attempt = w.attempt;
789
+ const args = body.arguments ?? {};
790
+ const server = String(body.server ?? ''), tool = String(body.tool ?? '');
791
+ // A host failing again and again is paused briefly (never called, so no operator MCP call
792
+ // is recorded); the agent gets a tool error and the run carries on.
793
+ return await callThroughBreaker(this.fetchBreaker, id, args, (paused) => this.store.transaction(() => this.store.event(id, 'fetch.host_paused', { attempt_id: attempt, ...paused })), async () => {
794
+ try {
795
+ const result = await this.operatorMcp.call(id, body.server, body.tool, args);
796
+ this.store.transaction(() => this.store.event(id, 'operator_mcp.call', {
797
+ attempt_id: attempt,
798
+ server,
799
+ tool,
800
+ argument_digest: digest(args),
801
+ outcome: result.is_error ? 'tool_error' : 'returned',
802
+ result_digest: digest(result),
803
+ }));
804
+ return result;
805
+ }
806
+ catch (error) {
807
+ this.store.transaction(() => this.store.event(id, 'operator_mcp.call', {
808
+ attempt_id: attempt,
809
+ server,
810
+ tool,
811
+ argument_digest: digest(args),
812
+ outcome: 'failed',
813
+ error: error.code ?? 'mcp_server_failed',
814
+ }));
815
+ throw error;
816
+ }
817
+ });
818
+ }
683
819
  if (method.startsWith('toolkit.')) {
684
820
  if (!this.toolkit || !w.toolkit || !w.attempt || this.children?.owned(id))
685
821
  throw new ServiceError('toolkit_disabled', 'Worker has no native Toolkit authority', 403);
@@ -732,6 +868,15 @@ export class Supervisor extends EventEmitter {
732
868
  : {}),
733
869
  };
734
870
  }
871
+ if (method === 'board.read' || method === 'board.status') {
872
+ // Read-only board reads, served by the hub (operator ruling 2026-10-28, option iii).
873
+ if (!this.station || !w.station || !w.attempt || this.children?.owned(id))
874
+ throw new ServiceError('station_disabled', 'Board reads need protected station work');
875
+ this.assertActive(id, w.epoch, w.attempt);
876
+ return method === 'board.read'
877
+ ? this.boardReads.request(id, string(body.tool_call_id, 'tool_call_id', 512), object(body.args))
878
+ : this.boardReads.get(id, string(body.read_id, 'read_id', 64));
879
+ }
735
880
  if (method.startsWith('station.')) {
736
881
  if (!this.station || !w.station || !w.attempt || this.children?.owned(id))
737
882
  throw new ServiceError('station_disabled', 'Station execution is unavailable');
@@ -948,6 +1093,8 @@ export class Supervisor extends EventEmitter {
948
1093
  }
949
1094
  catch (error) {
950
1095
  this.store.taskBudgetDenied?.(id, error);
1096
+ if (method.startsWith('station.'))
1097
+ this.recordStationRefusal(id, method, body, error);
951
1098
  if (method.startsWith('station.') &&
952
1099
  error instanceof ServiceError &&
953
1100
  [
@@ -960,6 +1107,16 @@ export class Supervisor extends EventEmitter {
960
1107
  'not_found',
961
1108
  ].includes(error.code))
962
1109
  throw error;
1110
+ if (method.startsWith('operator_mcp.') &&
1111
+ error instanceof ServiceError &&
1112
+ [
1113
+ 'invalid_field',
1114
+ 'invalid_mcp_server',
1115
+ 'invalid_mcp_tool',
1116
+ 'invalid_mcp_arguments',
1117
+ 'mcp_server_failed',
1118
+ ].includes(error.code))
1119
+ throw error;
963
1120
  if ((method.startsWith('mcp.') || method.startsWith('toolkit.')) &&
964
1121
  error instanceof ServiceError &&
965
1122
  [
@@ -1064,6 +1221,7 @@ export class Supervisor extends EventEmitter {
1064
1221
  ? null
1065
1222
  : 'No provider-enforced output cap; tasks must explicitly disable token budgeting',
1066
1223
  },
1224
+ ...(this.mcpServers(id) ? { mcp_servers: this.mcpServers(id) } : {}),
1067
1225
  };
1068
1226
  const w = this.workers.get(id);
1069
1227
  if (w?.invalid)
@@ -1317,11 +1475,17 @@ export class Supervisor extends EventEmitter {
1317
1475
  outcome = candidate;
1318
1476
  }
1319
1477
  }
1320
- if (value.type === 'tool_execution_end' && value.isError === true)
1478
+ // A checked task step is decided by its checks, not by a tool error the model went on
1479
+ // to recover from (operator decision, 2026-10-23). The error stays in the journal
1480
+ // (tool.finished failed=true). A delegated child turn, whose denied probes are its
1481
+ // boundary proof, and any attempt outside a checked step still fail on it.
1482
+ if (value.type === 'tool_execution_end' &&
1483
+ value.isError === true &&
1484
+ (this.children?.owned(id) || !this.checkedStepAttempt(work.attempt.id)))
1321
1485
  outcome ??= {
1322
1486
  version: 1,
1323
1487
  stopReason: 'error',
1324
- failure: acpFailure(new ServiceError('tool_failed', 'Tool execution failed')),
1488
+ failure: acpFailure(new ServiceError('tool_failed', toolFailureMessage(value.toolName))),
1325
1489
  };
1326
1490
  };
1327
1491
  w.pi.on('record', record);
@@ -1386,6 +1550,7 @@ export class Supervisor extends EventEmitter {
1386
1550
  try {
1387
1551
  this.managed?.invalidateWorker(w.id, w.epoch);
1388
1552
  this.cancelMcpWorker(w);
1553
+ void this.operatorMcp?.stopSession(w.id).catch(() => { });
1389
1554
  this.children?.workerLost(w.id, 'Worker stopped before child dispatch');
1390
1555
  }
1391
1556
  catch {
@@ -1414,3 +1579,10 @@ export class Supervisor extends EventEmitter {
1414
1579
  throw new AggregateError(errors, 'Worker shutdown could not persist all final states');
1415
1580
  }
1416
1581
  }
1582
+ /** Names the failing tool (never its arguments or output), so a failed step says which call
1583
+ * failed it. Only a plain tool identifier is echoed. */
1584
+ export function toolFailureMessage(toolName) {
1585
+ return typeof toolName === 'string' && /^[A-Za-z0-9_.:-]{1,64}$/.test(toolName)
1586
+ ? `Tool execution failed (tool ${toolName})`
1587
+ : 'Tool execution failed';
1588
+ }
@@ -6,6 +6,12 @@ export class TaskBudgetError extends ServiceError {
6
6
  this.session_id = session_id;
7
7
  }
8
8
  }
9
+ /** Tools a critic review child may hold and still be left out of the verification busy check. */
10
+ export const READ_ONLY_REVIEW_TOOLS = [
11
+ 'salaam_findings',
12
+ 'salaam_probe',
13
+ 'salaam_repository',
14
+ ];
9
15
  export class TaskBudget {
10
16
  store;
11
17
  tasks;
@@ -23,6 +29,8 @@ export class TaskBudget {
23
29
  store.taskChildFinished = (attempt) => this.releaseChild(attempt);
24
30
  store.taskCommandAdmission = (session) => this.command(session);
25
31
  store.taskRemainingRuntime = (session) => this.remaining(session);
32
+ // Only the task's own session: a child's attempt keeps the budget its reservation set.
33
+ store.taskAttemptRequestLimit = (session) => this.tasks.forSession(session)?.limits.requests;
26
34
  }
27
35
  task(session) {
28
36
  const direct = this.tasks.forSession(session);
@@ -39,14 +47,42 @@ export class TaskBudget {
39
47
  .n);
40
48
  return { ...runtime, invocations_spent: n, external_model_usage: n ? 'unknown' : 'none' };
41
49
  }
42
- active(task) {
50
+ /** The child attempt of each running critic review of this task revision whose recorded
51
+ * tools are all read-only (operator ruling 2026-10-30, option A). Only verification reads may
52
+ * leave these out of the busy count: the reviewer reads a digest-bound snapshot and cannot
53
+ * write the workspace, run a command or reach the station (staging card_019a647a169744ff). */
54
+ readOnlyReviews(task) {
55
+ if (!this.store.db
56
+ .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='task_critics'")
57
+ .get())
58
+ return [];
59
+ const rows = this.store.db
60
+ .prepare("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=?")
61
+ .all(task.id, task.plan_revision, task.session_id);
62
+ return rows
63
+ .filter((r) => {
64
+ try {
65
+ const tools = JSON.parse(r.authority);
66
+ return (Array.isArray(tools) &&
67
+ tools.every((t) => READ_ONLY_REVIEW_TOOLS.includes(t)));
68
+ }
69
+ catch {
70
+ return false;
71
+ }
72
+ })
73
+ .map((r) => r.child_attempt);
74
+ }
75
+ /** Live work in the task's session tree. `verification` leaves out a running read-only review's
76
+ * child attempt, for verification reads only; admission and concurrency count everything. */
77
+ active(task, opts = {}) {
78
+ const excluded = JSON.stringify(opts.verification ? this.readOnlyReviews(task) : []);
43
79
  const row = this.store.db
44
80
  .prepare(`WITH tree(id) AS (SELECT ? UNION ALL SELECT child_session FROM child_tasks WHERE parent_session=?) SELECT
45
- (SELECT count(*) FROM attempts WHERE session_id IN (SELECT id FROM tree) AND state IN ('accepted','dispatching','running')) +
81
+ (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(?))) +
46
82
  (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')) +
47
83
  (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')) +
48
84
  (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`)
49
- .get(task.session_id, task.session_id, task.session_id);
85
+ .get(task.session_id, task.session_id, excluded, task.session_id);
50
86
  return Number(row.n);
51
87
  }
52
88
  deny(session, message) {
@@ -0,0 +1,115 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ /**
3
+ * The completion critic (item 8). When the admitted plan declares a critic, the built-in reviewer
4
+ * reads an immutable snapshot of the verified deliverables against the plan's rubric before the
5
+ * task may complete. Its verdict is bound to the exact verified evidence (checks and tree): any
6
+ * later change makes it stale. A critical or high finding means changes are needed; a reviewer
7
+ * that reports nothing at all is a failure, never a pass.
8
+ */
9
+ export const CRITIC_BUDGET = 4;
10
+ export const CRITIC_REOPENS = 2;
11
+ export class TaskCritic {
12
+ store;
13
+ artifacts;
14
+ deps;
15
+ constructor(store, artifacts, deps) {
16
+ this.store = store;
17
+ this.artifacts = artifacts;
18
+ this.deps = deps;
19
+ store.db.exec(`CREATE TABLE IF NOT EXISTS task_critics(
20
+ id TEXT PRIMARY KEY NOT NULL,
21
+ task_id TEXT NOT NULL,
22
+ plan_revision INTEGER NOT NULL,
23
+ evidence_digest TEXT NOT NULL,
24
+ round INTEGER NOT NULL,
25
+ child_id TEXT NOT NULL,
26
+ state TEXT NOT NULL,
27
+ findings TEXT,
28
+ reason TEXT,
29
+ UNIQUE(task_id, plan_revision, evidence_digest))`);
30
+ }
31
+ /** Whether this exact verified evidence has a passing critic verdict. */
32
+ passed(task, evidenceDigest) {
33
+ return (this.store.db
34
+ .prepare("SELECT 1 FROM task_critics WHERE task_id=? AND plan_revision=? AND evidence_digest=? AND state='pass'")
35
+ .get(task.id, task.plan_revision, evidenceDigest) !== undefined);
36
+ }
37
+ /** How many times the critic has sent this plan revision back for changes. */
38
+ reopens(task) {
39
+ return Number(this.store.db
40
+ .prepare("SELECT count(*) AS n FROM task_critics WHERE task_id=? AND plan_revision=? AND state='changes_needed'")
41
+ .get(task.id, task.plan_revision).n);
42
+ }
43
+ /** The verdict for this verified evidence, starting the reviewer the first time it is asked. */
44
+ async gate(task, evidenceDigest, deliverables) {
45
+ const critic = task.completion_gate?.critic;
46
+ if (!critic)
47
+ return { state: 'pass' };
48
+ const row = this.store.db
49
+ .prepare('SELECT * FROM task_critics WHERE task_id=? AND plan_revision=? AND evidence_digest=?')
50
+ .get(task.id, task.plan_revision, evidenceDigest);
51
+ if (row)
52
+ return this.settle(row);
53
+ const last = this.store.db
54
+ .prepare('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')
55
+ .get(task.id);
56
+ if (!last)
57
+ return { state: 'failed', reason: 'No step attempt for the critic to follow' };
58
+ if (!deliverables.length)
59
+ return { state: 'failed', reason: 'No verified deliverables to review' };
60
+ const round = Number(this.store.db
61
+ .prepare('SELECT count(*) AS n FROM task_critics WHERE task_id=? AND plan_revision=?')
62
+ .get(task.id, task.plan_revision).n) + 1;
63
+ const snapshot = Object.fromEntries(deliverables.map((d) => [
64
+ d.path,
65
+ { content: Buffer.from(d.content, 'utf8').toString('base64'), executable: false },
66
+ ]));
67
+ const grant = await this.deps.admit(task, `critic:${task.id}:${task.plan_revision}`);
68
+ const child = this.deps.spawn(task, String(last.attempt_id), `critic-${task.plan_revision}-${round}`, [
69
+ 'Judge the delivered work against this rubric before it is accepted:',
70
+ critic.rubric,
71
+ '',
72
+ 'Objective: ' + task.objective,
73
+ 'Deliverables (the snapshot): ' + deliverables.map((d) => d.path).join(', '),
74
+ '',
75
+ 'Report findings with salaam_findings, citing the file and line. Use critical or high only',
76
+ 'for what must change before the work is accepted; medium or low for suggestions. If the',
77
+ 'work meets the rubric, report an empty list.',
78
+ ].join('\n'), CRITIC_BUDGET, grant, snapshot);
79
+ this.store.db
80
+ .prepare("INSERT INTO task_critics VALUES (?,?,?,?,?,?,'running',NULL,NULL)")
81
+ .run(randomUUID(), task.id, task.plan_revision, evidenceDigest, round, child.id);
82
+ return { state: 'pending' };
83
+ }
84
+ settle(row) {
85
+ if (row.state === 'pass')
86
+ return { state: 'pass' };
87
+ if (row.state === 'changes_needed')
88
+ return { state: 'changes_needed', findings: JSON.parse(String(row.findings)) };
89
+ if (row.state === 'failed')
90
+ return { state: 'failed', reason: String(row.reason) };
91
+ const child = this.store.db
92
+ .prepare('SELECT state FROM child_tasks WHERE id=?')
93
+ .get(String(row.child_id));
94
+ if (!child || child.state === 'accepted')
95
+ return { state: 'pending' };
96
+ const report = this.store.db
97
+ .prepare('SELECT artifact_id FROM specialist_reports WHERE worker_id=?')
98
+ .get(this.deps.workerId(String(row.child_id)));
99
+ const set = (state, findings, reason) => this.store.db
100
+ .prepare('UPDATE task_critics SET state=?,findings=?,reason=? WHERE id=?')
101
+ .run(state, findings ? JSON.stringify(findings) : null, reason, String(row.id));
102
+ if (!report) {
103
+ const reason = 'The critic returned no findings report';
104
+ set('failed', null, reason);
105
+ return { state: 'failed', reason };
106
+ }
107
+ const findings = JSON.parse(this.artifacts.read(String(report.artifact_id))).findings.map(({ path, line, severity, message }) => ({ path, line, severity, message }));
108
+ if (findings.some((f) => f.severity === 'critical' || f.severity === 'high')) {
109
+ set('changes_needed', findings, null);
110
+ return { state: 'changes_needed', findings };
111
+ }
112
+ set('pass', findings, null);
113
+ return { state: 'pass' };
114
+ }
115
+ }
@@ -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 { commandTerminal } from './coding.js';
3
6
  import { canonical, ServiceError } from './protocol.js';
@@ -14,6 +17,7 @@ export class TaskDriver {
14
17
  awaitingAuthorityRenewal;
15
18
  active;
16
19
  stopped = true;
20
+ rateLimits;
17
21
  constructor(store, tasks, budget, verification, supervisor, children, coding) {
18
22
  this.store = store;
19
23
  this.tasks = tasks;
@@ -22,6 +26,7 @@ export class TaskDriver {
22
26
  this.supervisor = supervisor;
23
27
  this.children = children;
24
28
  this.coding = coding;
29
+ this.rateLimits = new RateLimitRetries(store);
25
30
  tasks.assertProgressionBinding = (task, binding) => {
26
31
  if (canonical(this.verification.binding(task)) !== binding)
27
32
  throw new ServiceError('task_progression_stale', 'Workspace or execution environment changed since the bounded grant');
@@ -295,6 +300,22 @@ export class TaskDriver {
295
300
  return;
296
301
  }
297
302
  if (task.steps.some((s) => ['interrupted', 'failed', 'cancelled'].includes(s.state))) {
303
+ // A provider 429 is retried as a fresh attempt of the step after a backoff; past the limit,
304
+ // or with no progression step left, the task stops with a block recovery can settle.
305
+ const retry = this.rateLimits.decide(task);
306
+ if (retry.action === 'wait')
307
+ return;
308
+ if (retry.action === 'retry' && Number(r.remaining_steps) >= 1) {
309
+ if (!(await this.worker(task)) || this.stopped)
310
+ return;
311
+ this.tasks.retryStep(task.id, retry.step, retry.n, Number(r.auto_epoch));
312
+ return;
313
+ }
314
+ if (retry.action !== 'none') {
315
+ this.budget.block(task.session_id, RATE_LIMIT_STOP);
316
+ this.stop(task, RATE_LIMIT_STOP);
317
+ return;
318
+ }
298
319
  this.stop(task, 'An interrupted or failed step requires explicit operator review');
299
320
  return;
300
321
  }
@@ -334,14 +355,37 @@ export class TaskDriver {
334
355
  // A passed receipt cannot be re-observed while another final check owns
335
356
  // the native workspace. Wait for the whole set before testing freshness;
336
357
  // finishAsync still performs the final transactional evidence validation.
337
- if (task.acceptance.some((check) => {
358
+ if (requiredChecks(task).some((check) => {
338
359
  const receipt = this.verification.latest(task, check.key, null);
339
360
  return receipt?.state === 'accepted' || receipt?.state === 'running';
340
361
  }))
341
362
  return;
342
- for (const check of task.acceptance)
363
+ for (const check of requiredChecks(task))
343
364
  if (!(await this.check(task, check.key, null)))
344
365
  return;
366
+ // Item 8: a declared critic reviews the verified deliverables before completion.
367
+ if (task.completion_gate?.critic) {
368
+ const verdict = await this.verification.criticGateAsync(task);
369
+ if (verdict.state === 'pending')
370
+ return;
371
+ if (verdict.state === 'failed') {
372
+ this.stop(task, 'Critic review failed: ' + verdict.reason);
373
+ return;
374
+ }
375
+ if (verdict.state === 'changes_needed') {
376
+ const round = this.verification.critic.reopens(task);
377
+ if (round > CRITIC_REOPENS || Number(r.remaining_steps) < 1) {
378
+ this.stop(task, 'Critic requested changes');
379
+ return;
380
+ }
381
+ if (!(await this.worker(task)) || this.stopped)
382
+ return;
383
+ this.tasks.reopenForCritic(task.id, task.steps.at(-1).id, round, Number(r.auto_epoch), verdict.findings
384
+ .map((f) => `- ${f.path}:${f.line} (${f.severity}) ${f.message}`)
385
+ .join('\n'));
386
+ return;
387
+ }
388
+ }
345
389
  await this.verification.finishAsync(task.id, 'auto-finish:' + task.id + ':' + task.plan_revision, task.revision, null, () => {
346
390
  if (!this.budget.state(task.id).auto_enabled)
347
391
  throw new ServiceError('task_authority', 'Progression paused before completion');
@@ -1,5 +1,23 @@
1
1
  import { integer, object, ServiceError, string } from './protocol.js';
2
2
  import { filePath } from './workspaces.js';
3
+ /** Every check completion requires: the acceptance checks, then each hook as hook-<key>. */
4
+ export function requiredChecks(policy) {
5
+ return [
6
+ ...policy.acceptance,
7
+ ...(policy.completion_gate?.hooks ?? []).map((h) => ({ ...h, key: 'hook-' + h.key })),
8
+ ];
9
+ }
10
+ const check = (v, keys, what) => {
11
+ const c = object(v), key = string(c.key, what + ' key', 64);
12
+ if (!/^[a-zA-Z0-9_-]+$/.test(key) || keys.has(key))
13
+ throw new ServiceError('invalid_plan', what + ' keys must be unique identifiers', 400);
14
+ keys.add(key);
15
+ return {
16
+ key,
17
+ command: string(c.command, what + ' command', 4096),
18
+ cwd: c.cwd === undefined || c.cwd === '.' ? '.' : filePath(c.cwd),
19
+ };
20
+ };
3
21
  export const DEFAULT_TASK_LIMITS = {
4
22
  requests: 64,
5
23
  runtime_ms: 900000,
@@ -14,17 +32,7 @@ export function taskPolicy(value) {
14
32
  if (!Array.isArray(raw) || raw.length > 8)
15
33
  throw new ServiceError('invalid_plan', 'Supply at most eight acceptance checks', 400);
16
34
  const keys = new Set();
17
- const acceptance = raw.map((v) => {
18
- const c = object(v), key = string(c.key, 'check key', 64);
19
- if (!/^[a-zA-Z0-9_-]+$/.test(key) || keys.has(key))
20
- throw new ServiceError('invalid_plan', 'Acceptance keys must be unique identifiers', 400);
21
- keys.add(key);
22
- return {
23
- key,
24
- command: string(c.command, 'check command', 4096),
25
- cwd: c.cwd === undefined || c.cwd === '.' ? '.' : filePath(c.cwd),
26
- };
27
- });
35
+ const acceptance = raw.map((v) => check(v, keys, 'Acceptance'));
28
36
  const paths = value.deliverables ?? [];
29
37
  if (!Array.isArray(paths) || paths.length > 32)
30
38
  throw new ServiceError('invalid_plan', 'Supply at most 32 deliverable paths', 400);
@@ -66,5 +74,29 @@ export function taskPolicy(value) {
66
74
  throw new ServiceError('invalid_budget', 'Invocation limit must be 1–64', 400);
67
75
  limits.invocations = n;
68
76
  }
69
- return { acceptance, deliverables, limits };
77
+ let completion_gate;
78
+ if (value.completion_gate !== undefined) {
79
+ const g = object(value.completion_gate);
80
+ const hooks = g.hooks;
81
+ const c = g.critic;
82
+ const critic = c === null
83
+ ? null
84
+ : (() => {
85
+ const v = object(c);
86
+ if (Object.keys(v).join(',') !== 'rubric')
87
+ throw new ServiceError('invalid_plan', 'A critic names only its rubric', 400);
88
+ return { rubric: string(v.rubric, 'critic rubric', 4096) };
89
+ })();
90
+ if (Object.keys(g).sort().join(',') !== 'critic,hooks' ||
91
+ !Array.isArray(hooks) ||
92
+ hooks.length > 8 ||
93
+ (!critic && hooks.length < 1))
94
+ throw new ServiceError('invalid_plan', 'A completion gate has a critic or 1–8 hooks, and at most 8 hooks', 400);
95
+ const hookKeys = new Set();
96
+ completion_gate = { critic, hooks: hooks.map((v) => check(v, hookKeys, 'Hook')) };
97
+ if (requiredChecks({ acceptance, completion_gate }).length !==
98
+ new Set(requiredChecks({ acceptance, completion_gate }).map((c) => c.key)).size)
99
+ throw new ServiceError('invalid_plan', 'A hook collides with an acceptance check', 400);
100
+ }
101
+ return { acceptance, deliverables, limits, ...(completion_gate ? { completion_gate } : {}) };
70
102
  }