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/inbox.ts CHANGED
@@ -1,18 +1,58 @@
1
1
  import { readFile, readdir, stat } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  /**
4
- * An Inbox Item (ADR 0013): one positional id, the four values a Pod Template
4
+ * An Inbox Item (ADR 0013): one positional id, the values a Pod Template
5
5
  * binds, and the facts the two-pass stability guard compares. `{{path}}` is the
6
6
  * item file's absolute path (directory inbox) or the JSONL path plus line.
7
+ *
8
+ * The optional routing fields are the platform's passengers (ADR 0026): they
9
+ * never join the id or the body, they only feed the Delivery Address.
7
10
  */
8
11
  export interface InboxItem {
9
- /** Directory: the filename. File: the line number. Never content (ADR 0013). */
12
+ /** Directory: filename. File: line number. Source: the platform's id (ADRs 0013, 0024). */
10
13
  id: string;
11
14
  title: string;
12
15
  body: string;
16
+ /** Empty for a Source item, which has no filesystem attachment (ADR 0024). */
13
17
  path: string;
18
+ /** Inbox stability facts only; placeholders are zero for a Source item. */
14
19
  mtimeMs: number;
15
20
  size: number;
21
+ /** The chat a reply has to land in; without it there is no address. */
22
+ chatId?: string;
23
+ /** The root message: the Conversation's key, propagated by the platform. */
24
+ rootId?: string;
25
+ /** The topic thread, when the platform gave the message one. */
26
+ threadId?: string;
27
+ }
28
+
29
+ /**
30
+ * The Delivery Address (ADR 0026): the Conversation's key, the chat, and the
31
+ * optional thread as one opaque token — the string an outbox entry's `to`
32
+ * takes verbatim and a deliverer parses. Core never reads it back. An item with
33
+ * no chat has nowhere to be answered, so it renders the empty string rather
34
+ * than a token with a `chat=` hole; root and thread are passengers, never a
35
+ * substitute for the chat.
36
+ *
37
+ * The affinity is `root_id ?? item id`, exactly as ADR 0026 states it. For a
38
+ * directory or JSONL inbox the id is the item's position, not a platform
39
+ * message id; a Source (ADR 0024) is what makes the id the platform's, and
40
+ * therefore the fallback usable by a deliverer.
41
+ */
42
+ export function deliveryAddress(
43
+ item: Pick<InboxItem, "id" | "chatId" | "rootId" | "threadId">,
44
+ ): string {
45
+ if (item.chatId === undefined) return "";
46
+ const affinity = item.rootId ?? item.id;
47
+ const thread = item.threadId === undefined ? "" : `;thread=${item.threadId}`;
48
+ return `chat=${item.chatId};affinity=${affinity}${thread}`;
49
+ }
50
+
51
+ /** A routing field is a non-empty, trimmed string; anything else is as ignored as an unknown key. */
52
+ function routingField(value: unknown): string | undefined {
53
+ if (typeof value !== "string") return undefined;
54
+ const trimmed = value.trim();
55
+ return trimmed === "" ? undefined : trimmed;
16
56
  }
17
57
 
18
58
  /** A malformed JSONL line: skipped, unclaimed, warned on every pass (ADR 0013). */
