agents-pod 0.1.0 → 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/sources.ts ADDED
@@ -0,0 +1,278 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
4
+ import { dirname } from "node:path";
5
+ import type { BadInboxLine, InboxItem } from "./inbox.ts";
6
+ import type { ListenerRecord } from "./listeners.ts";
7
+ import { sourcesFile } from "./paths.ts";
8
+
9
+ /** A Source outlives its Listeners; its opaque cursor lives beside them (ADR 0024). */
10
+ export interface SourceRecord {
11
+ name: string;
12
+ cmd: string;
13
+ timeoutMs: number;
14
+ cursor?: string;
15
+ /** The previous successful drain is the claim-retention horizon (ADR 0024). */
16
+ lastDrainAt?: number;
17
+ }
18
+
19
+ export interface SourceDrain {
20
+ name: string;
21
+ items: InboxItem[];
22
+ bad: BadInboxLine[];
23
+ cursor?: string;
24
+ /** Successful drains carry their generation and the preceding one's boundary. */
25
+ drainedAt?: number;
26
+ previousDrainAt?: number;
27
+ /** A failed drain contributes nothing and leaves the cursor untouched. */
28
+ error?: string;
29
+ }
30
+
31
+ /** One MiB is the whole successful stdout budget (ADR 0024). */
32
+ export const DRAIN_STDOUT_CAP = 1024 * 1024;
33
+
34
+ export async function readSources(): Promise<SourceRecord[]> {
35
+ try {
36
+ const parsed: unknown = JSON.parse(await readFile(sourcesFile(), "utf8"));
37
+ return Array.isArray(parsed) ? (parsed as SourceRecord[]) : [];
38
+ } catch (error) {
39
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
40
+ throw error;
41
+ }
42
+ }
43
+
44
+ /** The cursor's own atomic write, separate from listeners.json (ADR 0024). */
45
+ export async function writeSources(sources: SourceRecord[]): Promise<void> {
46
+ const file = sourcesFile();
47
+ await mkdir(dirname(file), { recursive: true });
48
+ const tmp = `${file}.${randomBytes(4).toString("hex")}.tmp`;
49
+ await writeFile(tmp, `${JSON.stringify(sources, null, 2)}\n`);
50
+ await rename(tmp, file);
51
+ }
52
+
53
+ function routingField(value: unknown): string | undefined {
54
+ if (typeof value !== "string") return undefined;
55
+ const trimmed = value.trim();
56
+ return trimmed === "" ? undefined : trimmed;
57
+ }
58
+
59
+ /** A stream item is complete when its line parses; no two-pass guard applies. */
60
+ export function parseDrainOutput(name: string, text: string): SourceDrain {
61
+ const items: InboxItem[] = [];
62
+ const bad: BadInboxLine[] = [];
63
+ let cursor: string | undefined;
64
+ const lines = text.split("\n")
65
+ .map((raw, index) => ({ raw, line: index + 1 }))
66
+ .filter(({ raw }) => raw.trim() !== "");
67
+
68
+ for (let index = 0; index < lines.length; index += 1) {
69
+ const { raw, line } = lines[index]!;
70
+ let parsed: unknown;
71
+ try {
72
+ parsed = JSON.parse(raw);
73
+ } catch {
74
+ bad.push({ line, reason: "invalid JSON" });
75
+ continue;
76
+ }
77
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
78
+ bad.push({ line, reason: "invalid JSON (not an object)" });
79
+ continue;
80
+ }
81
+ const record = parsed as {
82
+ id?: unknown;
83
+ body?: unknown;
84
+ title?: unknown;
85
+ cursor?: unknown;
86
+ chat_id?: unknown;
87
+ root_id?: unknown;
88
+ thread_id?: unknown;
89
+ };
90
+ if (
91
+ index === lines.length - 1 &&
92
+ "cursor" in record &&
93
+ record.id === undefined &&
94
+ record.body === undefined
95
+ ) {
96
+ if (typeof record.cursor !== "string") {
97
+ bad.push({ line, reason: "invalid cursor" });
98
+ } else {
99
+ cursor = record.cursor;
100
+ }
101
+ continue;
102
+ }
103
+ if (typeof record.id !== "string" || record.id.trim() === "") {
104
+ bad.push({ line, reason: "no id" });
105
+ continue;
106
+ }
107
+ if (typeof record.body !== "string") {
108
+ bad.push({ line, reason: "no body" });
109
+ continue;
110
+ }
111
+ const title =
112
+ typeof record.title === "string" && record.title.trim() !== ""
113
+ ? record.title
114
+ : record.body.split("\n", 1)[0]!.trim();
115
+ const chatId = routingField(record.chat_id);
116
+ const rootId = routingField(record.root_id);
117
+ const threadId = routingField(record.thread_id);
118
+ items.push({
119
+ id: record.id,
120
+ title,
121
+ body: record.body,
122
+ path: "",
123
+ mtimeMs: 0,
124
+ size: 0,
125
+ ...(chatId === undefined ? {} : { chatId }),
126
+ ...(rootId === undefined ? {} : { rootId }),
127
+ ...(threadId === undefined ? {} : { threadId }),
128
+ });
129
+ }
130
+ return { name, items, bad, ...(cursor === undefined ? {} : { cursor }) };
131
+ }
132
+
133
+ async function drainOne(source: SourceRecord, project: string): Promise<SourceDrain> {
134
+ const empty = { name: source.name, items: [], bad: [] };
135
+ const result = await new Promise<{ stdout?: string; error?: string }>((resolve) => {
136
+ const child = spawn(source.cmd, [], {
137
+ cwd: project,
138
+ env: {
139
+ ...process.env,
140
+ AGENTS_POD_SOURCE_ID: source.name,
141
+ AGENTS_POD_CURSOR: source.cursor ?? "",
142
+ },
143
+ // A shell drain can leave a child holding stdout after the shell dies.
144
+ // Its own process group makes the timeout and output cap real bounds.
145
+ detached: true,
146
+ stdio: ["ignore", "pipe", "ignore"],
147
+ });
148
+ const kill = (): void => {
149
+ if (child.pid === undefined) {
150
+ child.kill("SIGKILL");
151
+ return;
152
+ }
153
+ try {
154
+ process.kill(-child.pid, "SIGKILL");
155
+ } catch {
156
+ child.kill("SIGKILL");
157
+ }
158
+ };
159
+ const chunks: Buffer[] = [];
160
+ let bytes = 0;
161
+ let oversized = false;
162
+ let timedOut = false;
163
+ let spawnError: string | undefined;
164
+ const timer = setTimeout(() => {
165
+ timedOut = true;
166
+ kill();
167
+ }, source.timeoutMs);
168
+
169
+ child.stdout.on("data", (chunk: Buffer) => {
170
+ bytes += chunk.length;
171
+ if (bytes > DRAIN_STDOUT_CAP) {
172
+ if (!oversized) {
173
+ oversized = true;
174
+ kill();
175
+ }
176
+ return;
177
+ }
178
+ chunks.push(chunk);
179
+ });
180
+ child.on("error", (error) => {
181
+ spawnError = error.message;
182
+ });
183
+ child.on("close", (code, signal) => {
184
+ clearTimeout(timer);
185
+ if (oversized) return resolve({ error: "stdout exceeded 1 MiB" });
186
+ if (timedOut) return resolve({ error: `timed out after ${source.timeoutMs / 1000}s` });
187
+ if (spawnError !== undefined) return resolve({ error: spawnError });
188
+ if (code !== 0) {
189
+ return resolve({
190
+ error: signal === null ? `exit ${code ?? "unknown"}` : `terminated by ${signal}`,
191
+ });
192
+ }
193
+ resolve({ stdout: Buffer.concat(chunks).toString("utf8") });
194
+ });
195
+ });
196
+ if (result.error !== undefined) return { ...empty, error: result.error };
197
+ // Distinct generations need distinct boundaries even when two local drains
198
+ // finish within one wall-clock millisecond.
199
+ const drainedAt = Math.max(Date.now(), (source.lastDrainAt ?? 0) + 1);
200
+ return {
201
+ ...parseDrainOutput(source.name, result.stdout ?? ""),
202
+ drainedAt,
203
+ ...(source.lastDrainAt === undefined ? {} : { previousDrainAt: source.lastDrainAt }),
204
+ };
205
+ }
206
+
207
+ /** Drain each distinct Source once, before the registry lock or any Pod work (ADR 0024). */
208
+ export async function drainSources(
209
+ project: string,
210
+ listeners: ListenerRecord[],
211
+ ): Promise<SourceDrain[]> {
212
+ const sources = await readSources();
213
+ const names = [...new Set(listeners.flatMap((listener) => listener.source ?? []))];
214
+ const drained: SourceDrain[] = [];
215
+ for (const name of names) {
216
+ const source = sources.find((candidate) => candidate.name === name);
217
+ drained.push(
218
+ source === undefined
219
+ ? { name, items: [], bad: [], error: "source record is missing" }
220
+ : await drainOne(source, project),
221
+ );
222
+ }
223
+ return drained;
224
+ }
225
+
226
+ /**
227
+ * Two generations are enough: after this drain, anything older than the prior
228
+ * successful one is behind the committed cursor and cannot arrive again. The
229
+ * store is transformed without I/O; the caller writes it inside the registry
230
+ * lock before advancing the Source records (ADR 0024). Legacy claims without a
231
+ * timestamp are retained because their generation cannot be proven.
232
+ */
233
+ export function pruneSourceClaims(
234
+ listeners: ListenerRecord[],
235
+ drained: SourceDrain[],
236
+ ): { listeners: ListenerRecord[]; changed: boolean } {
237
+ const horizons = new Map<string, number>();
238
+ for (const drain of drained) {
239
+ if (drain.error === undefined && drain.previousDrainAt !== undefined) {
240
+ horizons.set(`source:${drain.name}`, drain.previousDrainAt);
241
+ }
242
+ }
243
+ if (horizons.size === 0) return { listeners, changed: false };
244
+
245
+ let changed = false;
246
+ const pruned = listeners.map((listener) => {
247
+ const claimed = listener.claimed.filter((claim) => {
248
+ const horizon = horizons.get(claim.inbox);
249
+ return (
250
+ horizon === undefined || claim.claimedAt === undefined || claim.claimedAt >= horizon
251
+ );
252
+ });
253
+ if (claimed.length === listener.claimed.length) return listener;
254
+ changed = true;
255
+ return { ...listener, claimed };
256
+ });
257
+ return { listeners: pruned, changed };
258
+ }
259
+
260
+ /** Claims and pruning land before this cursor/retention-horizon write (ADR 0024). */
261
+ export async function commitSourceCursors(drained: SourceDrain[]): Promise<void> {
262
+ const sources = await readSources();
263
+ let changed = false;
264
+ for (const drain of drained) {
265
+ if (drain.error !== undefined || drain.drainedAt === undefined) continue;
266
+ const source = sources.find((candidate) => candidate.name === drain.name);
267
+ if (source === undefined) continue;
268
+ if (drain.cursor !== undefined && source.cursor !== drain.cursor) {
269
+ source.cursor = drain.cursor;
270
+ changed = true;
271
+ }
272
+ if (source.lastDrainAt !== drain.drainedAt) {
273
+ source.lastDrainAt = drain.drainedAt;
274
+ changed = true;
275
+ }
276
+ }
277
+ if (changed) await writeSources(sources);
278
+ }
package/src/workspace.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
- import type { InboxItem } from "./inbox.ts";
4
+ import { deliveryAddress, type InboxItem } from "./inbox.ts";
5
5
  import { podDir } from "./paths.ts";
