@amenophis1er/foreman 0.1.14 → 0.1.16
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/gitwork.test.ts +39 -1
- package/src/gitwork.ts +66 -0
- package/src/notify.ts +5 -1
- package/src/orchestrator.test.ts +25 -1
- package/src/orchestrator.ts +84 -9
- package/src/server.ts +92 -11
- package/src/services.test.ts +46 -1
- package/src/services.ts +62 -3
- package/src/types.ts +6 -1
- package/ui/dist/assets/index-DOVnExqF.js +68 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-Ct_GJThl.js +0 -68
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amenophis1er/foreman",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
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/gitwork.test.ts
CHANGED
|
@@ -4,7 +4,8 @@ import os from 'node:os';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { execFileSync } from 'node:child_process';
|
|
6
6
|
import { mkdtemp, writeFile } from 'node:fs/promises';
|
|
7
|
-
import { closeMissionBranch, ensureMissionBranch, gitInfo, missionBranchName, startMissionBranch, renameMissionBranch
|
|
7
|
+
import { closeMissionBranch, defaultBranch, ensureMissionBranch, gitInfo, missionBranchName, remoteHasBranch, resolvePrBase, startMissionBranch, renameMissionBranch, dirtyPaths,
|
|
8
|
+
} from './gitwork.js';
|
|
8
9
|
|
|
9
10
|
const sh = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, stdio: 'pipe', env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null' } }).toString();
|
|
10
11
|
|
|
@@ -108,3 +109,40 @@ test('renameMissionBranch takes the run title once there is one, and leaves a br
|
|
|
108
109
|
sh(dir, 'checkout', '-q', 'main');
|
|
109
110
|
assert.equal(await renameMissionBranch(dir, renamed!, 'Another title', '1788713434983-226123af'), null);
|
|
110
111
|
});
|
|
112
|
+
|
|
113
|
+
test('dirtyPaths names the uncommitted work a mission branch would carry', async () => {
|
|
114
|
+
const dir = await repo();
|
|
115
|
+
assert.deepEqual(await dirtyPaths(dir), [], 'a clean checkout carries nothing');
|
|
116
|
+
|
|
117
|
+
await writeFile(path.join(dir, 'README.md'), 'hello, edited\n');
|
|
118
|
+
await writeFile(path.join(dir, 'scratch.txt'), 'untracked\n');
|
|
119
|
+
const dirty = await dirtyPaths(dir);
|
|
120
|
+
assert.deepEqual(dirty.sort(), ['README.md', 'scratch.txt'], 'tracked edits and untracked files both count');
|
|
121
|
+
|
|
122
|
+
// The cap is for a message, not for the truth of it.
|
|
123
|
+
assert.equal((await dirtyPaths(dir, 1)).length, 1);
|
|
124
|
+
// Not a repository at all: nothing to report, and no throw.
|
|
125
|
+
assert.deepEqual(await dirtyPaths(os.tmpdir()), []);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('a pull request targets the default branch when the mission was branched from a local-only branch', async () => {
|
|
129
|
+
const dir = await repo();
|
|
130
|
+
// A bare origin, the way a real clone has one.
|
|
131
|
+
const origin = await mkdtemp(path.join(os.tmpdir(), 'gitwork-origin-'));
|
|
132
|
+
execFileSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { stdio: 'pipe' });
|
|
133
|
+
sh(dir, 'remote', 'add', 'origin', origin);
|
|
134
|
+
sh(dir, 'push', '-q', '-u', 'origin', 'main');
|
|
135
|
+
|
|
136
|
+
assert.equal(await defaultBranch(dir), 'main');
|
|
137
|
+
assert.equal(await remoteHasBranch(dir, 'main'), true);
|
|
138
|
+
assert.equal(await remoteHasBranch(dir, 'foreman/earlier-1234'), false);
|
|
139
|
+
|
|
140
|
+
// Branched from main: the base is real and is left alone.
|
|
141
|
+
assert.deepEqual(await resolvePrBase(dir, 'main'), { base: 'main', fellBack: false });
|
|
142
|
+
|
|
143
|
+
// The case from the field: the checkout was left on the previous mission's
|
|
144
|
+
// branch, so this mission recorded that as its base and it exists nowhere
|
|
145
|
+
// but here. gh would fail on it; the default branch is the honest target.
|
|
146
|
+
sh(dir, 'checkout', '-q', '-b', 'foreman/earlier-1234');
|
|
147
|
+
assert.deepEqual(await resolvePrBase(dir, 'foreman/earlier-1234'), { base: 'main', fellBack: true });
|
|
148
|
+
});
|
package/src/gitwork.ts
CHANGED
|
@@ -85,6 +85,22 @@ export function missionBranchName(mission: string, runId: string): string {
|
|
|
85
85
|
* Uncommitted changes come along, as `checkout -b` always does. Resolves to
|
|
86
86
|
* the record for the run, or to one sentence on why it could not.
|
|
87
87
|
*/
|
|
88
|
+
/**
|
|
89
|
+
* The paths with uncommitted changes, tracked or untracked, capped for a
|
|
90
|
+
* message. `checkout -b` carries all of them onto the mission's branch, and
|
|
91
|
+
* the closing commit sweeps whatever is still uncommitted into the mission's
|
|
92
|
+
* own commit — so this is what a human stands to have committed under a
|
|
93
|
+
* mission's name without noticing.
|
|
94
|
+
*/
|
|
95
|
+
export async function dirtyPaths(folder: string, limit = 8): Promise<string[]> {
|
|
96
|
+
try {
|
|
97
|
+
const out = await git(['status', '--porcelain', '--untracked-files=normal'], folder);
|
|
98
|
+
return out.split('\n').map((l) => l.slice(3).trim()).filter(Boolean).slice(0, limit);
|
|
99
|
+
} catch {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
88
104
|
export async function startMissionBranch(folder: string, mission: string, runId: string): Promise<MissionGit | { error: string }> {
|
|
89
105
|
const info = await gitInfo(folder);
|
|
90
106
|
if (!info.repo) return { error: 'not a git repository' };
|
|
@@ -245,6 +261,56 @@ export function pullRequestState(folder: string, url: string): Promise<{ state:
|
|
|
245
261
|
});
|
|
246
262
|
}
|
|
247
263
|
|
|
264
|
+
/**
|
|
265
|
+
* The repository's default branch as origin sees it, from the remote HEAD git
|
|
266
|
+
* recorded at clone time, then from the usual names, and `main` as the last
|
|
267
|
+
* word. Local only: no network, so it works offline and cannot hang.
|
|
268
|
+
*/
|
|
269
|
+
export async function defaultBranch(folder: string): Promise<string> {
|
|
270
|
+
try {
|
|
271
|
+
const ref = (await git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], folder)).trim();
|
|
272
|
+
const name = ref.replace(/^origin\//, '');
|
|
273
|
+
if (name) return name;
|
|
274
|
+
} catch { /* no remote HEAD recorded */ }
|
|
275
|
+
for (const name of ['main', 'master']) {
|
|
276
|
+
try {
|
|
277
|
+
await git(['rev-parse', '--verify', `refs/remotes/origin/${name}`], folder);
|
|
278
|
+
return name;
|
|
279
|
+
} catch { /* not this one */ }
|
|
280
|
+
}
|
|
281
|
+
return 'main';
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Does origin have this branch? Asks the remote, and says no if it cannot ask. */
|
|
285
|
+
export async function remoteHasBranch(folder: string, branch: string): Promise<boolean> {
|
|
286
|
+
try {
|
|
287
|
+
const out = await git(['ls-remote', '--heads', 'origin', branch], folder, 10_000);
|
|
288
|
+
return out.trim().length > 0;
|
|
289
|
+
} catch {
|
|
290
|
+
// Offline or unauthenticated: fall back to what the last fetch recorded.
|
|
291
|
+
try {
|
|
292
|
+
await git(['rev-parse', '--verify', `refs/remotes/origin/${branch}`], folder);
|
|
293
|
+
return true;
|
|
294
|
+
} catch { return false; }
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* What a pull request from this mission should actually target.
|
|
300
|
+
*
|
|
301
|
+
* A finished mission leaves the checkout on its own branch, so the next
|
|
302
|
+
* mission is branched from *that* — and records it as its base. That base
|
|
303
|
+
* lives only on this machine, so `gh pr create --base foreman/…` fails and a
|
|
304
|
+
* compare URL built from it 404s. When the recorded base is not on the
|
|
305
|
+
* remote, the default branch is the honest target, and the caller says so
|
|
306
|
+
* rather than quietly retargeting the request.
|
|
307
|
+
*/
|
|
308
|
+
export async function resolvePrBase(folder: string, recorded: string): Promise<{ base: string; fellBack: boolean }> {
|
|
309
|
+
if (recorded && await remoteHasBranch(folder, recorded)) return { base: recorded, fellBack: false };
|
|
310
|
+
const base = await defaultBranch(folder);
|
|
311
|
+
return { base, fellBack: base !== recorded };
|
|
312
|
+
}
|
|
313
|
+
|
|
248
314
|
/** `gh pr create`, as the user. Resolves to the PR's URL, or to why not. */
|
|
249
315
|
export function createPullRequest(folder: string, opts: { base: string; branch: string; title: string; body: string }): Promise<{ url?: string; error?: string }> {
|
|
250
316
|
return new Promise((resolve) => {
|
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';
|
|
@@ -619,6 +644,14 @@ export type Emitter = (event: string, data: unknown) => void;
|
|
|
619
644
|
/** Persists updated run metadata (fire-and-forget from the run's viewpoint). */
|
|
620
645
|
export type MetaSink = (meta: RunMeta) => void;
|
|
621
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
|
+
|
|
622
655
|
export const DIRECTOR_CHARTER = `
|
|
623
656
|
You are the FOREMAN DIRECTOR. You run a mission autonomously inside one folder by
|
|
624
657
|
directing worker agents. Non-negotiable rules, in priority order:
|
|
@@ -695,6 +728,16 @@ directing worker agents. Non-negotiable rules, in priority order:
|
|
|
695
728
|
(a dev server, a static preview), call mcp__foreman__expose_service with
|
|
696
729
|
its port and put the URL it returns in your report — the human can open
|
|
697
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.
|
|
698
741
|
4. REPORT WHAT YOU SEE. Judge the work as a competent professional would, not
|
|
699
742
|
only against the letter of the acceptance criteria. If you observe a defect
|
|
700
743
|
the criteria did not name — tap targets too small to use, unreadable
|
|
@@ -748,13 +791,6 @@ const USAGE_LIMIT_RE = /out of usage credits|usage limit reached|upgrade to incr
|
|
|
748
791
|
/** One worker's outcome, as runWorker hands it back. */
|
|
749
792
|
interface WorkerOutcome { report: string; isError: boolean }
|
|
750
793
|
|
|
751
|
-
/**
|
|
752
|
-
* Turns a worker gets before the SDK stops it. Raised from 60: five workers
|
|
753
|
-
* in four missions hit the cap mid-task on legitimate, brief-sized work
|
|
754
|
-
* (a hundred TypeScript errors in one package; a test-suite port), and each
|
|
755
|
-
* time the director paid to respawn one that re-read the same files.
|
|
756
|
-
*/
|
|
757
|
-
const WORKER_MAX_TURNS = 100;
|
|
758
794
|
/** What a worker stopped by the cap is told when its session is picked back up. */
|
|
759
795
|
const WORKER_CONTINUE_PROMPT =
|
|
760
796
|
'You were stopped by the turn cap, not by a failure. Your session and your files are as you left them; ' +
|
|
@@ -889,7 +925,7 @@ export class MissionRun {
|
|
|
889
925
|
* has no `expose_service` to offer.
|
|
890
926
|
*/
|
|
891
927
|
private readonly host: {
|
|
892
|
-
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 }>;
|
|
893
929
|
/** The browser channel detected at dispatch (src/browser.ts); Chrome when the host says nothing. */
|
|
894
930
|
browserChannel?: string;
|
|
895
931
|
} = {},
|
|
@@ -1080,6 +1116,7 @@ export class MissionRun {
|
|
|
1080
1116
|
// overrun quiet.
|
|
1081
1117
|
this.budgetNoticeSent = false;
|
|
1082
1118
|
this.budgetKillSent = false;
|
|
1119
|
+
this.budgetWarnSent = false;
|
|
1083
1120
|
this.budgetStopped = false;
|
|
1084
1121
|
}
|
|
1085
1122
|
}
|
|
@@ -1332,6 +1369,23 @@ export class MissionRun {
|
|
|
1332
1369
|
// the one lie a mission runner cannot afford. Downgrading to
|
|
1333
1370
|
// 'interrupted' is also the useful answer: it is what makes the run
|
|
1334
1371
|
// resumable rather than closed.
|
|
1372
|
+
// A mission whose own record says every criterion is verified is done,
|
|
1373
|
+
// even if the cap ended the turn it was writing its report in. Calling
|
|
1374
|
+
// that 'interrupted' told the fleet a finished mission had failed, and
|
|
1375
|
+
// invited a resume that spent more to rewrite a report already on disk.
|
|
1376
|
+
// The stop reason stays on the record, so nothing is hidden.
|
|
1377
|
+
if (this.meta.status === 'interrupted' && this.budgetStopped
|
|
1378
|
+
&& !this.wasInterrupted && !this.usageLimited) {
|
|
1379
|
+
const unmet = await this.unmetCriteria();
|
|
1380
|
+
if (doneAtCap({ budgetStopped: this.budgetStopped, wasInterrupted: this.wasInterrupted, usageLimited: this.usageLimited }, unmet)) {
|
|
1381
|
+
this.meta.status = 'done';
|
|
1382
|
+
this.emit('mission_done_at_cap', {
|
|
1383
|
+
costUsd: this.meta.costUsd, budgetUsd: this.meta.budgetUsd, reason: this.meta.stopReason,
|
|
1384
|
+
text: 'Every DONE WHEN criterion was verified before the cap ended the run, so this mission is done. ' +
|
|
1385
|
+
'It stopped at its limit rather than finishing under it — the report may be shorter than usual.',
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1335
1389
|
if (this.meta.status === 'done') {
|
|
1336
1390
|
const unmet = await this.unmetCriteria();
|
|
1337
1391
|
if (unmet?.length) {
|
|
@@ -1482,6 +1536,7 @@ export class MissionRun {
|
|
|
1482
1536
|
|
|
1483
1537
|
private budgetNoticeSent = false;
|
|
1484
1538
|
private budgetKillSent = false;
|
|
1539
|
+
private budgetWarnSent = false;
|
|
1485
1540
|
|
|
1486
1541
|
/**
|
|
1487
1542
|
* Keeps an "always allow" grant out of `git status`.
|
|
@@ -1652,6 +1707,26 @@ export class MissionRun {
|
|
|
1652
1707
|
void this.interrupt();
|
|
1653
1708
|
return;
|
|
1654
1709
|
}
|
|
1710
|
+
// Before the fence, a nudge. A director that only learns of the cap when
|
|
1711
|
+
// it hits it does its verification and its report inside the one turn it
|
|
1712
|
+
// has left — which is how two missions that had finished their work were
|
|
1713
|
+
// cut mid-report and read as failures. Warn while there is still room to
|
|
1714
|
+
// wind down deliberately.
|
|
1715
|
+
if (!this.budgetWarnSent && budgetWarnDue(costUsd, budgetUsd, this.meta.budgetWarnAt)) {
|
|
1716
|
+
this.budgetWarnSent = true;
|
|
1717
|
+
const pct = Math.round((costUsd / budgetUsd) * 100);
|
|
1718
|
+
this.emit('budget_alert', {
|
|
1719
|
+
level: 'warn', costUsd, budgetUsd,
|
|
1720
|
+
text: `${pct}% of the budget spent ($${costUsd.toFixed(2)} of $${budgetUsd.toFixed(2)}) — director told to start verifying.`,
|
|
1721
|
+
});
|
|
1722
|
+
this.directorInput?.push(
|
|
1723
|
+
'[BUDGET — automated notice]\n' +
|
|
1724
|
+
`You have spent $${costUsd.toFixed(2)} of the $${budgetUsd.toFixed(2)} cap (${pct}%). ` +
|
|
1725
|
+
'Start winding down now: finish or stop the work in flight, do not begin anything you ' +
|
|
1726
|
+
'cannot complete and verify within what is left, and get your verification and MISSION.md ' +
|
|
1727
|
+
'up to date. At the cap you get one final turn, and at 125% the run is interrupted.',
|
|
1728
|
+
);
|
|
1729
|
+
}
|
|
1655
1730
|
if (!this.budgetNoticeSent && costUsd >= budgetUsd) {
|
|
1656
1731
|
this.budgetNoticeSent = true;
|
|
1657
1732
|
this.emit('budget_alert', {
|
|
@@ -2393,7 +2468,7 @@ export class MissionRun {
|
|
|
2393
2468
|
if (!fn) return { content: [{ type: 'text' as const, text: 'Exposing services is not available in this run.' }] };
|
|
2394
2469
|
const r = await fn(this.meta.id, port, (label ?? '').trim() || `port ${port}`);
|
|
2395
2470
|
if (!r.ok) return { content: [{ type: 'text' as const, text: `Not exposed: ${r.reason}` }] };
|
|
2396
|
-
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 };
|
|
2397
2472
|
this.meta.services = [...(this.meta.services ?? []).filter((s) => s.port !== port), entry];
|
|
2398
2473
|
this.saveMeta(this.meta);
|
|
2399
2474
|
this.emit('service_exposed', { ...entry, url: r.url });
|
package/src/server.ts
CHANGED
|
@@ -73,10 +73,10 @@ import { budgetAnchor, modelRecords, projectRecord, recordLine } from './track-r
|
|
|
73
73
|
import { reconcileRole } from './role-provider.js';
|
|
74
74
|
import { detectBrowser, installChromium } from './browser.js';
|
|
75
75
|
import { frozenDeck, frozenMissionDoc, parkMissionDoc, restoreMissionDoc, snapshotRun } from './snapshot.js';
|
|
76
|
-
import { closeMissionBranch, compareUrl, createPullRequest, ensureMissionBranch, ghReady, gitInfo, missionBranchName, prDraft, pullRequestState, pushBranch, renameMissionBranch, startMissionBranch, type GitInfo } from './gitwork.js';
|
|
76
|
+
import { closeMissionBranch, compareUrl, createPullRequest, dirtyPaths, ensureMissionBranch, ghReady, gitInfo, resolvePrBase, missionBranchName, prDraft, pullRequestState, pushBranch, renameMissionBranch, startMissionBranch, type GitInfo } from './gitwork.js';
|
|
77
77
|
import { detectTailscale, tailnetUrl } from './tailscale.js';
|
|
78
78
|
import { checkForUpdate, currentVersion, type UpdateInfo } from './update.js';
|
|
79
|
-
import { ServiceRegistry, portOpen, servicesHandler } from './services.js';
|
|
79
|
+
import { ServiceRegistry, listeningPid, portOpen, servicesHandler, stopService } from './services.js';
|
|
80
80
|
import { HELP_TEXT, expandHome, parseCommand, projectsRoot, slug } from './notify/commands.js';
|
|
81
81
|
import {
|
|
82
82
|
DEFAULT_FLEET_MODEL, FLEET_CHAT_ID, PHONE_CONTEXT_MS, phoneRoute, runFleetTurn,
|
|
@@ -1578,8 +1578,11 @@ async function driveRun(
|
|
|
1578
1578
|
exposeService: async (runId, port, label) => {
|
|
1579
1579
|
if (servicesDown) return { ok: false, reason: servicesDown };
|
|
1580
1580
|
if (!(await portOpen(port))) return { ok: false, reason: `nothing is listening on 127.0.0.1:${port} — start the server first` };
|
|
1581
|
-
|
|
1582
|
-
|
|
1581
|
+
// Whoever holds the port right now is what the crew just started, and
|
|
1582
|
+
// the only process a later stop is allowed to touch.
|
|
1583
|
+
const pid = await listeningPid(port);
|
|
1584
|
+
const svc = services.register(runId, port, label, pid ?? undefined);
|
|
1585
|
+
return { ok: true, url: `${await servicesBase()}${svc.path}`, path: svc.path, pid: pid ?? undefined };
|
|
1583
1586
|
},
|
|
1584
1587
|
});
|
|
1585
1588
|
activeByProject.set(projectId, run);
|
|
@@ -1648,6 +1651,8 @@ async function effectiveSettings(projectId: string): Promise<{
|
|
|
1648
1651
|
directorProviderId?: string; workerProviderId?: string;
|
|
1649
1652
|
/** In a repository, each mission runs on a branch of its own (default on). */
|
|
1650
1653
|
gitBranchPerMission: boolean;
|
|
1654
|
+
/** Percent of the cap at which the director is told to start verifying (default 80). */
|
|
1655
|
+
budgetWarnAt: number;
|
|
1651
1656
|
}> {
|
|
1652
1657
|
const s = await store.readSettings()
|
|
1653
1658
|
.catch(() => ({ global: {}, projects: {} as Record<string, object> }));
|
|
@@ -1669,6 +1674,10 @@ async function effectiveSettings(projectId: string): Promise<{
|
|
|
1669
1674
|
directorProviderId: str(p.directorProviderId ?? g.directorProviderId),
|
|
1670
1675
|
workerProviderId: str(p.workerProviderId ?? g.workerProviderId),
|
|
1671
1676
|
gitBranchPerMission: (p.gitBranchPerMission ?? g.gitBranchPerMission) !== false,
|
|
1677
|
+
budgetWarnAt: (() => {
|
|
1678
|
+
const raw = Number(p.budgetWarnAt ?? g.budgetWarnAt);
|
|
1679
|
+
return Number.isFinite(raw) && raw > 0 && raw < 100 ? raw : 60;
|
|
1680
|
+
})(),
|
|
1672
1681
|
};
|
|
1673
1682
|
}
|
|
1674
1683
|
|
|
@@ -1708,6 +1717,7 @@ async function startRun(
|
|
|
1708
1717
|
workerProviderId: roleProviders.worker ?? settings.workerProviderId,
|
|
1709
1718
|
ownerPid: process.pid,
|
|
1710
1719
|
browserTools: browserTools || undefined,
|
|
1720
|
+
budgetWarnAt: settings.budgetWarnAt,
|
|
1711
1721
|
toolPolicy: settings.toolPolicy,
|
|
1712
1722
|
autoAllowReadOnly: settings.autoAllowReadOnly,
|
|
1713
1723
|
// Frozen at dispatch: a later change to the project or the server default
|
|
@@ -1737,6 +1747,7 @@ async function startRun(
|
|
|
1737
1747
|
// touches anything — so the deck's baseline, taken at the director's first
|
|
1738
1748
|
// turn, is the branch point, and the diff is exactly the mission.
|
|
1739
1749
|
if (settings.gitBranchPerMission) {
|
|
1750
|
+
const carried = await dirtyPaths(folder);
|
|
1740
1751
|
const g = await startMissionBranch(folder, mission, meta.id);
|
|
1741
1752
|
const emit = makeEmitter(meta.id, projectId);
|
|
1742
1753
|
if ('error' in g) {
|
|
@@ -1746,6 +1757,14 @@ async function startRun(
|
|
|
1746
1757
|
await store.writeMeta(meta).catch(() => {});
|
|
1747
1758
|
gitInfoCache.delete(folder);
|
|
1748
1759
|
emit('git_branch', { branch: g.branch, base: g.base, text: `On branch ${g.branch}, made from ${g.base}. Foreman commits the mission's work here when it ends; merging and pushing stay yours.` });
|
|
1760
|
+
// Whoever started this may not have been at the keyboard to be asked.
|
|
1761
|
+
if (carried.length) {
|
|
1762
|
+
emit('git_note', {
|
|
1763
|
+
text: `The checkout had uncommitted changes when this mission started, and they came along to ${g.branch}: `
|
|
1764
|
+
+ `${carried.slice(0, 5).join(', ')}${carried.length > 5 ? `, and ${carried.length - 5} more` : ''}. `
|
|
1765
|
+
+ "They will be part of the mission's closing commit unless you take them off the branch first.",
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1749
1768
|
}
|
|
1750
1769
|
}
|
|
1751
1770
|
await driveRun(projectId, meta);
|
|
@@ -1946,6 +1965,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
1946
1965
|
const runResumeMatch = url.pathname.match(/^\/runs\/([^/]+)\/resume$/);
|
|
1947
1966
|
const prMatch = url.pathname.match(/^\/runs\/([^/]+)\/pr$/);
|
|
1948
1967
|
const prStateMatch = url.pathname.match(/^\/runs\/([^/]+)\/pr\/state$/);
|
|
1968
|
+
const runServicesMatch = url.pathname.match(/^\/runs\/([^/]+)\/services$/);
|
|
1969
|
+
const stopServiceMatch = url.pathname.match(/^\/runs\/([^/]+)\/services\/(\d{1,5})\/stop$/);
|
|
1949
1970
|
const projectMatch = url.pathname.match(/^\/projects\/([^/]+)$/);
|
|
1950
1971
|
const providerKeyMatch = url.pathname.match(/^\/providers\/([A-Za-z0-9_-]{1,64})\/key$/);
|
|
1951
1972
|
|
|
@@ -2231,7 +2252,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
2231
2252
|
} else if (req.method === 'POST' && url.pathname === '/run') {
|
|
2232
2253
|
const {
|
|
2233
2254
|
projectId, mission, budgetUsd, directorModel, workerModel, browserTools,
|
|
2234
|
-
directorProviderId, workerProviderId,
|
|
2255
|
+
directorProviderId, workerProviderId, allowDirty,
|
|
2235
2256
|
} = await readBody(req);
|
|
2236
2257
|
if (typeof projectId !== 'string' || typeof mission !== 'string' || !mission.trim()) {
|
|
2237
2258
|
return json(res, 400, { error: 'projectId and mission are required' });
|
|
@@ -2239,6 +2260,29 @@ const server = http.createServer(async (req, res) => {
|
|
|
2239
2260
|
const project = await store.getProject(projectId);
|
|
2240
2261
|
if (!project) return json(res, 404, { error: 'unknown project' });
|
|
2241
2262
|
await mkdir(project.folder, { recursive: true });
|
|
2263
|
+
// A mission on its own branch takes the working tree with it: `checkout
|
|
2264
|
+
// -b` carries uncommitted work onto the mission branch, and the closing
|
|
2265
|
+
// commit sweeps whatever is left into the mission's own commit, under
|
|
2266
|
+
// the mission's name. So a dirty checkout is refused here rather than
|
|
2267
|
+
// quietly absorbed — with an override, because it is the human's tree
|
|
2268
|
+
// and their call. Only this route refuses: a mission started from the
|
|
2269
|
+
// phone has nobody standing at the keyboard to answer, and gets the
|
|
2270
|
+
// warning in its transcript instead.
|
|
2271
|
+
{
|
|
2272
|
+
const s = await effectiveSettings(projectId);
|
|
2273
|
+
if (s.gitBranchPerMission && allowDirty !== true) {
|
|
2274
|
+
const info = await gitInfo(project.folder);
|
|
2275
|
+
if (info.repo && info.dirty) {
|
|
2276
|
+
const files = await dirtyPaths(project.folder);
|
|
2277
|
+
return json(res, 409, {
|
|
2278
|
+
error: `This checkout has uncommitted changes on ${info.branch ?? 'HEAD'}. `
|
|
2279
|
+
+ 'They would follow the mission onto its branch and be committed with its work. '
|
|
2280
|
+
+ 'Commit or stash them first, or start anyway.',
|
|
2281
|
+
code: 'dirty-checkout', branch: info.branch ?? null, files,
|
|
2282
|
+
});
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2242
2286
|
// Reservation is the last step before dispatch — no awaits in between.
|
|
2243
2287
|
if (!reserveProject(projectId)) {
|
|
2244
2288
|
return json(res, 409, { error: 'this project already has an active mission' });
|
|
@@ -2666,6 +2710,33 @@ const server = http.createServer(async (req, res) => {
|
|
|
2666
2710
|
broadcastChat(FLEET_CHAT_ID, 'chat_cleared', {});
|
|
2667
2711
|
json(res, 200, { ok: true });
|
|
2668
2712
|
|
|
2713
|
+
} else if (runServicesMatch && req.method === 'GET') {
|
|
2714
|
+
// What this run exposed, and what of it is still up. Asked after a run
|
|
2715
|
+
// ends as much as during it: a finished mission leaves no agent behind
|
|
2716
|
+
// to tidy, so the human needs somewhere to see the leftovers.
|
|
2717
|
+
const meta = await store.readMeta(runServicesMatch[1]).catch(() => null);
|
|
2718
|
+
if (!meta) return json(res, 404, { error: 'unknown run' });
|
|
2719
|
+
const list = await Promise.all((meta.services ?? []).map(async (s) => {
|
|
2720
|
+
const holder = await listeningPid(s.port);
|
|
2721
|
+
return {
|
|
2722
|
+
port: s.port, label: s.label, since: s.since, path: s.path,
|
|
2723
|
+
listening: holder !== null,
|
|
2724
|
+
// Only a process Foreman watched start is one it will offer to stop.
|
|
2725
|
+
ours: holder !== null && s.pid !== undefined && holder === s.pid,
|
|
2726
|
+
};
|
|
2727
|
+
}));
|
|
2728
|
+
json(res, 200, { runId: meta.id, status: meta.status, services: list });
|
|
2729
|
+
|
|
2730
|
+
} else if (stopServiceMatch && req.method === 'POST') {
|
|
2731
|
+
const meta = await store.readMeta(stopServiceMatch[1]).catch(() => null);
|
|
2732
|
+
if (!meta) return json(res, 404, { error: 'unknown run' });
|
|
2733
|
+
const port = Number(stopServiceMatch[2]);
|
|
2734
|
+
const svc = (meta.services ?? []).find((s) => s.port === port);
|
|
2735
|
+
if (!svc) return json(res, 404, { error: 'this run did not expose that port' });
|
|
2736
|
+
const r = await stopService(port, svc.pid);
|
|
2737
|
+
if (!r.ok) return json(res, 409, { error: r.reason });
|
|
2738
|
+
json(res, 200, { ok: true, how: r.how });
|
|
2739
|
+
|
|
2669
2740
|
} else if (prStateMatch && req.method === 'GET') {
|
|
2670
2741
|
// What became of the run's pull request. A final answer (merged,
|
|
2671
2742
|
// closed) is written to the run so it is not asked again; open is.
|
|
@@ -2688,9 +2759,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
2688
2759
|
if (!info.remote) return json(res, 409, { error: 'the repository has no origin remote to push to' });
|
|
2689
2760
|
const doc = await readFile(path.join(meta.folder, '.foreman', 'MISSION.md'), 'utf8').catch(() => null);
|
|
2690
2761
|
const gh = await ghReady();
|
|
2762
|
+
// Where the request can actually go, which is not always where the
|
|
2763
|
+
// mission was branched from — see resolvePrBase.
|
|
2764
|
+
const target = await resolvePrBase(meta.folder, meta.git.base);
|
|
2691
2765
|
json(res, 200, {
|
|
2692
|
-
...prDraft(meta, doc), branch: meta.git.branch, base:
|
|
2693
|
-
|
|
2766
|
+
...prDraft(meta, doc), branch: meta.git.branch, base: target.base, commits: meta.git.commits ?? null,
|
|
2767
|
+
branchedFrom: meta.git.base, baseFellBack: target.fellBack,
|
|
2768
|
+
remote: info.remote, compareUrl: compareUrl(info.remote, target.base, meta.git.branch),
|
|
2694
2769
|
gh, pr: meta.git.pr ?? null, onBranch: info.branch === meta.git.branch, dirty: Boolean(info.dirty),
|
|
2695
2770
|
});
|
|
2696
2771
|
|
|
@@ -2718,18 +2793,24 @@ const server = http.createServer(async (req, res) => {
|
|
|
2718
2793
|
let url: string | undefined;
|
|
2719
2794
|
let method: 'gh' | 'compare' = 'compare';
|
|
2720
2795
|
let note: string | undefined;
|
|
2796
|
+
const target = await resolvePrBase(meta.folder, meta.git.base);
|
|
2721
2797
|
if (gh.present && gh.authed && /github\.com/.test(info.remote)) {
|
|
2722
|
-
const r = await createPullRequest(meta.folder, { base:
|
|
2798
|
+
const r = await createPullRequest(meta.folder, { base: target.base, branch: meta.git.branch, title: t, body: b });
|
|
2723
2799
|
if (r.url) { url = r.url; method = 'gh'; } else note = r.error;
|
|
2724
2800
|
}
|
|
2725
|
-
if (!url) url = compareUrl(info.remote,
|
|
2801
|
+
if (!url) url = compareUrl(info.remote, target.base, meta.git.branch) ?? undefined;
|
|
2726
2802
|
meta.git = { ...meta.git, pr: method === 'gh' ? url : meta.git.pr };
|
|
2727
2803
|
await store.writeMeta(meta).catch(() => {});
|
|
2728
2804
|
gitInfoCache.delete(meta.folder);
|
|
2729
2805
|
const text = method === 'gh'
|
|
2730
2806
|
? `Pushed ${meta.git.branch} and opened a pull request: ${url}`
|
|
2731
2807
|
: `Pushed ${meta.git.branch}.${note ? ` ${note}.` : ''} Finish the pull request in the browser: ${url ?? 'open the repository'}`;
|
|
2732
|
-
emit('pull_request', {
|
|
2808
|
+
emit('pull_request', {
|
|
2809
|
+
branch: meta.git.branch, base: target.base, url, method,
|
|
2810
|
+
text: target.fellBack
|
|
2811
|
+
? `${text} (targeting ${target.base}: this mission was branched from ${meta.git.base}, which is not on the remote)`
|
|
2812
|
+
: text,
|
|
2813
|
+
});
|
|
2733
2814
|
json(res, 200, { ok: true, url, method, pushed: true, note });
|
|
2734
2815
|
|
|
2735
2816
|
} else if (req.method === 'POST' && url.pathname === '/steer') {
|
|
@@ -2951,7 +3032,7 @@ void closeOrphanedChatTurns().then((ids) => {
|
|
|
2951
3032
|
// Services declared by earlier runs are still worth proxying if their
|
|
2952
3033
|
// processes outlived the run; the registry is rebuilt from what was saved.
|
|
2953
3034
|
void store.listRuns().then((runs) => {
|
|
2954
|
-
for (const r of runs) for (const s of (r as { services?: Array<{ port: number; label: string }> }).services ?? []) services.register(r.id, s.port, s.label);
|
|
3035
|
+
for (const r of runs) for (const s of (r as { services?: Array<{ port: number; label: string; pid?: number }> }).services ?? []) services.register(r.id, s.port, s.label, s.pid);
|
|
2955
3036
|
}).catch(() => {});
|
|
2956
3037
|
|
|
2957
3038
|
if (BIND === 'all') {
|
package/src/services.test.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import http from 'node:http';
|
|
4
|
-
import { ServiceRegistry, parseServicePath, portOpen, proxyToService, servicePath, servicesHandler } from './services.js';
|
|
4
|
+
import { ServiceRegistry, listeningPid, parseServicePath, portOpen, proxyToService, servicePath, servicesHandler, stopService } from './services.js';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
5
6
|
import { requestAllowed } from './guard.js';
|
|
6
7
|
|
|
7
8
|
test('service paths round-trip and reject junk', () => {
|
|
@@ -86,3 +87,47 @@ test('the services port serves only /svc/ — everything else is a 404', async (
|
|
|
86
87
|
server.close();
|
|
87
88
|
}
|
|
88
89
|
});
|
|
90
|
+
|
|
91
|
+
/** A child process holding a port, the way a crew's dev server does. */
|
|
92
|
+
async function server(): Promise<{ pid: number; port: number; kill: () => void }> {
|
|
93
|
+
const child = spawn(process.execPath, ['-e',
|
|
94
|
+
"const s=require('http').createServer((_,r)=>r.end('ok'));s.listen(0,'127.0.0.1',()=>console.log(s.address().port));"],
|
|
95
|
+
{ stdio: ['ignore', 'pipe', 'ignore'] });
|
|
96
|
+
const port = await new Promise<number>((resolve, reject) => {
|
|
97
|
+
child.stdout.once('data', (b) => resolve(Number(String(b).trim())));
|
|
98
|
+
child.once('error', reject);
|
|
99
|
+
setTimeout(() => reject(new Error('the test server never reported a port')), 5000);
|
|
100
|
+
});
|
|
101
|
+
return { pid: child.pid as number, port, kill: () => { try { child.kill('SIGKILL'); } catch { /* gone */ } } };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
test('listeningPid names the process holding a port, and nobody for a free one', async () => {
|
|
105
|
+
const s = await server();
|
|
106
|
+
try {
|
|
107
|
+
assert.equal(await listeningPid(s.port), s.pid);
|
|
108
|
+
} finally { s.kill(); }
|
|
109
|
+
// Once it is gone the port is nobody's.
|
|
110
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
111
|
+
assert.equal(await listeningPid(s.port), null);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('stopService stops the recorded process, and refuses every other case', async () => {
|
|
115
|
+
const s = await server();
|
|
116
|
+
try {
|
|
117
|
+
assert.deepEqual(await stopService(s.port, undefined), {
|
|
118
|
+
ok: false, reason: 'Foreman did not record which process this was, so it will not kill anything.',
|
|
119
|
+
}, 'no recorded pid means no killing');
|
|
120
|
+
|
|
121
|
+
const wrong = await stopService(s.port, s.pid + 100000);
|
|
122
|
+
assert.equal(wrong.ok, false, 'a port held by someone else is left alone');
|
|
123
|
+
assert.match((wrong as { reason: string }).reason, /not the/);
|
|
124
|
+
assert.equal(await portOpen(s.port), true, 'and the refusal really did leave it running');
|
|
125
|
+
|
|
126
|
+
const stopped = await stopService(s.port, s.pid);
|
|
127
|
+
assert.equal(stopped.ok, true);
|
|
128
|
+
assert.equal(await portOpen(s.port), false);
|
|
129
|
+
|
|
130
|
+
const gone = await stopService(s.port, s.pid);
|
|
131
|
+
assert.equal(gone.ok, false, 'stopping twice is not an error worth pretending about');
|
|
132
|
+
} finally { s.kill(); }
|
|
133
|
+
});
|