@@ -74,8 +114,10 @@ async function scanDirectory(dir: string): Promise<InboxScan> {
74
114
 
75
115
  /**
76
116
  * A file inbox is JSONL only (ADR 0013): `body` is required, `title` optional,
77
- * extra keys ignored. A line that does not parse, or carries no string body, is
78
- * skipped and reported — never claimed, never fatal to the pass.
117
+ * `chat_id`/`root_id`/`thread_id` are read as the item's routing fields
118
+ * (ADR 0026), and every other key is ignored. A line that does not parse, or
119
+ * carries no string body, is skipped and reported — never claimed, never fatal
120
+ * to the pass.
79
121
  */
80
122
  async function scanJsonl(file: string): Promise<InboxScan> {
81
123
  const info = await stat(file).catch(() => undefined);
@@ -101,7 +143,13 @@ async function scanJsonl(file: string): Promise<InboxScan> {
101
143
  bad.push({ line, reason: "invalid JSON (not an object)" });
102
144
  continue;
103
145
  }
104
- const record = parsed as { body?: unknown; title?: unknown };
146
+ const record = parsed as {
147
+ body?: unknown;
148
+ title?: unknown;
149
+ chat_id?: unknown;
150
+ root_id?: unknown;
151
+ thread_id?: unknown;
152
+ };
105
153
  if (typeof record.body !== "string") {
106
154
  bad.push({ line, reason: "no body" });
107
155
  continue;
@@ -110,6 +158,9 @@ async function scanJsonl(file: string): Promise<InboxScan> {
110
158
  typeof record.title === "string" && record.title.trim() !== ""
111
159
  ? record.title
112
160
  : firstLine(record.body);
161
+ const chatId = routingField(record.chat_id);
162
+ const rootId = routingField(record.root_id);
163
+ const threadId = routingField(record.thread_id);
113
164
  items.push({
114
165
  id: String(line),
115
166
  title,
@@ -117,6 +168,9 @@ async function scanJsonl(file: string): Promise<InboxScan> {
117
168
  path: `${file}#${line}`,
118
169
  mtimeMs: info.mtimeMs,
119
170
  size: info.size,
171
+ ...(chatId === undefined ? {} : { chatId }),
172
+ ...(rootId === undefined ? {} : { rootId }),
173
+ ...(threadId === undefined ? {} : { threadId }),
120
174
  });
121
175
  }
122
176
  return { items, bad };
@@ -129,8 +183,19 @@ export async function scanInbox(inbox: string): Promise<InboxScan> {
129
183
  return info.isDirectory() ? scanDirectory(inbox) : scanJsonl(inbox);
130
184
  }
131
185
 
132
- /** The exactly-four template placeholders (ADR 0013). */
133
- const PLACEHOLDERS = new Set(["id", "title", "body", "path"]);
186
+ /**
187
+ * The five template placeholders (ADR 0013, amended by ADR 0026), each one a
188
+ * renderer: adding a sixth is one row, not a set plus two casts.
189
+ */
190
+ const PLACEHOLDERS: Record<string, (item: InboxItem) => string> = {
191
+ id: (item) => item.id,
192
+ title: (item) => item.title,
193
+ body: (item) => item.body,
194
+ path: (item) => item.path,
195
+ // The address is derived, not a field: an item without routing fields
196
+ // renders empty, so a template stays valid for both kinds of item.
197
+ reply_to: deliveryAddress,
198
+ };
134
199
 
135
200
  /**
136
201
  * Render a Pod Template against one item. An unknown `{{...}}` is a
@@ -144,11 +209,12 @@ export function renderTemplate(
144
209
  const unknown: string[] = [];
145
210
  const goal = template.replace(/\{\{([^{}]*)\}\}/g, (whole: string, raw: string) => {
146
211
  const name = raw.trim();
147
- if (!PLACEHOLDERS.has(name)) {
212
+ const render = PLACEHOLDERS[name];
213
+ if (render === undefined) {
148
214
  unknown.push(name);
149
215
  return whole;
150
216
  }
151
- return item[name as "id" | "title" | "body" | "path"];
217
+ return render(item);
152
218
  });
153
219
  if (unknown.length > 0) return { error: `unknown placeholder {{${unknown[0]}}}` };
154
220
  return { goal };
package/src/listeners.ts CHANGED
@@ -11,9 +11,10 @@ import { listenersFile } from "./paths.ts";
11
11
  export type ListenerMode = "template" | "pod";
12
12
 
13
13
  /**
14
- * A consumed item, by position (ADR 0013): the claim namespace is the inbox, so
15
- * `inbox` + `item` is the key across listeners. `spawnedPodId` is what the
16
- * template materialized; `blocked` is a reason materialization will not happen
14
+ * A consumed item, by position for an inbox or platform id for a Source
15
+ * (ADRs 0013, 0024): the claim namespace is the origin, so `inbox` + `item` is
16
+ * the key across listeners. `spawnedPodId` is what the template materialized;
17
+ * `blocked` is a reason materialization will not happen
17
18
  * (a configuration error, or an item that vanished). `appended` is ADR 0014's
18
19
  * one boolean for a bound standing pod: the block is in the pod's `## Inbox`.
19
20
  * Beyond that the document is the truth — there is no per-item lifecycle.
@@ -21,6 +22,8 @@ export type ListenerMode = "template" | "pod";
21
22
  export interface ClaimRecord {
22
23
  inbox: string;
23
24
  item: string;
25
+ /** Source claims retain the current and previous successful drain (ADR 0024). */
26
+ claimedAt?: number;
24
27
  spawnedPodId?: string;
25
28
  blocked?: string;
26
29
  appended?: boolean;
@@ -35,13 +38,16 @@ export interface SeenFact {
35
38
 
36
39
  export interface ListenerRecord {
37
40
  id: string;
38
- /** Absolute path: a directory inbox or one append-only JSONL file. */
39
- inbox: string;
41
+ /** Exactly one origin: an absolute inbox path or a Source name (ADR 0024). */
42
+ inbox?: string;
43
+ source?: string;
40
44
  /** P0's rule: a plain substring; absent means every item matches. */
41
45
  match?: string;
42
46
  mode: ListenerMode;
43
47
  /** Template mode: the goal template file. */
44
48
  template?: string;
49
+ /** Template mode: materialize under this project instead of the tick's cwd (ADR 0007). */
50
+ project?: string;
45
51
  /** Pod mode: the standing pod whose `## Inbox` the items join (ADR 0014). */
46
52
  podId?: string;
47
53
  maxConcurrent: number;
package/src/outbox.ts ADDED
@@ -0,0 +1,294 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import {
4
+ appendFile,
5
+ mkdir,
6
+ readFile,
7
+ readdir,
8
+ rename,
9
+ rm,
10
+ stat,
11
+ writeFile,
12
+ } from "node:fs/promises";
13
+ import { dirname, join } from "node:path";
14
+ import { cronLogPath, delivererFile, podDir } from "./paths.ts";
15
+
16
+ /** The single operator delivery command (ADR 0025). */
17
+ export interface DelivererRecord {
18
+ name: string;
19
+ cmd: string;
20
+ timeoutMs: number;
21
+ }
22
+
23
+ export const OUTBOX_FLUSH_MAX_ENTRIES = 10;
24
+ export const OUTBOX_FLUSH_BUDGET_MS = 10_000;
25
+ export const OUTBOX_DEAD_AFTER_MS = 3_600_000;
26
+
27
+ export async function readDeliverer(): Promise<DelivererRecord | undefined> {
28
+ try {
29
+ const parsed: unknown = JSON.parse(await readFile(delivererFile(), "utf8"));
30
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
31
+ ? (parsed as DelivererRecord)
32
+ : undefined;
33
+ } catch (error) {
34
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
35
+ throw error;
36
+ }
37
+ }
38
+
39
+ /** The single record has its own atomic write, beside sources.json (ADR 0025). */
40
+ export async function writeDeliverer(deliverer: DelivererRecord): Promise<void> {
41
+ const file = delivererFile();
42
+ await mkdir(dirname(file), { recursive: true });
43
+ const tmp = `${file}.${randomBytes(4).toString("hex")}.tmp`;
44
+ await writeFile(tmp, `${JSON.stringify(deliverer, null, 2)}\n`);
45
+ await rename(tmp, file);
46
+ }
47
+
48
+ /**
49
+ * The filename is the uuid's third input, so a later pass can re-derive it
50
+ * without registry state (ADR 0025, amended by #14). Fifty hex chars fit the
51
+ * delivery platform's uuid limit.
52
+ */
53
+ export function outboxUuid(podId: string, wakeup: number, filename: string): string {
54
+ return createHash("sha256")
55
+ .update(podId)
56
+ .update("\0")
57
+ .update(String(wakeup))
58
+ .update("\0")
59
+ .update(filename)
60
+ .digest("hex")
61
+ .slice(0, 50);
62
+ }
63
+
64
+ async function logOutbox(line: string): Promise<void> {
65
+ const file = cronLogPath();
66
+ await mkdir(dirname(file), { recursive: true });
67
+ await appendFile(file, `${line}\n`);
68
+ }
69
+
70
+ function validEntry(raw: string): boolean {
71
+ try {
72
+ const parsed: unknown = JSON.parse(raw);
73
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return false;
74
+ const entry = parsed as { to?: unknown; text?: unknown };
75
+ // The shape is fixed by ADR 0025: two strings, the address and the words.
76
+ return typeof entry.to === "string" && entry.to !== "" && typeof entry.text === "string";
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+
82
+ /** Exit 0 is the only acknowledgement; every other outcome leaves the file. */
83
+ async function deliverOne(
84
+ deliverer: DelivererRecord,
85
+ podId: string,
86
+ project: string,
87
+ wakeup: number,
88
+ filename: string,
89
+ raw: string,
90
+ timeoutMs: number,
91
+ ): Promise<string | undefined> {
92
+ return new Promise((resolve) => {
93
+ const child = spawn(deliverer.cmd, [], {
94
+ cwd: project,
95
+ env: {
96
+ ...process.env,
97
+ AGENTS_POD_POD_ID: podId,
98
+ AGENTS_POD_WAKEUP_N: String(wakeup),
99
+ AGENTS_POD_OUTBOX_ENTRY: filename,
100
+ AGENTS_POD_OUTBOX_UUID: outboxUuid(podId, wakeup, filename),
101
+ },
102
+ // A delivery command can leave a child behind; its own process group
103
+ // makes the invocation timeout a hard bound (ADR 0025).
104
+ detached: true,
105
+ stdio: ["pipe", "ignore", "ignore"],
106
+ });
107
+ const kill = (): void => {
108
+ if (child.pid === undefined) {
109
+ child.kill("SIGKILL");
110
+ return;
111
+ }
112
+ try {
113
+ process.kill(-child.pid, "SIGKILL");
114
+ } catch {
115
+ child.kill("SIGKILL");
116
+ }
117
+ };
118
+ let timedOut = false;
119
+ let spawnError: string | undefined;
120
+ const timer = setTimeout(() => {
121
+ timedOut = true;
122
+ kill();
123
+ }, timeoutMs);
124
+ child.on("error", (error) => {
125
+ spawnError = error.message;
126
+ });
127
+ child.on("close", (code, signal) => {
128
+ clearTimeout(timer);
129
+ if (timedOut) return resolve(`timed out after ${timeoutMs / 1000}s`);
130
+ if (spawnError !== undefined) return resolve(spawnError);
131
+ if (code !== 0) {
132
+ return resolve(
133
+ signal === null ? `exit ${code ?? "unknown"}` : `terminated by ${signal}`,
134
+ );
135
+ }
136
+ resolve(undefined);
137
+ });
138
+ // A command may exit without reading stdin; that is its exit status, not a
139
+ // wrapper-level EPIPE failure.
140
+ child.stdin.on("error", () => undefined);
141
+ child.stdin.end(raw);
142
+ });
143
+ }
144
+
145
+ /** The leading filename field is the entry's immutable wake-up attribution. */
146
+ function entryWakeup(filename: string): number | undefined {
147
+ const match = /^([1-9]\d*)-/.exec(filename);
148
+ if (match === null) return undefined;
149
+ const wakeup = Number(match[1]);
150
+ return Number.isSafeInteger(wakeup) ? wakeup : undefined;
151
+ }
152
+
153
+ /** Dead-lettering changes the name, never the contents, and keeps the evidence. */
154
+ async function deadLetter(podId: string, file: string, name: string, reason: string): Promise<void> {
155
+ try {
156
+ await rename(file, `${file}.dead`);
157
+ await logOutbox(`outbox ${podId}: ${name} dead-lettered (${reason}); kept as ${name}.dead`);
158
+ } catch (error) {
159
+ await logOutbox(
160
+ `outbox ${podId}: ${name} dead-letter rename failed (${(error as Error).message}); file kept`,
161
+ );
162
+ }
163
+ }
164
+
165
+ /**
166
+ * The wrapper and reconcile share one bounded flush (ADR 0025). A wrapper
167
+ * touches only its own wake-up's entries: older ones belong to pass retries.
168
+ * A pass derives every uuid from the filename, so retries keep their original
169
+ * wake-up number. Failures are audit lines, never outcome, policy, or
170
+ * notification changes.
171
+ */
172
+ async function flushEntries(
173
+ podId: string,
174
+ project: string,
175
+ currentWakeup?: number,
176
+ ): Promise<void> {
177
+ const dir = join(podDir(project, podId), "outbox");
178
+ let names: string[];
179
+ try {
180
+ names = (await readdir(dir)).filter((name) => name.endsWith(".json")).sort();
181
+ } catch (error) {
182
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
183
+ await logOutbox(`outbox ${podId}: unreadable (${(error as Error).message})`);
184
+ return;
185
+ }
186
+ if (names.length === 0) return;
187
+
188
+ let deliverer: DelivererRecord | undefined;
189
+ try {
190
+ deliverer = await readDeliverer();
191
+ } catch (error) {
192
+ await logOutbox(`outbox ${podId}: deliverer unreadable (${(error as Error).message})`);
193
+ return;
194
+ }
195
+ // The unconfigured wrapper flush stays a no-op; the pass owns waiting and
196
+ // aging so each pass can make the missing configuration audible once.
197
+ if (deliverer === undefined && currentWakeup !== undefined) return;
198
+
199
+ const startedAt = Date.now();
200
+ let invocations = 0;
201
+ let waiting = 0;
202
+ for (const name of names) {
203
+ const elapsed = Date.now() - startedAt;
204
+ if (
205
+ deliverer !== undefined &&
206
+ (invocations >= OUTBOX_FLUSH_MAX_ENTRIES || elapsed >= OUTBOX_FLUSH_BUDGET_MS)
207
+ ) {
208
+ return;
209
+ }
210
+ const file = join(dir, name);
211
+ const wakeup = entryWakeup(name);
212
+ if (wakeup === undefined) {
213
+ await deadLetter(podId, file, name, "unparseable wake-up");
214
+ continue;
215
+ }
216
+ if (currentWakeup !== undefined) {
217
+ // An earlier wake-up's failed entry must survive later wrapper flushes;
218
+ // the pass retries it with the same uuid until the one-hour boundary.
219
+ if (wakeup < currentWakeup) continue;
220
+ if (wakeup > currentWakeup) {
221
+ await deadLetter(podId, file, name, `wake-up ${wakeup} is after ${currentWakeup}`);
222
+ continue;
223
+ }
224
+ }
225
+
226
+ const info = await stat(file).catch(() => undefined);
227
+ if (info === undefined) {
228
+ await logOutbox(`outbox ${podId}: ${name} unreadable; skipped`);
229
+ continue;
230
+ }
231
+ if (Date.now() - info.mtimeMs > OUTBOX_DEAD_AFTER_MS) {
232
+ await deadLetter(podId, file, name, "older than one hour");
233
+ continue;
234
+ }
235
+
236
+ let raw: string;
237
+ try {
238
+ raw = await readFile(file, "utf8");
239
+ } catch (error) {
240
+ await logOutbox(`outbox ${podId}: ${name} unreadable (${(error as Error).message}); skipped`);
241
+ continue;
242
+ }
243
+ if (!validEntry(raw)) {
244
+ await deadLetter(podId, file, name, "invalid entry (expected JSON with to and text)");
245
+ continue;
246
+ }
247
+ if (deliverer === undefined) {
248
+ waiting += 1;
249
+ continue;
250
+ }
251
+
252
+ const remainingMs = OUTBOX_FLUSH_BUDGET_MS - (Date.now() - startedAt);
253
+ if (remainingMs <= 0) return;
254
+ invocations += 1;
255
+ const error = await deliverOne(
256
+ deliverer,
257
+ podId,
258
+ project,
259
+ wakeup,
260
+ name,
261
+ raw,
262
+ Math.min(deliverer.timeoutMs, remainingMs),
263
+ );
264
+ if (error === undefined) {
265
+ try {
266
+ // Deletion is the acknowledgement; no completion marker exists.
267
+ await rm(file, { force: true });
268
+ } catch (deleteError) {
269
+ await logOutbox(
270
+ `outbox ${podId}: ${name} delivered but delete failed (${(deleteError as Error).message})`,
271
+ );
272
+ }
273
+ } else {
274
+ await logOutbox(`outbox ${podId}: ${name} delivery failed (${error}); file kept`);
275
+ }
276
+ }
277
+ if (deliverer === undefined && waiting > 0) {
278
+ await logOutbox(`outbox ${podId}: no deliverer configured; ${waiting} entries waiting`);
279
+ }
280
+ }
281
+
282
+ /** Flush this wake-up's outbox after adjudication and before idle (ADR 0025). */
283
+ export async function flushOutbox(
284
+ podId: string,
285
+ project: string,
286
+ wakeup: number,
287
+ ): Promise<void> {
288
+ await flushEntries(podId, project, wakeup);
289
+ }
290
+
291
+ /** Retry one non-active Pod's outbox during reconcile (ADR 0025). */
292
+ export async function retryOutbox(podId: string, project: string): Promise<void> {
293
+ await flushEntries(podId, project);
294
+ }
package/src/paths.ts CHANGED
@@ -31,6 +31,16 @@ export function listenersFile(): string {
31
31
  return join(storeRoot(), "listeners.json");
32
32
  }
33
33
 
34
+ /** Sources and their committed cursors, beside rather than inside the registry (ADR 0024). */
35
+ export function sourcesFile(): string {
36
+ return join(storeRoot(), "sources.json");
37
+ }
38
+
39
+ /** The single outbound delivery command, beside the Source records (ADR 0025). */
40
+ export function delivererFile(): string {
41
+ return join(storeRoot(), "deliverer.json");
42
+ }
43
+
34
44
  /** The pass's single-flight advisory lock, one for the whole registry (ADR 0018). */
35
45
  export function reconcileLockPath(): string {
36
46
  return join(storeRoot(), "reconcile.lock");