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/CHANGELOG.md +83 -0
- package/LICENSE +661 -0
- package/README.md +530 -2
- package/bin/agents-pod.mjs +15 -0
- package/bin/ts-loader.mjs +66 -0
- package/bin/wrapper.mjs +12 -0
- package/index.mjs +11 -0
- package/package.json +39 -4
- package/src/agents-store.ts +88 -0
- package/src/cli.ts +1864 -0
- package/src/extension/handoff.ts +57 -0
- package/src/extension/index.ts +34 -0
- package/src/extension/management.ts +149 -0
- package/src/extension/self.ts +80 -0
- package/src/inbox.ts +221 -0
- package/src/listeners.ts +84 -0
- package/src/liveness.ts +191 -0
- package/src/locks.ts +74 -0
- package/src/notify/config.ts +125 -0
- package/src/notify/dispatch.ts +63 -0
- package/src/notify/email.ts +72 -0
- package/src/notify/format.ts +42 -0
- package/src/notify/system.ts +79 -0
- package/src/notify/types.ts +96 -0
- package/src/notify/webhook.ts +39 -0
- package/src/outbox.ts +294 -0
- package/src/paths.ts +67 -0
- package/src/projects.ts +49 -0
- package/src/registry.ts +103 -0
- package/src/sources.ts +278 -0
- package/src/workspace.ts +131 -0
- package/src/wrapper.ts +533 -0
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
|
+
}
|