@yaag/extension 0.1.2 → 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 +7 -6
- package/src/cli-child.ts +28 -1
- package/src/index.ts +5 -2
- package/src/process-liveness.ts +51 -0
- package/src/run-details.ts +4 -2
- package/src/run-id.ts +24 -0
- package/src/run-record.ts +91 -0
- package/src/run-registry.ts +153 -16
- package/src/run-restore.ts +43 -0
- package/src/run-store.ts +103 -0
- package/src/run-tool.ts +22 -5
- package/src/runs-dir.ts +18 -0
- package/src/spawn-run.ts +3 -1
- package/src/status-tool.ts +39 -4
- package/src/stop-tool.ts +41 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yaag/extension",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -19,14 +19,15 @@
|
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
21
|
"typecheck": "tsc --noEmit",
|
|
22
|
-
"test": "for file in src/*.test.ts; do bun test \"$file\" || exit 1; done",
|
|
23
|
-
"test:e2e": "bun test e2e"
|
|
22
|
+
"test": "for file in src/*.test.ts; do bun test --timeout 30000 \"$file\" || exit 1; done",
|
|
23
|
+
"test:e2e": "bun test --timeout 30000 e2e"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@earendil-works/pi-tui": "^0.84.0",
|
|
27
|
-
"@yaag/
|
|
28
|
-
"@yaag/
|
|
29
|
-
"@yaag/
|
|
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
|
|
|
@@ -38,6 +40,7 @@ export function startCliChild(options: CliChildOptions): CliChild {
|
|
|
38
40
|
});
|
|
39
41
|
const stderr = new StderrTail();
|
|
40
42
|
const exited = observeExit(child);
|
|
43
|
+
releasePipesAfterExit(child);
|
|
41
44
|
const target: ReapTarget = {
|
|
42
45
|
exited,
|
|
43
46
|
endStdin: () => endStdin(child.stdin),
|
|
@@ -52,6 +55,7 @@ export function startCliChild(options: CliChildOptions): CliChild {
|
|
|
52
55
|
const eventStream = options.events === true ? child.stdio[3] : undefined;
|
|
53
56
|
return {
|
|
54
57
|
outcome,
|
|
58
|
+
pid: child.pid,
|
|
55
59
|
events: isReadable(eventStream) ? eventStream : undefined,
|
|
56
60
|
stop: () => void reap(target),
|
|
57
61
|
};
|
|
@@ -91,7 +95,30 @@ async function drain(stream: Readable | null, onChunk: (chunk: string) => void):
|
|
|
91
95
|
|
|
92
96
|
function observeExit(child: ReturnType<typeof spawn>): Promise<void> {
|
|
93
97
|
return new Promise((resolve, reject) => {
|
|
94
|
-
|
|
98
|
+
// "exit" (process death), not "close": "close" additionally waits for every
|
|
99
|
+
// stdio pipe to end, and a lost pipe EOF would park the Run forever.
|
|
100
|
+
child.on("exit", () => resolve());
|
|
95
101
|
child.on("error", reject);
|
|
96
102
|
});
|
|
97
103
|
}
|
|
104
|
+
|
|
105
|
+
/** How long an exited child's pipes may take to deliver buffered data and EOF. */
|
|
106
|
+
const PIPE_EOF_GRACE_MS = 2_000;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Failsafe for a lost pipe EOF: once the child process is dead, any of its
|
|
110
|
+
* pipes (stdout, stderr, the fd 3 Lifecycle Event stream) that has not ended
|
|
111
|
+
* within a bounded grace is destroyed, so the iterators that drain them finish
|
|
112
|
+
* instead of parking the Run's outcome — and the test process — forever.
|
|
113
|
+
*/
|
|
114
|
+
function releasePipesAfterExit(child: ReturnType<typeof spawn>): void {
|
|
115
|
+
child.once("exit", () => {
|
|
116
|
+
const timer = setTimeout(() => {
|
|
117
|
+
for (const stream of [child.stdout, child.stderr, child.stdio[3]]) {
|
|
118
|
+
if (isReadable(stream) && !stream.destroyed) stream.destroy();
|
|
119
|
+
}
|
|
120
|
+
}, PIPE_EOF_GRACE_MS);
|
|
121
|
+
// A housekeeping timer must never keep the host process alive.
|
|
122
|
+
timer.unref();
|
|
123
|
+
});
|
|
124
|
+
}
|
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
|
|
35
|
-
|
|
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
|
+
}
|
package/src/run-details.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/run-registry.ts
CHANGED
|
@@ -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
|
-
*
|
|
56
|
+
* Source of truth for every Run the Host Session can address.
|
|
34
57
|
*
|
|
35
|
-
* It
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/run-store.ts
ADDED
|
@@ -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 =
|
|
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
|
-
|
|
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 = {
|
|
243
|
-
|
|
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;
|
package/src/runs-dir.ts
ADDED
|
@@ -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
|
/**
|
package/src/status-tool.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
"
|
|
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.
|
|
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
|
|
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.
|
|
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": {
|