@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/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 { closeMissionBranch, compareUrl, createPullRequest, ensureMissionBranch, ghReady, gitInfo, prDraft, pullRequestState, pushBranch, startMissionBranch, type GitInfo } from './gitwork.js';
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
- return m ? { folder: m.folder } : null;
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
- const doc = await readFile(path.join(meta.folder, '.foreman', 'MISSION.md'), 'utf8')
2783
- .catch(() => null);
2784
- json(res, 200, { doc });
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
+ });
@@ -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;