@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.
- package/CHANGELOG.md +45 -0
- package/README.md +12 -11
- package/dist/src/acp-outcome.js +177 -17
- package/dist/src/acp.js +41 -7
- package/dist/src/agent-profiles.js +45 -0
- package/dist/src/agentpod-authority.js +169 -11
- package/dist/src/agentpod-default-config.js +12 -0
- package/dist/src/agentpod-work-decision-protocol.js +40 -6
- package/dist/src/agentpod-work-decisions.js +108 -4
- package/dist/src/agentpod-work.js +1214 -127
- package/dist/src/board-reads.js +106 -0
- package/dist/src/children.js +83 -0
- package/dist/src/cli-help.js +27 -2
- package/dist/src/cli.js +87 -3
- package/dist/src/command-network.js +200 -0
- package/dist/src/command-sandbox.js +217 -0
- package/dist/src/execution-evidence.js +33 -1
- package/dist/src/host-bind.js +111 -0
- package/dist/src/host-breaker.js +88 -0
- package/dist/src/host-start.js +41 -1
- package/dist/src/host.js +192 -6
- package/dist/src/house-extension.js +162 -57
- package/dist/src/local-coding.js +214 -18
- package/dist/src/local-edit-helper.js +25 -0
- package/dist/src/local-undo.js +25 -16
- package/dist/src/managed.js +1 -1
- package/dist/src/operator-mcp-cli.js +107 -0
- package/dist/src/operator-mcp.js +342 -0
- package/dist/src/persona.js +31 -0
- package/dist/src/pi.js +22 -1
- package/dist/src/protocol.js +5 -2
- package/dist/src/rate-limit-retries.js +62 -0
- package/dist/src/recurring-work.js +1 -0
- package/dist/src/repository-snapshots.js +5 -2
- package/dist/src/run-grants.js +62 -0
- package/dist/src/service.js +141 -6
- package/dist/src/station-checkpoints.js +139 -0
- package/dist/src/station-config.js +8 -1
- package/dist/src/station-files.js +20 -1
- package/dist/src/station-task-verification.js +6 -3
- package/dist/src/station-work.js +315 -22
- package/dist/src/store.js +19 -0
- package/dist/src/supervisor.js +177 -5
- package/dist/src/task-budget.js +39 -3
- package/dist/src/task-critic.js +115 -0
- package/dist/src/task-driver.js +46 -2
- package/dist/src/task-policy.js +44 -12
- package/dist/src/task-verification.js +42 -9
- package/dist/src/task-work.js +47 -0
- package/dist/src/tool-identity.js +69 -0
- package/dist/src/usage.js +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/acp-outcome.ts +169 -19
- package/src/acp.ts +53 -7
- package/src/agent-profiles.ts +48 -0
- package/src/agentpod-authority.ts +234 -15
- package/src/agentpod-default-config.ts +16 -0
- package/src/agentpod-work-decision-protocol.ts +69 -8
- package/src/agentpod-work-decisions.ts +123 -5
- package/src/agentpod-work.ts +1455 -152
- package/src/board-reads.ts +148 -0
- package/src/children.ts +112 -0
- package/src/cli-help.ts +27 -2
- package/src/cli.ts +109 -3
- package/src/command-network.ts +215 -0
- package/src/command-sandbox.ts +273 -0
- package/src/execution-evidence.ts +31 -1
- package/src/host-bind.ts +156 -0
- package/src/host-breaker.ts +89 -0
- package/src/host-start.ts +51 -1
- package/src/host.ts +244 -6
- package/src/house-extension.ts +174 -56
- package/src/local-coding.ts +270 -18
- package/src/local-edit-helper.ts +26 -0
- package/src/local-undo.ts +29 -14
- package/src/managed.ts +2 -2
- package/src/operator-mcp-cli.ts +108 -0
- package/src/operator-mcp.ts +420 -0
- package/src/persona.ts +37 -0
- package/src/pi.ts +23 -1
- package/src/protocol.ts +6 -1
- package/src/rate-limit-retries.ts +69 -0
- package/src/recurring-work.ts +1 -0
- package/src/repository-snapshots.ts +5 -2
- package/src/run-grants.ts +107 -0
- package/src/service.ts +222 -6
- package/src/station-checkpoints.ts +183 -0
- package/src/station-config.ts +10 -1
- package/src/station-files.ts +20 -1
- package/src/station-task-verification.ts +6 -3
- package/src/station-work.ts +369 -20
- package/src/store.ts +18 -0
- package/src/supervisor.ts +203 -4
- package/src/task-budget.ts +48 -3
- package/src/task-critic.ts +171 -0
- package/src/task-driver.ts +49 -2
- package/src/task-policy.ts +65 -13
- package/src/task-verification.ts +53 -9
- package/src/task-work.ts +75 -0
- package/src/tool-identity.ts +110 -0
- 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
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
+
}
|
package/src/task-budget.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/task-driver.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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,
|