@amenophis1er/foreman 0.1.14 → 0.1.15
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/package.json +1 -1
- package/src/notify.ts +5 -1
- package/src/orchestrator.test.ts +25 -1
- package/src/orchestrator.ts +64 -0
- package/src/server.ts +7 -0
- package/src/types.ts +5 -0
- package/ui/dist/assets/{index-Ct_GJThl.js → index-CnCv2QiH.js} +24 -24
- package/ui/dist/index.html +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amenophis1er/foreman",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
4
4
|
"description": "Autonomous mission runner on the Claude Agent SDK: a director plans, delegates to workers, verifies, and reports — from one dashboard, your phone, or the CLI.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/src/notify.ts
CHANGED
|
@@ -234,10 +234,14 @@ export function shape(env: Envelope, ctx: NotifyContext): Shaped | null {
|
|
|
234
234
|
// --- money and completion -----------------------------------------
|
|
235
235
|
case 'budget_alert':
|
|
236
236
|
return { key: `budget:${env.runId}:${d.level}`, gate: 'budget',
|
|
237
|
-
text: `${head(d.level === 'exceeded' ? 'Budget exceeded — run interrupted'
|
|
237
|
+
text: `${head(d.level === 'exceeded' ? 'Budget exceeded — run interrupted'
|
|
238
|
+
: d.level === 'warn' ? 'Budget warning — winding down' : 'Budget cap reached')}${runLine}\n${esc(clip(d.text))}${foot}` };
|
|
238
239
|
case 'budget_stop':
|
|
239
240
|
return { key: `stop:${env.runId}`, gate: 'budget',
|
|
240
241
|
text: `${head('Run winding down')}${runLine}\n${esc(clip(d.reason))}${foot}` };
|
|
242
|
+
case 'mission_done_at_cap':
|
|
243
|
+
return { key: `donecap:${env.runId}`, gate: 'done',
|
|
244
|
+
text: `${head('Done, at the cap')}${runLine}\n${esc(clip(d.text))}${foot}` };
|
|
241
245
|
case 'mission_incomplete':
|
|
242
246
|
return { key: `incomplete:${env.runId}`, gate: 'done',
|
|
243
247
|
text: `${head('Not done')}${runLine}\n${esc(clip(d.text))}${foot}` };
|
package/src/orchestrator.test.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
stalledWorkerReport, workerStatusBlock,
|
|
10
10
|
watchRepeats, watchSilence, REPEAT_EXEMPT, observeToolUse,
|
|
11
11
|
DEFAULT_ASK_TIMEOUT_MS, armAskTimeout, unattendedAnswer, unattendedDenyMessage,
|
|
12
|
-
tokenCapLabel, stopReasonOf,
|
|
12
|
+
tokenCapLabel, stopReasonOf, budgetWarnDue, doneAtCap,
|
|
13
13
|
} from './orchestrator.js';
|
|
14
14
|
import { makePolicy, type PendingPermission } from './policy.js';
|
|
15
15
|
import type { AgentEnv } from './provider.js';
|
|
@@ -1256,3 +1256,27 @@ test('stopReasonOf names the cap behind a capReached sentence', () => {
|
|
|
1256
1256
|
assert.equal(stopReasonOf('TIME CAP REACHED: 240 minutes.'), 'time');
|
|
1257
1257
|
assert.equal(stopReasonOf('TOKEN CAP REACHED: 20.1M tokens.'), 'tokens');
|
|
1258
1258
|
});
|
|
1259
|
+
|
|
1260
|
+
test('budgetWarnDue: once past the threshold, and only while still under the cap', () => {
|
|
1261
|
+
assert.equal(budgetWarnDue(7.9, 10, 80), false);
|
|
1262
|
+
assert.equal(budgetWarnDue(8, 10, 80), true);
|
|
1263
|
+
assert.equal(budgetWarnDue(9.99, 10, 80), true);
|
|
1264
|
+
// At and past the cap the wind-down order speaks instead.
|
|
1265
|
+
assert.equal(budgetWarnDue(10, 10, 80), false);
|
|
1266
|
+
assert.equal(budgetWarnDue(12, 10, 80), false);
|
|
1267
|
+
// No threshold, a nonsensical one, or no cap: nothing to warn about.
|
|
1268
|
+
assert.equal(budgetWarnDue(9, 10, undefined), false);
|
|
1269
|
+
assert.equal(budgetWarnDue(9, 10, 0), false);
|
|
1270
|
+
assert.equal(budgetWarnDue(9, 10, 100), false);
|
|
1271
|
+
assert.equal(budgetWarnDue(9, 0, 80), false);
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1274
|
+
test('doneAtCap: budget-stopped with every criterion ticked is done, and nothing else is', () => {
|
|
1275
|
+
const budget = { budgetStopped: true, wasInterrupted: false, usageLimited: false };
|
|
1276
|
+
assert.equal(doneAtCap(budget, []), true);
|
|
1277
|
+
assert.equal(doneAtCap(budget, ['screenshots saved']), false, 'an unticked box is not done');
|
|
1278
|
+
assert.equal(doneAtCap(budget, null), false, 'no DONE WHEN section says nothing either way');
|
|
1279
|
+
assert.equal(doneAtCap({ ...budget, wasInterrupted: true }, []), false, 'a human stop is not a finish');
|
|
1280
|
+
assert.equal(doneAtCap({ ...budget, usageLimited: true }, []), false, 'a usage limit is not a finish');
|
|
1281
|
+
assert.equal(doneAtCap({ ...budget, budgetStopped: false }, []), false);
|
|
1282
|
+
});
|
package/src/orchestrator.ts
CHANGED
|
@@ -266,6 +266,31 @@ const DEFAULT_MAX_TURNS = 150;
|
|
|
266
266
|
export const DEFAULT_MAX_TOKENS = 20_000_000;
|
|
267
267
|
|
|
268
268
|
/** The cap behind a capReached() sentence, as the run record stores it. */
|
|
269
|
+
/**
|
|
270
|
+
* Is it time to tell the director to start winding down? True once spend
|
|
271
|
+
* crosses the warn threshold and while it is still under the cap — past the
|
|
272
|
+
* cap the wind-down order takes over, and a warning then would be noise.
|
|
273
|
+
*/
|
|
274
|
+
export function budgetWarnDue(costUsd: number, budgetUsd: number, warnAt: number | undefined): boolean {
|
|
275
|
+
if (!Number.isFinite(warnAt as number) || (warnAt as number) <= 0 || (warnAt as number) >= 100) return false;
|
|
276
|
+
if (!(budgetUsd > 0)) return false;
|
|
277
|
+
return costUsd >= budgetUsd * ((warnAt as number) / 100) && costUsd < budgetUsd;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* A run the budget stopped, whose own DONE WHEN criteria are all ticked, is
|
|
282
|
+
* done: it reached its criteria and then reached its limit, in that order.
|
|
283
|
+
* Only for the budget — a human interrupt or a usage limit says nothing
|
|
284
|
+
* about the work — and only when the doc actually had criteria to tick.
|
|
285
|
+
*/
|
|
286
|
+
export function doneAtCap(
|
|
287
|
+
flags: { budgetStopped: boolean; wasInterrupted: boolean; usageLimited: boolean },
|
|
288
|
+
unmet: string[] | null,
|
|
289
|
+
): boolean {
|
|
290
|
+
if (!flags.budgetStopped || flags.wasInterrupted || flags.usageLimited) return false;
|
|
291
|
+
return Array.isArray(unmet) && unmet.length === 0;
|
|
292
|
+
}
|
|
293
|
+
|
|
269
294
|
export function stopReasonOf(cap: string): 'budget' | 'turns' | 'time' | 'tokens' {
|
|
270
295
|
if (cap.startsWith('TURN')) return 'turns';
|
|
271
296
|
if (cap.startsWith('TIME')) return 'time';
|
|
@@ -1080,6 +1105,7 @@ export class MissionRun {
|
|
|
1080
1105
|
// overrun quiet.
|
|
1081
1106
|
this.budgetNoticeSent = false;
|
|
1082
1107
|
this.budgetKillSent = false;
|
|
1108
|
+
this.budgetWarnSent = false;
|
|
1083
1109
|
this.budgetStopped = false;
|
|
1084
1110
|
}
|
|
1085
1111
|
}
|
|
@@ -1332,6 +1358,23 @@ export class MissionRun {
|
|
|
1332
1358
|
// the one lie a mission runner cannot afford. Downgrading to
|
|
1333
1359
|
// 'interrupted' is also the useful answer: it is what makes the run
|
|
1334
1360
|
// resumable rather than closed.
|
|
1361
|
+
// A mission whose own record says every criterion is verified is done,
|
|
1362
|
+
// even if the cap ended the turn it was writing its report in. Calling
|
|
1363
|
+
// that 'interrupted' told the fleet a finished mission had failed, and
|
|
1364
|
+
// invited a resume that spent more to rewrite a report already on disk.
|
|
1365
|
+
// The stop reason stays on the record, so nothing is hidden.
|
|
1366
|
+
if (this.meta.status === 'interrupted' && this.budgetStopped
|
|
1367
|
+
&& !this.wasInterrupted && !this.usageLimited) {
|
|
1368
|
+
const unmet = await this.unmetCriteria();
|
|
1369
|
+
if (doneAtCap({ budgetStopped: this.budgetStopped, wasInterrupted: this.wasInterrupted, usageLimited: this.usageLimited }, unmet)) {
|
|
1370
|
+
this.meta.status = 'done';
|
|
1371
|
+
this.emit('mission_done_at_cap', {
|
|
1372
|
+
costUsd: this.meta.costUsd, budgetUsd: this.meta.budgetUsd, reason: this.meta.stopReason,
|
|
1373
|
+
text: 'Every DONE WHEN criterion was verified before the cap ended the run, so this mission is done. ' +
|
|
1374
|
+
'It stopped at its limit rather than finishing under it — the report may be shorter than usual.',
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1335
1378
|
if (this.meta.status === 'done') {
|
|
1336
1379
|
const unmet = await this.unmetCriteria();
|
|
1337
1380
|
if (unmet?.length) {
|
|
@@ -1482,6 +1525,7 @@ export class MissionRun {
|
|
|
1482
1525
|
|
|
1483
1526
|
private budgetNoticeSent = false;
|
|
1484
1527
|
private budgetKillSent = false;
|
|
1528
|
+
private budgetWarnSent = false;
|
|
1485
1529
|
|
|
1486
1530
|
/**
|
|
1487
1531
|
* Keeps an "always allow" grant out of `git status`.
|
|
@@ -1652,6 +1696,26 @@ export class MissionRun {
|
|
|
1652
1696
|
void this.interrupt();
|
|
1653
1697
|
return;
|
|
1654
1698
|
}
|
|
1699
|
+
// Before the fence, a nudge. A director that only learns of the cap when
|
|
1700
|
+
// it hits it does its verification and its report inside the one turn it
|
|
1701
|
+
// has left — which is how two missions that had finished their work were
|
|
1702
|
+
// cut mid-report and read as failures. Warn while there is still room to
|
|
1703
|
+
// wind down deliberately.
|
|
1704
|
+
if (!this.budgetWarnSent && budgetWarnDue(costUsd, budgetUsd, this.meta.budgetWarnAt)) {
|
|
1705
|
+
this.budgetWarnSent = true;
|
|
1706
|
+
const pct = Math.round((costUsd / budgetUsd) * 100);
|
|
1707
|
+
this.emit('budget_alert', {
|
|
1708
|
+
level: 'warn', costUsd, budgetUsd,
|
|
1709
|
+
text: `${pct}% of the budget spent ($${costUsd.toFixed(2)} of $${budgetUsd.toFixed(2)}) — director told to start verifying.`,
|
|
1710
|
+
});
|
|
1711
|
+
this.directorInput?.push(
|
|
1712
|
+
'[BUDGET — automated notice]\n' +
|
|
1713
|
+
`You have spent $${costUsd.toFixed(2)} of the $${budgetUsd.toFixed(2)} cap (${pct}%). ` +
|
|
1714
|
+
'Start winding down now: finish or stop the work in flight, do not begin anything you ' +
|
|
1715
|
+
'cannot complete and verify within what is left, and get your verification and MISSION.md ' +
|
|
1716
|
+
'up to date. At the cap you get one final turn, and at 125% the run is interrupted.',
|
|
1717
|
+
);
|
|
1718
|
+
}
|
|
1655
1719
|
if (!this.budgetNoticeSent && costUsd >= budgetUsd) {
|
|
1656
1720
|
this.budgetNoticeSent = true;
|
|
1657
1721
|
this.emit('budget_alert', {
|
package/src/server.ts
CHANGED
|
@@ -1648,6 +1648,8 @@ async function effectiveSettings(projectId: string): Promise<{
|
|
|
1648
1648
|
directorProviderId?: string; workerProviderId?: string;
|
|
1649
1649
|
/** In a repository, each mission runs on a branch of its own (default on). */
|
|
1650
1650
|
gitBranchPerMission: boolean;
|
|
1651
|
+
/** Percent of the cap at which the director is told to start verifying (default 80). */
|
|
1652
|
+
budgetWarnAt: number;
|
|
1651
1653
|
}> {
|
|
1652
1654
|
const s = await store.readSettings()
|
|
1653
1655
|
.catch(() => ({ global: {}, projects: {} as Record<string, object> }));
|
|
@@ -1669,6 +1671,10 @@ async function effectiveSettings(projectId: string): Promise<{
|
|
|
1669
1671
|
directorProviderId: str(p.directorProviderId ?? g.directorProviderId),
|
|
1670
1672
|
workerProviderId: str(p.workerProviderId ?? g.workerProviderId),
|
|
1671
1673
|
gitBranchPerMission: (p.gitBranchPerMission ?? g.gitBranchPerMission) !== false,
|
|
1674
|
+
budgetWarnAt: (() => {
|
|
1675
|
+
const raw = Number(p.budgetWarnAt ?? g.budgetWarnAt);
|
|
1676
|
+
return Number.isFinite(raw) && raw > 0 && raw < 100 ? raw : 80;
|
|
1677
|
+
})(),
|
|
1672
1678
|
};
|
|
1673
1679
|
}
|
|
1674
1680
|
|
|
@@ -1708,6 +1714,7 @@ async function startRun(
|
|
|
1708
1714
|
workerProviderId: roleProviders.worker ?? settings.workerProviderId,
|
|
1709
1715
|
ownerPid: process.pid,
|
|
1710
1716
|
browserTools: browserTools || undefined,
|
|
1717
|
+
budgetWarnAt: settings.budgetWarnAt,
|
|
1711
1718
|
toolPolicy: settings.toolPolicy,
|
|
1712
1719
|
autoAllowReadOnly: settings.autoAllowReadOnly,
|
|
1713
1720
|
// Frozen at dispatch: a later change to the project or the server default
|
package/src/types.ts
CHANGED
|
@@ -330,6 +330,11 @@ export interface RunMeta {
|
|
|
330
330
|
* to offer the one action that helps — raise the budget and resume — and
|
|
331
331
|
* a resume clears it. Absent when the run ended for any other reason.
|
|
332
332
|
*/
|
|
333
|
+
/**
|
|
334
|
+
* Percent of the cap at which the director is told to start winding down,
|
|
335
|
+
* frozen at dispatch like the cap itself. Absent means the default.
|
|
336
|
+
*/
|
|
337
|
+
budgetWarnAt?: number;
|
|
333
338
|
stopReason?: 'budget' | 'turns' | 'time' | 'tokens';
|
|
334
339
|
/** When the run's record was frozen (MISSION.md and deck copied beside it); absent on older runs. */
|
|
335
340
|
snapshotAt?: number;
|