@amenophis1er/foreman 0.1.0

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 (65) hide show
  1. package/DESIGN.md +408 -0
  2. package/LICENSE +15 -0
  3. package/README.md +133 -0
  4. package/bin/foreman.mjs +58 -0
  5. package/package.json +68 -0
  6. package/scripts/prepare.mjs +48 -0
  7. package/skills/director/SKILL.md +65 -0
  8. package/src/anthropic-models.ts +54 -0
  9. package/src/ask.test.ts +88 -0
  10. package/src/ask.ts +95 -0
  11. package/src/attachments.test.ts +33 -0
  12. package/src/attachments.ts +60 -0
  13. package/src/cli.test.ts +27 -0
  14. package/src/cli.ts +297 -0
  15. package/src/codex.test.ts +328 -0
  16. package/src/codex.ts +196 -0
  17. package/src/cost-basis.test.ts +76 -0
  18. package/src/deck.test.ts +402 -0
  19. package/src/deck.ts +892 -0
  20. package/src/fork.test.ts +31 -0
  21. package/src/gateway/ledger.cjs +326 -0
  22. package/src/gateway/ledger.test.ts +255 -0
  23. package/src/gateway/llm-gateway.cjs +1411 -0
  24. package/src/gateway/llm-gateway.test.ts +478 -0
  25. package/src/gateway.test.ts +226 -0
  26. package/src/gateway.ts +309 -0
  27. package/src/instance.ts +124 -0
  28. package/src/models.test.ts +147 -0
  29. package/src/models.ts +158 -0
  30. package/src/notify/commands.test.ts +28 -0
  31. package/src/notify/commands.ts +73 -0
  32. package/src/notify/telegram.ts +259 -0
  33. package/src/notify.test.ts +343 -0
  34. package/src/notify.ts +495 -0
  35. package/src/ollama.test.ts +49 -0
  36. package/src/ollama.ts +49 -0
  37. package/src/openai-prices.test.ts +58 -0
  38. package/src/openai-prices.ts +106 -0
  39. package/src/orchestrator.test.ts +1147 -0
  40. package/src/orchestrator.ts +2325 -0
  41. package/src/planner.test.ts +60 -0
  42. package/src/planner.ts +505 -0
  43. package/src/policy.test.ts +411 -0
  44. package/src/policy.ts +599 -0
  45. package/src/preflight.ts +348 -0
  46. package/src/prices.test.ts +69 -0
  47. package/src/prices.ts +90 -0
  48. package/src/provider.test.ts +366 -0
  49. package/src/provider.ts +502 -0
  50. package/src/secrets.test.ts +143 -0
  51. package/src/secrets.ts +66 -0
  52. package/src/server.ts +1992 -0
  53. package/src/services.test.ts +53 -0
  54. package/src/services.ts +102 -0
  55. package/src/sse-events.test.ts +83 -0
  56. package/src/store.test.ts +119 -0
  57. package/src/store.ts +346 -0
  58. package/src/tailscale.test.ts +32 -0
  59. package/src/tailscale.ts +79 -0
  60. package/src/title.ts +138 -0
  61. package/src/types.ts +442 -0
  62. package/ui/dist/assets/index-LAj0Dy9p.css +1 -0
  63. package/ui/dist/assets/index-lcBy-uRZ.js +65 -0
  64. package/ui/dist/favicon.svg +8 -0
  65. package/ui/dist/index.html +14 -0
