pi-minimal-dispatch 0.0.0-stage → 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/CHANGELOG.md +41 -0
- package/LICENSE +21 -0
- package/README.md +189 -2
- package/docs/API.md +163 -0
- package/package.json +42 -5
- package/src/config.ts +96 -0
- package/src/derive.ts +219 -0
- package/src/events.ts +64 -0
- package/src/format.ts +37 -0
- package/src/index.ts +443 -0
- package/src/lane.ts +169 -0
package/src/derive.ts
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-minimal-dispatch derived-state core — manifest registry, process
|
|
3
|
+
* liveness, and lane state derivation. Pure Node builtins: importable and
|
|
4
|
+
* testable without the pi host. Contract: README.md + docs/API.md.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { type LaneEvent, type StreamMessage, assistantMessageEnd, messageText, parseEvents } from "./events.ts";
|
|
11
|
+
|
|
12
|
+
export type LaneRecord = {
|
|
13
|
+
name: string;
|
|
14
|
+
briefPath: string; // absolute
|
|
15
|
+
eventsPath: string;
|
|
16
|
+
errPath: string;
|
|
17
|
+
pid: number | null;
|
|
18
|
+
processStart: string | null; // `ps -o lstart=` captured at spawn
|
|
19
|
+
startedAt: string; // ISO, stamped at creation
|
|
20
|
+
deadlineSec: number;
|
|
21
|
+
model?: string;
|
|
22
|
+
tools?: string;
|
|
23
|
+
spawned: boolean;
|
|
24
|
+
spawnError?: string;
|
|
25
|
+
killedAt?: string; // tombstone — the only post-spawn write
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type Manifest = {
|
|
29
|
+
version: 1;
|
|
30
|
+
wave: string;
|
|
31
|
+
workDir: string;
|
|
32
|
+
createdAt: string;
|
|
33
|
+
lanes: LaneRecord[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type LaneFailureCause = "spawn" | "killed" | "deadline" | "crashed";
|
|
37
|
+
|
|
38
|
+
export type Usage = { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number };
|
|
39
|
+
|
|
40
|
+
export type LaneStatus = {
|
|
41
|
+
name: string;
|
|
42
|
+
state: "starting" | "running" | "settled" | "failed";
|
|
43
|
+
cause?: LaneFailureCause;
|
|
44
|
+
/** Set while a lane is alive past its deadline (leash misfire) — informational. */
|
|
45
|
+
overdue?: boolean;
|
|
46
|
+
turns: number;
|
|
47
|
+
usage: Usage;
|
|
48
|
+
stopReason?: string;
|
|
49
|
+
lastEventAgeSec: number | null;
|
|
50
|
+
spawnError?: string;
|
|
51
|
+
errTail?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// --- manifest --------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/** Path of a wave's manifest inside its workDir. */
|
|
57
|
+
export function manifestPathFor(workDir: string, wave: string): string {
|
|
58
|
+
return path.join(workDir, `${wave}-manifest.json`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Read and parse a wave manifest; throws with the path when absent or invalid. */
|
|
62
|
+
export function readManifest(workDir: string, wave: string): Manifest {
|
|
63
|
+
const p = manifestPathFor(workDir, wave);
|
|
64
|
+
if (!fs.existsSync(p)) throw new Error(`unknown wave '${wave}' (no manifest at ${p})`);
|
|
65
|
+
return JSON.parse(fs.readFileSync(p, "utf8")) as Manifest;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Atomically replace a wave manifest (pid-suffixed tmp, same-fs rename). */
|
|
69
|
+
export function writeManifestAtomic(m: Manifest): void {
|
|
70
|
+
const p = manifestPathFor(m.workDir, m.wave);
|
|
71
|
+
const tmp = `${p}.${process.pid}.tmp`;
|
|
72
|
+
fs.writeFileSync(tmp, JSON.stringify(m, null, "\t"));
|
|
73
|
+
fs.renameSync(tmp, p);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- process liveness ------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
/** Process start-time per `ps -o lstart=`; empty string means the pid is dead. */
|
|
79
|
+
export function psLstart(pid: number): string {
|
|
80
|
+
try {
|
|
81
|
+
return execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8" }).trim();
|
|
82
|
+
} catch {
|
|
83
|
+
return ""; // a dead pid makes ps exit nonzero — empty means dead, never an exception
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** PID alive AND its start-time still matches — a reused PID is a different process, never ours. */
|
|
88
|
+
export function pidAlive(pid: number, expectedStart: string | null): boolean {
|
|
89
|
+
let reachable = false;
|
|
90
|
+
try {
|
|
91
|
+
process.kill(pid, 0);
|
|
92
|
+
reachable = true;
|
|
93
|
+
} catch (e: any) {
|
|
94
|
+
if (e.code === "ESRCH") return false;
|
|
95
|
+
// EPERM: exists but not signalable — fall through to the start-time check
|
|
96
|
+
}
|
|
97
|
+
if (expectedStart === null) return reachable;
|
|
98
|
+
const actual = psLstart(pid);
|
|
99
|
+
return actual !== "" && actual === expectedStart;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// --- state derivation ------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
function emptyUsage(): Usage {
|
|
105
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function errTailFor(lane: LaneRecord): string | undefined {
|
|
109
|
+
if (!fs.existsSync(lane.errPath)) return undefined;
|
|
110
|
+
const err = fs.readFileSync(lane.errPath, "utf8").trim();
|
|
111
|
+
return err === "" ? undefined : err.split("\n").slice(-5).join("\n");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Derive a lane's status from its events file, PID liveness, and manifest facts. Never stored. */
|
|
115
|
+
export function deriveLane(lane: LaneRecord, now: number): LaneStatus {
|
|
116
|
+
const base: LaneStatus = {
|
|
117
|
+
name: lane.name,
|
|
118
|
+
state: "failed",
|
|
119
|
+
turns: 0,
|
|
120
|
+
usage: emptyUsage(),
|
|
121
|
+
lastEventAgeSec: null,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
if (!lane.spawned) return { ...base, cause: "spawn", spawnError: lane.spawnError };
|
|
125
|
+
|
|
126
|
+
const events = parseEvents(lane.eventsPath);
|
|
127
|
+
for (const e of events) {
|
|
128
|
+
if (e.type === "turn_start") base.turns++;
|
|
129
|
+
const message = assistantMessageEnd(e);
|
|
130
|
+
if (message !== null) {
|
|
131
|
+
const u = message.usage ?? {};
|
|
132
|
+
base.usage.input += u.input ?? 0;
|
|
133
|
+
base.usage.output += u.output ?? 0;
|
|
134
|
+
base.usage.cacheRead += u.cacheRead ?? 0;
|
|
135
|
+
base.usage.cacheWrite += u.cacheWrite ?? 0;
|
|
136
|
+
base.usage.cost += u.cost?.total ?? 0;
|
|
137
|
+
if (typeof message.stopReason === "string") base.stopReason = message.stopReason;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (fs.existsSync(lane.eventsPath)) {
|
|
141
|
+
base.lastEventAgeSec = Math.round((now - fs.statSync(lane.eventsPath).mtimeMs) / 100) / 10;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const pastDeadline = now > Date.parse(lane.startedAt) + lane.deadlineSec * 1000;
|
|
145
|
+
|
|
146
|
+
if (events.some((e) => e.type === "agent_settled")) return { ...base, state: "settled" };
|
|
147
|
+
|
|
148
|
+
const alive = lane.pid !== null && pidAlive(lane.pid, lane.processStart);
|
|
149
|
+
if (!alive) {
|
|
150
|
+
if (lane.killedAt) return { ...base, cause: "killed" };
|
|
151
|
+
if (pastDeadline) return { ...base, cause: "deadline" };
|
|
152
|
+
return { ...base, cause: "crashed", errTail: errTailFor(lane) };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
...base,
|
|
157
|
+
state: events.some((e) => e.type === "session") ? "running" : "starting",
|
|
158
|
+
overdue: pastDeadline,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Whether every lane of a wave has reached a terminal state (`settled` or `failed`). */
|
|
163
|
+
export function waveTerminal(lanes: LaneRecord[], now: number): boolean {
|
|
164
|
+
return lanes.every((lane) => {
|
|
165
|
+
const state = deriveLane(lane, now).state;
|
|
166
|
+
return state === "settled" || state === "failed";
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Poll a wave until every lane is terminal, `waitMs` elapses, or the signal
|
|
172
|
+
* aborts; returns the final statuses. Re-reads the manifest on every poll —
|
|
173
|
+
* the same derived truth as the status action, held open.
|
|
174
|
+
*/
|
|
175
|
+
export async function waitForTerminal(
|
|
176
|
+
workDir: string,
|
|
177
|
+
wave: string,
|
|
178
|
+
waitMs: number,
|
|
179
|
+
pollMs: number,
|
|
180
|
+
signal?: AbortSignal,
|
|
181
|
+
): Promise<LaneStatus[]> {
|
|
182
|
+
const deadline = Date.now() + waitMs;
|
|
183
|
+
for (;;) {
|
|
184
|
+
const lanes = readManifest(workDir, wave).lanes;
|
|
185
|
+
const now = Date.now();
|
|
186
|
+
if (waveTerminal(lanes, now) || now >= deadline || signal?.aborted) {
|
|
187
|
+
return lanes.map((lane) => deriveLane(lane, now));
|
|
188
|
+
}
|
|
189
|
+
// The sleep races the abort so a cancelled call returns at once, not at the next poll.
|
|
190
|
+
await new Promise<void>((resolve) => {
|
|
191
|
+
const delay = Math.min(pollMs, deadline - now);
|
|
192
|
+
if (signal === undefined) {
|
|
193
|
+
setTimeout(resolve, delay);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const timer = setTimeout(() => {
|
|
197
|
+
signal.removeEventListener("abort", onAbort);
|
|
198
|
+
resolve();
|
|
199
|
+
}, delay);
|
|
200
|
+
const onAbort = () => {
|
|
201
|
+
clearTimeout(timer);
|
|
202
|
+
resolve();
|
|
203
|
+
};
|
|
204
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Final assistant message text (concatenated text blocks of the last assistant message_end). */
|
|
210
|
+
export function finalAssistantText(events: LaneEvent[]): string {
|
|
211
|
+
let text = "";
|
|
212
|
+
for (const e of events) {
|
|
213
|
+
const message = assistantMessageEnd(e);
|
|
214
|
+
if (message !== null) text = messageText(message);
|
|
215
|
+
}
|
|
216
|
+
return text;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export { parseEvents };
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-minimal-dispatch lane event stream — typed parsing of a lane's
|
|
3
|
+
* `pi --mode json` JSONL output (see pi's JSON Event Stream docs).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
|
|
8
|
+
export type UsageDelta = {
|
|
9
|
+
input?: number;
|
|
10
|
+
output?: number;
|
|
11
|
+
cacheRead?: number;
|
|
12
|
+
cacheWrite?: number;
|
|
13
|
+
cost?: { total?: number };
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type ContentBlock = { type: string; text?: string; thinking?: string };
|
|
17
|
+
|
|
18
|
+
export type StreamMessage = {
|
|
19
|
+
role: string;
|
|
20
|
+
stopReason?: string;
|
|
21
|
+
usage?: UsageDelta;
|
|
22
|
+
content?: ContentBlock[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type LaneEvent = { type: string } & Record<string, unknown>;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parse a lane's JSONL event file. A trailing line lacking its newline is a
|
|
29
|
+
* SIGTERM-truncated write and is skipped; any other unparseable line is
|
|
30
|
+
* corruption and throws. Returns [] when the file does not exist yet.
|
|
31
|
+
*/
|
|
32
|
+
export function parseEvents(eventsPath: string): LaneEvent[] {
|
|
33
|
+
if (!fs.existsSync(eventsPath)) return [];
|
|
34
|
+
const raw = fs.readFileSync(eventsPath, "utf8");
|
|
35
|
+
if (raw === "") return [];
|
|
36
|
+
const lines = raw.endsWith("\n") ? raw.slice(0, -1).split("\n") : raw.split("\n").slice(0, -1);
|
|
37
|
+
const events: LaneEvent[] = [];
|
|
38
|
+
for (let i = 0; i < lines.length; i++) {
|
|
39
|
+
if (lines[i] === "") continue;
|
|
40
|
+
try {
|
|
41
|
+
events.push(JSON.parse(lines[i]) as LaneEvent);
|
|
42
|
+
} catch (e: any) {
|
|
43
|
+
throw new Error(`corrupt event line ${i + 1} in ${eventsPath}: ${e.message}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return events;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Narrow an event to an assistant `message_end`, else null. */
|
|
50
|
+
export function assistantMessageEnd(e: LaneEvent): StreamMessage | null {
|
|
51
|
+
if (e.type !== "message_end") return null;
|
|
52
|
+
const message = (e as { message?: StreamMessage }).message;
|
|
53
|
+
if (message === undefined || message.role !== "assistant") return null;
|
|
54
|
+
return message;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Concatenated text blocks of an assistant message; empty when it has none. */
|
|
58
|
+
export function messageText(message: StreamMessage): string {
|
|
59
|
+
const blocks = Array.isArray(message.content) ? message.content : [];
|
|
60
|
+
return blocks
|
|
61
|
+
.filter((b) => b.type === "text" && typeof b.text === "string")
|
|
62
|
+
.map((b) => b.text)
|
|
63
|
+
.join("\n");
|
|
64
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-minimal-dispatch presentation — human-facing text for lane status,
|
|
3
|
+
* shared by the `lanes` tool, the `/lanes` command, and the statusbar.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { LaneStatus } from "./derive.ts";
|
|
7
|
+
|
|
8
|
+
/** One status-table line per lane (two-space indent, err tail four-space indented). */
|
|
9
|
+
export function formatLaneLine(s: LaneStatus): string {
|
|
10
|
+
const bits = [s.name, s.state];
|
|
11
|
+
if (s.cause) bits.push(`(${s.cause}${s.spawnError ? `: ${s.spawnError}` : ""})`);
|
|
12
|
+
if (s.overdue) bits.push("OVERDUE");
|
|
13
|
+
bits.push(s.turns === 1 ? "1 turn" : `${s.turns} turns`);
|
|
14
|
+
bits.push(`tok ${s.usage.input}in/${s.usage.output}out, $${s.usage.cost.toFixed(4)}`);
|
|
15
|
+
if (s.stopReason !== undefined) bits.push(`stop:${s.stopReason}`);
|
|
16
|
+
if (s.lastEventAgeSec !== null) bits.push(`last ${s.lastEventAgeSec}s ago`);
|
|
17
|
+
return ` ${bits.join(" · ")}` + (s.errTail ? `\n err: ${s.errTail.replace(/\n/g, "\n ")}` : "");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Full status table for one wave. */
|
|
21
|
+
export function formatWaveStatus(wave: string, statuses: LaneStatus[]): string {
|
|
22
|
+
return `wave '${wave}':\n${statuses.map(formatLaneLine).join("\n")}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Footer summary across tracked waves; undefined when nothing is in flight — clear the status entry. */
|
|
26
|
+
export function formatStatusBarSummary(statuses: LaneStatus[]): string | undefined {
|
|
27
|
+
let running = 0;
|
|
28
|
+
const done: Record<string, number> = {};
|
|
29
|
+
for (const s of statuses) {
|
|
30
|
+
if (s.state === "running" || s.state === "starting") running++;
|
|
31
|
+
else done[s.state] = (done[s.state] ?? 0) + 1;
|
|
32
|
+
}
|
|
33
|
+
if (running === 0) return undefined;
|
|
34
|
+
const parts = [`${running} running`];
|
|
35
|
+
for (const [k, v] of Object.entries(done).sort()) if (v > 0) parts.push(`${v} ${k}`);
|
|
36
|
+
return `lanes: ${parts.join(" · ")}`;
|
|
37
|
+
}
|