@yaag/extension 0.6.1 → 0.6.2

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.6.1",
3
+ "version": "0.6.2",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -24,9 +24,9 @@
24
24
  },
25
25
  "dependencies": {
26
26
  "@earendil-works/pi-tui": "^0.84.0",
27
- "@yaag/cli": "0.6.1",
28
- "@yaag/runtime": "0.6.1",
29
- "@yaag/tui": "0.6.1",
27
+ "@yaag/cli": "0.6.2",
28
+ "@yaag/runtime": "0.6.2",
29
+ "@yaag/tui": "0.6.2",
30
30
  "nanoid": "^6.0.1"
31
31
  },
32
32
  "peerDependencies": {
@@ -1,8 +1,9 @@
1
- import type { EndedRunSummary, RunSummary } from "@yaag/runtime";
1
+ import type { RunSummary, RunOutcome as SummaryOutcome } from "@yaag/runtime";
2
2
  import type { ProcessIdentity, RunOutcome } from "../process/index.ts";
3
3
  import { pickInlineResumeSource } from "./resume-source.ts";
4
4
  import { type RunLaunch, type RunRecord, startedRecord } from "./run-record.ts";
5
5
  import { restoreRecords } from "./run-restore.ts";
6
+ import { failedSummary, settledRecord } from "./run-settle-record.ts";
6
7
  import type { RunStore } from "./run-store.ts";
7
8
 
8
9
  export type { RunLaunch } from "./run-record.ts";
@@ -148,8 +149,30 @@ export class RunRegistry {
148
149
  }
149
150
 
150
151
  /**
151
- * `restoreRecords` already settled every stale `live` record, so this check
152
- * can never adopt a Run as live (ADR-0036).
152
+ * Persists the settled form of a Run read back from disk, and refreshes the
153
+ * retained entry so the next snapshot of this session shows the verdict.
154
+ *
155
+ * Only a `restored` Run settles here: a live Run settles through its own
156
+ * outcome, and a finished Run is history (ADR-0036), so either is refused
157
+ * rather than written over. A Run this session never listed stays unlisted —
158
+ * it is written, but not adopted.
159
+ */
160
+ async settleRestored(record: RunRecord, outcome: SummaryOutcome): Promise<RestoredRun> {
161
+ const known = this.#runs.get(record.id);
162
+ if (known !== undefined && known.state !== "restored")
163
+ throw new Error(`RunRegistry: Run ${record.id} is ${known.state}, not a restored Run`);
164
+ const settled = settledRecord({ record, outcome, now: this.#now() });
165
+ this.#records.set(settled.id, settled);
166
+ if (known !== undefined)
167
+ this.#runs.set(settled.id, { state: "restored", run: restored(settled) });
168
+ await this.#store?.save(settled);
169
+ return restored(settled);
170
+ }
171
+
172
+ /**
173
+ * `restoreRecords` already settled every stale `live` record, and every
174
+ * `orphaned` record whose process is gone, so this check can never adopt a
175
+ * Run as live (ADR-0036).
153
176
  */
154
177
  #adoptable(record: RunRecord): boolean {
155
178
  if (record.state === "orphaned" || record.state === "interrupted") return true;
@@ -228,7 +251,10 @@ export class RunRegistry {
228
251
  reject(id: string, reason: unknown): void {
229
252
  const record = this.#runs.get(id);
230
253
  if (record === undefined || record.state !== "live") return;
231
- this.#settle(id, failedSummary(record.run.summary), { kind: "rejected", reason });
254
+ this.#settle(id, failedSummary(record.run.summary, this.#now().getTime()), {
255
+ kind: "rejected",
256
+ reason,
257
+ });
232
258
  }
233
259
 
234
260
  /**
@@ -310,9 +336,3 @@ function persistedOutcome(outcome: RunSettlement): RunRecord["outcome"] {
310
336
  function errorMessage(reason: unknown): string {
311
337
  return reason instanceof Error ? reason.message : String(reason);
312
338
  }
313
-
314
- function failedSummary(summary: RunSummary): EndedRunSummary {
315
- if (summary.runState === "ended")
316
- return summary.ok ? { ...summary, outcome: "failed", ok: false } : summary;
317
- return { ...summary, runState: "ended", outcome: "failed", ok: false, endedAt: null };
318
- }
@@ -1,5 +1,6 @@
1
1
  import { isProcessAlive, type ProcessIdentity } from "../process/index.ts";
2
2
  import type { RunRecord } from "./run-record.ts";
3
+ import { settledRecord } from "./run-settle-record.ts";
3
4
  import type { RunStore } from "./run-store.ts";
4
5
 
5
6
  /** Decides whether the process a record names is still running. */
@@ -19,6 +20,11 @@ export interface RestoreOptions {
19
20
  * gone: its child is dead and the Run is `interrupted`, or its child still runs
20
21
  * without an owner and the Run is `orphaned`. Both re-writes are persisted, so
21
22
  * the verdict survives this session too (ticket 02).
23
+ *
24
+ * An `orphaned` record is probed again: its child can have ended since the
25
+ * session that orphaned it, and a dead one settles to `interrupted` with an end
26
+ * instant and an ended Summary. An orphan that still runs is left as it is, so
27
+ * a session start does not rewrite every record.
22
28
  */
23
29
  export async function restoreRecords(options: RestoreOptions): Promise<readonly RunRecord[]> {
24
30
  const isAlive = options.isAlive ?? isProcessAlive;
@@ -33,11 +39,12 @@ async function settleStale(
33
39
  now: () => Date,
34
40
  store: RunStore,
35
41
  ): Promise<RunRecord> {
36
- if (record.state !== "live") return record;
42
+ if (record.state !== "live" && record.state !== "orphaned") return record;
37
43
  const alive = record.process !== null && isAlive(record.process);
44
+ if (alive && record.state === "orphaned") return record;
38
45
  const settled: RunRecord = alive
39
46
  ? { ...record, state: "orphaned" }
40
- : { ...record, state: "interrupted", endedAt: now().toISOString() };
47
+ : settledRecord({ record, outcome: "interrupted", now: now() });
41
48
  await store.save(settled);
42
49
  return settled;
43
50
  }
@@ -0,0 +1,67 @@
1
+ import type { EndedRunSummary, RunOutcome, RunSummary } from "@yaag/runtime";
2
+ import type { RunRecord } from "./run-record.ts";
3
+
4
+ /**
5
+ * Marks a folded Summary ended, keeping every accounting fact it holds.
6
+ *
7
+ * An already-ended fold is history and is returned unchanged: a Run keeps the
8
+ * first outcome observed for it, the invariant the registry keeps when it
9
+ * retains a settlement. `endedAt` stays null because it is the `run_end.at`
10
+ * event fact and no such event was seen.
11
+ */
12
+ export function endedSummary(
13
+ summary: RunSummary,
14
+ outcome: RunOutcome,
15
+ endedAtMs: number,
16
+ ): EndedRunSummary {
17
+ if (summary.runState === "ended") return summary;
18
+ return {
19
+ ...summary,
20
+ runState: "ended",
21
+ outcome,
22
+ ok: outcome === "completed",
23
+ endedAt: null,
24
+ durationMs: durationOf(summary, endedAtMs),
25
+ };
26
+ }
27
+
28
+ /**
29
+ * The fold never synthesizes a duration from a clock (architecture §8), so the
30
+ * wall-clock value is supplied here, once, by the caller that settles the Run.
31
+ * Without it a settled Run reads `0s` instead of how long it really ran.
32
+ */
33
+ function durationOf(summary: RunSummary, endedAtMs: number): number {
34
+ if (summary.durationMs !== 0) return summary.durationMs;
35
+ if (summary.startedAt === null) return 0;
36
+ return Math.max(0, endedAtMs - summary.startedAt);
37
+ }
38
+
39
+ /** Marks a fold ended as failed, for a Run whose child rejected. */
40
+ export function failedSummary(summary: RunSummary, endedAtMs: number): EndedRunSummary {
41
+ if (summary.runState === "ended")
42
+ return summary.ok ? { ...summary, outcome: "failed", ok: false } : summary;
43
+ return endedSummary(summary, "failed", endedAtMs);
44
+ }
45
+
46
+ /**
47
+ * The settled form of a record no process is attached to any more.
48
+ *
49
+ * The persisted state is `interrupted`: no new state member is added, so an
50
+ * older yaag still reads the record. The verdict lives in the Summary fold —
51
+ * `stopped` when yaag_stop killed the group, `interrupted` when the process was
52
+ * already gone. The persisted outcome stays null, because the Run gave no exit
53
+ * code and no rejection reason.
54
+ */
55
+ export function settledRecord(options: {
56
+ readonly record: RunRecord;
57
+ readonly outcome: RunOutcome;
58
+ readonly now: Date;
59
+ }): RunRecord {
60
+ const { record, outcome, now } = options;
61
+ return {
62
+ ...record,
63
+ state: "interrupted",
64
+ endedAt: now.toISOString(),
65
+ summary: endedSummary(record.summary, outcome, now.getTime()),
66
+ };
67
+ }
@@ -1,7 +1,7 @@
1
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 { isProcessAlive } from "../process/index.ts";
4
+ import { isProcessAlive, type ProcessIdentity } from "../process/index.ts";
5
5
  import type { RestoredRun, RunRegistry } from "../record/index.ts";
6
6
  import { toUsage } from "../view/index.ts";
7
7
 
@@ -21,9 +21,22 @@ const DESCRIPTION = [
21
21
  "it spent before it stopped.",
22
22
  "",
23
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.",
24
+ "process group is killed, its record is settled, and the report holds its last",
25
+ "persisted Summary. If its process already ended, the record is settled too and",
26
+ "the report says the Run was no longer running.",
25
27
  ].join("\n");
26
28
 
29
+ /** Seams the tests replace, so no test kills a real process group. */
30
+ export interface StopToolOptions {
31
+ readonly isAlive?: (identity: ProcessIdentity) => boolean;
32
+ readonly kill?: (pid: number) => void;
33
+ }
34
+
35
+ interface StopDeps {
36
+ readonly isAlive: (identity: ProcessIdentity) => boolean;
37
+ readonly kill: (pid: number) => void;
38
+ }
39
+
27
40
  /** What a stopped Run got through and what it cost, from the fold (ADR-0006). */
28
41
  export function stopReport(id: string, summary: RunSummary): string {
29
42
  const agents = Object.keys(summary.agents).length;
@@ -71,18 +84,28 @@ function agentStateReport(agent: RunSummary["agents"][string]): string {
71
84
  * pipes belong to a dead Host Session — so the process group is killed outright
72
85
  * (ADR-0008). The pid is re-probed first, because a record can name a pid that
73
86
  * a different process has since inherited (ticket 02).
87
+ *
88
+ * The record is settled either way: a process that already ended is not an
89
+ * error, it only means the verdict is `interrupted` instead of `stopped`.
74
90
  */
75
- function stopOrphan(run: RestoredRun): AgentToolResult<StopDetails> {
91
+ async function stopOrphan(
92
+ run: RestoredRun,
93
+ registry: RunRegistry,
94
+ deps: StopDeps,
95
+ ): Promise<AgentToolResult<StopDetails>> {
76
96
  const { record } = run;
77
97
  if (record.state !== "orphaned" || record.process === null)
78
98
  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);
99
+ const alive = deps.isAlive(record.process);
100
+ if (alive) deps.kill(record.process.pid);
101
+ const settled = await registry.settleRestored(record, alive ? "stopped" : "interrupted");
102
+ const note = alive
103
+ ? ""
104
+ : `Run ${record.id} was no longer running; its record is settled as interrupted.\n`;
82
105
  return {
83
- content: [{ type: "text", text: stopReport(record.id, record.summary) }],
84
- details: { summary: record.summary },
85
- usage: toUsage(record.summary),
106
+ content: [{ type: "text", text: `${note}${stopReport(record.id, settled.summary)}` }],
107
+ details: { summary: settled.summary },
108
+ usage: toUsage(settled.summary),
86
109
  };
87
110
  }
88
111
 
@@ -111,7 +134,12 @@ function toError(reason: unknown): Error {
111
134
  */
112
135
  export function createStopTool(
113
136
  registry: RunRegistry,
137
+ options: StopToolOptions = {},
114
138
  ): ToolDefinition<typeof parameters, StopDetails> {
139
+ const deps: StopDeps = {
140
+ isAlive: options.isAlive ?? isProcessAlive,
141
+ kill: options.kill ?? killGroup,
142
+ };
115
143
  return {
116
144
  name: "yaag_stop",
117
145
  label: "Stop Run",
@@ -123,7 +151,7 @@ export function createStopTool(
123
151
  case "unknown":
124
152
  throw new Error(`yaag_stop: no Run with id ${params.id}`);
125
153
  case "restored":
126
- return stopOrphan(status.run);
154
+ return await stopOrphan(status.run, registry, deps);
127
155
  case "finished":
128
156
  throw new Error(`yaag_stop: Run ${params.id} has already finished`);
129
157
  case "live": {