agents-pod 0.1.0 → 0.3.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/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;