@@ -0,0 +1,1147 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
6
+ import {
7
+ DEFAULT_REPEAT_LIMIT, DIRECTOR_CHARTER, MissionRun, RECENT_LINES, WORKER_CHARTER, WORK_DIR, accumulateUsage,
8
+ activityHint, ensureIgnoreLines, loopingWorkerReport,
9
+ stalledWorkerReport, workerStatusBlock,
10
+ watchRepeats, watchSilence, REPEAT_EXEMPT, observeToolUse,
11
+ DEFAULT_ASK_TIMEOUT_MS, armAskTimeout, unattendedAnswer, unattendedDenyMessage,
12
+ } from './orchestrator.js';
13
+ import { makePolicy, type PendingPermission } from './policy.js';
14
+ import type { AgentEnv } from './provider.js';
15
+ import type { RunMeta } from './types.js';
16
+
17
+ function meta(over: Partial<RunMeta> = {}): RunMeta {
18
+ return {
19
+ id: 'run-1', folder: '/tmp/x', mission: 'test', budgetUsd: 5,
20
+ status: 'running', costUsd: 0, createdAt: Date.now(), workers: [],
21
+ ...over,
22
+ };
23
+ }
24
+
25
+ const noopAgentEnv = { director: {} as AgentEnv, worker: {} as AgentEnv };
26
+
27
+ // accumulateUsage is exported precisely so this needs no SDK, no query()
28
+ // mock, and no network — the shape it defends is the raw `usage` object off
29
+ // an SDK `result` message, which the tests below construct by hand.
30
+
31
+ test('accumulateUsage sums two result messages', () => {
32
+ const first = accumulateUsage(
33
+ { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
34
+ { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 10, cache_creation_input_tokens: 5 },
35
+ );
36
+ const second = accumulateUsage(first, {
37
+ input_tokens: 200, output_tokens: 75, cache_read_input_tokens: 0, cache_creation_input_tokens: 20,
38
+ });
39
+ assert.deepEqual(second, {
40
+ inputTokens: 300, outputTokens: 125, cacheReadTokens: 10, cacheWriteTokens: 25,
41
+ });
42
+ });
43
+
44
+ test('accumulateUsage tolerates a missing usage object', () => {
45
+ const start = { inputTokens: 1, outputTokens: 1, cacheReadTokens: 1, cacheWriteTokens: 1 };
46
+ assert.deepEqual(accumulateUsage(start, undefined), start);
47
+ assert.deepEqual(accumulateUsage(start, null), start);
48
+ });
49
+
50
+ test('accumulateUsage tolerates a partial usage object without producing NaN', () => {
51
+ const start = { inputTokens: 10, outputTokens: 10, cacheReadTokens: 10, cacheWriteTokens: 10 };
52
+ // Only input_tokens present — the other three fields are absent, as a
53
+ // provider that does not report cache usage at all would send it.
54
+ const next = accumulateUsage(start, { input_tokens: 40 });
55
+ assert.deepEqual(next, { inputTokens: 50, outputTokens: 10, cacheReadTokens: 10, cacheWriteTokens: 10 });
56
+ for (const v of Object.values(next)) assert.ok(Number.isFinite(v), `${v} is not finite`);
57
+ });
58
+
59
+ test('accumulateUsage ignores non-numeric or NaN fields rather than throwing or poisoning the sum', () => {
60
+ const start = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
61
+ const next = accumulateUsage(start, {
62
+ input_tokens: 'lots', output_tokens: NaN, cache_read_input_tokens: undefined, cache_creation_input_tokens: 5,
63
+ });
64
+ assert.deepEqual(next, { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 5 });
65
+ });
66
+
67
+ // MissionRun's own addUsage/turns bookkeeping is private, reached here via
68
+ // bracket access — the SDK connection it would otherwise require (query(),
69
+ // a live director) is out of scope for what this covers: the accumulation
70
+ // into meta, not the mission loop around it.
71
+
72
+ test('MissionRun accumulates usage across two result messages into meta.usage', () => {
73
+ const saved: RunMeta[] = [];
74
+ const run = new MissionRun(meta(), () => {}, (m) => saved.push({ ...m }), noopAgentEnv);
75
+
76
+ assert.deepEqual(run.meta.usage, { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 });
77
+
78
+ (run as unknown as { addUsage(raw: unknown): void }).addUsage({
79
+ input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 2, cache_creation_input_tokens: 1,
80
+ });
81
+ (run as unknown as { addUsage(raw: unknown): void }).addUsage({
82
+ input_tokens: 20, output_tokens: 8,
83
+ });
84
+
85
+ assert.deepEqual(run.meta.usage, { inputTokens: 30, outputTokens: 13, cacheReadTokens: 2, cacheWriteTokens: 1 });
86
+ assert.ok(saved.length >= 2, 'saveMeta should be called on every accumulation');
87
+ });
88
+
89
+ test('MissionRun does not throw on a result message with no usage object at all', () => {
90
+ const run = new MissionRun(meta(), () => {}, () => {}, noopAgentEnv);
91
+ assert.doesNotThrow(() => (run as unknown as { addUsage(raw: unknown): void }).addUsage(undefined));
92
+ assert.deepEqual(run.meta.usage, { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 });
93
+ });
94
+
95
+ test('MissionRun persists director turns into meta.turns as it counts them', () => {
96
+ const run = new MissionRun(meta(), () => {}, () => {}, noopAgentEnv) as unknown as {
97
+ turns: number;
98
+ addUsage(raw: unknown): void;
99
+ };
100
+ run.turns++;
101
+ run.turns++;
102
+ run.addUsage({ input_tokens: 1 });
103
+ assert.equal((run as unknown as { meta: RunMeta }).meta.turns, 2);
104
+ });
105
+
106
+ test('MissionRun resumes usage and turns from persisted meta rather than resetting to zero', () => {
107
+ const prior = meta({
108
+ usage: { inputTokens: 100, outputTokens: 40, cacheReadTokens: 5, cacheWriteTokens: 5 },
109
+ turns: 7,
110
+ });
111
+ const run = new MissionRun(prior, () => {}, () => {}, noopAgentEnv);
112
+ assert.deepEqual(run.meta.usage, { inputTokens: 100, outputTokens: 40, cacheReadTokens: 5, cacheWriteTokens: 5 });
113
+ assert.equal((run as unknown as { turns: number }).turns, 7);
114
+
115
+ (run as unknown as { addUsage(raw: unknown): void }).addUsage({ input_tokens: 1, output_tokens: 1 });
116
+ assert.deepEqual(run.meta.usage, { inputTokens: 101, outputTokens: 41, cacheReadTokens: 5, cacheWriteTokens: 5 });
117
+ });
118
+
119
+ test('cost event carries usage and metered alongside the dollar figure', () => {
120
+ const events: Array<{ event: string; data: unknown }> = [];
121
+ const run = new MissionRun(meta({ metered: false }), (event, data) => events.push({ event, data }), () => {}, noopAgentEnv);
122
+
123
+ (run as unknown as { addCost(usd: number | undefined): void }).addCost(0.01);
124
+
125
+ const cost = events.find((e) => e.event === 'cost');
126
+ assert.ok(cost, 'a cost event should have been emitted');
127
+ const data = cost!.data as { costUsd: number; budgetUsd: number; usage: unknown; metered: boolean };
128
+ assert.equal(data.costUsd, 0.01);
129
+ assert.equal(data.metered, false);
130
+ assert.deepEqual(data.usage, { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 });
131
+ });
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // A director's exit is not proof its mission succeeded
135
+ // ---------------------------------------------------------------------------
136
+
137
+ /** Writes a mission doc into a temp folder and reads back the unmet criteria. */
138
+ async function unmetFor(doc: string | null): Promise<string[] | null> {
139
+ const folder = await mkdtemp(path.join(os.tmpdir(), 'foreman-donewhen-'));
140
+ if (doc !== null) {
141
+ await mkdir(path.join(folder, '.foreman'), { recursive: true });
142
+ await writeFile(path.join(folder, '.foreman', 'MISSION.md'), doc);
143
+ }
144
+ const run = new MissionRun({ ...meta(), folder }, () => {}, () => {}, noopAgentEnv);
145
+ try {
146
+ return await (run as unknown as { unmetCriteria(): Promise<string[] | null> }).unmetCriteria();
147
+ } finally {
148
+ await rm(folder, { recursive: true, force: true });
149
+ }
150
+ }
151
+
152
+ test('unticked DONE WHEN criteria are reported', async () => {
153
+ assert.deepEqual(await unmetFor([
154
+ '# MISSION', '', '## DONE WHEN',
155
+ '- [x] The site is built',
156
+ '- [ ] Screenshots are saved',
157
+ '- [ ] The console is clean',
158
+ '', '## Plan',
159
+ '- [ ] a plan step that is NOT a completion criterion',
160
+ ].join('\n')), ['Screenshots are saved', 'The console is clean']);
161
+ });
162
+
163
+ test('a fully ticked doc reports nothing unmet', async () => {
164
+ assert.deepEqual(await unmetFor([
165
+ '## DONE WHEN', '- [x] one', '- [X] two (capital X counts)',
166
+ ].join('\n')), []);
167
+ });
168
+
169
+ test('nothing to judge by returns null, not failure', async () => {
170
+ // No doc at all, and a doc with no DONE WHEN section. Absence of evidence is
171
+ // not evidence of failure — a director that never wrote a doc has already
172
+ // failed more visibly than this check could report.
173
+ assert.equal(await unmetFor(null), null);
174
+ assert.equal(await unmetFor('# MISSION\n\nno criteria here'), null);
175
+ assert.equal(await unmetFor('## DONE WHEN\n\nprose, no checkboxes'), null);
176
+ });
177
+
178
+ test('the plan section cannot mask an unfinished criterion', async () => {
179
+ // The section ends at the next heading; Plan boxes describe the route, and a
180
+ // route can legitimately change.
181
+ assert.deepEqual(await unmetFor([
182
+ '## DONE WHEN', '- [ ] the one that matters',
183
+ '## Plan', '- [x] every plan step ticked',
184
+ ].join('\n')), ['the one that matters']);
185
+ });
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Pricing a run from the endpoint's own rates
189
+ // ---------------------------------------------------------------------------
190
+
191
+ const RATES = { input: 0.000002, output: 0.00001 };
192
+
193
+ test('a role with published rates is billed from its own tokens', () => {
194
+ const run = new MissionRun(meta({ costBasis: 'priced' }), () => {}, () => {}, noopAgentEnv,
195
+ { director: RATES }) as unknown as { addUsage(raw: unknown, role?: string): void; meta: RunMeta };
196
+
197
+ run.addUsage({ input_tokens: 1000, output_tokens: 500 });
198
+ assert.ok(Math.abs(run.meta.costUsd - 0.007) < 1e-9, `got ${run.meta.costUsd}`);
199
+ });
200
+
201
+ test('each turn is billed for its own tokens, never for the running total', () => {
202
+ // Charging the per-token rate against the accumulated figure would bill
203
+ // every turn for every turn before it — a cost curve that looks like real
204
+ // spend and is quadratic in the number of turns.
205
+ const run = new MissionRun(meta({ costBasis: 'priced' }), () => {}, () => {}, noopAgentEnv,
206
+ { director: RATES }) as unknown as { addUsage(raw: unknown, role?: string): void; meta: RunMeta };
207
+
208
+ for (let i = 0; i < 3; i++) run.addUsage({ input_tokens: 1000 });
209
+ assert.ok(Math.abs(run.meta.costUsd - 0.006) < 1e-9, `got ${run.meta.costUsd}`);
210
+ assert.equal(run.meta.usage!.inputTokens, 3000);
211
+ });
212
+
213
+ test('the SDK figure is ignored for a role Foreman prices itself', () => {
214
+ // The double-count trap. The SDK prices every response with Anthropic's
215
+ // table, so on a gateway role its number is fiction; adding it to a total
216
+ // computed from the endpoint's own rates would corrupt the honest figure.
217
+ const run = new MissionRun(meta({ costBasis: 'priced' }), () => {}, () => {}, noopAgentEnv,
218
+ { director: RATES }) as unknown as {
219
+ addUsage(raw: unknown, role?: string): void;
220
+ addCost(usd: number | undefined, role?: string): void;
221
+ meta: RunMeta;
222
+ };
223
+
224
+ run.addUsage({ input_tokens: 1000 });
225
+ run.addCost(4.20);
226
+ assert.ok(Math.abs(run.meta.costUsd - 0.002) < 1e-9, `SDK fiction leaked in: ${run.meta.costUsd}`);
227
+ });
228
+
229
+ test('a role with no published rates still uses the SDK figure', () => {
230
+ // An Anthropic-native role: the SDK's number is the real one, and Foreman
231
+ // must not stop trusting it just because the other half of the run is on a
232
+ // gateway.
233
+ const run = new MissionRun(meta({ costBasis: 'priced' }), () => {}, () => {}, noopAgentEnv,
234
+ { worker: RATES }) as unknown as {
235
+ addCost(usd: number | undefined, role?: string): void; meta: RunMeta;
236
+ };
237
+
238
+ run.addCost(0.5, 'director');
239
+ assert.equal(run.meta.costUsd, 0.5);
240
+ });
241
+
242
+ test('a mixed run bills each role with its own rates', () => {
243
+ const run = new MissionRun(meta({ costBasis: 'priced' }), () => {}, () => {}, noopAgentEnv, {
244
+ director: { input: 0.00001, output: 0.00001 },
245
+ worker: { input: 0.000001, output: 0.000001 },
246
+ }) as unknown as { addUsage(raw: unknown, role?: string): void; meta: RunMeta };
247
+
248
+ run.addUsage({ input_tokens: 1000 }, 'director'); // 0.01
249
+ run.addUsage({ input_tokens: 1000 }, 'worker'); // 0.001
250
+ assert.ok(Math.abs(run.meta.costUsd - 0.011) < 1e-9, `got ${run.meta.costUsd}`);
251
+ });
252
+
253
+ test('a priced gateway run still arms the budget cap', () => {
254
+ // The point of pricing it: a real figure is a figure the cap may act on.
255
+ const events: Array<{ event: string; data: any }> = [];
256
+ const run = new MissionRun(
257
+ meta({ costBasis: 'priced', budgetUsd: 0.01 }), (event, data) => events.push({ event, data }),
258
+ () => {}, noopAgentEnv, { director: RATES },
259
+ ) as unknown as { addUsage(raw: unknown, role?: string): void };
260
+
261
+ run.addUsage({ output_tokens: 2000 }); // 0.02 — past 125% of a $0.01 cap
262
+ assert.ok(events.some((e) => e.event === 'budget_alert' && e.data.level === 'exceeded'),
263
+ 'a real overrun on real rates must still stop the run');
264
+ });
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // A worker that goes quiet must not hold the mission open
268
+ // ---------------------------------------------------------------------------
269
+
270
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
271
+
272
+ test('silence past the threshold fires exactly once', async () => {
273
+ let fired = 0;
274
+ let quietFor = 0;
275
+ const w = watchSilence(60, (q) => { fired++; quietFor = q; });
276
+ await sleep(250);
277
+ w.stop();
278
+ assert.equal(fired, 1, 'a stall is reported once, not once per poll');
279
+ assert.ok(quietFor >= 60, `should report how long it was quiet, got ${quietFor}`);
280
+ });
281
+
282
+ test('any sign of life resets the clock', async () => {
283
+ // The property that decides whether a slow-but-working worker survives: a
284
+ // worker emits a message for every tool call, so activity must postpone the
285
+ // verdict indefinitely.
286
+ let fired = 0;
287
+ const w = watchSilence(120, () => fired++);
288
+ for (let i = 0; i < 6; i++) { await sleep(40); w.touch(); }
289
+ assert.equal(fired, 0, 'a working worker must never be killed');
290
+ w.stop();
291
+ });
292
+
293
+ test('a stopped watchdog never fires afterwards', async () => {
294
+ // It is stopped in a finally block, so this is the difference between a
295
+ // clean finish and a spurious "stalled" on a worker that already returned.
296
+ let fired = 0;
297
+ const w = watchSilence(50, () => fired++);
298
+ w.stop();
299
+ await sleep(200);
300
+ assert.equal(fired, 0);
301
+ });
302
+
303
+ test('touching after a stall cannot revive the verdict', async () => {
304
+ // The worker has already been interrupted by then; letting a late message
305
+ // clear the flag would leave the run reporting success for a worker that
306
+ // was killed.
307
+ let fired = 0;
308
+ const w = watchSilence(50, () => fired++);
309
+ await sleep(200);
310
+ w.touch();
311
+ await sleep(150);
312
+ assert.equal(fired, 1);
313
+ w.stop();
314
+ });
315
+
316
+ test('the stall report steers the director away from repeating it', () => {
317
+ const r = stalledWorkerReport('worker-2', 8 * 60_000);
318
+ assert.match(r, /STALLED/);
319
+ assert.match(r, /8 minute/);
320
+ // The one response guaranteed to waste the same minutes again.
321
+ assert.match(r, /Do NOT immediately respawn/);
322
+ // And the escape hatch, so a run cannot spend itself entirely on silence.
323
+ assert.match(r, /ask_human/);
324
+ // It must not read as a task failure: that framing invites a retry.
325
+ assert.match(r, /did not fail a task/);
326
+ });
327
+
328
+ test('partial output before the silence is handed back, not discarded', () => {
329
+ const r = stalledWorkerReport('worker-1', 60_000, 'wrote index.html');
330
+ assert.match(r, /Partial output/);
331
+ assert.match(r, /wrote index\.html/);
332
+ assert.doesNotMatch(stalledWorkerReport('worker-1', 60_000), /Partial output/);
333
+ });
334
+
335
+ // watchRepeats is the loop rule on its own, fed by hand: the tool_use blocks
336
+ // it sees in production come off SDK assistant messages, and none of what
337
+ // follows depends on the SDK to construct them.
338
+
339
+ test('watchRepeats fires once at exactly the limit of consecutive identical calls', () => {
340
+ const hits: Array<{ toolName: string; count: number }> = [];
341
+ const r = watchRepeats(3, (i) => hits.push({ toolName: i.toolName, count: i.count }));
342
+ r.observe('Bash', { command: 'npm test' });
343
+ r.observe('Bash', { command: 'npm test' });
344
+ assert.equal(hits.length, 0); // two identical calls is a retry, not a loop
345
+ r.observe('Bash', { command: 'npm test' });
346
+ assert.deepEqual(hits, [{ toolName: 'Bash', count: 3 }]);
347
+ });
348
+
349
+ test('a different call between repeats breaks the streak', () => {
350
+ let fired = 0;
351
+ const r = watchRepeats(3, () => fired++);
352
+ r.observe('Bash', { command: 'npm test' });
353
+ r.observe('Bash', { command: 'npm test' });
354
+ r.observe('Read', { file_path: 'a.ts' }); // reading the failure output is progress
355
+ r.observe('Bash', { command: 'npm test' });
356
+ r.observe('Bash', { command: 'npm test' });
357
+ assert.equal(fired, 0);
358
+ // Same tool with different input is a different call too.
359
+ r.observe('Bash', { command: 'npm test -- --grep x' });
360
+ r.observe('Bash', { command: 'npm test' });
361
+ assert.equal(fired, 0);
362
+ });
363
+
364
+ test('key order in the input does not defeat detection', () => {
365
+ let fired = 0;
366
+ const r = watchRepeats(3, () => fired++);
367
+ r.observe('Edit', { file_path: 'a.ts', old_string: 'x', new_string: 'y' });
368
+ r.observe('Edit', { new_string: 'y', file_path: 'a.ts', old_string: 'x' });
369
+ r.observe('Edit', { old_string: 'x', new_string: 'y', file_path: 'a.ts' });
370
+ assert.equal(fired, 1);
371
+ // Including nested objects.
372
+ const r2 = watchRepeats(2, () => fired++);
373
+ r2.observe('T', { a: { b: 1, c: [1, { d: 2, e: 3 }] } });
374
+ r2.observe('T', { a: { c: [1, { e: 3, d: 2 }], b: 1 } });
375
+ assert.equal(fired, 2);
376
+ });
377
+
378
+ test('a streak that continues past the report does not re-fire', () => {
379
+ // The caller has already acted on the first report; a second one for the
380
+ // same loop would be a second interrupt, or a second notice, for nothing.
381
+ let fired = 0;
382
+ const r = watchRepeats(2, () => fired++);
383
+ for (let i = 0; i < 10; i++) r.observe('Bash', { command: 'ls' });
384
+ assert.equal(fired, 1);
385
+ });
386
+
387
+ test('reset() re-arms detection for the same call', () => {
388
+ // The director path notifies then resets, so that a director which ignores
389
+ // the notice and starts the same streak again is caught a second time.
390
+ let fired = 0;
391
+ const r = watchRepeats(2, () => fired++);
392
+ r.observe('Bash', { command: 'ls' });
393
+ r.observe('Bash', { command: 'ls' });
394
+ assert.equal(fired, 1);
395
+ r.observe('Bash', { command: 'ls' });
396
+ assert.equal(fired, 1);
397
+ r.reset();
398
+ r.observe('Bash', { command: 'ls' });
399
+ assert.equal(fired, 1); // one call after a reset is a first call, not a streak
400
+ r.observe('Bash', { command: 'ls' });
401
+ assert.equal(fired, 2);
402
+ });
403
+
404
+ test('the default repeat limit tolerates an ordinary retry-with-backoff', () => {
405
+ let fired = 0;
406
+ const r = watchRepeats(DEFAULT_REPEAT_LIMIT, () => fired++);
407
+ for (let i = 0; i < 3; i++) r.observe('Bash', { command: 'curl localhost:3000' });
408
+ assert.equal(fired, 0);
409
+ });
410
+
411
+ test('the looping report steers the director away from resending the brief', () => {
412
+ const r = loopingWorkerReport('worker-3', 'Bash', 5);
413
+ assert.match(r, /LOOPING/);
414
+ assert.match(r, /worker-3/);
415
+ // What it was repeating, so the director can see the trap.
416
+ assert.match(r, /identical Bash call 5 times/);
417
+ assert.match(r, /Do NOT respawn/);
418
+ assert.match(r, /ask_human/);
419
+ // It must not read as a task failure: that framing invites a retry.
420
+ assert.match(r, /not a task that failed/);
421
+ assert.doesNotMatch(r, /Output before/);
422
+ assert.match(loopingWorkerReport('worker-3', 'Bash', 5, 'ran tests'), /Output before it was stopped:\nran tests/);
423
+ });
424
+
425
+ // ---------------------------------------------------------------------------
426
+ // Spawning is asynchronous; the director supervises instead of waiting
427
+ // ---------------------------------------------------------------------------
428
+
429
+ type Outcome = { report: string; isError: boolean };
430
+ type ToolSurface = {
431
+ runWorker(id: string, prompt: string, resume?: string): Promise<Outcome>;
432
+ capReached(): string | null;
433
+ spawnWorkerTool(a: { task: string }): string;
434
+ checkWorkersTool(a?: { workerId?: string }): string;
435
+ waitForWorkerTool(a?: { workerId?: string; timeoutSeconds?: number }): Promise<string>;
436
+ messageWorkerTool(a: { worker_id: string; message: string }): Promise<string>;
437
+ workers: Map<string, {
438
+ status: string; sessionId?: string; recent?: string[]; toolCalls?: number;
439
+ progress?: { status: string; done?: string[]; next?: string; blocked?: string; at: number };
440
+ }>;
441
+ };
442
+
443
+ /**
444
+ * A run whose workers are simulated: launchWorker() does the bookkeeping
445
+ * (record, events, done promise, stored report) around a runWorker() that
446
+ * here just sleeps and answers, so the tool handlers can be driven without an
447
+ * SDK session behind them.
448
+ */
449
+ function stubbedRun(script: (id: string, prompt: string) => Promise<Outcome>) {
450
+ const events: Array<{ event: string; data: any }> = [];
451
+ const run = new MissionRun(meta(), (event, data) => events.push({ event, data }), () => {}, noopAgentEnv);
452
+ const t = run as unknown as ToolSurface;
453
+ t.runWorker = script;
454
+ return { run, t, events };
455
+ }
456
+
457
+ const slowWorker = (ms: number, report = 'did the thing') =>
458
+ async (id: string) => { await sleep(ms); return { report: `${id}: ${report}`, isError: false }; };
459
+
460
+ test('spawn_worker returns before the worker resolves', async () => {
461
+ const { t, events } = stubbedRun(slowWorker(80));
462
+ const before = Date.now();
463
+ const reply = t.spawnWorkerTool({ task: 'build it' });
464
+ assert.ok(Date.now() - before < 50, 'the director must not be held for the worker');
465
+ assert.match(reply, /worker-1/);
466
+ assert.match(reply, /running/);
467
+ assert.match(reply, /check_workers/);
468
+ assert.match(reply, /wait_for_worker/);
469
+ // The record and its start event exist by the time the reply is built.
470
+ assert.equal(t.workers.get('worker-1')?.status, 'running');
471
+ assert.ok(events.some((e) => e.event === 'worker_started' && e.data.id === 'worker-1'));
472
+ await t.waitForWorkerTool({ workerId: 'worker-1' });
473
+ });
474
+
475
+ test('two spawns run concurrently and both show as running', async () => {
476
+ const { t } = stubbedRun(slowWorker(100));
477
+ t.spawnWorkerTool({ task: 'a' });
478
+ t.spawnWorkerTool({ task: 'b' });
479
+ const view = t.checkWorkersTool();
480
+ assert.match(view, /worker-1 running/);
481
+ assert.match(view, /worker-2 running/);
482
+ // Concurrent, not sequential: both finish in about one worker's time.
483
+ const start = Date.now();
484
+ await t.waitForWorkerTool({ workerId: 'worker-1' });
485
+ await t.waitForWorkerTool({ workerId: 'worker-2' });
486
+ assert.ok(Date.now() - start < 180, `took ${Date.now() - start}ms — workers ran one after the other`);
487
+ });
488
+
489
+ test('check_workers shows a finished report, and shows it again on the next call', async () => {
490
+ const { t, events } = stubbedRun(slowWorker(10, 'wrote index.html'));
491
+ t.spawnWorkerTool({ task: 'x' });
492
+ await t.waitForWorkerTool({ workerId: 'worker-1' });
493
+ const first = t.checkWorkersTool();
494
+ assert.match(first, /worker-1 done/);
495
+ assert.match(first, /report:\n\s+worker-1: wrote index\.html/);
496
+ // Already shown is not deleted: the director may need to re-read it.
497
+ assert.match(t.checkWorkersTool({ workerId: 'worker-1' }), /wrote index\.html/);
498
+ // The footer appears once for the whole response, not per worker.
499
+ assert.equal(first.match(/\[Run cost so far/g)?.length, 1);
500
+ const finished = events.find((e) => e.event === 'worker_finished');
501
+ assert.equal(finished?.data.status, 'done');
502
+ assert.match(finished?.data.report, /wrote index\.html/);
503
+ });
504
+
505
+ test('wait_for_worker returns the report on finish', async () => {
506
+ const { t } = stubbedRun(slowWorker(30, 'tests pass'));
507
+ t.spawnWorkerTool({ task: 'x' });
508
+ const out = await t.waitForWorkerTool({ workerId: 'worker-1' });
509
+ assert.match(out, /^\[worker-1 finished\] worker-1: tests pass/);
510
+ assert.match(out, /Run cost so far/);
511
+ // Asking again for a worker that has already finished answers immediately.
512
+ assert.match(await t.waitForWorkerTool({ workerId: 'worker-1' }), /tests pass/);
513
+ });
514
+
515
+ test('wait_for_worker on timeout reports still-running and does not throw', async () => {
516
+ const { t } = stubbedRun(slowWorker(150));
517
+ t.spawnWorkerTool({ task: 'x' });
518
+ const out = await t.waitForWorkerTool({ workerId: 'worker-1', timeoutSeconds: 0.02 });
519
+ assert.match(out, /worker-1 running/);
520
+ assert.match(out, /Still running/);
521
+ assert.match(out, /call wait_for_worker again/i);
522
+ assert.equal(t.workers.get('worker-1')?.status, 'running');
523
+ await t.waitForWorkerTool({ workerId: 'worker-1' });
524
+ });
525
+
526
+ test('wait_for_worker with no id returns when the first of two finishes', async () => {
527
+ const { t } = stubbedRun(async (id) => {
528
+ await sleep(id === 'worker-2' ? 20 : 200);
529
+ return { report: `${id} done`, isError: false };
530
+ });
531
+ t.spawnWorkerTool({ task: 'slow' });
532
+ t.spawnWorkerTool({ task: 'fast' });
533
+ const start = Date.now();
534
+ const out = await t.waitForWorkerTool();
535
+ assert.ok(Date.now() - start < 150, 'must not wait for the slow one');
536
+ assert.match(out, /^\[worker-2 finished\] worker-2 done/);
537
+ assert.equal(t.workers.get('worker-1')?.status, 'running');
538
+ await t.waitForWorkerTool({ workerId: 'worker-1' });
539
+ assert.match(await t.waitForWorkerTool(), /No worker is running/);
540
+ });
541
+
542
+ test('a failed worker is reported as FAILED and kept as error', async () => {
543
+ const { t } = stubbedRun(async () => ({ report: 'BLOCKED: which port?', isError: true }));
544
+ t.spawnWorkerTool({ task: 'x' });
545
+ const out = await t.waitForWorkerTool({ workerId: 'worker-1' });
546
+ assert.match(out, /^\[worker-1 FAILED\] BLOCKED/);
547
+ assert.equal(t.workers.get('worker-1')?.status, 'error');
548
+ });
549
+
550
+ test('the spawn gate still refuses once a cap is reached', () => {
551
+ const { t } = stubbedRun(slowWorker(10));
552
+ t.capReached = () => 'TURN CAP REACHED: 150 director turns.';
553
+ const reply = t.spawnWorkerTool({ task: 'one more' });
554
+ assert.match(reply, /Do not start new work/);
555
+ assert.equal(t.workers.size, 0, 'no worker may be started past the cap');
556
+ });
557
+
558
+ test('message_worker refuses a worker that is still running rather than forking its session', async () => {
559
+ const { t } = stubbedRun(async (id) => {
560
+ t.workers.get(id)!.sessionId = 'sess-1';
561
+ await sleep(60);
562
+ return { report: 'ok', isError: false };
563
+ });
564
+ t.spawnWorkerTool({ task: 'x' });
565
+ await sleep(5);
566
+ assert.match(await t.messageWorkerTool({ worker_id: 'worker-1', message: 'also do y' }), /still running/);
567
+ await t.waitForWorkerTool({ workerId: 'worker-1' });
568
+ // Finished: the follow-up resumes it, alongside whatever else is running.
569
+ t.spawnWorkerTool({ task: 'other' });
570
+ const out = await t.messageWorkerTool({ worker_id: 'worker-1', message: 'also do y' });
571
+ assert.match(out, /^\[worker-1 finished\] ok/);
572
+ await t.waitForWorkerTool({ workerId: 'worker-2' });
573
+ });
574
+
575
+ test('a worker persisted as running is not resurrected as running on resume', () => {
576
+ const run = new MissionRun(meta({ workers: [
577
+ { id: 'worker-1', status: 'running', costUsd: 0, task: 'x', sessionId: 's' },
578
+ ] }), () => {}, () => {}, noopAgentEnv) as unknown as ToolSurface;
579
+ const view = run.checkWorkersTool({ workerId: 'worker-1' });
580
+ assert.match(view, /worker-1 error/);
581
+ assert.match(view, /restarted/);
582
+ });
583
+
584
+ test('activityHint names the argument that identifies the call', () => {
585
+ assert.equal(activityHint('Bash', { command: 'npm test', description: 'run tests' }), 'Bash npm test');
586
+ assert.equal(activityHint('Read', { file_path: 'src/a.ts' }), 'Read src/a.ts');
587
+ assert.equal(activityHint('Custom', { whatever: 42 }), 'Custom');
588
+ assert.equal(activityHint('Custom', { note: 'a\n multi line' }), 'Custom a multi line');
589
+ assert.ok(activityHint('Bash', { command: 'x'.repeat(200) }).length < 70);
590
+ });
591
+
592
+ test('workerStatusBlock reads as one glance: age, last activity, calls, recent, report', () => {
593
+ const now = 1_000_000;
594
+ const running = workerStatusBlock({
595
+ id: 'worker-1', status: 'running', costUsd: 0, task: 't',
596
+ startedAt: now - 134_000, lastActivityAt: now - 3_000, toolCalls: 12, recent: ['Read a.ts', 'Bash npm test'],
597
+ }, now);
598
+ assert.match(running, /^worker-1 running age 2m14s last activity 3s ago 12 tool calls/);
599
+ assert.match(running, /recent:\n Read a\.ts\n Bash npm test/);
600
+ assert.doesNotMatch(running, /report/);
601
+ const done = workerStatusBlock({
602
+ id: 'worker-2', status: 'error', costUsd: 0, task: 't', isError: true, report: 'line1\nline2',
603
+ }, now);
604
+ assert.match(done, /report \(FAILED\):\n line1\n line2/);
605
+ });
606
+
607
+ test('the recent window is bounded and counts tool calls', () => {
608
+ const run = new MissionRun(meta(), () => {}, () => {}, noopAgentEnv) as unknown as {
609
+ noteActivity(w: any, m: any): void;
610
+ };
611
+ const w: any = { id: 'w', status: 'running', costUsd: 0, task: 't' };
612
+ for (let i = 0; i < RECENT_LINES + 4; i++) {
613
+ run.noteActivity(w, { type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash', input: { command: `c${i}` } }] } });
614
+ }
615
+ run.noteActivity(w, { type: 'assistant', message: { content: [{ type: 'text', text: 'Now I will run the tests.' }] } });
616
+ assert.equal(w.toolCalls, RECENT_LINES + 4);
617
+ assert.equal(w.recent.length, RECENT_LINES);
618
+ assert.equal(w.recent.at(-1), '"Now I will run the tests."');
619
+ assert.ok(typeof w.lastActivityAt === 'number');
620
+ });
621
+
622
+ // ---------------------------------------------------------------------------
623
+ // report_progress: the worker's own account, beside the harness's
624
+ // ---------------------------------------------------------------------------
625
+
626
+ type ProgressSurface = ToolSurface & {
627
+ reportProgressTool(id: string, a: { status: string; done?: string[]; next?: string; blocked?: string }): string;
628
+ };
629
+
630
+ test('a progress report is stored on the record with a timestamp and emitted', async () => {
631
+ const { run, t, events } = stubbedRun(slowWorker(60));
632
+ const p = t as ProgressSurface;
633
+ t.spawnWorkerTool({ task: 'x' });
634
+ const before = Date.now();
635
+ const ack = p.reportProgressTool('worker-1', {
636
+ status: 'two of three files written', done: ['a.ts', 'b.ts'], next: 'c.ts', blocked: undefined,
637
+ });
638
+ assert.match(ack, /Noted/);
639
+ const w = p.workers.get('worker-1')!;
640
+ assert.equal(w.progress?.status, 'two of three files written');
641
+ assert.deepEqual(w.progress?.done, ['a.ts', 'b.ts']);
642
+ assert.equal(w.progress?.next, 'c.ts');
643
+ assert.equal(w.progress?.blocked, undefined);
644
+ assert.ok(typeof w.progress?.at === 'number' && w.progress.at >= before);
645
+ const ev = events.find((e) => e.event === 'worker_progress');
646
+ assert.deepEqual(ev?.data, {
647
+ id: 'worker-1', status: 'two of three files written', done: ['a.ts', 'b.ts'], next: 'c.ts', blocked: undefined,
648
+ });
649
+ // Persisted like every other field: the director may read it after a restart.
650
+ assert.equal(run.meta.workers.find((x) => x.id === 'worker-1')?.progress?.status, 'two of three files written');
651
+ await t.waitForWorkerTool({ workerId: 'worker-1' });
652
+ });
653
+
654
+ test('syncWorkersMeta hands progress to the meta sink', async () => {
655
+ let saved: RunMeta | undefined;
656
+ const run = new MissionRun(meta(), () => {}, (m) => { saved = structuredClone(m); }, noopAgentEnv);
657
+ const t = run as unknown as ProgressSurface;
658
+ t.runWorker = slowWorker(30);
659
+ t.spawnWorkerTool({ task: 'x' });
660
+ t.reportProgressTool('worker-1', { status: 'halfway', blocked: 'port 3000 is taken' });
661
+ assert.equal(saved?.workers[0]?.progress?.status, 'halfway');
662
+ assert.equal(saved?.workers[0]?.progress?.blocked, 'port 3000 is taken');
663
+ await t.waitForWorkerTool({ workerId: 'worker-1' });
664
+ });
665
+
666
+ test('the progress line lands in recent, and check_workers prints progress above recent', async () => {
667
+ const { t } = stubbedRun(slowWorker(60));
668
+ const p = t as ProgressSurface;
669
+ t.spawnWorkerTool({ task: 'x' });
670
+ p.reportProgressTool('worker-1', { status: 'tests green', done: ['migration'], next: 'wire the route' });
671
+ const w = p.workers.get('worker-1')!;
672
+ assert.equal(w.recent?.at(-1), 'progress: tests green');
673
+ const view = t.checkWorkersTool({ workerId: 'worker-1' });
674
+ assert.match(view, /progress \(\d+s ago\): tests green\n done: migration\n next: wire the route/);
675
+ assert.ok(view.indexOf('progress') < view.indexOf('recent:'), 'the worker\'s account comes before the harness\'s');
676
+ // A blocker is loud in the timeline too, since that line may outlive the report.
677
+ p.reportProgressTool('worker-1', { status: 'stuck', blocked: 'no DB credentials' });
678
+ assert.equal(w.recent?.at(-1), 'progress: stuck — BLOCKED: no DB credentials');
679
+ assert.match(t.checkWorkersTool({ workerId: 'worker-1' }), /blocked: no DB credentials/);
680
+ await t.waitForWorkerTool({ workerId: 'worker-1' });
681
+ });
682
+
683
+ test('wait_for_worker on timeout shows the progress report too', async () => {
684
+ const { t } = stubbedRun(slowWorker(150));
685
+ const p = t as ProgressSurface;
686
+ t.spawnWorkerTool({ task: 'x' });
687
+ p.reportProgressTool('worker-1', { status: 'step 3 of 5' });
688
+ const out = await t.waitForWorkerTool({ workerId: 'worker-1', timeoutSeconds: 0.02 });
689
+ assert.match(out, /progress .*: step 3 of 5/);
690
+ await t.waitForWorkerTool({ workerId: 'worker-1' });
691
+ });
692
+
693
+ test('progress status is capped at 200 chars and flattened to one line', async () => {
694
+ const { t } = stubbedRun(slowWorker(30));
695
+ const p = t as ProgressSurface;
696
+ t.spawnWorkerTool({ task: 'x' });
697
+ p.reportProgressTool('worker-1', { status: `a\n b ${'x'.repeat(400)}` });
698
+ const status = p.workers.get('worker-1')!.progress!.status;
699
+ assert.equal(status.length, 200);
700
+ assert.doesNotMatch(status, /\n/);
701
+ assert.match(status, /^a b x+…$/);
702
+ await t.waitForWorkerTool({ workerId: 'worker-1' });
703
+ });
704
+
705
+ test('a progress report for an unknown worker does not throw', () => {
706
+ const { t, events } = stubbedRun(slowWorker(10));
707
+ const p = t as ProgressSurface;
708
+ assert.doesNotThrow(() => p.reportProgressTool('worker-9', { status: 'hello' }));
709
+ assert.match(p.reportProgressTool('worker-9', { status: 'hello' }), /No record/);
710
+ assert.ok(!events.some((e) => e.event === 'worker_progress'));
711
+ });
712
+
713
+ test('the report_progress call itself is counted but not duplicated in recent', () => {
714
+ const run = new MissionRun(meta(), () => {}, () => {}, noopAgentEnv) as unknown as {
715
+ noteActivity(w: any, m: any): void;
716
+ };
717
+ const w: any = { id: 'w', status: 'running', costUsd: 0, task: 't' };
718
+ run.noteActivity(w, { type: 'assistant', message: { content: [
719
+ { type: 'tool_use', name: 'mcp__foreman__report_progress', input: { status: 'halfway' } },
720
+ ] } });
721
+ assert.equal(w.toolCalls, 1);
722
+ assert.deepEqual(w.recent ?? [], []);
723
+ });
724
+
725
+ // ---------------------------------------------------------------------------
726
+ // The sanctioned scratch space and the gitignore hygiene around it
727
+ // ---------------------------------------------------------------------------
728
+
729
+ /** Runs `fn` inside a fresh temp folder and removes it afterwards. */
730
+ async function inTempFolder<T>(fn: (folder: string) => Promise<T>): Promise<T> {
731
+ const folder = await mkdtemp(path.join(os.tmpdir(), 'foreman-work-'));
732
+ try {
733
+ return await fn(folder);
734
+ } finally {
735
+ await rm(folder, { recursive: true, force: true });
736
+ }
737
+ }
738
+
739
+ test('ensureIgnoreLines creates a missing file with exactly the rules', async () => {
740
+ await inTempFolder(async (folder) => {
741
+ const file = path.join(folder, '.gitignore');
742
+ await ensureIgnoreLines(file, ['work/', '.gitignore']);
743
+ assert.equal(await readFile(file, 'utf8'), 'work/\n.gitignore\n');
744
+ });
745
+ });
746
+
747
+ test('ensureIgnoreLines appends only the missing rules and keeps existing content', async () => {
748
+ await inTempFolder(async (folder) => {
749
+ const file = path.join(folder, '.gitignore');
750
+ await writeFile(file, '# mine\nnode_modules/\n work/ \n');
751
+ await ensureIgnoreLines(file, ['work/', '.gitignore']);
752
+ // `work/` was already there (whitespace around a rule does not make it a
753
+ // different rule), so only `.gitignore` is added — after what was there.
754
+ assert.equal(await readFile(file, 'utf8'), '# mine\nnode_modules/\n work/ \n.gitignore\n');
755
+ });
756
+ });
757
+
758
+ test('ensureIgnoreLines does not fuse a new rule onto a last line without a newline', async () => {
759
+ await inTempFolder(async (folder) => {
760
+ const file = path.join(folder, '.gitignore');
761
+ await writeFile(file, 'dist');
762
+ await ensureIgnoreLines(file, ['work/']);
763
+ assert.equal(await readFile(file, 'utf8'), 'dist\nwork/\n');
764
+ });
765
+ });
766
+
767
+ test('ensureIgnoreLines leaves a complete file untouched', async () => {
768
+ await inTempFolder(async (folder) => {
769
+ const file = path.join(folder, '.gitignore');
770
+ await writeFile(file, 'work/\n.gitignore\n');
771
+ const before = (await stat(file)).mtimeMs;
772
+ await ensureIgnoreLines(file, ['work/', '.gitignore']);
773
+ assert.equal(await readFile(file, 'utf8'), 'work/\n.gitignore\n');
774
+ assert.equal((await stat(file)).mtimeMs, before);
775
+ });
776
+ });
777
+
778
+ // start() ensures `.foreman/.gitignore` with exactly this call; it is tested
779
+ // here directly because start() also launches the director's SDK query.
780
+
781
+ test('.foreman/.gitignore ignores the directory wholesale, scratch space included', async () => {
782
+ await inTempFolder(async (folder) => {
783
+ await mkdir(path.join(folder, '.foreman'), { recursive: true });
784
+ const file = path.join(folder, '.foreman', '.gitignore');
785
+ await ensureIgnoreLines(file, ['*']);
786
+ assert.equal(await readFile(file, 'utf8'), '*\n');
787
+ // Idempotent across runs: a second start adds nothing.
788
+ await ensureIgnoreLines(file, ['*']);
789
+ assert.equal(await readFile(file, 'utf8'), '*\n');
790
+ });
791
+ });
792
+
793
+ test('a hand-written rule in .foreman/.gitignore survives; "*" is appended, not written over it', async () => {
794
+ await inTempFolder(async (folder) => {
795
+ await mkdir(path.join(folder, '.foreman'), { recursive: true });
796
+ const file = path.join(folder, '.foreman', '.gitignore');
797
+ await writeFile(file, '!MISSION.md\n');
798
+ await ensureIgnoreLines(file, ['*']);
799
+ assert.equal(await readFile(file, 'utf8'), '!MISSION.md\n*\n');
800
+ });
801
+ });
802
+
803
+ test('both charters name the scratch space by path', () => {
804
+ // A place, not a principle: the model needs the literal path. The charters
805
+ // interpolate WORK_DIR, so this also guards against the constant moving
806
+ // without the text following it.
807
+ assert.equal(WORK_DIR, '.foreman/work');
808
+ assert.match(DIRECTOR_CHARTER, /WORK INSIDE THE WORKSPACE/);
809
+ assert.match(WORKER_CHARTER, /WORK INSIDE THE WORKSPACE/);
810
+ assert.ok(DIRECTOR_CHARTER.includes(`${WORK_DIR}/`));
811
+ assert.ok(WORKER_CHARTER.includes(`${WORK_DIR}/`));
812
+ assert.ok(!DIRECTOR_CHARTER.includes('${'), 'WORK_DIR was not interpolated');
813
+ });
814
+
815
+ // ---------------------------------------------------------------------------
816
+ // Blocking prompts vs. an autonomous orchestrator (crew-resilience item 8)
817
+ // ---------------------------------------------------------------------------
818
+
819
+ type Pending = PendingPermission & { agent: string };
820
+ type Internals = {
821
+ pendingPermissions: Map<string, Pending>;
822
+ allowedRoots: Set<string>;
823
+ runAllowed: Set<string>;
824
+ askTimers: Map<string, unknown>;
825
+ policyFor(agent: string): ReturnType<typeof makePolicy>;
826
+ };
827
+ const internals = (run: MissionRun) => run as unknown as Internals;
828
+ const policyOpts = (signal: AbortSignal, id: string) =>
829
+ ({ signal, toolUseID: id }) as unknown as Parameters<ReturnType<typeof makePolicy>>[2];
830
+
831
+ test('resolvePermission allow_always on a pending WITH escapedPath grants the path, not the tool, and persists it', () => {
832
+ const events: Array<{ event: string; data: unknown }> = [];
833
+ let saved: RunMeta | undefined;
834
+ const run = new MissionRun(meta(), (event, data) => events.push({ event, data }), (m) => { saved = structuredClone(m); }, noopAgentEnv);
835
+ let result: unknown;
836
+ internals(run).pendingPermissions.set('p1', {
837
+ resolve: (r) => { result = r; }, toolName: 'Bash', escapedPath: '/Users/x/other', agent: 'worker-1',
838
+ });
839
+
840
+ assert.equal(run.resolvePermission('p1', 'allow_always'), true);
841
+ assert.deepEqual([...internals(run).allowedRoots], ['/Users/x/other']);
842
+ assert.equal(internals(run).runAllowed.size, 0, 'the tool is NOT granted — that is the re-prompt loop');
843
+ assert.deepEqual(saved?.allowedRoots, ['/Users/x/other'], 'persisted for resume');
844
+ assert.deepEqual(saved?.allowedTools, []);
845
+ assert.deepEqual(result, { behavior: 'allow' }, 'no SDK tool rule rides along with a path grant');
846
+ const rootAllowed = events.find((e) => e.event === 'root_allowed');
847
+ assert.deepEqual(rootAllowed?.data, { path: '/Users/x/other', agent: 'worker-1', toolName: 'Bash' });
848
+ assert.ok(events.some((e) => e.event === 'permission_resolved'));
849
+ });
850
+
851
+ test('resolvePermission allow_always on a pending WITHOUT escapedPath keeps the old meaning: a tool grant', () => {
852
+ const events: Array<{ event: string; data: unknown }> = [];
853
+ let saved: RunMeta | undefined;
854
+ const run = new MissionRun(meta(), (event, data) => events.push({ event, data }), (m) => { saved = structuredClone(m); }, noopAgentEnv);
855
+ let result: unknown;
856
+ internals(run).pendingPermissions.set('p2', { resolve: (r) => { result = r; }, toolName: 'WebFetch', agent: 'director' });
857
+
858
+ run.resolvePermission('p2', 'allow_always');
859
+ assert.deepEqual([...internals(run).runAllowed], ['WebFetch']);
860
+ assert.equal(internals(run).allowedRoots.size, 0);
861
+ assert.deepEqual(saved?.allowedTools, ['WebFetch']);
862
+ assert.equal((result as { behavior: string }).behavior, 'allow');
863
+ assert.ok(!events.some((e) => e.event === 'root_allowed'));
864
+ });
865
+
866
+ test('a resumed run rehydrates allowedRoots from meta, and the policy honours them', async () => {
867
+ const run = new MissionRun(meta({ allowedRoots: ['/Users/x/other'], allowedTools: ['WebFetch'] }), () => {}, () => {}, noopAgentEnv);
868
+ assert.deepEqual([...internals(run).allowedRoots], ['/Users/x/other']);
869
+ assert.deepEqual([...internals(run).runAllowed], ['WebFetch']);
870
+ const policy = internals(run).policyFor('director');
871
+ const ac = new AbortController();
872
+ const r = await policy('Write', { file_path: '/Users/x/other/f.txt', content: '' }, policyOpts(ac.signal, 'tu_r'));
873
+ assert.equal(r!.behavior, 'allow', 'a root granted before the restart is not re-asked');
874
+ });
875
+
876
+ test('armAskTimeout fires once after the delay; cancel prevents it; 0 never arms', async () => {
877
+ let fired = 0;
878
+ const a = armAskTimeout(20, () => { fired++; });
879
+ await sleep(60);
880
+ assert.equal(fired, 1);
881
+ a.cancel(); // after firing: harmless
882
+ assert.equal(fired, 1);
883
+
884
+ const b = armAskTimeout(20, () => { fired++; });
885
+ b.cancel();
886
+ await sleep(60);
887
+ assert.equal(fired, 1, 'cancelled before firing');
888
+
889
+ const c = armAskTimeout(0, () => { fired++; });
890
+ await sleep(30);
891
+ assert.equal(fired, 1, '0 disables');
892
+ c.cancel();
893
+ });
894
+
895
+ test('a pending permission unanswered past the timeout is denied with the unattended message and permission_timeout is emitted', async () => {
896
+ const events: Array<{ event: string; data: Record<string, unknown> }> = [];
897
+ const run = new MissionRun(meta({ askTimeoutMs: 30 }), (event, data) => events.push({ event, data: data as Record<string, unknown> }), () => {}, noopAgentEnv);
898
+ const policy = internals(run).policyFor('worker-2');
899
+ const ac = new AbortController();
900
+ // Outside the folder (/tmp/x) and NOT a temp dir, so it reaches the ask path.
901
+ const pending = policy('Write', { file_path: '/Users/x/other-repo/f', content: '' }, policyOpts(ac.signal, 'tu_t1'));
902
+ assert.deepEqual(run.pendingPermissionIds, ['tu_t1']);
903
+ assert.equal(internals(run).askTimers.size, 1);
904
+
905
+ const r = await pending;
906
+ assert.equal(r!.behavior, 'deny');
907
+ assert.equal((r as { message: string }).message, unattendedDenyMessage(30));
908
+ assert.equal((r as { message: string }).message,
909
+ 'Auto-denied after 1 minutes unattended — Foreman does not block a mission on a human who is away. ' +
910
+ 'Redo this inside the workspace (.foreman/work/) or record in MISSION.md why the outside is needed.');
911
+ const timeout = events.find((e) => e.event === 'permission_timeout');
912
+ assert.deepEqual(timeout?.data, { id: 'tu_t1', agent: 'worker-2', toolName: 'Write', afterMs: 30 });
913
+ assert.ok(!events.some((e) => e.event === 'permission_resolved'), 'a timeout is not a human decision');
914
+ assert.deepEqual(run.pendingPermissionIds, []);
915
+ assert.equal(internals(run).askTimers.size, 0);
916
+ // Late answers find nothing to answer.
917
+ assert.equal(run.resolvePermission('tu_t1', 'allow'), false);
918
+ });
919
+
920
+ test('a pending permission answered in time is not timed out, and its timer is cleared', async () => {
921
+ const events: Array<{ event: string; data: unknown }> = [];
922
+ const run = new MissionRun(meta({ askTimeoutMs: 40 }), (event, data) => events.push({ event, data }), () => {}, noopAgentEnv);
923
+ const policy = internals(run).policyFor('director');
924
+ const ac = new AbortController();
925
+ const pending = policy('Write', { file_path: '/Users/x/other-repo/f', content: '' }, policyOpts(ac.signal, 'tu_t2'));
926
+ assert.equal(run.resolvePermission('tu_t2', 'allow'), true);
927
+ assert.equal((await pending)!.behavior, 'allow');
928
+ assert.equal(internals(run).askTimers.size, 0);
929
+ await sleep(80);
930
+ assert.ok(!events.some((e) => e.event === 'permission_timeout'));
931
+ });
932
+
933
+ test('askTimeoutMs: 0 leaves a pending permission waiting (a babysat run); an abort still settles it', async () => {
934
+ const run = new MissionRun(meta({ askTimeoutMs: 0 }), () => {}, () => {}, noopAgentEnv);
935
+ const policy = internals(run).policyFor('director');
936
+ const ac = new AbortController();
937
+ const pending = policy('Write', { file_path: '/Users/x/other-repo/f', content: '' }, policyOpts(ac.signal, 'tu_t3'));
938
+ await sleep(30);
939
+ assert.deepEqual(run.pendingPermissionIds, ['tu_t3'], 'still waiting');
940
+ ac.abort();
941
+ assert.equal((await pending)!.behavior, 'deny');
942
+ assert.equal(internals(run).askTimers.size, 0);
943
+ });
944
+
945
+ test('the unattended messages and the temp-dir denial say where to go, and the charter carries the rule', () => {
946
+ assert.equal(DEFAULT_ASK_TIMEOUT_MS, 10 * 60_000);
947
+ assert.equal(unattendedAnswer(DEFAULT_ASK_TIMEOUT_MS),
948
+ 'No answer after 10 minutes — the human is away. Decide yourself, record the decision and its ' +
949
+ 'reasoning in MISSION.md, and continue; do not ask again unless the mission cannot proceed at all.');
950
+ assert.match(unattendedDenyMessage(DEFAULT_ASK_TIMEOUT_MS), /after 10 minutes/);
951
+ assert.ok(DIRECTOR_CHARTER.includes('DECIDE AND RECORD, DON\'T ASK'));
952
+ assert.ok(DIRECTOR_CHARTER.includes('10\n minutes is auto-answered "decide yourself"') ||
953
+ DIRECTOR_CHARTER.includes('10 minutes is auto-answered "decide yourself"'));
954
+ assert.ok(DIRECTOR_CHARTER.includes('DENIED outright'));
955
+ assert.ok(WORKER_CHARTER.includes('denied outright'));
956
+ assert.ok(!DIRECTOR_CHARTER.includes('prompts the human and stalls'), 'the old sentence is gone');
957
+ assert.ok(!WORKER_CHARTER.includes('prompts the human and stalls'));
958
+ });
959
+
960
+ // ---------------------------------------------------------------------------
961
+ // Supervision is not a loop
962
+ // ---------------------------------------------------------------------------
963
+
964
+ test('polling check_workers with identical input never counts as a loop', () => {
965
+ // Fired 27 seconds into the first mixed-provider run: a director calling
966
+ // check_workers five times while a worker built. That is a director doing
967
+ // its job; a second streak would have interrupted a healthy mission.
968
+ let fired = 0;
969
+ const r = watchRepeats(DEFAULT_REPEAT_LIMIT, () => fired++);
970
+ for (let i = 0; i < 20; i++) observeToolUse(r, 'mcp__foreman__check_workers', {});
971
+ for (let i = 0; i < 20; i++) observeToolUse(r, 'mcp__foreman__wait_for_worker', { workerId: 'worker-1' });
972
+ for (let i = 0; i < 20; i++) observeToolUse(r, 'mcp__foreman__report_progress', { status: 'building' });
973
+ assert.equal(fired, 0, 'status reads carry their information in when they are made, not in their input');
974
+ for (const name of REPEAT_EXEMPT) assert.match(name, /^mcp__foreman__/, 'only Foreman’s own supervision tools are exempt');
975
+ });
976
+
977
+ test('a real repeat still fires through the same path', () => {
978
+ // The exemption must not have quietly disabled the detector.
979
+ let fired = 0;
980
+ const r = watchRepeats(3, () => fired++);
981
+ for (let i = 0; i < 3; i++) observeToolUse(r, 'Bash', { command: 'npm test' });
982
+ assert.equal(fired, 1);
983
+ });
984
+
985
+ // ---------------------------------------------------------------------------
986
+ // An upstream that states its own cost outranks the rated figure
987
+ // ---------------------------------------------------------------------------
988
+
989
+ test('a ledger-reported cost replaces the rated cost for gateway tokens and arms the cap', async () => {
990
+ const events: Array<{ event: string; data: any }> = [];
991
+ let ledgerCost: number | undefined;
992
+ const run = new MissionRun(
993
+ meta({ costBasis: 'unpriced', budgetUsd: 5 }), (event, data) => events.push({ event, data }), () => {},
994
+ noopAgentEnv, { worker: { input: 0.000002, output: 0.00001 } },
995
+ { key: 'r.0', roles: { director: false, worker: true }, read: async () => ({
996
+ inputTokens: 1000, outputTokens: 100, cacheReadTokens: 0, cacheWriteTokens: 0, calls: 1, costUsd: ledgerCost,
997
+ }) },
998
+ ) as unknown as {
999
+ addUsage(raw: unknown, role?: string): void; addCost(usd: number, role?: string): void;
1000
+ pollLedger(): Promise<void>; meta: RunMeta;
1001
+ };
1002
+
1003
+ // Rated path first: worker tokens priced from the table.
1004
+ run.addUsage({ input_tokens: 1000 }, 'worker');
1005
+ assert.ok(Math.abs(run.meta.costUsd - 0.002) < 1e-9);
1006
+ assert.equal(run.meta.costBasis, 'unpriced', 'a rated figure alone does not change the recorded basis here');
1007
+
1008
+ // Then the upstream states what it actually charged for the same tokens.
1009
+ ledgerCost = 0.0031;
1010
+ await run.pollLedger();
1011
+ assert.ok(Math.abs(run.meta.costUsd - 0.0031) < 1e-9, `the bill-sender's figure replaces the rated one, got ${run.meta.costUsd}`);
1012
+ assert.equal(run.meta.costBasis, 'priced');
1013
+ assert.ok(events.some((e) => e.event === 'settings_changed' && /now priced/.test(e.data.changes?.[0] ?? '')),
1014
+ 'the flip to priced is announced, because the dollar cap arms with it');
1015
+ assert.deepEqual(run.meta.costParts, { native: 0, rated: 0.002, ledger: 0.0031 });
1016
+
1017
+ // Native (Anthropic) cost still adds on top; it prices different tokens.
1018
+ run.addCost(0.5, 'director');
1019
+ assert.ok(Math.abs(run.meta.costUsd - 0.5031) < 1e-9);
1020
+ });
1021
+
1022
+ test('a resumed run adds this attempt’s ledger cost to what earlier attempts persisted', async () => {
1023
+ const run = new MissionRun(
1024
+ meta({ costBasis: 'priced', costUsd: 1.25, costParts: { native: 0.25, rated: 0, ledger: 1.0 } }),
1025
+ () => {}, () => {}, noopAgentEnv, {},
1026
+ { key: 'r.1', roles: { director: false, worker: true }, read: async () => ({
1027
+ inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0, calls: 1, costUsd: 0.4,
1028
+ }) },
1029
+ ) as unknown as { pollLedger(): Promise<void>; meta: RunMeta };
1030
+ await run.pollLedger();
1031
+ assert.ok(Math.abs(run.meta.costUsd - 1.65) < 1e-9, `0.25 native + (1.0 earlier + 0.4 now), got ${run.meta.costUsd}`);
1032
+ });
1033
+
1034
+ // ---------------------------------------------------------------------------
1035
+ // Item 6: a stalled gateway worker asks the human, with the retry one tap away
1036
+ // ---------------------------------------------------------------------------
1037
+
1038
+ function fallbackRun(over: Partial<RunMeta>, roleBasis?: { director: 'priced' | 'free' | 'unpriced'; worker: 'priced' | 'free' | 'unpriced' }) {
1039
+ const events: Array<{ event: string; data: any }> = [];
1040
+ const launched: any[] = [];
1041
+ const directorEnv = { env: { A: 'director' } } as unknown as AgentEnv;
1042
+ const workerEnv = { env: { A: 'worker' } } as unknown as AgentEnv;
1043
+ const run = new MissionRun(
1044
+ meta({ directorModel: 'sonnet', workerModel: 'glm-5.3-flash:cloud', costBasis: 'free', askTimeoutMs: 20, ...over }),
1045
+ (event, data) => events.push({ event, data }), () => {},
1046
+ { director: directorEnv, worker: workerEnv }, {}, undefined, roleBasis,
1047
+ ) as unknown as {
1048
+ askFallback(id: string, prompt: string, out: { report: string; isError: boolean }, why: string): Promise<{ report: string; isError: boolean }>;
1049
+ launchWorker(...a: unknown[]): unknown;
1050
+ answerQuestion(id: string, text: string): boolean;
1051
+ meta: RunMeta;
1052
+ };
1053
+ run.launchWorker = (...a: unknown[]) => { launched.push(a); return {}; };
1054
+ return { run, events, launched, directorEnv };
1055
+ }
1056
+
1057
+ test('the human taps retry: the brief relaunches on the director’s provider and the basis flips, announced', async () => {
1058
+ const { run, events, launched, directorEnv } = fallbackRun({ askTimeoutMs: 60_000 }, { director: 'priced', worker: 'free' });
1059
+ const p = run.askFallback('worker-1', 'build the page', { report: 'WORKER STALLED…', isError: true }, 'stalled');
1060
+ await new Promise((r) => setTimeout(r, 10));
1061
+ const q = events.find((e) => e.event === 'question');
1062
+ assert.ok(q, 'the human is asked');
1063
+ assert.equal(q!.data.options.length, 2);
1064
+ assert.match(q!.data.options[1], /Retry once on the director's provider \(sonnet\)/);
1065
+ assert.equal(run.answerQuestion(q!.data.id, q!.data.options[1]), true);
1066
+ const out = await p;
1067
+ assert.equal(launched.length, 1, 'relaunched exactly once');
1068
+ const [id, prompt, resume, overrides] = launched[0] as [string, string, undefined, any];
1069
+ assert.match(id, /^worker-\d+$/);
1070
+ assert.equal(prompt, 'build the page');
1071
+ assert.equal(resume, undefined);
1072
+ assert.equal(overrides.env, directorEnv);
1073
+ assert.equal(overrides.model, 'sonnet');
1074
+ assert.equal(overrides.priceRole, 'director');
1075
+ assert.equal(run.meta.costBasis, 'priced', 'free worker → priced director: the run is priced now');
1076
+ assert.ok(events.some((e) => e.event === 'settings_changed' && /now priced/.test(e.data.changes[0])), 'and it is announced');
1077
+ assert.match(out.report, /THE HUMAN CHOSE TO RETRY/);
1078
+ assert.match(out.report, new RegExp(`running as ${id}`));
1079
+ });
1080
+
1081
+ test('nobody answers: after the timeout the director simply continues, nothing is relaunched', async () => {
1082
+ const { run, events, launched } = fallbackRun({}, { director: 'priced', worker: 'free' });
1083
+ const out = await run.askFallback('worker-1', 'brief', { report: 'WORKER STALLED…', isError: true }, 'stalled');
1084
+ assert.equal(launched.length, 0);
1085
+ assert.equal(out.report, 'WORKER STALLED…', 'the outcome is handed back unchanged');
1086
+ assert.ok(events.some((e) => e.event === 'question_timeout'));
1087
+ assert.equal(run.meta.costBasis, 'free', 'no money was committed on the human’s behalf');
1088
+ });
1089
+
1090
+ test('a worker already on the director’s provider is not asked — there is nowhere else to go', async () => {
1091
+ const { run, events, launched } = fallbackRun({ directorModel: 'sonnet', workerModel: 'sonnet' }, { director: 'priced', worker: 'priced' });
1092
+ (run as any).agentEnv.worker = (run as any).agentEnv.director;
1093
+ const out = await run.askFallback('worker-1', 'brief', { report: 'r', isError: true }, 'looping');
1094
+ assert.equal(events.filter((e) => e.event === 'question').length, 0);
1095
+ assert.equal(launched.length, 0);
1096
+ assert.equal(out.report, 'r');
1097
+ // And with no role bases known at all, likewise.
1098
+ const bare = fallbackRun({});
1099
+ await bare.run.askFallback('worker-1', 'brief', { report: 'r', isError: true }, 'stalled');
1100
+ assert.equal(bare.events.filter((e) => e.event === 'question').length, 0);
1101
+ });
1102
+
1103
+ test('the SDK’s dollar figure is discarded for a gateway role — it prices the wrong tokens', () => {
1104
+ // "$30.14" on a fleet card for a run on free and unpriced models: Anthropic's
1105
+ // table applied to 5.8M Ollama tokens. Stopped where it is recorded.
1106
+ const run = new MissionRun(meta({ costBasis: 'unpriced' }), () => {}, () => {}, noopAgentEnv, {},
1107
+ { key: 'r.0', roles: { director: true, worker: true }, read: async () => null },
1108
+ ) as unknown as { addCost(usd: number, role?: string): void; meta: RunMeta };
1109
+ run.addCost(30.14, 'director');
1110
+ run.addCost(1.5, 'worker');
1111
+ assert.equal(run.meta.costUsd, 0);
1112
+ // A native role's figure is still real and still counted.
1113
+ const mixed = new MissionRun(meta({ costBasis: 'priced' }), () => {}, () => {}, noopAgentEnv, {},
1114
+ { key: 'r.0', roles: { director: false, worker: true }, read: async () => null },
1115
+ ) as unknown as { addCost(usd: number, role?: string): void; meta: RunMeta };
1116
+ mixed.addCost(0.4, 'director');
1117
+ mixed.addCost(9, 'worker');
1118
+ assert.equal(mixed.meta.costUsd, 0.4);
1119
+ });
1120
+
1121
+ test('pendingAsks exposes an open question with its text and options, and forgets it once answered', async () => {
1122
+ // The fleet board and a phone answer asks away from the transcript; they
1123
+ // need what the ask *is*, not just that one exists.
1124
+ const events: Array<{ event: string; data: any }> = [];
1125
+ const directorEnv = { env: { A: 'd' } } as unknown as AgentEnv;
1126
+ const workerEnv = { env: { A: 'w' } } as unknown as AgentEnv;
1127
+ const run = new MissionRun(
1128
+ meta({ directorModel: 'sonnet', workerModel: 'glm', costBasis: 'free', askTimeoutMs: 60_000 }),
1129
+ (event, data) => events.push({ event, data }), () => {},
1130
+ { director: directorEnv, worker: workerEnv }, {}, undefined, { director: 'priced', worker: 'free' },
1131
+ ) as unknown as {
1132
+ askFallback(id: string, prompt: string, out: { report: string; isError: boolean }, why: string): Promise<unknown>;
1133
+ pendingAsks(): Array<{ id: string; kind: string; text: string; options?: string[]; since: number }>;
1134
+ answerQuestion(id: string, text: string): boolean;
1135
+ };
1136
+ const p = run.askFallback('worker-1', 'brief', { report: 'stalled', isError: true }, 'stalled');
1137
+ await new Promise((r) => setTimeout(r, 10));
1138
+ const open = run.pendingAsks();
1139
+ assert.equal(open.length, 1);
1140
+ assert.equal(open[0].kind, 'question');
1141
+ assert.match(open[0].text, /worker-1 stalled/);
1142
+ assert.equal(open[0].options?.length, 2);
1143
+ assert.ok(Date.now() - open[0].since < 5000);
1144
+ run.answerQuestion(open[0].id, open[0].options![0]);
1145
+ await p;
1146
+ assert.deepEqual(run.pendingAsks(), []);
1147
+ });