@amenophis1er/foreman 0.1.15 → 0.1.17

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/src/mcp.ts CHANGED
@@ -10,17 +10,20 @@
10
10
  * What it offers is what an agent watching or launching missions needs: the
11
11
  * fleet, runs, a run's status with a `wait` (one call that returns when
12
12
  * something changes, instead of a polling loop), the transcript, the mission
13
- * doc and memory, linking a project, starting a mission, steering a director.
13
+ * doc and memory, the schedules, linking a project, starting a mission,
14
+ * steering a director.
14
15
  *
15
16
  * What it does not offer, on purpose: approving or denying, answering the
16
17
  * director's questions, interrupt, resume, raising a budget, opening a pull
17
- * request, settings and keys. Those are the moments Foreman exists to put a
18
+ * request, settings and keys, and any change to a schedule. Those are the
19
+ * moments Foreman exists to put a
18
20
  * human in; `run_status` says when a run needs one, and with what, so the
19
21
  * agent's job is to send the human to decide, not to decide.
20
22
  */
21
23
  import fs from 'node:fs';
22
24
  import path from 'node:path';
23
25
  import { z } from 'zod';
26
+ import { describeCadence, type Cadence } from './schedule.js';
24
27
 
25
28
  export interface ToolResult {
26
29
  /** What the model reads. */
@@ -70,6 +73,45 @@ interface ProjectCard {
70
73
  pendingPermissions: number; pendingQuestions: number; needs?: Need[]; git?: { branch?: string; dirty?: boolean } | null;
71
74
  }
72
75
 
76
+ /** A schedule as `GET /projects/{id}/schedules` reports it. */
77
+ interface ScheduleSummary {
78
+ id: string; name: string; cadence: Cadence; budgetUsd: number; enabled: boolean;
79
+ nextRunAt: number | null; pausedReason: null | 'failures' | 'monthly-cap' | 'human';
80
+ consecutiveFailures?: number; lastOutcome?: string; lastRunId?: string; lastRunAt?: number; lastNote?: string;
81
+ }
82
+ interface SchedulePayload { schedules: ScheduleSummary[]; monthSpendUsd?: number; monthlyCapUsd?: number }
83
+
84
+ /** A moment as a reader would say it: "in 15 h", "3 days ago". */
85
+ export function relativeTime(ms: number): string {
86
+ const s = Math.round(ms / 1000);
87
+ const a = Math.abs(s);
88
+ const span = a < 90 ? 'a minute' : a < 5400 ? `${Math.round(a / 60)} min` : a < 172800 ? `${Math.round(a / 3600)} h` : `${Math.round(a / 86400)} days`;
89
+ return s >= 0 ? `in ${span}` : `${span} ago`;
90
+ }
91
+
92
+ /** Why a schedule is not going to fire, in the words that say what would undo it. */
93
+ function pausedPhrase(s: ScheduleSummary): string {
94
+ switch (s.pausedReason) {
95
+ case 'failures': return `paused after ${s.consecutiveFailures ?? 2} failed scheduled runs in a row`;
96
+ case 'monthly-cap': return 'paused at the project\'s monthly cap for scheduled spend';
97
+ case 'human': return 'paused by hand';
98
+ default: return s.enabled ? 'enabled' : 'disabled';
99
+ }
100
+ }
101
+
102
+ /**
103
+ * One line for a schedule. The next run is said twice — absolutely, because a
104
+ * schedule is a wall-clock promise, and relatively, because "in 15 h" is what
105
+ * the reader actually wanted to know.
106
+ */
107
+ function scheduleLine(s: ScheduleSummary): string {
108
+ const next = s.pausedReason || !s.enabled ? 'no next run while paused'
109
+ : s.nextRunAt ? `next ${new Date(s.nextRunAt).toLocaleString()} (${relativeTime(s.nextRunAt - Date.now())})`
110
+ : 'next never — this cadence has no future firing';
111
+ const last = s.lastOutcome ? `last ${s.lastOutcome}${s.lastRunId ? ` (${s.lastRunId})` : ''}${s.lastRunAt ? ` ${relativeTime(s.lastRunAt - Date.now())}` : ''}` : 'never run yet';
112
+ return `${s.name} · ${describeCadence(s.cadence)} · ${next} · ${pausedPhrase(s)} · ${usd(s.budgetUsd)} per run · ${last}`;
113
+ }
114
+
73
115
  /** One line for a run, the way the fleet board says it. */
74
116
  function runLine(r: RunSummary): string {
75
117
  const cost = r.costBasis && r.costBasis !== 'priced' ? `${r.costBasis}` : `${usd(r.costUsd)} of ${usd(r.budgetUsd)}`;
@@ -373,6 +415,36 @@ export function foremanTools(opts: ForemanClientOptions): ToolDef[] {
373
415
  },
374
416
  };
375
417
 
418
+ const schedules: ToolDef = {
419
+ name: 'list_schedules',
420
+ description: 'The standing schedules: missions that start themselves in their project on a cadence. Per schedule — the project, the name, the cadence in words, the next run, enabled or paused and why, the per-run cap, and how the last firing ended with its run id. Read-only, and the only schedule tool there is: creating, editing, pausing, resuming or running one now happens on the dashboard, because a schedule is standing configuration and remote surfaces never grant standing changes. Do not look for another tool.',
421
+ schema: { projectId: z.string().optional() },
422
+ run: async ({ projectId }) => {
423
+ const all = (await get<{ projects: ProjectCard[] }>('/projects')).projects;
424
+ const ref = projectId === undefined ? '' : String(projectId).trim();
425
+ const wanted = ref ? all.filter((p) => p.id === ref || p.name.toLowerCase() === ref.toLowerCase()) : all;
426
+ if (ref && !wanted.length) return { text: `No project ${ref}. fleet_status lists them by id and name.` };
427
+ const blocks: string[] = [];
428
+ const data: Array<{ projectId: string; project: string; schedules: ScheduleSummary[]; monthSpendUsd?: number; monthlyCapUsd?: number }> = [];
429
+ for (const p of wanted) {
430
+ const d = await get<SchedulePayload>(`/projects/${encodeURIComponent(p.id)}/schedules`).catch(() => null);
431
+ if (!d) continue;
432
+ const list = d.schedules ?? [];
433
+ data.push({ projectId: p.id, project: p.name, schedules: list, monthSpendUsd: d.monthSpendUsd, monthlyCapUsd: d.monthlyCapUsd });
434
+ if (!list.length) {
435
+ if (ref) blocks.push(`${p.name} (${p.id}) — no schedules.`);
436
+ continue;
437
+ }
438
+ const month = typeof d.monthSpendUsd === 'number' && typeof d.monthlyCapUsd === 'number'
439
+ ? ` · scheduled this month ${usd(d.monthSpendUsd)} of ${usd(d.monthlyCapUsd)}` : '';
440
+ blocks.push(`${p.name} (${p.id}) — ${list.length} schedule${list.length === 1 ? '' : 's'}${month}\n${list.map((s) => ` ${scheduleLine(s)}`).join('\n')}`);
441
+ }
442
+ if (!blocks.length) return { text: 'No schedules. They are created on the dashboard, in a project\'s view.', data: { projects: data } };
443
+ blocks.push('Read-only here: a schedule is created, edited, paused or resumed on the dashboard.');
444
+ return { text: blocks.join('\n'), data: { projects: data } };
445
+ },
446
+ };
447
+
376
448
  const search: ToolDef = {
377
449
  name: 'search_runs',
378
450
  description: 'Runs across the fleet whose title, brief, project or folder match.',
@@ -456,7 +528,7 @@ export function foremanTools(opts: ForemanClientOptions): ToolDef[] {
456
528
  },
457
529
  };
458
530
 
459
- return [fleet, listRuns, runStatus, runReport, transcript, missionDoc, memory, search, doctor, link, start, steer];
531
+ return [fleet, listRuns, runStatus, runReport, transcript, missionDoc, memory, schedules, search, doctor, link, start, steer];
460
532
  }
