@yaag/extension 0.1.4 → 0.2.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaag/extension",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -24,9 +24,10 @@
24
24
  },
25
25
  "dependencies": {
26
26
  "@earendil-works/pi-tui": "^0.84.0",
27
- "@yaag/runtime": "0.1.4",
28
- "@yaag/tui": "0.1.4",
29
- "@yaag/cli": "0.1.4"
27
+ "@yaag/cli": "0.2.0",
28
+ "@yaag/runtime": "0.2.0",
29
+ "@yaag/tui": "0.2.0",
30
+ "nanoid": "^6.0.1"
30
31
  },
31
32
  "peerDependencies": {
32
33
  "@earendil-works/pi-coding-agent": "*",
package/src/cli-child.ts CHANGED
@@ -22,6 +22,8 @@ export interface CliChildOptions {
22
22
  export interface CliChild {
23
23
  readonly outcome: Promise<CliChildOutcome>;
24
24
  readonly events: Readable | undefined;
25
+ /** The child's pid, and the group id its reap ladder signals. */
26
+ readonly pid?: number | undefined;
25
27
  stop(): void;
26
28
  }
27
29
 
@@ -53,6 +55,7 @@ export function startCliChild(options: CliChildOptions): CliChild {
53
55
  const eventStream = options.events === true ? child.stdio[3] : undefined;
54
56
  return {
55
57
  outcome,
58
+ pid: child.pid,
56
59
  events: isReadable(eventStream) ? eventStream : undefined,
57
60
  stop: () => void reap(target),
58
61
  };
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ import { resolveBun } from "./resolve-bun.ts";
6
6
  import { resolveCliEntry } from "./resolve-cli.ts";
7
7
  import { createRunCompleteRenderer } from "./run-complete-renderer.ts";
8
8
  import { RunRegistry } from "./run-registry.ts";
9
+ import { RunStore } from "./run-store.ts";
9
10
  import { createRunTool } from "./run-tool.ts";
10
11
  import { RunTreeStore } from "./run-trees.ts";
11
12
  import { createSetupWorkspaceExecutor } from "./setup-workspace.ts";
@@ -31,8 +32,10 @@ export default async function (pi: ExtensionAPI): Promise<void> {
31
32
  const cli = resolveCliEntry();
32
33
  const report = statusReport(bun, cli);
33
34
 
34
- // One registry per session retains every foreground and background Run.
35
- const registry = new RunRegistry();
35
+ // One registry per session, mirrored to a durable store so a restart of this
36
+ // Host Session keeps every Run id addressable (ticket 02).
37
+ const registry = new RunRegistry({ store: new RunStore() });
38
+ await registry.restore();
36
39
  // The renderer projections and the one combined inline widget both live for
37
40
  // the session, beside the registry, so a background Run keeps a tree.
38
41
  const store = new RunTreeStore();
@@ -0,0 +1,51 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ /** What a Run record remembers about the process that carried it. */
4
+ export interface ProcessIdentity {
5
+ readonly pid: number;
6
+ /** Kernel start time of that pid, when the platform exposes it. */
7
+ readonly startedAt: string | null;
8
+ }
9
+
10
+ /**
11
+ * Reads the start time of a live pid, so a later session can tell the original
12
+ * process from an unrelated one that inherited the pid. Linux exposes it as
13
+ * field 22 of `/proc/<pid>/stat`; every other platform returns `null` and the
14
+ * liveness probe falls back to the pid alone.
15
+ */
16
+ export function readProcessStart(pid: number): string | null {
17
+ if (process.platform !== "linux") return null;
18
+ try {
19
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
20
+ // The second field is the executable name in parentheses and may itself
21
+ // contain spaces and parentheses, so fields are counted after the last ")".
22
+ const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
23
+ return fields[19] ?? null;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Reports whether the exact process a record names is still running. A pid that
31
+ * exists but reports a different start time is a reused pid, and is treated as
32
+ * dead: killing it would end an unrelated process.
33
+ */
34
+ export function isProcessAlive(identity: ProcessIdentity): boolean {
35
+ if (!Number.isInteger(identity.pid) || identity.pid <= 0) return false;
36
+ try {
37
+ process.kill(identity.pid, 0);
38
+ } catch (error) {
39
+ // EPERM means the process exists but belongs to another user.
40
+ return isPermissionError(error);
41
+ }
42
+ if (identity.startedAt === null) return true;
43
+ const current = readProcessStart(identity.pid);
44
+ return current === null || current === identity.startedAt;
45
+ }
46
+
47
+ function isPermissionError(error: unknown): boolean {
48
+ if (typeof error !== "object" || error === null || !("code" in error)) return false;
49
+ const { code } = error;
50
+ return code === "EPERM";
51
+ }
@@ -43,7 +43,8 @@ function isRunSummary(value: unknown): value is RunSummary {
43
43
  value.runState === "ended" &&
44
44
  isOutcome(value.outcome) &&
45
45
  typeof value.ok === "boolean" &&
46
- nullableNumber(value.endedAt)
46
+ nullableNumber(value.endedAt) &&
47
+ optionalString(value.checkpointLost)
47
48
  );
48
49
  }
49
50
 
@@ -207,7 +208,8 @@ function isEvent(value: unknown): value is LifecycleEvent {
207
208
  number(value.cost) &&
208
209
  nullableTokens(value.tokens) &&
209
210
  typeof value.incomplete === "boolean" &&
210
- natural(value.worstFrameGapMs)
211
+ natural(value.worstFrameGapMs) &&
212
+ optionalString(value.checkpointLost)
211
213
  );
212
214
  default:
213
215
  return false;
package/src/run-id.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { customAlphabet } from "nanoid";
2
+
3
+ /**
4
+ * Lowercase letters and digits only: a Run id is typed back by a human into
5
+ * `yaag_status` and `yaag_stop`, so the alphabet holds no case distinction and
6
+ * no punctuation.
7
+ */
8
+ const ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz";
9
+
10
+ /** Nine random characters over 36 symbols; the `r` prefix is not random. */
11
+ const ID_LENGTH = 9;
12
+
13
+ const nanoid = customAlphabet(ALPHABET, ID_LENGTH);
14
+
15
+ /**
16
+ * Mints one globally unique Run id.
17
+ *
18
+ * The id must stay unique across Host Sessions, because Run records outlive the
19
+ * session that started them (ticket 02); a per-session counter would collide
20
+ * with every earlier session's `r1`.
21
+ */
22
+ export function mintRunId(): string {
23
+ return `r${nanoid()}`;
24
+ }
@@ -0,0 +1,91 @@
1
+ import { initialSummary, type RunSummary } from "@yaag/runtime";
2
+ import type { ProcessIdentity } from "./process-liveness.ts";
3
+
4
+ /**
5
+ * The durable state of a Run.
6
+ *
7
+ * `live` and `finished` are written by the session that owns the Run.
8
+ * `interrupted` and `orphaned` are written by a later session that found a
9
+ * `live` record its own memory knows nothing about: the child is dead, or the
10
+ * child is still running without an owner (ticket 02).
11
+ */
12
+ export type PersistedRunState = "live" | "finished" | "interrupted" | "orphaned";
13
+
14
+ /** How a Run ended, reduced to values that survive JSON. */
15
+ export type PersistedOutcome =
16
+ | { readonly kind: "fulfilled"; readonly code: number | null; readonly result: string }
17
+ | { readonly kind: "rejected"; readonly reason: string };
18
+
19
+ /** What starting a Run said about it; enough to describe it in a later session. */
20
+ export interface RunLaunch {
21
+ readonly file: string;
22
+ readonly args?: string;
23
+ readonly record?: string;
24
+ readonly resume?: string;
25
+ }
26
+
27
+ /** One Run as stored on disk, at `<runs dir>/<id>.json`. */
28
+ export interface RunRecord {
29
+ readonly id: string;
30
+ readonly launch: RunLaunch;
31
+ readonly process: ProcessIdentity | null;
32
+ readonly startedAt: string;
33
+ readonly endedAt: string | null;
34
+ readonly state: PersistedRunState;
35
+ readonly summary: RunSummary;
36
+ readonly outcome: PersistedOutcome | null;
37
+ }
38
+
39
+ /**
40
+ * Narrows an untrusted parsed JSON value to a {@link RunRecord}.
41
+ *
42
+ * A record may have been written by an older yaag, or truncated by a crash, so
43
+ * every field the extension reads is checked here and a record that fails is
44
+ * dropped rather than repaired.
45
+ */
46
+ export function isRunRecord(value: unknown): value is RunRecord {
47
+ if (typeof value !== "object" || value === null) return false;
48
+ const candidate: Record<string, unknown> = { ...value };
49
+ return (
50
+ typeof candidate["id"] === "string" &&
51
+ typeof candidate["startedAt"] === "string" &&
52
+ isState(candidate["state"]) &&
53
+ isLaunch(candidate["launch"]) &&
54
+ isSummary(candidate["summary"])
55
+ );
56
+ }
57
+
58
+ /** A Run record for a Run that has just started. */
59
+ export function startedRecord(options: {
60
+ readonly id: string;
61
+ readonly launch: RunLaunch;
62
+ readonly process: ProcessIdentity | null;
63
+ readonly now: string;
64
+ }): RunRecord {
65
+ return {
66
+ id: options.id,
67
+ launch: options.launch,
68
+ process: options.process,
69
+ startedAt: options.now,
70
+ endedAt: null,
71
+ state: "live",
72
+ summary: initialSummary(),
73
+ outcome: null,
74
+ };
75
+ }
76
+
77
+ function isState(value: unknown): value is PersistedRunState {
78
+ return (
79
+ value === "live" || value === "finished" || value === "interrupted" || value === "orphaned"
80
+ );
81
+ }
82
+
83
+ function isLaunch(value: unknown): value is RunLaunch {
84
+ return (
85
+ typeof value === "object" && value !== null && "file" in value && typeof value.file === "string"
86
+ );
87
+ }
88
+
89
+ function isSummary(value: unknown): value is RunSummary {
90
+ return typeof value === "object" && value !== null && "runState" in value;
91
+ }
@@ -1,6 +1,12 @@
1
1
  import type { EndedRunSummary, RunSummary } from "@yaag/runtime";
2
+ import type { ProcessIdentity } from "./process-liveness.ts";
3
+ import { type RunLaunch, type RunRecord, startedRecord } from "./run-record.ts";
4
+ import { restoreRecords } from "./run-restore.ts";
5
+ import type { RunStore } from "./run-store.ts";
2
6
  import type { RunOutcome } from "./spawn-run.ts";
3
7
 
8
+ export type { RunLaunch } from "./run-record.ts";
9
+
4
10
  /** A Run in flight, including its stop capability and latest Summary fold. */
5
11
  export interface LiveRun {
6
12
  readonly id: string;
@@ -21,30 +27,51 @@ export interface FinishedRun {
21
27
  readonly outcome: RunSettlement;
22
28
  }
23
29
 
30
+ /** A Run read back from disk, started by an earlier Host Session. */
31
+ export interface RestoredRun {
32
+ readonly id: string;
33
+ readonly summary: RunSummary;
34
+ readonly record: RunRecord;
35
+ }
36
+
24
37
  /** The retained state of a registered Run. */
25
38
  export type RegisteredRun =
26
39
  | { readonly state: "live"; readonly run: LiveRun }
27
- | { readonly state: "finished"; readonly run: FinishedRun };
40
+ | { readonly state: "finished"; readonly run: FinishedRun }
41
+ | { readonly state: "restored"; readonly run: RestoredRun };
28
42
 
29
43
  /** What the registry knows about an id. */
30
44
  export type RunStatus = RegisteredRun | { readonly state: "unknown" };
31
45
 
46
+ /** How often a Run's latest Summary is persisted while it runs. */
47
+ export const SUMMARY_WRITE_INTERVAL_MS = 2_000;
48
+
49
+ export interface RunRegistryOptions {
50
+ /** Durable mirror; omit it and the registry stays memory-only. */
51
+ readonly store?: RunStore;
52
+ readonly now?: () => Date;
53
+ }
54
+
32
55
  /**
33
- * Per-session source of truth for every Run started by the Host Session.
56
+ * Source of truth for every Run the Host Session can address.
34
57
  *
35
- * It mints monotonically increasing ids, retains live stop capabilities, and
36
- * preserves final outcomes for the rest of the session. Concurrent Runs remain
37
- * independently controllable (ADR-0008).
58
+ * It retains live stop capabilities and final outcomes in memory, and mirrors
59
+ * each Run to a {@link RunStore} so a restart of the Host Session does not lose
60
+ * the id (ticket 02). Concurrent Runs remain independently controllable
61
+ * (ADR-0008).
38
62
  */
39
63
  export class RunRegistry {
40
- #counter = 0;
41
64
  readonly #live = new Map<string, LiveRun>();
42
65
  readonly #runs = new Map<string, RegisteredRun>();
43
-
44
- /** The next Run id for this session; it is never reused. */
45
- mint(): string {
46
- this.#counter += 1;
47
- return `r${this.#counter}`;
66
+ readonly #records = new Map<string, RunRecord>();
67
+ readonly #lastWrite = new Map<string, number>();
68
+ readonly #trailing = new Map<string, ReturnType<typeof setTimeout>>();
69
+ readonly #store: RunStore | undefined;
70
+ readonly #now: () => Date;
71
+
72
+ constructor(options: RunRegistryOptions = {}) {
73
+ this.#store = options.store;
74
+ this.#now = options.now ?? (() => new Date());
48
75
  }
49
76
 
50
77
  /** Every currently-live Run, in registration order. */
@@ -52,31 +79,72 @@ export class RunRegistry {
52
79
  return [...this.#live.values()];
53
80
  }
54
81
 
55
- /** Every registered Run, live and finished, in registration order. */
82
+ /** Every addressable Run: this session's Runs, plus unaccounted earlier ones. */
56
83
  get runs(): readonly RegisteredRun[] {
57
84
  return [...this.#runs.values()];
58
85
  }
59
86
 
60
- /** Every registered Run id, in registration order. */
87
+ /** Every addressable Run id, in registration order. */
61
88
  get knownIds(): readonly string[] {
62
89
  return [...this.#runs.keys()];
63
90
  }
64
91
 
92
+ /**
93
+ * Loads persisted Runs, settles the ones an earlier session left `live`, and
94
+ * seeds the still-unaccounted ones. Finished Runs of earlier sessions stay on
95
+ * disk and are reachable through {@link recall} by id.
96
+ */
97
+ async restore(): Promise<void> {
98
+ if (this.#store === undefined) return;
99
+ const records = await restoreRecords({ store: this.#store, now: this.#now });
100
+ for (const record of records) {
101
+ if (record.state !== "orphaned" && record.state !== "interrupted") continue;
102
+ this.#runs.set(record.id, { state: "restored", run: restored(record) });
103
+ }
104
+ }
105
+
65
106
  /** Registers a newly-started Run and synchronously observes its settlement. */
66
- add(run: LiveRun): void {
107
+ add(run: LiveRun, launch?: RunLaunch, identity?: ProcessIdentity | null): void {
67
108
  this.#live.set(run.id, run);
68
109
  this.#runs.set(run.id, { state: "live", run });
110
+ if (launch !== undefined) this.#persistStart(run.id, launch, identity ?? null);
69
111
  void run.outcome.then(
70
112
  (outcome) => this.finish(run.id, outcome),
71
113
  (reason: unknown) => this.reject(run.id, reason),
72
114
  );
73
115
  }
74
116
 
117
+ /** Records the latest fold for a live Run and persists it at a bounded rate. */
118
+ progress(id: string, summary: RunSummary): void {
119
+ const run = this.#live.get(id);
120
+ if (run === undefined) return;
121
+ run.summary = summary;
122
+ const record = this.#records.get(id);
123
+ if (record === undefined) return;
124
+ this.#records.set(id, { ...record, summary });
125
+ const waited = this.#now().getTime() - (this.#lastWrite.get(id) ?? 0);
126
+ if (waited < SUMMARY_WRITE_INTERVAL_MS) {
127
+ this.#scheduleTrailing(id, waited);
128
+ return;
129
+ }
130
+ this.#persist(id);
131
+ }
132
+
75
133
  /** Looks up a live capability or the retained completed Run. */
76
134
  lookup(id: string): RunStatus {
77
135
  return this.#runs.get(id) ?? { state: "unknown" };
78
136
  }
79
137
 
138
+ /** Reads a Run this session never started back from the store, by id. */
139
+ async recall(id: string): Promise<RunStatus> {
140
+ const known = this.lookup(id);
141
+ if (known.state !== "unknown" || this.#store === undefined) return known;
142
+ const record = (await this.#store.load()).find((entry) => entry.id === id);
143
+ return record === undefined
144
+ ? { state: "unknown" }
145
+ : { state: "restored", run: restored(record) };
146
+ }
147
+
80
148
  /**
81
149
  * Retains a Run's first observed final outcome and removes only its live
82
150
  * capability. Repeated observers are harmless and cannot overwrite history.
@@ -88,16 +156,85 @@ export class RunRegistry {
88
156
  /** Retains a rejected child outcome with its latest terminal accounting projection. */
89
157
  reject(id: string, reason: unknown): void {
90
158
  const record = this.#runs.get(id);
91
- if (record === undefined || record.state === "finished") return;
159
+ if (record === undefined || record.state !== "live") return;
92
160
  this.#settle(id, failedSummary(record.run.summary), { kind: "rejected", reason });
93
161
  }
94
162
 
163
+ /**
164
+ * Persists the latest Summary when the interval ends. Without it, everything
165
+ * folded inside one interval would be lost if the Host Session died before
166
+ * the next write (ticket 02).
167
+ */
168
+ #scheduleTrailing(id: string, waited: number): void {
169
+ if (this.#trailing.has(id)) return;
170
+ const timer = setTimeout(() => {
171
+ this.#trailing.delete(id);
172
+ if (this.#live.has(id)) this.#persist(id);
173
+ }, SUMMARY_WRITE_INTERVAL_MS - waited);
174
+ // Housekeeping must never keep the Host Session alive.
175
+ timer.unref();
176
+ this.#trailing.set(id, timer);
177
+ }
178
+
95
179
  #settle(id: string, summary: RunSummary, outcome: RunSettlement): void {
96
180
  const record = this.#runs.get(id);
97
- if (record === undefined || record.state === "finished") return;
181
+ if (record === undefined || record.state !== "live") return;
98
182
  this.#live.delete(id);
183
+ const trailing = this.#trailing.get(id);
184
+ if (trailing !== undefined) clearTimeout(trailing);
185
+ this.#trailing.delete(id);
99
186
  this.#runs.set(id, { state: "finished", run: { id, summary, outcome } });
187
+ this.#persistEnd(id, summary, outcome);
188
+ }
189
+
190
+ #persistStart(id: string, launch: RunLaunch, identity: ProcessIdentity | null): void {
191
+ if (this.#store === undefined) return;
192
+ const store = this.#store;
193
+ this.#records.set(
194
+ id,
195
+ startedRecord({ id, launch, process: identity, now: this.#now().toISOString() }),
196
+ );
197
+ this.#persist(id);
198
+ void store.prune();
199
+ }
200
+
201
+ #persistEnd(id: string, summary: RunSummary, outcome: RunSettlement): void {
202
+ const record = this.#records.get(id);
203
+ if (record === undefined) return;
204
+ this.#records.set(id, {
205
+ ...record,
206
+ state: "finished",
207
+ endedAt: this.#now().toISOString(),
208
+ summary,
209
+ outcome: persistedOutcome(outcome),
210
+ });
211
+ this.#persist(id);
100
212
  }
213
+
214
+ #persist(id: string): void {
215
+ const record = this.#records.get(id);
216
+ if (record === undefined || this.#store === undefined) return;
217
+ this.#lastWrite.set(id, this.#now().getTime());
218
+ void this.#store.save(record);
219
+ }
220
+ }
221
+
222
+ function restored(record: RunRecord): RestoredRun {
223
+ return { id: record.id, summary: record.summary, record };
224
+ }
225
+
226
+ function persistedOutcome(outcome: RunSettlement): RunRecord["outcome"] {
227
+ if (outcome.kind === "rejected")
228
+ return { kind: "rejected", reason: errorMessage(outcome.reason) };
229
+ return {
230
+ kind: "fulfilled",
231
+ code: outcome.outcome.code,
232
+ result: outcome.outcome.stdout.trimEnd(),
233
+ };
234
+ }
235
+
236
+ function errorMessage(reason: unknown): string {
237
+ return reason instanceof Error ? reason.message : String(reason);
101
238
  }
102
239
 
103
240
  function failedSummary(summary: RunSummary): EndedRunSummary {
@@ -0,0 +1,43 @@
1
+ import { isProcessAlive, type ProcessIdentity } from "./process-liveness.ts";
2
+ import type { RunRecord } from "./run-record.ts";
3
+ import type { RunStore } from "./run-store.ts";
4
+
5
+ /** Decides whether the process a record names is still running. */
6
+ export type LivenessProbe = (identity: ProcessIdentity) => boolean;
7
+
8
+ export interface RestoreOptions {
9
+ readonly store: RunStore;
10
+ readonly isAlive?: LivenessProbe;
11
+ readonly now?: () => Date;
12
+ }
13
+
14
+ /**
15
+ * Reads every persisted Run and settles the ones a previous Host Session left
16
+ * behind.
17
+ *
18
+ * A `live` record can only be stale here, because the session that owned it is
19
+ * gone: its child is dead and the Run is `interrupted`, or its child still runs
20
+ * without an owner and the Run is `orphaned`. Both re-writes are persisted, so
21
+ * the verdict survives this session too (ticket 02).
22
+ */
23
+ export async function restoreRecords(options: RestoreOptions): Promise<readonly RunRecord[]> {
24
+ const isAlive = options.isAlive ?? isProcessAlive;
25
+ const now = options.now ?? (() => new Date());
26
+ const records = await options.store.load();
27
+ return Promise.all(records.map((record) => settleStale(record, isAlive, now, options.store)));
28
+ }
29
+
30
+ async function settleStale(
31
+ record: RunRecord,
32
+ isAlive: LivenessProbe,
33
+ now: () => Date,
34
+ store: RunStore,
35
+ ): Promise<RunRecord> {
36
+ if (record.state !== "live") return record;
37
+ const alive = record.process !== null && isAlive(record.process);
38
+ const settled: RunRecord = alive
39
+ ? { ...record, state: "orphaned" }
40
+ : { ...record, state: "interrupted", endedAt: now().toISOString() };
41
+ await store.save(settled);
42
+ return settled;
43
+ }
@@ -0,0 +1,103 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { isRunRecord, type RunRecord } from "./run-record.ts";
5
+ import { resolveRunsDirectory } from "./runs-dir.ts";
6
+
7
+ /** How many finished records are kept before the oldest ones are pruned. */
8
+ export const FINISHED_RECORD_CAP = 200;
9
+
10
+ export interface RunStoreOptions {
11
+ readonly directory?: string;
12
+ readonly cap?: number;
13
+ }
14
+
15
+ /**
16
+ * The durable side of the Run registry: one JSON file per Run.
17
+ *
18
+ * Every method is best-effort and never throws — a Host Session must keep
19
+ * working when the state directory is read-only or full. Writes go to a temp
20
+ * file and are renamed into place, so a reader never sees a half-written record
21
+ * (ADR-0021).
22
+ */
23
+ export class RunStore {
24
+ readonly #directory: string;
25
+ readonly #cap: number;
26
+ #chain: Promise<void> = Promise.resolve();
27
+
28
+ constructor(options: RunStoreOptions = {}) {
29
+ this.#directory = options.directory ?? resolveRunsDirectory();
30
+ this.#cap = options.cap ?? FINISHED_RECORD_CAP;
31
+ }
32
+
33
+ get directory(): string {
34
+ return this.#directory;
35
+ }
36
+
37
+ /** Queues one record write; later writes for any Run never overtake earlier ones. */
38
+ save(record: RunRecord): Promise<void> {
39
+ this.#chain = this.#chain.then(() => this.#write(record));
40
+ return this.#chain;
41
+ }
42
+
43
+ /** Resolves once every queued write has been attempted. */
44
+ async settled(): Promise<void> {
45
+ await this.#chain;
46
+ }
47
+
48
+ /** Every readable record, oldest start first. Unreadable records are skipped. */
49
+ async load(): Promise<readonly RunRecord[]> {
50
+ let entries: readonly string[];
51
+ try {
52
+ entries = await readdir(this.#directory);
53
+ } catch {
54
+ return [];
55
+ }
56
+ const records = await Promise.all(
57
+ entries.filter((entry) => entry.endsWith(".json")).map((entry) => this.#read(entry)),
58
+ );
59
+ return records
60
+ .filter((record): record is RunRecord => record !== null)
61
+ .sort((left, right) => left.startedAt.localeCompare(right.startedAt));
62
+ }
63
+
64
+ /**
65
+ * Removes the oldest finished records above the cap. `live`, `orphaned`, and
66
+ * `interrupted` records are never pruned: they are the only trace of a Run
67
+ * nobody accounted for.
68
+ */
69
+ async prune(): Promise<void> {
70
+ const finished = (await this.load()).filter((record) => record.state === "finished");
71
+ const excess = finished.slice(0, Math.max(0, finished.length - this.#cap));
72
+ await Promise.all(excess.map((record) => this.remove(record.id)));
73
+ }
74
+
75
+ async remove(id: string): Promise<void> {
76
+ try {
77
+ await unlink(join(this.#directory, `${id}.json`));
78
+ } catch {
79
+ // Already gone, or never written; nothing to report.
80
+ }
81
+ }
82
+
83
+ async #write(record: RunRecord): Promise<void> {
84
+ const target = join(this.#directory, `${record.id}.json`);
85
+ const temp = `${target}.${randomUUID()}.tmp`;
86
+ try {
87
+ await mkdir(this.#directory, { recursive: true });
88
+ await writeFile(temp, JSON.stringify(record), "utf8");
89
+ await rename(temp, target);
90
+ } catch {
91
+ await unlink(temp).catch(() => {});
92
+ }
93
+ }
94
+
95
+ async #read(entry: string): Promise<RunRecord | null> {
96
+ try {
97
+ const value: unknown = JSON.parse(await readFile(join(this.#directory, entry), "utf8"));
98
+ return isRunRecord(value) ? value : null;
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+ }
package/src/run-tool.ts CHANGED
@@ -12,6 +12,8 @@ import { RunTreeStore } from "./run-trees.ts";
12
12
 
13
13
  export type { RunDetails } from "./run-details.ts";
14
14
 
15
+ import { type ProcessIdentity, readProcessStart } from "./process-liveness.ts";
16
+ import { mintRunId } from "./run-id.ts";
15
17
  import type { LiveRun, RunRegistry, RunSettlement } from "./run-registry.ts";
16
18
  import { errorText, failure, observedSettlement } from "./run-settlement.ts";
17
19
  import { type RunHandle, type RunOutcome, type StartRunOptions, startRun } from "./spawn-run.ts";
@@ -122,7 +124,7 @@ export function createRunTool(deps: RunToolOptions): ToolDefinition<typeof param
122
124
  throw new Error(`yaag_run: no such Orchestration Program: ${file}`);
123
125
  }
124
126
 
125
- const id = registry.mint();
127
+ const id = mintRunId();
126
128
  const background = params.background === true;
127
129
  const interactive = interactiveAvailable(ctx, background);
128
130
  // Attached before start(): the Ask ledger folds from events, so a state
@@ -224,7 +226,6 @@ function registeredRun(options: {
224
226
  /** Redraw request for the pushed path: fd 3 fold → ingest → requestRender. */
225
227
  readonly onIngest?: () => void;
226
228
  }): LiveRun {
227
- let run: LiveRun;
228
229
  const handle = options.start({
229
230
  bun: options.bun,
230
231
  cli: options.cli,
@@ -233,17 +234,33 @@ function registeredRun(options: {
233
234
  record: options.record,
234
235
  resume: options.resume,
235
236
  onProgress: (summary, event, sequence) => {
236
- run.summary = summary;
237
+ options.registry.progress(options.id, summary);
237
238
  options.store.ingest(options.id, { summary, event, sequence, id: options.id });
238
239
  options.onUpdate?.({ content: [], details: { summary, event, sequence, id: options.id } });
239
240
  options.onIngest?.();
240
241
  },
241
242
  });
242
- run = { id: options.id, stop: handle.stop, outcome: handle.outcome, summary: initialSummary() };
243
- options.registry.add(run);
243
+ const run: LiveRun = {
244
+ id: options.id,
245
+ stop: handle.stop,
246
+ outcome: handle.outcome,
247
+ summary: initialSummary(),
248
+ };
249
+ const launch = {
250
+ file: options.file,
251
+ ...(options.args === undefined ? {} : { args: options.args }),
252
+ ...(options.record === undefined ? {} : { record: options.record }),
253
+ ...(options.resume === undefined ? {} : { resume: options.resume }),
254
+ };
255
+ options.registry.add(run, launch, processIdentity(handle.pid));
244
256
  return run;
245
257
  }
246
258
 
259
+ /** A pid alone cannot be trusted after a restart, so its start time goes with it. */
260
+ function processIdentity(pid: number | undefined): ProcessIdentity | null {
261
+ return pid === undefined ? null : { pid, startedAt: readProcessStart(pid) };
262
+ }
263
+
247
264
  function completionContent(id: string, settlement: RunSettlement): string {
248
265
  if (settlement.kind === "rejected") return `Run ${id} failed: ${errorText(settlement.reason)}`;
249
266
  const { outcome } = settlement;
@@ -0,0 +1,18 @@
1
+ import { homedir } from "node:os";
2
+ import { isAbsolute, join } from "node:path";
3
+
4
+ const RUNS_SUFFIX = join("yaag", "runs");
5
+
6
+ /**
7
+ * Resolves where Run records are stored: `YAAG_RUNS_DIR`, then an absolute
8
+ * `XDG_STATE_HOME`, then `$HOME/.local/state`. A relative `XDG_STATE_HOME` is
9
+ * ignored (XDG spec). Same conventions as the checkpoint directory (ADR-0021).
10
+ */
11
+ export function resolveRunsDirectory(env: NodeJS.ProcessEnv = process.env): string {
12
+ const override = env["YAAG_RUNS_DIR"];
13
+ if (override !== undefined && override !== "") return override;
14
+ const xdg = env["XDG_STATE_HOME"];
15
+ if (xdg !== undefined && isAbsolute(xdg)) return join(xdg, RUNS_SUFFIX);
16
+ const home = env["HOME"] !== undefined && env["HOME"] !== "" ? env["HOME"] : homedir();
17
+ return join(home, ".local", "state", RUNS_SUFFIX);
18
+ }
package/src/spawn-run.ts CHANGED
@@ -38,6 +38,8 @@ export interface SpawnRunOptions extends StartRunOptions {
38
38
  export interface RunHandle {
39
39
  readonly outcome: Promise<RunOutcome>;
40
40
  readonly stop: () => void;
41
+ /** The child's pid, when the spawn produced one. */
42
+ readonly pid?: number | undefined;
41
43
  }
42
44
 
43
45
  /**
@@ -65,7 +67,7 @@ export function startRun(options: StartRunOptions): RunHandle {
65
67
  options.onProgress?.(summary, event, sequence);
66
68
  }),
67
69
  ]).then(([ended]) => ({ ...ended, summary }));
68
- return { outcome, stop: child.stop };
70
+ return { outcome, stop: child.stop, pid: child.pid };
69
71
  }
70
72
 
71
73
  /**
@@ -2,7 +2,8 @@ import type { AgentToolResult, ToolDefinition } from "@earendil-works/pi-coding-
2
2
  import type { RunSummary } from "@yaag/runtime";
3
3
  import { renderSnapshot, TreeState } from "@yaag/tui";
4
4
  import { Type } from "typebox";
5
- import type { RegisteredRun, RunRegistry } from "./run-registry.ts";
5
+ import type { PersistedRunState } from "./run-record.ts";
6
+ import type { RegisteredRun, RestoredRun, RunRegistry } from "./run-registry.ts";
6
7
  import { toUsage } from "./usage.ts";
7
8
 
8
9
  const parameters = Type.Object({
@@ -25,10 +26,15 @@ export interface StatusToolOptions {
25
26
  }
26
27
 
27
28
  const DESCRIPTION = [
28
- "Show a snapshot of a Run started in this Host Session.",
29
+ "Show a snapshot of a Run started by yaag_run.",
29
30
  "",
30
31
  "With an id, returns that Run's live snapshot or its retained final snapshot.",
31
- "Without an id, lists every Run started this session in registration order.",
32
+ "Run records outlive the Host Session, so an id from an earlier session still",
33
+ "resolves: a Run whose process died is reported as interrupted, and a Run whose",
34
+ "process is still alive is reported as orphaned.",
35
+ "",
36
+ "Without an id, lists this session's Runs plus every orphaned or interrupted",
37
+ "Run left by an earlier session.",
32
38
  ].join("\n");
33
39
 
34
40
  /**
@@ -50,7 +56,7 @@ export function createStatusTool(
50
56
  parameters,
51
57
  async execute(_id, params) {
52
58
  if (params.id === undefined) return overview(registry.runs, now, width);
53
- const found = registry.lookup(params.id);
59
+ const found = await registry.recall(params.id);
54
60
  if (found.state === "unknown") throw unknownId(params.id, registry.knownIds);
55
61
  const details = detailsFor(found);
56
62
  return {
@@ -85,6 +91,7 @@ function overview(
85
91
 
86
92
  function detailsFor(run: RegisteredRun): SnapshotDetails {
87
93
  if (run.state === "live") return { id: run.run.id, summary: run.run.summary };
94
+ if (run.state === "restored") return restoredDetails(run.run);
88
95
  const { outcome } = run.run;
89
96
  return {
90
97
  id: run.run.id,
@@ -95,6 +102,34 @@ function detailsFor(run: RegisteredRun): SnapshotDetails {
95
102
  };
96
103
  }
97
104
 
105
+ /**
106
+ * A Run of an earlier Host Session. Its persisted state is stated in the
107
+ * Result region, because the folded Summary alone cannot say that nobody owns
108
+ * the Run any more (ticket 02).
109
+ */
110
+ function restoredDetails(run: RestoredRun): SnapshotDetails {
111
+ const { record } = run;
112
+ const state = `Run ${record.id} is ${record.state} (started ${record.startedAt}).`;
113
+ const alive =
114
+ record.state === "orphaned" && record.process !== null
115
+ ? ` Its process ${record.process.pid} is still running; stop it with yaag_stop.`
116
+ : "";
117
+ const detail = `${alive}${resumeHint(record.state, run.summary.artifact)}`;
118
+ const result = record.outcome?.kind === "fulfilled" ? `\n${record.outcome.result}` : "";
119
+ return { id: record.id, summary: run.summary, result: `${state}${detail}${result}` };
120
+ }
121
+
122
+ /**
123
+ * Names the Checkpoint an unowned Run left behind (ADR-0031). The Run
124
+ * republishes it at each Ask boundary, so it exists whenever the Run reached
125
+ * one, for a dead Run and for a still-running orphan alike.
126
+ */
127
+ function resumeHint(state: PersistedRunState, artifact: string | null): string {
128
+ if (artifact === null) return "";
129
+ if (state !== "interrupted" && state !== "orphaned") return "";
130
+ return ` Resume it from its checkpoint: ${artifact}.`;
131
+ }
132
+
98
133
  function snapshot(
99
134
  details: SnapshotDetails,
100
135
  now: () => number,
package/src/stop-tool.ts CHANGED
@@ -1,7 +1,8 @@
1
- import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
1
+ import type { AgentToolResult, ToolDefinition } from "@earendil-works/pi-coding-agent";
2
2
  import type { RunSummary } from "@yaag/runtime";
3
3
  import { Type } from "typebox";
4
- import type { RunRegistry } from "./run-registry.ts";
4
+ import { isProcessAlive } from "./process-liveness.ts";
5
+ import type { RestoredRun, RunRegistry } from "./run-registry.ts";
5
6
  import { toUsage } from "./usage.ts";
6
7
 
7
8
  /** What the stop result carries for rendering — never shown to the model. */
@@ -18,6 +19,9 @@ const DESCRIPTION = [
18
19
  "",
19
20
  "The Run's Agents are reaped, and the report says what it got through and what",
20
21
  "it spent before it stopped.",
22
+ "",
23
+ "An orphaned Run left behind by an earlier Host Session is stopped too: its",
24
+ "process group is killed, and the report holds its last persisted Summary.",
21
25
  ].join("\n");
22
26
 
23
27
  /** What a stopped Run got through and what it cost, from the fold (ADR-0006). */
@@ -62,6 +66,38 @@ function agentStateReport(agent: RunSummary["agents"][string]): string {
62
66
  }
63
67
  }
64
68
 
69
+ /**
70
+ * Ends a Run nobody owns any more. There is no reap ladder to run — its pipes
71
+ * belong to a dead Host Session — so the process group is killed outright
72
+ * (ADR-0008). The pid is re-probed first, because a record can name a pid that
73
+ * a different process has since inherited (ticket 02).
74
+ */
75
+ function stopOrphan(run: RestoredRun): AgentToolResult<StopDetails> {
76
+ const { record } = run;
77
+ if (record.state !== "orphaned" || record.process === null)
78
+ throw new Error(`yaag_stop: Run ${record.id} has already finished`);
79
+ if (!isProcessAlive(record.process))
80
+ throw new Error(`yaag_stop: Run ${record.id} is no longer running`);
81
+ killGroup(record.process.pid);
82
+ return {
83
+ content: [{ type: "text", text: stopReport(record.id, record.summary) }],
84
+ details: { summary: record.summary },
85
+ usage: toUsage(record.summary),
86
+ };
87
+ }
88
+
89
+ function killGroup(pid: number): void {
90
+ try {
91
+ process.kill(-pid, "SIGKILL");
92
+ } catch {
93
+ try {
94
+ process.kill(pid, "SIGKILL");
95
+ } catch {
96
+ // The process ended between the liveness probe and the signal.
97
+ }
98
+ }
99
+ }
100
+
65
101
  function toError(reason: unknown): Error {
66
102
  return reason instanceof Error ? reason : new Error(String(reason));
67
103
  }
@@ -82,10 +118,12 @@ export function createStopTool(
82
118
  description: DESCRIPTION,
83
119
  parameters,
84
120
  async execute(_id, params) {
85
- const status = registry.lookup(params.id);
121
+ const status = await registry.recall(params.id);
86
122
  switch (status.state) {
87
123
  case "unknown":
88
124
  throw new Error(`yaag_stop: no Run with id ${params.id}`);
125
+ case "restored":
126
+ return stopOrphan(status.run);
89
127
  case "finished":
90
128
  throw new Error(`yaag_stop: Run ${params.id} has already finished`);
91
129
  case "live": {