agents-pod 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/src/wrapper.ts ADDED
@@ -0,0 +1,533 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import {
4
+ appendFile,
5
+ copyFile,
6
+ mkdir,
7
+ open,
8
+ readdir,
9
+ readFile,
10
+ rm,
11
+ stat,
12
+ writeFile,
13
+ type FileHandle,
14
+ } from "node:fs/promises";
15
+ import { join } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import { agentTypeRefusal, readAgents, resolveAgentDefinition } from "./agents-store.ts";
18
+ import {
19
+ currentBootTime,
20
+ psStart,
21
+ type WrapperRecord,
22
+ } from "./liveness.ts";
23
+ import { podDir } from "./paths.ts";
24
+ import { flushOutbox } from "./outbox.ts";
25
+ import { sleep } from "./locks.ts";
26
+ import {
27
+ readPods,
28
+ withRegistryLock,
29
+ writePods,
30
+ type PodRecord,
31
+ type WakeUpOutcome,
32
+ } from "./registry.ts";
33
+ import { snapshotDocs } from "./workspace.ts";
34
+
35
+ /** The heartbeat interval: the mtime is the liveness signal (ADR 0017). */
36
+ const HEARTBEAT_MS = 30_000;
37
+
38
+ /**
39
+ * The cancel grace (ADR 0017): SIGTERM first (exit 143 is `cancelled`, ADR
40
+ * 0009), SIGKILL after ten seconds — enough to finish a line, not a thought.
41
+ */
42
+ export const CANCEL_GRACE_MS = 10_000;
43
+
44
+ /**
45
+ * Tail semantics, not a state machine (ADR 0009): at exit only the last lines
46
+ * of the event stream can change the verdict, and the settled marker and the
47
+ * final assistant turn are always among them.
48
+ */
49
+ const EVENTS_TAIL_LINES = 8;
50
+
51
+ function fail(message: string): never {
52
+ console.error(`wrapper: ${message}`);
53
+ process.exit(2);
54
+ }
55
+
56
+ export interface ComposedPrompt {
57
+ preamble: string;
58
+ goal: string;
59
+ full: string;
60
+ }
61
+
62
+ /**
63
+ * The wake-up prompt in the fixed order (ADR 0012): Pod Contract preamble,
64
+ * then Role Instructions (empty for the default agent), then the Goal with the
65
+ * pointer block (ADR 0008: pointers, not inlines).
66
+ */
67
+ export function composePrompt(goal: string, docsDir: string, roleInstructions = ""): ComposedPrompt {
68
+ const preamble = [
69
+ "You are one wake-up inside an agents-pod. Work only from these documents:",
70
+ `1. ${join(docsDir, "HANDOFF.md")} — your predecessor's handoff; start where it ended.`,
71
+ `2. ${join(docsDir, "TASKS.md")} — the plan and its checkboxes.`,
72
+ `3. ${join(docsDir, "DECISIONS.md")} — the why behind the plan.`,
73
+ "When this window's work is done, end with pod_handoff (status: rotating or done) so your successor starts clean. Write-then-exit is the protocol: an exit without a fresh handoff counts as a failure.",
74
+ ].join("\n");
75
+ const goalBlock = `Goal: ${goal}`;
76
+ const sections = [preamble, roleInstructions.trim(), goalBlock].filter(
77
+ (section) => section.length > 0,
78
+ );
79
+ return { preamble, goal, full: sections.join("\n\n") };
80
+ }
81
+
82
+ /** An explicit binary override resolves before $PATH (ADR 0015). */
83
+ export function resolveAgentBinary(env: NodeJS.ProcessEnv): string {
84
+ return env.AGENTS_POD_PI_BIN?.trim() || "pi";
85
+ }
86
+
87
+ async function nextWakeupNumber(podRoot: string): Promise<number> {
88
+ const dir = join(podRoot, "wakeups");
89
+ const entries = await readdir(dir).catch(() => []);
90
+ const numbers = entries
91
+ .map((name) => Number.parseInt(name, 10))
92
+ .filter((n) => Number.isInteger(n));
93
+ return numbers.length > 0 ? Math.max(...numbers) + 1 : 1;
94
+ }
95
+
96
+ /** The one shape the wrapper reads out of pi's JSONL stream (ADR 0009). */
97
+ interface EventEnvelope {
98
+ type?: string;
99
+ aborted?: boolean;
100
+ message?: { role?: string; content?: unknown; stopReason?: string };
101
+ }
102
+
103
+ function parseEvent(line: string): EventEnvelope | undefined {
104
+ try {
105
+ const parsed: unknown = JSON.parse(line);
106
+ if (typeof parsed !== "object" || parsed === null) return undefined;
107
+ return parsed as EventEnvelope;
108
+ } catch {
109
+ return undefined; // a torn or foreign line never breaks the wake-up
110
+ }
111
+ }
112
+
113
+ /**
114
+ * The wrapper-extracted assistant text (ADR 0009's dual logs): only the
115
+ * assistant's `message_end` is human-facing; everything else stays machine
116
+ * data in events.jsonl. The user echo and the bulky agent_end are skipped.
117
+ */
118
+ function assistantTextOf(event: EventEnvelope): string | undefined {
119
+ if (event.type !== "message_end" || event.message?.role !== "assistant") {
120
+ return undefined;
121
+ }
122
+ const content = event.message.content;
123
+ if (!Array.isArray(content)) return undefined;
124
+ const texts = content.flatMap((part: unknown) => {
125
+ if (typeof part !== "object" || part === null) return [];
126
+ const { type, text } = part as { type?: unknown; text?: unknown };
127
+ return type === "text" && typeof text === "string" ? [text] : [];
128
+ });
129
+ return texts.length > 0 ? texts.join("") : undefined;
130
+ }
131
+
132
+ /** What the event stream says about how the agent settled (ADR 0009 layer 2). */
133
+ interface EventFacts {
134
+ /** Non-blank lines in the whole file: zero means the agent never spoke. */
135
+ lines: number;
136
+ /** Whether an `agent_settled` marker appeared in the tail. */
137
+ settled: boolean;
138
+ /** Its `aborted` flag, when the adapter reported one. */
139
+ aborted?: boolean;
140
+ /** The last assistant turn's `stopReason`, when one was reported. */
141
+ stopReason?: string;
142
+ }
143
+
144
+ async function observeEvents(eventsFile: string): Promise<EventFacts> {
145
+ const body = await readFile(eventsFile, "utf8").catch(() => "");
146
+ const all = body.split("\n").filter((line) => line.trim().length > 0);
147
+ const facts: EventFacts = { lines: all.length, settled: false };
148
+ for (const line of all.slice(-EVENTS_TAIL_LINES)) {
149
+ const event = parseEvent(line);
150
+ if (!event) continue;
151
+ if (event.type === "agent_settled") {
152
+ facts.settled = true;
153
+ if (typeof event.aborted === "boolean") facts.aborted = event.aborted;
154
+ } else if (event.type === "message_end" && event.message?.role === "assistant") {
155
+ if (typeof event.message.stopReason === "string") {
156
+ facts.stopReason = event.message.stopReason;
157
+ }
158
+ }
159
+ }
160
+ return facts;
161
+ }
162
+
163
+ export interface Adjudication {
164
+ outcome: WakeUpOutcome;
165
+ /** The raw signals behind the verdict, for the pod record (ADR 0009). */
166
+ detail?: string;
167
+ }
168
+
169
+ /** ADR 0009 layer 1: only a handoff written during this wake-up counts. */
170
+ async function freshDeclaration(
171
+ docsDir: string,
172
+ wakeStart: number,
173
+ ): Promise<"more" | "done" | undefined> {
174
+ const handoff = join(docsDir, "HANDOFF.md");
175
+ const info = await stat(handoff).catch(() => undefined);
176
+ if (!info || info.mtimeMs <= wakeStart) return undefined;
177
+ const firstLine = (await readFile(handoff, "utf8")).split("\n", 1)[0]!.trim();
178
+ const declared = /^status:\s*(rotating|done)\s*$/.exec(firstLine);
179
+ if (!declared) return undefined;
180
+ return declared[1] === "done" ? "done" : "more";
181
+ }
182
+
183
+ /**
184
+ * Three-layer adjudication, higher overrides lower (ADR 0009): the fresh
185
+ * Handoff declaration first, the wrapper's event observation next, raw exit
186
+ * facts last. A clean exit without a fresh handoff is `failed` — write-then-
187
+ * exit has teeth. A declaration always wins; when the events disagree the
188
+ * outcome is unchanged and the discrepancy is recorded instead.
189
+ */
190
+ async function adjudicate(
191
+ docsDir: string,
192
+ wakeStart: number,
193
+ eventsFile: string,
194
+ exitCode: number,
195
+ ): Promise<Adjudication> {
196
+ const events = await observeEvents(eventsFile);
197
+ const declared = await freshDeclaration(docsDir, wakeStart);
198
+
199
+ if (declared) {
200
+ const discrepancies: string[] = [];
201
+ if (events.aborted === true) discrepancies.push("agent settled aborted");
202
+ if (events.stopReason !== undefined && events.stopReason !== "stop") {
203
+ discrepancies.push(`last stop reason ${events.stopReason}`);
204
+ }
205
+ return discrepancies.length === 0
206
+ ? { outcome: declared }
207
+ : { outcome: declared, detail: `declared ${declared} but ${discrepancies.join("; ")}` };
208
+ }
209
+
210
+ if (events.lines === 0) {
211
+ return { outcome: "failed", detail: `no fresh handoff (no events, exit ${exitCode})` };
212
+ }
213
+ return {
214
+ outcome: "failed",
215
+ detail: `no fresh handoff (settled: ${events.settled}, exit ${exitCode})`,
216
+ };
217
+ }
218
+
219
+
220
+ /** Twelve hex chars of sha256: enough to attribute a wake-up's instructions (ADR 0012). */
221
+ function instructionsHash(instructions: string): string {
222
+ return createHash("sha256").update(instructions).digest("hex").slice(0, 12);
223
+ }
224
+
225
+ /**
226
+ * One pod's read-modify-write belongs wholly inside the registry lock (ADR
227
+ * 0018). A wrapper that cannot record its result must fail loudly and keep its
228
+ * identity: reconcile may call that wake-up lost, but its outcome is never
229
+ * silently discarded.
230
+ */
231
+ async function updatePodRecord(
232
+ podId: string,
233
+ podRoot: string,
234
+ n: number,
235
+ mutate: (record: PodRecord) => void,
236
+ ): Promise<boolean> {
237
+ for (let attempt = 0; attempt < 3; attempt += 1) {
238
+ const recorded = await withRegistryLock(async () => {
239
+ const pods = await readPods();
240
+ const record = pods.find((candidate) => candidate.id === podId);
241
+ if (record) {
242
+ mutate(record);
243
+ await writePods(pods);
244
+ }
245
+ return true;
246
+ });
247
+ if (recorded !== undefined) return true;
248
+ if (attempt < 2) await sleep(100 * 2 ** attempt);
249
+ }
250
+ await appendFile(
251
+ join(podRoot, "output.log"),
252
+ `wrapper: registry lock unavailable; wake-up ${n} outcome not recorded\n`,
253
+ );
254
+ return false;
255
+ }
256
+
257
+ /**
258
+ * Records a wake-up that never reached an agent. The outcome is a fact; the
259
+ * policy that follows from it — backoff, streak, park — stays reconcile's.
260
+ */
261
+ async function recordUnresolved(
262
+ podId: string,
263
+ podRoot: string,
264
+ n: number,
265
+ what: string,
266
+ ): Promise<boolean> {
267
+ return updatePodRecord(podId, podRoot, n, (record) => {
268
+ record.wakeUps.push({ n, outcome: "failed", detail: what });
269
+ record.state = "idle";
270
+ });
271
+ }
272
+
273
+ export async function runWrapper(argv: string[]): Promise<number> {
274
+ // `--warm` is a wrapper flag, not a positional (ADR 0011): the agent id is
275
+ // the first word that is not a flag.
276
+ const warm = argv.includes("--warm");
277
+ const [podId, agentWord] = argv.filter((word) => !word.startsWith("--"));
278
+ if (!podId) fail("usage: wrapper <podId> [agentId] [--warm]");
279
+
280
+ const pod = (await readPods()).find((p) => p.id === podId);
281
+ if (!pod) fail(`unknown pod ${podId}`);
282
+
283
+ // The CLI refuses an illegal warm at the surface; the wrapper repeats the
284
+ // check so a hand-built argv cannot resume a session that never crashed
285
+ // (ADR 0011).
286
+ const lastWake = pod.wakeUps.at(-1);
287
+ if (warm && lastWake?.outcome !== "lost") {
288
+ fail(`--warm requires the last outcome to be lost (last: ${lastWake?.outcome ?? "none"})`);
289
+ }
290
+
291
+ const podRoot = podDir(pod.project, podId);
292
+ const n = await nextWakeupNumber(podRoot);
293
+ const docsDir = join(podRoot, "docs");
294
+
295
+ // The wake names its agent; the wrapper resolves it against the store, and
296
+ // always the latest definition — nothing is pinned between wake-ups
297
+ // (ADR 0012). An unresolvable id fails the wake-up, not the pod: the pod
298
+ // returns to idle and reconcile's streak machinery takes it from there.
299
+ const agentId = agentWord ?? pod.policy?.agent ?? "default";
300
+ const agent = resolveAgentDefinition(await readAgents(), agentId);
301
+ if (!agent) {
302
+ return (await recordUnresolved(podId, podRoot, n, "agent resolution failed")) ? 0 : 1;
303
+ }
304
+
305
+ // The second seat of the Pi-only guard (ADR 0023, agents-store.ts): the CLI refuses an
306
+ // unsupported --type at `agent add`, and this catches a definition that got
307
+ // in anyway — a store written by an older build, or a hand-edited file. A
308
+ // failed wake-up returns the pod to idle and the streak machinery takes it
309
+ // from there; it never silently runs pi in claude's name.
310
+ const refusal = agentTypeRefusal(agent.type);
311
+ if (refusal !== undefined) {
312
+ return (await recordUnresolved(
313
+ podId,
314
+ podRoot,
315
+ n,
316
+ `agent ${agent.id} has type ${agent.type}: ${refusal}`,
317
+ ))
318
+ ? 0
319
+ : 1;
320
+ }
321
+
322
+ const wakeupDir = join(podRoot, "wakeups", String(n));
323
+ await mkdir(wakeupDir, { recursive: true });
324
+
325
+ // The model chain: the pod's spawn pin beats the definition's model, which
326
+ // beats the adapter's default (ADR 0012).
327
+ const model = pod.policy?.model ?? agent.model;
328
+
329
+ // Every wake-up is attributable after the fact: the resolved definition is
330
+ // snapshotted beside its handoff (ADR 0012). A warm wake-up says so, so a
331
+ // resumed session is distinguishable from a fresh one after the fact.
332
+ const snapshot = {
333
+ id: agent.id,
334
+ type: agent.type,
335
+ model: model ?? null,
336
+ instructionsHash: instructionsHash(agent.instructions),
337
+ ...(warm ? { warm: true } : {}),
338
+ };
339
+ await writeFile(join(wakeupDir, "agent.json"), `${JSON.stringify(snapshot, null, 2)}\n`);
340
+
341
+ const prompt = composePrompt(pod.goal, docsDir, agent.instructions);
342
+ // Warm is session-level adoption (ADR 0011, 0020): the wake-up is still n+1
343
+ // with its own directory, wrapper, and outcome, but the agent opens the
344
+ // lost wake-up's session file — pi continues that conversation — instead of
345
+ // a fresh one. `n` itself is untouched, so the directories still count
346
+ // wake-ups honestly.
347
+ const sessionFile =
348
+ warm && lastWake
349
+ ? join(podRoot, "wakeups", String(lastWake.n), "session.jsonl")
350
+ : join(wakeupDir, "session.jsonl");
351
+ const heartbeatFile = join(podRoot, "heartbeat");
352
+ const outputLog = join(podRoot, "output.log");
353
+ const eventsFile = join(wakeupDir, "events.jsonl");
354
+ const wakeStart = Date.now();
355
+
356
+ // Cancel is a signal, not a queue (ADR 0003): trap SIGTERM, forward it to
357
+ // the agent, let ADR 0017's grace expire, then kill. `cancel` signals the
358
+ // whole group — the wrapper leads it — so the trap must be installed before
359
+ // the identity record tells anyone where to signal.
360
+ let cancelRequested = false;
361
+ let killedAfterGrace = false;
362
+ let agentProcess: ChildProcess | undefined;
363
+ let graceTimer: ReturnType<typeof setTimeout> | undefined;
364
+ const onSigterm = (): void => {
365
+ if (cancelRequested) return; // a repeated signal changes nothing
366
+ cancelRequested = true;
367
+ agentProcess?.kill("SIGTERM");
368
+ graceTimer = setTimeout(() => {
369
+ killedAfterGrace = true;
370
+ agentProcess?.kill("SIGKILL");
371
+ }, CANCEL_GRACE_MS);
372
+ };
373
+ process.on("SIGTERM", onSigterm);
374
+
375
+ // The wrapper's identity (ADR 0003): written before the agent is spawned, so
376
+ // a running wake-up can be probed by pid and process group, and removed only
377
+ // after the outcome is recorded — a missing record with state active is a
378
+ // ghost reconcile will mark lost.
379
+ //
380
+ // The spawn-time facts (ADR 0020) make the identity verifiable: `pidStart`
381
+ // is read once here (the raw `ps` rendering, compared as a string, never
382
+ // parsed) and `bootTime` is this wall-clock boot moment, so a later killer
383
+ // can prove the pid it holds is still this wrapper on the same machine.
384
+ const wrapperRecordFile = join(podRoot, "wrapper.json");
385
+ const pidStart = await psStart(process.pid);
386
+ // The progress baseline (ADR 0019): the documents as this wake-up found
387
+ // them. Reconcile compares it at verdict time; the pod log is never consulted.
388
+ const docs = await snapshotDocs(docsDir);
389
+ const identity: WrapperRecord = {
390
+ pid: process.pid,
391
+ pgid: process.pid,
392
+ wakeup: n,
393
+ startedAt: wakeStart,
394
+ bootTime: currentBootTime(),
395
+ ...(pidStart === undefined ? {} : { pidStart }),
396
+ docs,
397
+ };
398
+ await writeFile(wrapperRecordFile, `${JSON.stringify(identity, null, 2)}\n`);
399
+
400
+ const log = await open(outputLog, "a");
401
+ await log.write(`\n--- wake-up ${n} ${new Date().toISOString()} ---\n`);
402
+ // Created before the spawn: an empty stream is itself a fact ("the agent
403
+ // never emitted an event"), and adjudication reads it that way.
404
+ const eventsLog = await open(eventsFile, "a");
405
+
406
+ // Writes are serialized so the header, the extracted text, and stderr keep
407
+ // their arrival order inside each log (ADR 0009's dual logs).
408
+ let writes: Promise<unknown> = Promise.resolve();
409
+ const queue = (handle: FileHandle, text: string): void => {
410
+ writes = writes.then(() => handle.write(text));
411
+ };
412
+
413
+ // One JSON object per line is the adapter contract (ADR 0009); a torn last
414
+ // line is held back until close, and blank lines carry no event.
415
+ let pending = "";
416
+ const receiveLine = (line: string): void => {
417
+ if (line.trim().length === 0) return;
418
+ queue(eventsLog, `${line}\n`);
419
+ const event = parseEvent(line);
420
+ const text = event ? assistantTextOf(event) : undefined;
421
+ if (text !== undefined) {
422
+ queue(log, text.endsWith("\n") ? text : `${text}\n`);
423
+ }
424
+ };
425
+
426
+ const touch = () => writeFile(heartbeatFile, `${Date.now()}\n`);
427
+ await touch();
428
+ const beat = setInterval(touch, HEARTBEAT_MS);
429
+
430
+ const child = spawn(
431
+ resolveAgentBinary(process.env),
432
+ [
433
+ "--print",
434
+ "--mode",
435
+ "json",
436
+ "--session",
437
+ sessionFile,
438
+ ...(model === undefined ? [] : ["--model", model]),
439
+ prompt.full,
440
+ ],
441
+ {
442
+ cwd: pod.project,
443
+ env: {
444
+ ...process.env,
445
+ AGENTS_POD_POD_ID: podId,
446
+ AGENTS_POD_WAKEUP_N: String(n),
447
+ AGENTS_POD_DOCS_DIR: docsDir,
448
+ },
449
+ stdio: ["ignore", "pipe", "pipe"],
450
+ },
451
+ );
452
+ agentProcess = child;
453
+ // The group signal can land between the identity write and this spawn; the
454
+ // child would have missed it, so the forward is repeated once it exists.
455
+ if (cancelRequested) child.kill("SIGTERM");
456
+
457
+ // Event mode by default (ADR 0009): stdout is JSONL, so the wrapper splits
458
+ // it per line instead of forwarding it — raw to events.jsonl, assistant
459
+ // text to output.log. stderr is human-facing noise and stays as-is.
460
+ child.stdout.setEncoding("utf8");
461
+ child.stdout.on("data", (chunk: string) => {
462
+ pending += chunk;
463
+ const lines = pending.split("\n");
464
+ pending = lines.pop() ?? "";
465
+ for (const line of lines) receiveLine(line);
466
+ });
467
+ child.stderr.setEncoding("utf8");
468
+ child.stderr.on("data", (chunk: string) => queue(log, chunk));
469
+
470
+ const code: number = await new Promise((resolve) => {
471
+ child.on("error", (error) => fail(`agent binary failed: ${error.message}`));
472
+ child.on("close", (exitCode) => {
473
+ if (pending.trim().length > 0) receiveLine(pending);
474
+ pending = "";
475
+ resolve(exitCode ?? -1);
476
+ });
477
+ });
478
+
479
+ clearInterval(beat);
480
+ await writes;
481
+ await log.close();
482
+ await eventsLog.close();
483
+ clearTimeout(graceTimer);
484
+
485
+ // A cancelled wake-up is not adjudicated (ADR 0009): `cancelled` has an
486
+ // owner, and its detail is the wind-down, not the agent's last words. The
487
+ // pod is terminal, so reconcile never walks it back to a wake (ADR 0011).
488
+ if (cancelRequested) {
489
+ const recorded = await updatePodRecord(podId, podRoot, n, (record) => {
490
+ // A reconcile kill already adjudicated this wake-up (ADRs 0017, 0019):
491
+ // its intent was written before the signal, so the wrapper adds no
492
+ // second, contradictory outcome — whether the cause was progressless or
493
+ // a wedged kill. A human cancel has no intent and records exactly as
494
+ // before.
495
+ if (record.killIntent?.n !== n) {
496
+ record.wakeUps.push(
497
+ killedAfterGrace
498
+ ? { n, outcome: "cancelled", detail: "killed after grace" }
499
+ : { n, outcome: "cancelled" },
500
+ );
501
+ record.state = "cancelled";
502
+ }
503
+ });
504
+ if (!recorded) return 1;
505
+ // The identity outlives its purpose only until the outcome is recorded.
506
+ await rm(wrapperRecordFile, { force: true });
507
+ return 0;
508
+ }
509
+
510
+ // The wrapper records facts (ADR 0011): snapshot the handoff, adjudicate the
511
+ // outcome, and move the pod on. Policy — backoff, park — is reconcile's.
512
+ await copyFile(join(docsDir, "HANDOFF.md"), join(wakeupDir, "handoff.md")).catch(
513
+ () => undefined,
514
+ );
515
+ const { outcome, detail } = await adjudicate(docsDir, wakeStart, eventsFile, code);
516
+ // Delivery holds the active state until its bounded flush is over; a
517
+ // concurrent pass must not wake a second wrapper over the same outbox
518
+ // (ADR 0025). Failures are logged inside the flush and never change outcome.
519
+ await flushOutbox(podId, pod.project, n);
520
+ const recorded = await updatePodRecord(podId, podRoot, n, (record) => {
521
+ record.wakeUps.push(detail === undefined ? { n, outcome } : { n, outcome, detail });
522
+ record.state = outcome === "done" ? "done" : "idle";
523
+ });
524
+ if (!recorded) return 1;
525
+ // The identity outlives its purpose only until the outcome is recorded.
526
+ await rm(wrapperRecordFile, { force: true });
527
+ return 0;
528
+ }
529
+
530
+ /** Located next to the CLI's own bin so installed and repo layouts both work. */
531
+ export function wrapperScript(): string {
532
+ return fileURLToPath(new URL("../bin/wrapper.mjs", import.meta.url));
533
+ }