461
533
 
462
534
  /** Runs the MCP server over stdio until the client goes away. Nothing may be written to stdout but the protocol. */
@@ -11,6 +11,8 @@ test('parseCommand: one shape per command, bot suffix tolerated, junk is null',
11
11
  assert.deepEqual(parseCommand('/run lp1 ship it'), { cmd: 'run', project: 'lp1', text: 'ship it' });
12
12
  assert.deepEqual(parseCommand('/stop'), { cmd: 'stop' });
13
13
  assert.deepEqual(parseCommand('/stop lp1'), { cmd: 'stop', project: 'lp1' });
14
+ assert.deepEqual(parseCommand('/schedules'), { cmd: 'schedules' });
15
+ assert.deepEqual(parseCommand('/schedules@ForemanBot lp1'), { cmd: 'schedules', project: 'lp1' });
14
16
  assert.equal(parseCommand('/plan test-4'), null);
15
17
  assert.equal(parseCommand('/new'), null);
16
18
  assert.equal(parseCommand('/dance'), null);
@@ -17,6 +17,8 @@ export type Command =
17
17
  | { cmd: 'plan'; project: string; text: string }
18
18
  | { cmd: 'run'; project: string; text: string }
19
19
  | { cmd: 'stop'; project?: string }
20
+ /** Read-only: what stands, for one project or the whole fleet. */
21
+ | { cmd: 'schedules'; project?: string }
20
22
  | { cmd: 'fleet'; text: string };
21
23
 
22
24
  /** `/plan@ForemanBot test-4 add a footer` → { cmd: 'plan', project: 'test-4', text: 'add a footer' }. */
@@ -37,6 +39,9 @@ export function parseCommand(text: string): Command | null {
37
39
  case 'plan': { const [project, t] = split(); return project && t ? { cmd: 'plan', project, text: t } : null; }
38
40
  case 'run': { const [project, t] = split(); return project && t ? { cmd: 'run', project, text: t } : null; }
39
41
  case 'stop': return { cmd: 'stop', ...(rest ? { project: rest } : {}) };
42
+ // Listing only. A schedule is standing configuration, and remote surfaces
43
+ // never grant standing changes — see the reply in server.ts.
44
+ case 'schedules': return { cmd: 'schedules', ...(rest ? { project: rest } : {}) };
40
45
  case 'fleet': case 'f': return { cmd: 'fleet', text: rest };
41
46
  default: return null;
42
47
  }
@@ -70,6 +75,7 @@ export const HELP_TEXT = [
70
75
  '/plan &lt;project&gt; &lt;what you want&gt; — talk to that project\'s planner',
71
76
  '/run &lt;project&gt; &lt;brief&gt; — skip the talk: start a mission at the project\'s default cap',
72
77
  '/stop [project] — stop the planner reply in flight',
78
+ '/schedules [project] — the standing schedules and when they next run (reading only; they are changed in the dashboard)',
73
79
  '/fleet [anything] — the front desk: ask how things are going, or say what you want started where',
74
80
  '',
75
81
  'Anything else you type answers the open question, continues the planning conversation you were just in, or goes to the front desk.',
@@ -98,6 +98,7 @@ export const BOT_COMMANDS: Array<{ command: string; description: string }> = [
98
98
  { command: 'plan', description: 'Talk to a planner: /plan <project> <what you want>' },
99
99
  { command: 'run', description: 'Skip the talk: /run <project> <brief>' },
100
100
  { command: 'stop', description: 'Stop the planner reply in flight' },
101
+ { command: 'schedules', description: 'Standing schedules and when they next run (read-only)' },
101
102
  { command: 'fleet', description: 'The front desk: /fleet how is everything going?' },
102
103
  { command: 'help', description: 'What you can say here' },
103
104
  ];
@@ -123,6 +123,67 @@ test('a transport that throws costs a failure count, never an exception on the e
123
123
  assert.equal(hub.delivered, 0);
124
124
  });
125
125
 
126
+ test('a paused schedule says which one and why, and is gated by its cause', () => {
127
+ const c = ctx()();
128
+ const cap = shape(env('schedule_paused', { scheduleId: 's1', name: 'nightly deps', reason: 'monthly-cap' }), c)!;
129
+ assert.equal(cap.key, 'schedule:s1');
130
+ assert.equal(cap.gate, 'budget', 'the monthly ceiling is a spending guard');
131
+ assert.match(cap.text, /nightly deps/);
132
+ assert.match(cap.text, /this month&#x27;s scheduled spend would go past the ceiling|this month's scheduled spend would go past the ceiling/);
133
+
134
+ const fail = shape(env('schedule_paused', { scheduleId: 's1', name: 'nightly deps', reason: 'failures' }), c)!;
135
+ assert.equal(fail.key, 'schedule:s1');
136
+ assert.equal(fail.gate, 'needsYou', 'only a human can resume it');
137
+ assert.match(fail.text, /two scheduled runs in a row failed/);
138
+
139
+ // Resuming is deliberately a dashboard act, so there is nothing to tap.
140
+ for (const s of [cap, fail]) {
141
+ assert.match(s.text, /Resume it from the Foreman dashboard/);
142
+ assert.equal(s.buttons, undefined);
143
+ }
144
+ });
145
+
146
+ test('an unknown pause reason still shapes, and does not throw', () => {
147
+ const c = ctx()();
148
+ assert.doesNotThrow(() => shape(env('schedule_paused', { scheduleId: 's1', name: 'nightly deps', reason: 'kaput' }), c));
149
+ const s = shape(env('schedule_paused', { scheduleId: 's1', name: 'nightly deps', reason: 'kaput' }), c)!;
150
+ assert.equal(s.key, 'schedule:s1');
151
+ assert.equal(s.gate, 'done');
152
+ assert.match(s.text, /nightly deps/);
153
+ assert.doesNotThrow(() => shape(env('schedule_skipped', { scheduleId: 's1', name: 'nightly deps', reason: 'moon phase' }), c));
154
+ });
155
+
156
+ test('a skipped scheduled run is informational, and consecutive skips are not deduped', () => {
157
+ const c = ctx()();
158
+ const s = shape(env('schedule_skipped', { scheduleId: 's1', name: 'nightly deps', reason: 'project busy' }), c)!;
159
+ assert.equal(s.gate, 'done');
160
+ assert.equal(s.key, 'skip:s1:1000000');
161
+ assert.match(s.text, /nightly deps/);
162
+ assert.match(s.text, /already had a mission running/);
163
+ assert.match(s.text, /next scheduled run stands/);
164
+ // Like a stall, the timestamp is in the key so tonight's skip is not
165
+ // swallowed by last night's.
166
+ const later = shape(env('schedule_skipped', { scheduleId: 's1', name: 'nightly deps', reason: 'project busy' }, { ts: 1_000_500 }), c)!;
167
+ assert.notEqual(later.key, s.key);
168
+ });
169
+
170
+ test('a scheduled run says nobody pressed start; an ordinary one reads exactly as before', () => {
171
+ const c = ctx()();
172
+ const plain = shape(env('run_finished', { status: 'done' }), c)!;
173
+ assert.equal(plain.text,
174
+ '<b>Mission done</b> · shop\n<i>Build the checkout page</i>' +
175
+ '\n<a href="http://box.local:4177/#/p/p1/r/r1">Open in Foreman</a>');
176
+
177
+ const sched = shape(env('run_finished', { status: 'done', scheduled: true, scheduleName: 'nightly deps' }), c)!;
178
+ assert.equal(sched.key, plain.key, 'same key, so it still edits and dedupes as before');
179
+ assert.equal(sched.gate, plain.gate);
180
+ assert.match(sched.text, /Mission done · scheduled · nightly deps/);
181
+
182
+ // Without a name it still marks itself as unattended.
183
+ const anon = shape(env('run_finished', { status: 'error', scheduled: true }), c)!;
184
+ assert.match(anon.text, /Mission failed · scheduled/);
185
+ });
186
+
126
187
  // ---------------------------------------------------------------------------
127
188
  // Telegram, against a stub of the Bot API
128
189
  // ---------------------------------------------------------------------------
package/src/notify.ts CHANGED
@@ -260,12 +260,56 @@ export function shape(env: Envelope, ctx: NotifyContext): Shaped | null {
260
260
  case 'run_finished': {
261
261
  const status = String(d.status ?? 'done');
262
262
  const title = status === 'done' ? 'Mission done' : status === 'error' ? 'Mission failed' : 'Mission interrupted';
263
+ // A scheduled run says so in its title: nobody pressed start, so the
264
+ // first question a phone raises — "who did this?" — is already answered.
265
+ const sched = d.scheduled
266
+ ? ` · scheduled${d.scheduleName ? ` · ${clip(d.scheduleName, 40)}` : ''}`
267
+ : '';
263
268
  return { key: `finished:${env.runId}`, gate: 'done',
264
- text: `${head(title)}${runLine}${foot}` };
269
+ text: `${head(title + sched)}${runLine}${foot}` };
265
270
  }
266
271
  case 'run_error':
267
272
  return { key: `error:${env.runId}:${env.ts ?? ''}`, gate: 'done',
268
273
  text: `${head('Mission error')}${runLine}\n${esc(clip(d.error))}${foot}` };
274
+
275
+ // --- scheduled missions -------------------------------------------
276
+ case 'schedule_paused': {
277
+ // A paused schedule is the one thing here that stays broken until
278
+ // someone acts, so it says why in plain words. Which toggle carries it
279
+ // follows the cause: the monthly ceiling is a spending guard, repeated
280
+ // failures are a thing only a human can clear. Anything else — a hand
281
+ // on the switch — is news, not a summons.
282
+ const reason = String(d.reason ?? '');
283
+ const gate: keyof NotifyPrefs = reason === 'monthly-cap' ? 'budget' : reason === 'failures' ? 'needsYou' : 'done';
284
+ const why = reason === 'failures'
285
+ ? 'two scheduled runs in a row failed'
286
+ : reason === 'monthly-cap'
287
+ ? "this month's scheduled spend would go past the ceiling"
288
+ : reason === 'human'
289
+ ? 'you paused it'
290
+ : `paused${reason ? ` — ${esc(clip(reason, 60))}` : ''}`;
291
+ return {
292
+ key: `schedule:${d.scheduleId}`, gate,
293
+ // No buttons on purpose: resuming a schedule is a dashboard act, where
294
+ // the cadence, the caps and what it last did are all in view.
295
+ text: `${head('Schedule paused')}\n<b>${esc(clip(d.name, 60))}</b> — ${why}.` +
296
+ `\n<i>Resume it from the Foreman dashboard; there is no resume from here.</i>${foot}`,
297
+ };
298
+ }
299
+ case 'schedule_skipped': {
300
+ // Nothing is wrong and nothing is owed: the cadence simply stepped over
301
+ // a busy project. Keyed by timestamp like a stall, so a run of skips
302
+ // reads as a run of skips rather than one deduped line.
303
+ const reason = String(d.reason ?? '');
304
+ const why = reason === 'project busy'
305
+ ? 'the project already had a mission running, so this turn was not started'
306
+ : `not started${reason ? ` — ${esc(clip(reason, 60))}` : ''}`;
307
+ return {
308
+ key: `skip:${d.scheduleId}:${env.ts ?? ''}`, gate: 'done',
309
+ text: `${head('Scheduled run skipped')}\n<b>${esc(clip(d.name, 60))}</b> — ${why}.` +
310
+ `\n<i>The next scheduled run stands.</i>${foot}`,
311
+ };
312
+ }
269
313
  default:
270
314
  return null;
271
315
  }
@@ -644,6 +644,14 @@ export type Emitter = (event: string, data: unknown) => void;
644
644
  /** Persists updated run metadata (fire-and-forget from the run's viewpoint). */
645
645
  export type MetaSink = (meta: RunMeta) => void;
646
646
 
647
+ /**
648
+ * Turns a worker gets before the SDK stops it. Raised from 60: five workers
649
+ * in four missions hit the cap mid-task on legitimate, brief-sized work
650
+ * (a hundred TypeScript errors in one package; a test-suite port), and each
651
+ * time the director paid to respawn one that re-read the same files.
652
+ */
653
+ const WORKER_MAX_TURNS = 100;
654
+
647
655
  export const DIRECTOR_CHARTER = `
648
656
  You are the FOREMAN DIRECTOR. You run a mission autonomously inside one folder by
649
657
  directing worker agents. Non-negotiable rules, in priority order:
@@ -720,6 +728,16 @@ directing worker agents. Non-negotiable rules, in priority order:
720
728
  (a dev server, a static preview), call mcp__foreman__expose_service with
721
729
  its port and put the URL it returns in your report — the human can open
722
730
  it from their phone. Keep that server up until the mission ends.
731
+ CLEAN UP WHAT YOU STARTED: before you finish, stop every server, watcher or
732
+ background process the crew started that you are not deliberately leaving
733
+ for the human, and name in your report any you left up and on which port.
734
+ A process that outlives the mission holds its port until somebody hunts it
735
+ down by hand.
736
+ SIZE THE TASK TO THE WORKER: a worker is stopped after ${WORKER_MAX_TURNS}
737
+ turns of its own. It gets one continuation to finish what it was doing, and
738
+ after that the task comes back to you half-done. So give a worker a task it
739
+ can finish in that many steps — split the big ones — rather than one brief
740
+ that has to be rescued twice.
723
741
  4. REPORT WHAT YOU SEE. Judge the work as a competent professional would, not
724
742
  only against the letter of the acceptance criteria. If you observe a defect
725
743
  the criteria did not name — tap targets too small to use, unreadable
@@ -773,13 +791,6 @@ const USAGE_LIMIT_RE = /out of usage credits|usage limit reached|upgrade to incr
773
791
  /** One worker's outcome, as runWorker hands it back. */
774
792
  interface WorkerOutcome { report: string; isError: boolean }
775
793
 
776
- /**
777
- * Turns a worker gets before the SDK stops it. Raised from 60: five workers
778
- * in four missions hit the cap mid-task on legitimate, brief-sized work
779
- * (a hundred TypeScript errors in one package; a test-suite port), and each
780
- * time the director paid to respawn one that re-read the same files.
781
- */
782
- const WORKER_MAX_TURNS = 100;
783
794
  /** What a worker stopped by the cap is told when its session is picked back up. */
784
795
  const WORKER_CONTINUE_PROMPT =
785
796
  'You were stopped by the turn cap, not by a failure. Your session and your files are as you left them; ' +
@@ -914,7 +925,7 @@ export class MissionRun {
914
925
  * has no `expose_service` to offer.
915
926
  */
916
927
  private readonly host: {
917
- exposeService?: (runId: string, port: number, label: string) => Promise<{ ok: true; url: string; path: string } | { ok: false; reason: string }>;
928
+ exposeService?: (runId: string, port: number, label: string) => Promise<{ ok: true; url: string; path: string; pid?: number } | { ok: false; reason: string }>;
918
929
  /** The browser channel detected at dispatch (src/browser.ts); Chrome when the host says nothing. */
919
930
  browserChannel?: string;
920
931
  } = {},
@@ -2457,7 +2468,7 @@ export class MissionRun {
2457
2468
  if (!fn) return { content: [{ type: 'text' as const, text: 'Exposing services is not available in this run.' }] };
2458
2469
  const r = await fn(this.meta.id, port, (label ?? '').trim() || `port ${port}`);
2459
2470
  if (!r.ok) return { content: [{ type: 'text' as const, text: `Not exposed: ${r.reason}` }] };
2460
- const entry = { port, label: (label ?? '').trim() || `port ${port}`, path: r.path, since: Date.now() };
2471
+ const entry = { port, label: (label ?? '').trim() || `port ${port}`, path: r.path, since: Date.now(), pid: r.pid };
2461
2472
  this.meta.services = [...(this.meta.services ?? []).filter((s) => s.port !== port), entry];
2462
2473
  this.saveMeta(this.meta);
2463
2474
  this.emit('service_exposed', { ...entry, url: r.url });
@@ -0,0 +1,236 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import type { Schedule } from './types.js';
4
+ import {
5
+ DEFAULT_SCHEDULED_MONTHLY_CAP_USD,
6
+ monthlyScheduledSpend,
7
+ isFailedOutcome,
8
+ afterRunOutcome,
9
+ decideTicks,
10
+ type TickAction,
11
+ } from './schedule-guards.js';
12
+
13
+ /** Local time, because months and cadences are both local here. */
14
+ const at = (y: number, m: number, d: number, h = 0, min = 0) => new Date(y, m, d, h, min, 0, 0);
15
+
16
+ /** A run row as monthlyScheduledSpend sees one. */
17
+ function run(over: Partial<{ projectId: string; scheduleId: string; createdAt: number; costUsd: number }> = {}) {
18
+ return { projectId: 'p1', scheduleId: 's1', createdAt: at(2026, 4, 10, 3).getTime(), costUsd: 1, ...over };
19
+ }
20
+
21
+ /** A schedule, due at `nextRunAt`, with only the fields the ticker reads. */
22
+ function schedule(over: Partial<Schedule> = {}): Schedule {
23
+ return {
24
+ id: 's1', projectId: 'p1', name: 'nightly', brief: 'tidy up',
25
+ cadence: { kind: 'daily', at: '03:00' },
26
+ budgetUsd: 5,
27
+ enabled: true,
28
+ createdAt: at(2026, 0, 1).getTime(),
29
+ nextRunAt: at(2026, 4, 10, 3).getTime(),
30
+ consecutiveFailures: 0,
31
+ pausedReason: null,
32
+ ...over,
33
+ };
34
+ }
35
+
36
+ /** decideTicks with the plumbing filled in; NOW is 2026-05-10 09:00 local. */
37
+ const NOW = at(2026, 4, 10, 9);
38
+ function decide(schedules: Schedule[], over: Partial<Parameters<typeof decideTicks>[0]> = {}): TickAction[] {
39
+ return decideTicks({
40
+ schedules,
41
+ now: NOW,
42
+ busyProjectIds: [],
43
+ monthSpend: {},
44
+ capFor: () => DEFAULT_SCHEDULED_MONTHLY_CAP_USD,
45
+ ...over,
46
+ });
47
+ }
48
+
49
+ test('monthlyScheduledSpend: only this project\'s scheduled runs, only this month', () => {
50
+ const now = at(2026, 4, 20, 12);
51
+ const runs = [
52
+ run({ costUsd: 3 }),
53
+ run({ costUsd: 4, scheduleId: 's2' }),
54
+ // A hand-started mission spends the human's attention, not the allowance.
55
+ run({ costUsd: 100, scheduleId: undefined }),
56
+ // Another project's schedule, and a run with no project at all.
57
+ run({ costUsd: 100, projectId: 'p2' }),
58
+ run({ costUsd: 100, projectId: undefined }),
59
+ // Last month, and next month.
60
+ run({ costUsd: 100, createdAt: at(2026, 3, 30, 23, 59).getTime() }),
61
+ run({ costUsd: 100, createdAt: at(2026, 5, 1, 0, 0).getTime() }),
62
+ ];
63
+ assert.equal(monthlyScheduledSpend(runs, 'p1', now), 7);
64
+ assert.equal(monthlyScheduledSpend(runs, 'p2', now), 100);
65
+ assert.equal(monthlyScheduledSpend([], 'p1', now), 0);
66
+ });
67
+
68
+ test('monthlyScheduledSpend: the month boundary is local midnight, and December rolls to January', () => {
69
+ // The last minute of April and the first of May are different months even
70
+ // though they are a minute apart.
71
+ const april = [run({ costUsd: 2, createdAt: at(2026, 3, 30, 23, 59).getTime() })];
72
+ const may = [run({ costUsd: 2, createdAt: at(2026, 4, 1, 0, 0).getTime() })];
73
+ assert.equal(monthlyScheduledSpend(april, 'p1', at(2026, 3, 15)), 2);
74
+ assert.equal(monthlyScheduledSpend(april, 'p1', at(2026, 4, 15)), 0);
75
+ assert.equal(monthlyScheduledSpend(may, 'p1', at(2026, 4, 15)), 2);
76
+ assert.equal(monthlyScheduledSpend(may, 'p1', at(2026, 3, 15)), 0);
77
+
78
+ // December 2026 and January 2027 are both "month 0-ish" traps: the same
79
+ // month number, or the same year, is not the same month.
80
+ const dec = run({ costUsd: 6, createdAt: at(2026, 11, 31, 23, 30).getTime() });
81
+ const jan = run({ costUsd: 9, createdAt: at(2027, 0, 1, 0, 30).getTime() });
82
+ assert.equal(monthlyScheduledSpend([dec, jan], 'p1', at(2026, 11, 31, 23, 59)), 6);
83
+ assert.equal(monthlyScheduledSpend([dec, jan], 'p1', at(2027, 0, 2)), 9);
84
+ // Same month number, previous year: not this month.
85
+ assert.equal(monthlyScheduledSpend([run({ costUsd: 5, createdAt: at(2025, 4, 10).getTime() })], 'p1', at(2026, 4, 10)), 0);
86
+ });
87
+
88
+ test('isFailedOutcome: errors and capped interruptions, not restarts', () => {
89
+ assert.equal(isFailedOutcome({ status: 'error' }), true);
90
+ assert.equal(isFailedOutcome({ status: 'interrupted', stopReason: 'budget' }), true);
91
+ assert.equal(isFailedOutcome({ status: 'interrupted', stopReason: 'turns' }), true);
92
+ assert.equal(isFailedOutcome({ status: 'interrupted', stopReason: 'time' }), true);
93
+ assert.equal(isFailedOutcome({ status: 'interrupted', stopReason: 'tokens' }), true);
94
+ // A sweep on startup leaves no stopReason: the server restarted, the
95
+ // schedule did nothing wrong.
96
+ assert.equal(isFailedOutcome({ status: 'interrupted' }), false);
97
+ assert.equal(isFailedOutcome({ status: 'interrupted', stopReason: null }), false);
98
+ assert.equal(isFailedOutcome({ status: 'done' }), false);
99
+ assert.equal(isFailedOutcome({ status: 'running' }), false);
100
+ });
101
+
102
+ test('afterRunOutcome: two failures in a row pause, a success resets', () => {
103
+ const fresh = { consecutiveFailures: 0, pausedReason: null } as const;
104
+ const once = afterRunOutcome(fresh, { status: 'error' });
105
+ assert.deepEqual(once, { consecutiveFailures: 1, pausedReason: null, pausedNow: false });
106
+
107
+ const twice = afterRunOutcome({ consecutiveFailures: 1, pausedReason: null }, { status: 'interrupted', stopReason: 'budget' });
108
+ assert.deepEqual(twice, { consecutiveFailures: 2, pausedReason: 'failures', pausedNow: true });
109
+
110
+ // A done run clears the count before it ever reaches two.
111
+ assert.deepEqual(
112
+ afterRunOutcome({ consecutiveFailures: 1, pausedReason: null }, { status: 'done' }),
113
+ { consecutiveFailures: 0, pausedReason: null, pausedNow: false },
114
+ );
115
+ // A restart is not a failure, so it does not count towards the pause.
116
+ assert.deepEqual(
117
+ afterRunOutcome({ consecutiveFailures: 1, pausedReason: null }, { status: 'interrupted' }),
118
+ { consecutiveFailures: 0, pausedReason: null, pausedNow: false },
119
+ );
120
+ });
121
+
122
+ test('afterRunOutcome: does not un-pause, and does not re-report a pause it did not cause', () => {
123
+ // A schedule the human stopped, or one the monthly cap stopped, stays
124
+ // stopped until they resume it — a stray success is not consent.
125
+ for (const reason of ['human', 'monthly-cap', 'failures'] as const) {
126
+ assert.deepEqual(
127
+ afterRunOutcome({ consecutiveFailures: 0, pausedReason: reason }, { status: 'done' }),
128
+ { consecutiveFailures: 0, pausedReason: reason, pausedNow: false },
129
+ );
130
+ assert.deepEqual(
131
+ afterRunOutcome({ consecutiveFailures: 3, pausedReason: reason }, { status: 'error' }),
132
+ { consecutiveFailures: 4, pausedReason: reason, pausedNow: false },
133
+ );
134
+ }
135
+ });
136
+
137
+ test('decideTicks: nothing to do for schedules that are not due, disabled or paused', () => {
138
+ assert.deepEqual(decide([schedule({ nextRunAt: at(2026, 4, 11, 3).getTime() })]), []);
139
+ assert.deepEqual(decide([schedule({ nextRunAt: null })]), []);
140
+ assert.deepEqual(decide([schedule({ enabled: false })]), []);
141
+ for (const reason of ['failures', 'monthly-cap', 'human'] as const) {
142
+ assert.deepEqual(decide([schedule({ pausedReason: reason })]), []);
143
+ }
144
+ });
145
+
146
+ test('decideTicks: a due schedule starts, and its next firing is after now', () => {
147
+ const actions = decide([schedule()]);
148
+ assert.equal(actions.length, 1);
149
+ const a = actions[0];
150
+ assert.equal(a.kind, 'start');
151
+ assert.equal(a.scheduleId, 's1');
152
+ assert.equal(a.kind === 'start' && a.at, NOW.getTime());
153
+ // Daily 03:00 from 09:00 today is 03:00 tomorrow.
154
+ assert.equal(a.nextRunAt, at(2026, 4, 11, 3).getTime());
155
+ });
156
+
157
+ test('decideTicks: a busy project misses the mission rather than queueing it', () => {
158
+ const busy = decide([schedule()], { busyProjectIds: new Set(['p1']) });
159
+ assert.deepEqual(busy, [{ kind: 'skip', scheduleId: 's1', reason: 'project busy', nextRunAt: at(2026, 4, 11, 3).getTime() }]);
160
+ // The array form of busyProjectIds says the same thing.
161
+ assert.deepEqual(decide([schedule()], { busyProjectIds: ['p1'] }), busy);
162
+ // Another project being busy is not this one's problem.
163
+ assert.equal(decide([schedule()], { busyProjectIds: ['p2'] })[0].kind, 'start');
164
+ });
165
+
166
+ test('decideTicks: catch-up happens once, however long Foreman was down', () => {
167
+ // A schedule whose slot was three months ago fires once now and then goes
168
+ // back to its cadence — the next firing is computed from now, not from the
169
+ // 90 missed 03:00s.
170
+ const stale = schedule({ nextRunAt: at(2026, 1, 4, 3).getTime() });
171
+ const [start] = decide([stale]);
172
+ assert.equal(start.kind, 'start');
173
+ assert.equal(start.nextRunAt, at(2026, 4, 11, 3).getTime());
174
+
175
+ // Same for a skip: the missed slot does not linger in the past waiting to
176
+ // fire again on the next pass.
177
+ const [skip] = decide([stale], { busyProjectIds: ['p1'] });
178
+ assert.equal(skip.kind, 'skip');
179
+ assert.ok(skip.nextRunAt !== null && skip.nextRunAt > NOW.getTime());
180
+ });
181
+
182
+ test('decideTicks: the monthly ceiling pauses, and equal to the cap is still allowed', () => {
183
+ const s = schedule({ budgetUsd: 5 });
184
+ // 20 spent + 5 budget = 25 = the cap: allowed, the rule is strictly past it.
185
+ assert.equal(decide([s], { monthSpend: { p1: 20 } })[0].kind, 'start');
186
+ // A cent more and the run would cross it.
187
+ assert.deepEqual(decide([s], { monthSpend: { p1: 20.01 } }), [
188
+ { kind: 'pause', scheduleId: 's1', reason: 'monthly-cap', spent: 20.01, cap: 25 },
189
+ ]);
190
+ // The Map form says the same thing, and a pause does not advance the next
191
+ // firing: a human resumes it and gets a fresh next time.
192
+ const paused = decide([s], { monthSpend: new Map([['p1', 30]]) });
193
+ assert.deepEqual(paused, [{ kind: 'pause', scheduleId: 's1', reason: 'monthly-cap', spent: 30, cap: 25 }]);
194
+ assert.ok(!('nextRunAt' in paused[0]));
195
+ // The ceiling is per project, and capFor decides it.
196
+ assert.equal(decide([s], { monthSpend: { p1: 30 }, capFor: () => 100 })[0].kind, 'start');
197
+ });
198
+
199
+ test('decideTicks: one start per project, earliest due first, and the start charges the month', () => {
200
+ const early = schedule({ id: 's-early', nextRunAt: at(2026, 4, 10, 3).getTime(), createdAt: 200 });
201
+ const late = schedule({ id: 's-late', nextRunAt: at(2026, 4, 10, 7).getTime(), createdAt: 100 });
202
+ // Given in the wrong order on purpose: due time decides, not array order.
203
+ const actions = decide([late, early]);
204
+ assert.deepEqual(actions.map((a) => [a.kind, a.scheduleId]), [['start', 's-early'], ['skip', 's-late']]);
205
+ assert.equal(actions[1].kind === 'skip' && actions[1].reason, 'project busy');
206
+
207
+ // Due at the same moment: the older schedule wins, so a later-created
208
+ // neighbour cannot starve it night after night.
209
+ const tie = decide([
210
+ schedule({ id: 's-new', createdAt: 999 }),
211
+ schedule({ id: 's-old', createdAt: 1 }),
212
+ ]);
213
+ assert.deepEqual(tie.map((a) => [a.kind, a.scheduleId]), [['start', 's-old'], ['skip', 's-new']]);
214
+
215
+ // Different projects: both start, each on its own allowance.
216
+ const two = decide([schedule({ id: 'a', projectId: 'p1' }), schedule({ id: 'b', projectId: 'p2' })]);
217
+ assert.deepEqual(two.map((a) => a.kind), ['start', 'start']);
218
+
219
+ // A start charges the project's month straight away, so no second schedule
220
+ // can slip under the ceiling by being decided in the same pass. Here 24
221
+ // spent leaves room for exactly one $1 run: the first takes it, and the
222
+ // second is turned away — as busy, because one mission per project is the
223
+ // stricter rule and it is checked first.
224
+ const cheap = [
225
+ schedule({ id: 'a', projectId: 'p1', budgetUsd: 1, createdAt: 1 }),
226
+ schedule({ id: 'b', projectId: 'p1', budgetUsd: 1, createdAt: 2 }),
227
+ ];
228
+ const charged = decide(cheap, { monthSpend: { p1: 24 } });
229
+ assert.deepEqual(charged.map((a) => [a.kind, a.scheduleId]), [['start', 'a'], ['skip', 'b']]);
230
+ assert.equal(charged[1].kind === 'skip' && charged[1].reason, 'project busy');
231
+ // Nothing started, so nothing is charged: both are judged against the same
232
+ // spend, and the ceiling is what turns them away once the project is free.
233
+ const none = decide(cheap, { monthSpend: { p1: 24.5 }, busyProjectIds: ['p1'] });
234
+ assert.deepEqual(none.map((a) => a.kind), ['skip', 'skip']);
235
+ assert.deepEqual(decide([cheap[0]], { monthSpend: { p1: 24.5 } }).map((a) => a.kind), ['pause']);
236
+ });