@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/README.md +43 -3
- package/package.json +1 -1
- package/src/fleet-planner.test.ts +39 -1
- package/src/fleet-planner.ts +83 -3
- package/src/gitwork.test.ts +89 -2
- package/src/gitwork.ts +134 -0
- package/src/mcp.test.ts +38 -1
- package/src/mcp.ts +75 -3
- package/src/notify/commands.test.ts +2 -0
- package/src/notify/commands.ts +6 -0
- package/src/notify/telegram.ts +1 -0
- package/src/notify.test.ts +61 -0
- package/src/notify.ts +45 -1
- package/src/orchestrator.ts +20 -9
- package/src/schedule-guards.test.ts +236 -0
- package/src/schedule-guards.ts +149 -0
- package/src/schedule.test.ts +240 -0
- package/src/schedule.ts +343 -0
- package/src/server.ts +624 -17
- package/src/services.test.ts +46 -1
- package/src/services.ts +62 -3
- package/src/store.test.ts +81 -1
- package/src/store.ts +117 -3
- package/src/types.ts +64 -1
- package/ui/dist/assets/index-Bcc4KMtO.js +68 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-CnCv2QiH.js +0 -68
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,
|
|
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
|
|
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);
|
package/src/notify/commands.ts
CHANGED
|
@@ -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 <project> <what you want> — talk to that project\'s planner',
|
|
71
76
|
'/run <project> <brief> — 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.',
|
package/src/notify/telegram.ts
CHANGED
|
@@ -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
|
];
|
package/src/notify.test.ts
CHANGED
|
@@ -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'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
|
}
|
package/src/orchestrator.ts
CHANGED
|
@@ -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
|
+
});
|