@amenophis1er/foreman 0.1.13 → 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/README.md +6 -4
- package/package.json +1 -1
- package/src/deck.ts +4 -2
- package/src/gitwork.test.ts +16 -1
- package/src/gitwork.ts +27 -1
- package/src/mcp.test.ts +56 -6
- package/src/mcp.ts +91 -12
- package/src/notify.ts +5 -1
- package/src/orchestrator.test.ts +25 -1
- package/src/orchestrator.ts +92 -1
- package/src/server.ts +60 -5
- package/src/snapshot.test.ts +68 -0
- package/src/snapshot.ts +116 -0
- package/src/store.test.ts +16 -0
- package/src/store.ts +17 -0
- package/src/types.ts +14 -0
- package/ui/dist/assets/{index--dvfX3Mo.js → index-CnCv2QiH.js} +24 -24
- package/ui/dist/index.html +1 -1
package/src/server.ts
CHANGED
|
@@ -72,7 +72,8 @@ import { readMemory } from './memory.js';
|
|
|
72
72
|
import { budgetAnchor, modelRecords, projectRecord, recordLine } from './track-record.js';
|
|
73
73
|
import { reconcileRole } from './role-provider.js';
|
|
74
74
|
import { detectBrowser, installChromium } from './browser.js';
|
|
75
|
-
import {
|
|
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
77
|
import { detectTailscale, tailnetUrl } from './tailscale.js';
|
|
77
78
|
import { checkForUpdate, currentVersion, type UpdateInfo } from './update.js';
|
|
78
79
|
import { ServiceRegistry, portOpen, servicesHandler } from './services.js';
|
|
@@ -593,6 +594,21 @@ function makeEmitter(runId: string, projectId: string) {
|
|
|
593
594
|
runLabelCache.set(runId, { ...runLabelCache.get(runId), mission: String(d.mission ?? '') });
|
|
594
595
|
} else if (event === 'run_titled') {
|
|
595
596
|
runLabelCache.set(runId, { ...runLabelCache.get(runId), title: String(d.title ?? '') });
|
|
597
|
+
// The branch was named from the brief before the title existed; now it
|
|
598
|
+
// can carry the title. Only while it is still the brief-derived name and
|
|
599
|
+
// nothing is committed on it — the mission's first turns.
|
|
600
|
+
const run = activeRuns().find((r) => r.meta.id === runId);
|
|
601
|
+
const title = String(d.title ?? '').trim();
|
|
602
|
+
if (run?.meta.git && title && run.meta.git.branch === missionBranchName(run.meta.mission, runId) && !(run.meta.git.commits)) {
|
|
603
|
+
void renameMissionBranch(run.meta.folder, run.meta.git.branch, title, runId).then(async (renamed) => {
|
|
604
|
+
if (!renamed || !run.meta.git) return;
|
|
605
|
+
const was = run.meta.git.branch;
|
|
606
|
+
run.meta.git = { ...run.meta.git, branch: renamed };
|
|
607
|
+
await store.writeMeta(run.meta).catch(() => {});
|
|
608
|
+
gitInfoCache.delete(run.meta.folder);
|
|
609
|
+
makeEmitter(runId, projectId)('git_branch', { branch: renamed, base: run.meta.git.base, text: `Branch renamed to ${renamed} (was ${was}) now that the run has a title.` });
|
|
610
|
+
});
|
|
611
|
+
}
|
|
596
612
|
}
|
|
597
613
|
if (!projectsCache.has(projectId)) {
|
|
598
614
|
void store.getProject(projectId).then((p) => { if (p) projectsCache.set(projectId, { name: p.name }); });
|
|
@@ -1595,6 +1611,15 @@ async function driveRun(
|
|
|
1595
1611
|
// However the run ended, it no longer needs its gateways.
|
|
1596
1612
|
releaseGateways(meta.id);
|
|
1597
1613
|
if (activeByProject.get(projectId) === run) activeByProject.delete(projectId);
|
|
1614
|
+
// Freeze the record: the mission doc and the deck as they stand at this
|
|
1615
|
+
// moment, beside the run's meta and log. Done first, before the branch
|
|
1616
|
+
// closes, so the record is what the crew left — a later mission in the
|
|
1617
|
+
// same folder rewrites MISSION.md and the live diff, never this copy.
|
|
1618
|
+
const frozen = await snapshotRun(store.runDirectory(meta.id), meta.folder, meta.id);
|
|
1619
|
+
if (frozen.doc || frozen.deck) {
|
|
1620
|
+
meta.snapshotAt = Date.now();
|
|
1621
|
+
await store.writeMeta(meta).catch(() => {});
|
|
1622
|
+
}
|
|
1598
1623
|
// A finished mission on its own branch closes with a commit of whatever
|
|
1599
1624
|
// the crew left uncommitted. Done only: an interrupted run resumes on
|
|
1600
1625
|
// the same branch and its tree, and an error is not a result to record.
|
|
@@ -1623,6 +1648,8 @@ async function effectiveSettings(projectId: string): Promise<{
|
|
|
1623
1648
|
directorProviderId?: string; workerProviderId?: string;
|
|
1624
1649
|
/** In a repository, each mission runs on a branch of its own (default on). */
|
|
1625
1650
|
gitBranchPerMission: boolean;
|
|
1651
|
+
/** Percent of the cap at which the director is told to start verifying (default 80). */
|
|
1652
|
+
budgetWarnAt: number;
|
|
1626
1653
|
}> {
|
|
1627
1654
|
const s = await store.readSettings()
|
|
1628
1655
|
.catch(() => ({ global: {}, projects: {} as Record<string, object> }));
|
|
@@ -1644,6 +1671,10 @@ async function effectiveSettings(projectId: string): Promise<{
|
|
|
1644
1671
|
directorProviderId: str(p.directorProviderId ?? g.directorProviderId),
|
|
1645
1672
|
workerProviderId: str(p.workerProviderId ?? g.workerProviderId),
|
|
1646
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
|
+
})(),
|
|
1647
1678
|
};
|
|
1648
1679
|
}
|
|
1649
1680
|
|
|
@@ -1681,7 +1712,9 @@ async function startRun(
|
|
|
1681
1712
|
// choosing a model chooses where that role runs.
|
|
1682
1713
|
directorProviderId: roleProviders.director ?? settings.directorProviderId,
|
|
1683
1714
|
workerProviderId: roleProviders.worker ?? settings.workerProviderId,
|
|
1715
|
+
ownerPid: process.pid,
|
|
1684
1716
|
browserTools: browserTools || undefined,
|
|
1717
|
+
budgetWarnAt: settings.budgetWarnAt,
|
|
1685
1718
|
toolPolicy: settings.toolPolicy,
|
|
1686
1719
|
autoAllowReadOnly: settings.autoAllowReadOnly,
|
|
1687
1720
|
// Frozen at dispatch: a later change to the project or the server default
|
|
@@ -1699,6 +1732,14 @@ async function startRun(
|
|
|
1699
1732
|
return;
|
|
1700
1733
|
}
|
|
1701
1734
|
await consumeProposal(projectId, meta.id, mission).catch(() => {});
|
|
1735
|
+
// The folder's MISSION.md belongs to whichever run wrote it. Parked into
|
|
1736
|
+
// that run's record (when it lacks one) and cleared, so this run's status
|
|
1737
|
+
// reads empty until its own director writes the plan — not 13/14 done.
|
|
1738
|
+
{
|
|
1739
|
+
const previous = (await store.listRuns().catch(() => [] as RunMeta[]))
|
|
1740
|
+
.find((r) => r.projectId === projectId && r.id !== meta.id && r.status !== 'running');
|
|
1741
|
+
await parkMissionDoc(folder, previous ? store.runDirectory(previous.id) : null).catch(() => {});
|
|
1742
|
+
}
|
|
1702
1743
|
// In a repository, the mission gets a branch of its own before the crew
|
|
1703
1744
|
// touches anything — so the deck's baseline, taken at the director's first
|
|
1704
1745
|
// turn, is the branch point, and the diff is exactly the mission.
|
|
@@ -1761,6 +1802,7 @@ async function resumeRun(projectId: string, meta: RunMeta, pick: {
|
|
|
1761
1802
|
meta.stopReason = undefined;
|
|
1762
1803
|
meta.status = 'running';
|
|
1763
1804
|
meta.endedAt = undefined;
|
|
1805
|
+
meta.ownerPid = process.pid;
|
|
1764
1806
|
meta.resumes = (meta.resumes ?? 0) + 1;
|
|
1765
1807
|
if (meta.budgetUsd !== budgetWas) {
|
|
1766
1808
|
makeEmitter(meta.id, projectId)('settings_changed', {
|
|
@@ -1773,6 +1815,15 @@ async function resumeRun(projectId: string, meta: RunMeta, pick: {
|
|
|
1773
1815
|
if (back) makeEmitter(meta.id, projectId)('git_note', { text: `Could not return to ${meta.git.branch} (${back}); the resumed mission runs on whatever is checked out.` });
|
|
1774
1816
|
gitInfoCache.delete(meta.folder);
|
|
1775
1817
|
}
|
|
1818
|
+
// The director resumes from MISSION.md. If another mission ran here since,
|
|
1819
|
+
// the folder's copy is that mission's; this run's own goes back first.
|
|
1820
|
+
const restored = await restoreMissionDoc(store.runDirectory(meta.id), meta.folder).catch(() => 'none' as const);
|
|
1821
|
+
if (restored === 'restored') {
|
|
1822
|
+
const emit = makeEmitter(meta.id, projectId);
|
|
1823
|
+
emit('mission_doc_restored', {
|
|
1824
|
+
text: 'Restored this run\'s own MISSION.md into the folder before resuming; a later mission had overwritten it.',
|
|
1825
|
+
});
|
|
1826
|
+
}
|
|
1776
1827
|
await store.writeMeta(meta).catch((err) => {
|
|
1777
1828
|
console.error(`failed to persist resume of ${meta.id}:`, err);
|
|
1778
1829
|
});
|
|
@@ -1895,7 +1946,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
1895
1946
|
return project ? { folder: project.folder } : null;
|
|
1896
1947
|
}
|
|
1897
1948
|
const m = await store.readMeta(id).catch(() => null);
|
|
1898
|
-
|
|
1949
|
+
if (!m) return null;
|
|
1950
|
+
const frozen = m.status !== 'running' ? await frozenDeck(store.runDirectory(m.id)) : null;
|
|
1951
|
+
return { folder: m.folder, ...(frozen ? { deck: frozen } : {}) };
|
|
1899
1952
|
})) return;
|
|
1900
1953
|
const runResumeMatch = url.pathname.match(/^\/runs\/([^/]+)\/resume$/);
|
|
1901
1954
|
const prMatch = url.pathname.match(/^\/runs\/([^/]+)\/pr$/);
|
|
@@ -2779,9 +2832,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
2779
2832
|
if (!runId) return json(res, 400, { error: 'run parameter is required' });
|
|
2780
2833
|
const meta = await store.readMeta(runId);
|
|
2781
2834
|
if (!meta) return json(res, 404, { error: 'unknown run' });
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2835
|
+
// A finished run answers with the doc as it ended; only a running run
|
|
2836
|
+
// reads the folder, which is the one mission running there right now.
|
|
2837
|
+
const frozen = meta.status !== 'running' ? await frozenMissionDoc(store.runDirectory(meta.id)) : null;
|
|
2838
|
+
const doc = frozen ?? await readFile(path.join(meta.folder, '.foreman', 'MISSION.md'), 'utf8').catch(() => null);
|
|
2839
|
+
json(res, 200, { doc, frozen: frozen !== null });
|
|
2785
2840
|
|
|
2786
2841
|
} else if (req.method === 'GET' && url.pathname === '/browse') {
|
|
2787
2842
|
const requested = url.searchParams.get('path') || os.homedir();
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { frozenDeck, frozenMissionDoc, hasSnapshot, parkMissionDoc, restoreMissionDoc, snapshotRun } from './snapshot.js';
|
|
7
|
+
|
|
8
|
+
async function site() {
|
|
9
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'foreman-snap-'));
|
|
10
|
+
const folder = path.join(root, 'proj'); const runDir = path.join(root, 'runs', 'r1');
|
|
11
|
+
await mkdir(path.join(folder, '.foreman'), { recursive: true });
|
|
12
|
+
await mkdir(runDir, { recursive: true });
|
|
13
|
+
return { root, folder, runDir };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test('snapshotRun freezes the mission doc and a deck beside the run, and reads them back', async () => {
|
|
17
|
+
const { root, folder, runDir } = await site();
|
|
18
|
+
await writeFile(path.join(folder, '.foreman', 'MISSION.md'), '# Mission A\n- [x] done\n');
|
|
19
|
+
await writeFile(path.join(folder, 'a.txt'), 'hello\n');
|
|
20
|
+
const r = await snapshotRun(runDir, folder, 'r1', 1234);
|
|
21
|
+
assert.deepEqual(r, { doc: true, deck: true });
|
|
22
|
+
assert.equal(await frozenMissionDoc(runDir), '# Mission A\n- [x] done\n');
|
|
23
|
+
const deck = await frozenDeck(runDir);
|
|
24
|
+
assert.equal(deck?.frozenAt, 1234);
|
|
25
|
+
assert.match(deck?.note ?? '', /As the folder stood when the run ended/);
|
|
26
|
+
assert.equal(await hasSnapshot(runDir), true);
|
|
27
|
+
// A later mission overwrites the folder's doc; the record does not move.
|
|
28
|
+
await writeFile(path.join(folder, '.foreman', 'MISSION.md'), '# Mission B\n');
|
|
29
|
+
assert.equal(await frozenMissionDoc(runDir), '# Mission A\n- [x] done\n');
|
|
30
|
+
await rm(root, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('restoreMissionDoc puts the run\'s own doc back, and says whether it had to', async () => {
|
|
34
|
+
const { root, folder, runDir } = await site();
|
|
35
|
+
assert.equal(await restoreMissionDoc(runDir, folder), 'none', 'no record yet');
|
|
36
|
+
await writeFile(path.join(runDir, 'MISSION.md'), '# Mission A\n');
|
|
37
|
+
await writeFile(path.join(folder, '.foreman', 'MISSION.md'), '# Mission B\n');
|
|
38
|
+
assert.equal(await restoreMissionDoc(runDir, folder), 'restored');
|
|
39
|
+
assert.equal(await readFile(path.join(folder, '.foreman', 'MISSION.md'), 'utf8'), '# Mission A\n');
|
|
40
|
+
assert.equal(await restoreMissionDoc(runDir, folder), 'same');
|
|
41
|
+
await rm(root, { recursive: true, force: true });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('a run with no mission doc still freezes its deck; a missing folder freezes nothing and does not throw', async () => {
|
|
45
|
+
const { root, folder, runDir } = await site();
|
|
46
|
+
const r = await snapshotRun(runDir, folder, 'r1');
|
|
47
|
+
assert.equal(r.doc, false);
|
|
48
|
+
assert.equal(await hasSnapshot(runDir), r.deck);
|
|
49
|
+
const gone = await snapshotRun(path.join(root, 'runs', 'r2'), path.join(root, 'nowhere'), 'r2');
|
|
50
|
+
assert.equal(gone.doc, false);
|
|
51
|
+
assert.equal(await hasSnapshot(path.join(root, 'runs', 'r2')), gone.deck);
|
|
52
|
+
await rm(root, { recursive: true, force: true });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
test('parkMissionDoc clears the folder for a new run and keeps the old doc with the run that wrote it', async () => {
|
|
57
|
+
const { root, folder, runDir } = await site();
|
|
58
|
+
assert.equal(await parkMissionDoc(folder, runDir), 'none', 'nothing to park');
|
|
59
|
+
await writeFile(path.join(folder, '.foreman', 'MISSION.md'), '# Old mission\n- [x] all done\n');
|
|
60
|
+
assert.equal(await parkMissionDoc(folder, runDir), 'parked');
|
|
61
|
+
assert.equal(await readFile(path.join(folder, '.foreman', 'MISSION.md'), 'utf8').catch(() => null), null, 'the folder starts clean');
|
|
62
|
+
assert.equal(await frozenMissionDoc(runDir), '# Old mission\n- [x] all done\n', 'kept with the previous run');
|
|
63
|
+
// A previous run that already has its own copy is not overwritten by a later folder state.
|
|
64
|
+
await writeFile(path.join(folder, '.foreman', 'MISSION.md'), '# Something else\n');
|
|
65
|
+
await parkMissionDoc(folder, runDir);
|
|
66
|
+
assert.equal(await frozenMissionDoc(runDir), '# Old mission\n- [x] all done\n');
|
|
67
|
+
await rm(root, { recursive: true, force: true });
|
|
68
|
+
});
|
package/src/snapshot.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a finished run said, kept with the run.
|
|
3
|
+
*
|
|
4
|
+
* `.foreman/MISSION.md` is one file per project folder and every mission
|
|
5
|
+
* writes it, and the deck is a live diff of the folder against the run's
|
|
6
|
+
* baseline. Both were read fresh whenever a run was looked at — so the moment
|
|
7
|
+
* a newer mission started in the same project, every older run's page, its
|
|
8
|
+
* `run_report` and its resume were reading the new mission's doc and a diff
|
|
9
|
+
* that kept growing with later work. A resume of an older interrupted run
|
|
10
|
+
* handed its director the wrong mission outright.
|
|
11
|
+
*
|
|
12
|
+
* So a run's record freezes when the run ends: its MISSION.md and a summary
|
|
13
|
+
* of its deck are copied into `~/.foreman/runs/<id>/`. Finished runs are read
|
|
14
|
+
* from the copy; a running run still reads the live folder. On resume the
|
|
15
|
+
* copy goes back into the folder first, so the director picks up its own
|
|
16
|
+
* mission. Diffs in the frozen deck are capped so a record stays a record,
|
|
17
|
+
* not an archive of the repository.
|
|
18
|
+
*/
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { mkdir, readFile, writeFile, rename, stat, unlink } from 'node:fs/promises';
|
|
21
|
+
import crypto from 'node:crypto';
|
|
22
|
+
import { deckFor, type Deck } from './deck.js';
|
|
23
|
+
|
|
24
|
+
export const SNAPSHOT_DOC = 'MISSION.md';
|
|
25
|
+
export const SNAPSHOT_DECK = 'deck.json';
|
|
26
|
+
/** A single file's diff kept in the frozen deck; longer ones are cut with a note. */
|
|
27
|
+
const DIFF_CAP = 20_000;
|
|
28
|
+
|
|
29
|
+
export interface FrozenDeck extends Deck {
|
|
30
|
+
/** When the run ended and this was taken. */
|
|
31
|
+
frozenAt: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const missionDocPath = (folder: string) => path.join(folder, '.foreman', 'MISSION.md');
|
|
35
|
+
|
|
36
|
+
async function writeAtomic(file: string, body: string): Promise<void> {
|
|
37
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
38
|
+
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${crypto.randomBytes(4).toString('hex')}.tmp`);
|
|
39
|
+
await writeFile(tmp, body);
|
|
40
|
+
await rename(tmp, file);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Copies the run's mission doc and deck into `runDir`. Never throws; a missing doc is simply not copied. */
|
|
44
|
+
export async function snapshotRun(runDir: string, folder: string, runId: string, now = Date.now()): Promise<{ doc: boolean; deck: boolean }> {
|
|
45
|
+
let doc = false; let deck = false;
|
|
46
|
+
const text = await readFile(missionDocPath(folder), 'utf8').catch(() => null);
|
|
47
|
+
if (text !== null) {
|
|
48
|
+
await writeAtomic(path.join(runDir, SNAPSHOT_DOC), text).then(() => { doc = true; }).catch(() => {});
|
|
49
|
+
}
|
|
50
|
+
const live = await deckFor(folder, runId).catch(() => null);
|
|
51
|
+
if (live) {
|
|
52
|
+
const frozen: FrozenDeck = {
|
|
53
|
+
...live,
|
|
54
|
+
frozenAt: now,
|
|
55
|
+
files: live.files.map((f) => (f.diff && f.diff.length > DIFF_CAP
|
|
56
|
+
? { ...f, diff: `${f.diff.slice(0, DIFF_CAP)}\n… diff cut at ${DIFF_CAP} characters in the run's record`, truncated: true }
|
|
57
|
+
: f)),
|
|
58
|
+
note: [live.note, 'As the folder stood when the run ended.'].filter(Boolean).join(' '),
|
|
59
|
+
};
|
|
60
|
+
await writeAtomic(path.join(runDir, SNAPSHOT_DECK), JSON.stringify(frozen)).then(() => { deck = true; }).catch(() => {});
|
|
61
|
+
}
|
|
62
|
+
return { doc, deck };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The frozen doc, or null when this run has none (older runs, or nothing was written). */
|
|
66
|
+
export async function frozenMissionDoc(runDir: string): Promise<string | null> {
|
|
67
|
+
return readFile(path.join(runDir, SNAPSHOT_DOC), 'utf8').catch(() => null);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The frozen deck, or null. */
|
|
71
|
+
export async function frozenDeck(runDir: string): Promise<FrozenDeck | null> {
|
|
72
|
+
const raw = await readFile(path.join(runDir, SNAPSHOT_DECK), 'utf8').catch(() => null);
|
|
73
|
+
if (!raw) return null;
|
|
74
|
+
try { return JSON.parse(raw) as FrozenDeck; } catch { return null; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Puts the run's own mission doc back into the folder before a resume, so the
|
|
79
|
+
* director reads its mission and not whichever one ran last. Returns what
|
|
80
|
+
* happened, for the transcript: `restored` when the folder's doc differed,
|
|
81
|
+
* `same` when it already matched, `none` when this run has no copy.
|
|
82
|
+
*/
|
|
83
|
+
export async function restoreMissionDoc(runDir: string, folder: string): Promise<'restored' | 'same' | 'none'> {
|
|
84
|
+
const frozen = await frozenMissionDoc(runDir);
|
|
85
|
+
if (frozen === null) return 'none';
|
|
86
|
+
const current = await readFile(missionDocPath(folder), 'utf8').catch(() => null);
|
|
87
|
+
if (current === frozen) return 'same';
|
|
88
|
+
await writeAtomic(missionDocPath(folder), frozen);
|
|
89
|
+
return 'restored';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A new run starts with no mission doc in the folder. The previous run's
|
|
94
|
+
* MISSION.md would otherwise sit there until the new director rewrites it,
|
|
95
|
+
* and everything reading the folder — run_status, the fleet card, the rail —
|
|
96
|
+
* showed the old DONE WHEN, ticked, on a run that had just begun. The old doc
|
|
97
|
+
* is kept: copied into the previous run's record when that run has no copy
|
|
98
|
+
* yet (runs from before records were frozen), then removed from the folder.
|
|
99
|
+
*/
|
|
100
|
+
export async function parkMissionDoc(folder: string, previousRunDir: string | null): Promise<'parked' | 'none'> {
|
|
101
|
+
const file = missionDocPath(folder);
|
|
102
|
+
const text = await readFile(file, 'utf8').catch(() => null);
|
|
103
|
+
if (text === null) return 'none';
|
|
104
|
+
if (previousRunDir && (await frozenMissionDoc(previousRunDir)) === null) {
|
|
105
|
+
await writeAtomic(path.join(previousRunDir, SNAPSHOT_DOC), text).catch(() => {});
|
|
106
|
+
}
|
|
107
|
+
await unlink(file).catch(() => {});
|
|
108
|
+
return 'parked';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Does the run have a frozen record at all? */
|
|
112
|
+
export async function hasSnapshot(runDir: string): Promise<boolean> {
|
|
113
|
+
const doc = await stat(path.join(runDir, SNAPSHOT_DOC)).then(() => true, () => false);
|
|
114
|
+
if (doc) return true;
|
|
115
|
+
return stat(path.join(runDir, SNAPSHOT_DECK)).then(() => true, () => false);
|
|
116
|
+
}
|
package/src/store.test.ts
CHANGED
|
@@ -135,3 +135,19 @@ test('archiveChat retires a conversation: gone from the project, kept on disk, a
|
|
|
135
135
|
await store.archiveChat('p-abc', 'again');
|
|
136
136
|
await rm(root, { recursive: true, force: true });
|
|
137
137
|
});
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
test('sweepOrphans leaves a running run alone while another live process owns it', async () => {
|
|
141
|
+
const { store, root } = await tmpStore();
|
|
142
|
+
const mine = newRunId(1000); const theirs = newRunId(2000); const dead = newRunId(3000);
|
|
143
|
+
await store.createRun(meta(mine, { ownerPid: process.pid }));
|
|
144
|
+
// The test runner's parent is alive and is not us.
|
|
145
|
+
await store.createRun(meta(theirs, { ownerPid: process.ppid }));
|
|
146
|
+
await store.createRun(meta(dead, { ownerPid: 2 ** 22 - 7 }));
|
|
147
|
+
const swept = await store.sweepOrphans();
|
|
148
|
+
assert.ok(swept.includes(mine), 'our own record from a previous life is swept');
|
|
149
|
+
assert.ok(swept.includes(dead), 'a dead owner is an orphan');
|
|
150
|
+
assert.ok(!swept.includes(theirs), 'a live foreign owner keeps its run');
|
|
151
|
+
assert.equal((await store.readMeta(theirs))!.status, 'running');
|
|
152
|
+
await rm(root, { recursive: true, force: true });
|
|
153
|
+
});
|
package/src/store.ts
CHANGED
|
@@ -34,6 +34,14 @@ export function newRunId(now = Date.now()): string {
|
|
|
34
34
|
return `${String(now).padStart(13, '0')}-${crypto.randomBytes(4).toString('hex')}`;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/** Is a process with this pid alive? Signal 0 checks without sending anything. */
|
|
38
|
+
export function processAlive(pid: number): boolean {
|
|
39
|
+
try { process.kill(pid, 0); return true; } catch (err) {
|
|
40
|
+
// EPERM means it exists but is not ours to signal — still alive.
|
|
41
|
+
return (err as NodeJS.ErrnoException).code === 'EPERM';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
37
45
|
export class RunStore {
|
|
38
46
|
/** Absolute data root; exposed so startup checks can report and test it. */
|
|
39
47
|
readonly root: string;
|
|
@@ -182,6 +190,11 @@ export class RunStore {
|
|
|
182
190
|
return path.join(this.runsDir, runId);
|
|
183
191
|
}
|
|
184
192
|
|
|
193
|
+
/** The run's own directory, for the record a run keeps beside its meta and log (see snapshot.ts). */
|
|
194
|
+
runDirectory(runId: string): string {
|
|
195
|
+
return this.runDir(runId);
|
|
196
|
+
}
|
|
197
|
+
|
|
185
198
|
/** Creates the run directory and writes initial metadata. */
|
|
186
199
|
async createRun(meta: RunMeta): Promise<void> {
|
|
187
200
|
await mkdir(this.runDir(meta.id), { recursive: true });
|
|
@@ -351,6 +364,10 @@ export class RunStore {
|
|
|
351
364
|
const swept: string[] = [];
|
|
352
365
|
for (const meta of await this.listRuns()) {
|
|
353
366
|
if (meta.status !== 'running') continue;
|
|
367
|
+
// Another Foreman may be driving this run right now — the installed
|
|
368
|
+
// service while a dev server starts on the same data directory. Its
|
|
369
|
+
// record is not ours to close; only a run whose owner is gone is orphaned.
|
|
370
|
+
if (meta.ownerPid && meta.ownerPid !== process.pid && processAlive(meta.ownerPid)) continue;
|
|
354
371
|
const ended: RunMeta = { ...meta, status: 'interrupted', endedAt: Date.now() };
|
|
355
372
|
await this.append(meta.id, {
|
|
356
373
|
ts: Date.now(),
|
package/src/types.ts
CHANGED
|
@@ -330,7 +330,21 @@ 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';
|
|
339
|
+
/** When the run's record was frozen (MISSION.md and deck copied beside it); absent on older runs. */
|
|
340
|
+
snapshotAt?: number;
|
|
341
|
+
/**
|
|
342
|
+
* The server process driving this run. The startup sweep that marks
|
|
343
|
+
* abandoned runs interrupted leaves a run alone while its owner is alive —
|
|
344
|
+
* a second Foreman on the same data directory (a dev server beside the
|
|
345
|
+
* installed one) once swept a live mission's record out from under it.
|
|
346
|
+
*/
|
|
347
|
+
ownerPid?: number;
|
|
334
348
|
folder: string;
|
|
335
349
|
mission: string;
|
|
336
350
|
budgetUsd: number;
|