6
6
  import type { PodRecord } from "./registry.ts";
7
7
 
@@ -48,7 +48,7 @@ const TEMPLATES: Record<string, string> = {
48
48
  "One line per decision, with its reason. Append-only.",
49
49
  "",
50
50
  ].join("\n"),
51
- "TASKS.md": ["# Tasks", "", "## Plan", "", "## Inbox", ""].join("\n"),
51
+ "TASKS.md": ["# Tasks", "", "## Plan", "", "## Waiting", "", "## Inbox", ""].join("\n"),
52
52
  };
53
53
 
54
54
  /** The one section reconcile may write (ADR 0008, 0014). */
@@ -58,6 +58,9 @@ export async function createPodWorkspace(pod: PodRecord): Promise<string> {
58
58
  const dir = podDir(pod.project, pod.id);
59
59
  const docs = join(dir, "docs");
60
60
  await mkdir(docs, { recursive: true });
61
+ // The outbox is beside the three-slot Document Store, never a fourth slot
62
+ // (ADRs 0008, 0025).
63
+ await mkdir(join(dir, "outbox"), { recursive: true });
61
64
  for (const [name, body] of Object.entries(TEMPLATES)) {
62
65
  await writeFile(join(docs, name), body);
63
66
  }
@@ -70,9 +73,13 @@ export async function createPodWorkspace(pod: PodRecord): Promise<string> {
70
73
  * audit have an anchor; the body is copied because the source file belongs to
71
74
  * its writer and may be deleted once the item is claimed.
72
75
  */
73
- export function inboxBlock(item: Pick<InboxItem, "id" | "title" | "body">): string {
76
+ export function inboxBlock(
77
+ item: Pick<InboxItem, "id" | "title" | "body" | "chatId" | "rootId" | "threadId">,
78
+ ): string {
74
79
  const title = item.title.split("\n", 1)[0]!.trim();
75
- const lines = [`- [ ] ${item.id} — ${title}`];
80
+ const address = deliveryAddress(item);
81
+ const suffix = address === "" ? "" : ` — to: ${address}`;
82
+ const lines = [`- [ ] ${item.id} — ${title}${suffix}`];
76
83
  const body = item.body.replace(/\r\n/g, "\n").replace(/\n+$/, "");
77
84
  if (body.length > 0) {
78
85
  for (const line of body.split("\n")) {
package/src/wrapper.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import {
4
+ appendFile,
4
5
  copyFile,
5
6
  mkdir,
6
7
  open,
@@ -20,7 +21,15 @@ import {
20
21
  type WrapperRecord,
21
22
  } from "./liveness.ts";
22
23
  import { podDir } from "./paths.ts";
23
- import { readPods, writePods, type WakeUpOutcome } from "./registry.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";
24
33
  import { snapshotDocs } from "./workspace.ts";
25
34
 
26
35
  /** The heartbeat interval: the mtime is the liveness signal (ADR 0017). */
@@ -213,17 +222,52 @@ function instructionsHash(instructions: string): string {
213
222
  return createHash("sha256").update(instructions).digest("hex").slice(0, 12);
214
223
  }
215
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
+
216
257
  /**
217
258
  * Records a wake-up that never reached an agent. The outcome is a fact; the
218
259
  * policy that follows from it — backoff, streak, park — stays reconcile's.
219
260
  */
220
- async function recordUnresolved(podId: string, n: number, what: string): Promise<void> {
221
- const pods = await readPods();
222
- const record = pods.find((candidate) => candidate.id === podId);
223
- if (!record) return;
224
- record.wakeUps.push({ n, outcome: "failed", detail: what });
225
- record.state = "idle";
226
- await writePods(pods);
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
+ });
227
271
  }
228
272
 
229
273
  export async function runWrapper(argv: string[]): Promise<number> {
@@ -255,8 +299,7 @@ export async function runWrapper(argv: string[]): Promise<number> {
255
299
  const agentId = agentWord ?? pod.policy?.agent ?? "default";
256
300
  const agent = resolveAgentDefinition(await readAgents(), agentId);
257
301
  if (!agent) {
258
- await recordUnresolved(podId, n, "agent resolution failed");
259
- return 0;
302
+ return (await recordUnresolved(podId, podRoot, n, "agent resolution failed")) ? 0 : 1;
260
303
  }
261
304
 
262
305
  // The second seat of the Pi-only guard (ADR 0023, agents-store.ts): the CLI refuses an
@@ -266,8 +309,14 @@ export async function runWrapper(argv: string[]): Promise<number> {
266
309
  // from there; it never silently runs pi in claude's name.
267
310
  const refusal = agentTypeRefusal(agent.type);
268
311
  if (refusal !== undefined) {
269
- await recordUnresolved(podId, n, `agent ${agent.id} has type ${agent.type}: ${refusal}`);
270
- return 0;
312
+ return (await recordUnresolved(
313
+ podId,
314
+ podRoot,
315
+ n,
316
+ `agent ${agent.id} has type ${agent.type}: ${refusal}`,
317
+ ))
318
+ ? 0
319
+ : 1;
271
320
  }
272
321
 
273
322
  const wakeupDir = join(podRoot, "wakeups", String(n));
@@ -437,9 +486,7 @@ export async function runWrapper(argv: string[]): Promise<number> {
437
486
  // owner, and its detail is the wind-down, not the agent's last words. The
438
487
  // pod is terminal, so reconcile never walks it back to a wake (ADR 0011).
439
488
  if (cancelRequested) {
440
- const pods = await readPods();
441
- const record = pods.find((p) => p.id === podId);
442
- if (record) {
489
+ const recorded = await updatePodRecord(podId, podRoot, n, (record) => {
443
490
  // A reconcile kill already adjudicated this wake-up (ADRs 0017, 0019):
444
491
  // its intent was written before the signal, so the wrapper adds no
445
492
  // second, contradictory outcome — whether the cause was progressless or
@@ -452,9 +499,9 @@ export async function runWrapper(argv: string[]): Promise<number> {
452
499
  : { n, outcome: "cancelled" },
453
500
  );
454
501
  record.state = "cancelled";
455
- await writePods(pods);
456
502
  }
457
- }
503
+ });
504
+ if (!recorded) return 1;
458
505
  // The identity outlives its purpose only until the outcome is recorded.
459
506
  await rm(wrapperRecordFile, { force: true });
460
507
  return 0;
@@ -466,13 +513,15 @@ export async function runWrapper(argv: string[]): Promise<number> {
466
513
  () => undefined,
467
514
  );
468
515
  const { outcome, detail } = await adjudicate(docsDir, wakeStart, eventsFile, code);
469
- const pods = await readPods();
470
- const record = pods.find((p) => p.id === podId);
471
- if (record) {
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) => {
472
521
  record.wakeUps.push(detail === undefined ? { n, outcome } : { n, outcome, detail });
473
522
  record.state = outcome === "done" ? "done" : "idle";
474
- await writePods(pods);
475
- }
523
+ });
524
+ if (!recorded) return 1;
476
525
  // The identity outlives its purpose only until the outcome is recorded.
477
526
  await rm(wrapperRecordFile, { force: true });
478
527
  return 0;