@fradser/pi-agent-teams 0.10.0 → 1.0.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/statefile.ts CHANGED
@@ -1,41 +1,34 @@
1
1
  /**
2
2
  * Shared session files for the leader-owned snapshot, teammate outboxes and
3
- * inboxes, and the persistent task board.
3
+ * inboxes. The persistent task storage lives in @fradser/pi-tasks; `sessionKey` comes from
4
+ * pi-kit so both packages agree on which session a directory belongs to.
4
5
  *
5
6
  * Runtime layout (removed at session shutdown):
6
7
  * ~/.pi/agent/teammate/<sessionKey>/state.json leader-owned snapshot
7
8
  * ~/.pi/agent/teammate/<sessionKey>/events/*.jsonl per-spawn report outboxes
8
9
  * ~/.pi/agent/teammate/<sessionKey>/mail/*.jsonl peer inbox files
9
- * ~/.pi/agent/teammate/<sessionKey>/roster.json living teammates, worker-readable
10
+ * ~/.pi/agent/teammate/<sessionKey>/roster.json living agents, worker-readable
10
11
  *
11
- * Board layout (persists across restarts, never auto-cleaned):
12
- * ~/.pi/agent/tasks/<sessionKey>/board.json leader-owned board file
13
- * ~/.pi/agent/tasks/<sessionKey>/claims/*.json exclusive-create claim intents
14
- * ~/.pi/agent/tasks/<sessionKey>/submissions/*.json exclusive-create submit intents
15
- *
16
- * Concurrency: the parent is the sole writer of state.json and board.json
12
+ * Concurrency: the parent is the sole writer of state.json and tasks.json
17
13
  * (atomic tmp+rename). Teammates append only to their own outbox, append to
18
- * recipient inboxes, and express board intent through exclusive-create files.
14
+ * recipient inboxes, and express tasks intent through exclusive-create files.
19
15
  */
20
16
 
21
- import * as crypto from "node:crypto";
22
17
  import * as fs from "node:fs";
23
18
  import * as path from "node:path";
24
19
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
25
- import { TEAM_RUNTIME_VERSION, type TaskIntent, type TeamState, type WorkerEvent } from "./types.ts";
26
-
27
- const MAX_WORKER_EVENT_BYTES = 64 * 1024;
28
- const MAX_INBOX_MESSAGE_BYTES = 64 * 1024;
29
- const MAX_OUTBOX_READ_BYTES = 256 * 1024;
30
- const MAX_INTENT_BYTES = 16 * 1024;
31
-
32
- export function sessionKey(sessionFile: string | undefined, cwd: string): string {
33
- return crypto
34
- .createHash("sha256")
35
- .update(sessionFile ?? cwd)
36
- .digest("hex")
37
- .slice(0, 16);
38
- }
20
+ import {
21
+ appendJsonlLine,
22
+ readJsonlBatch,
23
+ safeFileName,
24
+ sessionKey,
25
+ writeJsonAtomic,
26
+ } from "@fradser/pi-kit";
27
+ // Re-exported so consumers of this module keep one import site for session
28
+ // files. The implementations are pi-kit's: an incremental JSONL read and an
29
+ // atomic replace are not agent-teams concepts.
30
+ export { readJsonlBatch, writeJsonAtomic };
31
+ import type { TeamState, WorkerEvent } from "./types.ts";
39
32
 
40
33
  export function sessionStateDir(sessionFile: string | undefined, cwd: string): string {
41
34
  return path.join(getAgentDir(), "teammate", sessionKey(sessionFile, cwd));
@@ -52,16 +45,12 @@ export function writeStateFile(file: string, state: TeamState): void {
52
45
 
53
46
  // ── Peer mail ─────────────────────────────────────────────────────
54
47
 
55
- function safeName(name: string): string {
56
- return encodeURIComponent(name);
57
- }
58
-
59
48
  export function mailDir(stateFile: string): string {
60
49
  return path.join(path.dirname(stateFile), "mail");
61
50
  }
62
51
 
63
52
  export function inboxPath(stateFile: string, teammateName: string): string {
64
- return path.join(mailDir(stateFile), `inbox-${safeName(teammateName)}.jsonl`);
53
+ return path.join(mailDir(stateFile), `inbox-${safeFileName(teammateName)}.jsonl`);
65
54
  }
66
55
 
67
56
  export function rosterPath(stateFile: string): string {
@@ -70,81 +59,30 @@ export function rosterPath(stateFile: string): string {
70
59
 
71
60
  /** Append one message to a teammate inbox. Sent means this write succeeded. */
72
61
  export function appendInboxMessage(file: string, message: { id: string; from: string; subject: string; body: string }): void {
73
- const record = `${JSON.stringify(message)}\n`;
74
- if (Buffer.byteLength(record, "utf-8") > MAX_INBOX_MESSAGE_BYTES) {
75
- throw new Error(`Message exceeds ${MAX_INBOX_MESSAGE_BYTES} bytes.`);
76
- }
77
- fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
78
- fs.appendFileSync(file, record, { encoding: "utf-8", mode: 0o600 });
62
+ appendJsonlLine(file, message, { label: "Message" });
79
63
  }
80
64
 
81
- /** Publish the worker-readable roster of living teammates. */
82
- export function writeRoster(file: string, teammates: Array<{ name: string; agent: string; spawnId?: string; status: string; tools?: string[]; currentTaskId?: string; assignment?: import("./types.ts").WorkerAssignment }>): void {
65
+ /** Publish the worker-readable roster of living agents. */
66
+ export function writeRoster(file: string, agents: Array<{ name: string; role: string; spawnId?: string; status: string; tools?: string[]; currentTaskId?: string; assignment?: import("./types.ts").WorkerAssignment }>): void {
83
67
  fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
84
- writeJsonAtomic(file, { teammates });
68
+ writeJsonAtomic(file, { agents });
85
69
  }
86
70
 
87
71
  /** Read the roster from inside a teammate process; unknown roster = empty. */
88
- export function readRoster(file: string): Array<{ name: string; agent: string; spawnId?: string; status: string; tools?: string[]; currentTaskId?: string; assignment?: import("./types.ts").WorkerAssignment }> {
72
+ export function readRoster(file: string): Array<{ name: string; role: string; spawnId?: string; status: string; tools?: string[]; currentTaskId?: string; assignment?: import("./types.ts").WorkerAssignment }> {
89
73
  try {
90
- const parsed = JSON.parse(fs.readFileSync(file, "utf-8")) as { teammates?: Array<{ name: string; agent: string; spawnId?: string; status: string; tools?: string[]; currentTaskId?: string; assignment?: import("./types.ts").WorkerAssignment }> };
91
- return Array.isArray(parsed.teammates) ? parsed.teammates : [];
74
+ const parsed = JSON.parse(fs.readFileSync(file, "utf-8")) as { agents?: Array<{ name: string; role: string; spawnId?: string; status: string; tools?: string[]; currentTaskId?: string; assignment?: import("./types.ts").WorkerAssignment }> };
75
+ return Array.isArray(parsed.agents) ? parsed.agents : [];
92
76
  } catch {
93
77
  return [];
94
78
  }
95
79
  }
96
80
 
97
- /** Read complete JSONL records after byteOffset (shared by outboxes and inboxes). */
98
- export function readJsonlBatch(file: string, byteOffset: number, endOffset = Number.POSITIVE_INFINITY): {
99
- records: unknown[];
100
- nextOffset: number;
101
- diagnostics: string[];
102
- } {
103
- let fd: number | undefined;
104
- try {
105
- fd = fs.openSync(file, "r");
106
- const size = fs.fstatSync(fd).size;
107
- // A truncated/recreated file starts at zero; message ids make replay safe.
108
- const offset = byteOffset > size ? 0 : Math.max(0, byteOffset);
109
- const toRead = Math.max(0, Math.min(MAX_OUTBOX_READ_BYTES, Math.min(size, endOffset) - offset));
110
- if (toRead === 0) return { records: [], nextOffset: offset, diagnostics: [] };
111
- const raw = Buffer.allocUnsafe(toRead);
112
- const bytesRead = fs.readSync(fd, raw, 0, toRead, offset);
113
- const unread = raw.subarray(0, bytesRead);
114
- const lastNewline = unread.lastIndexOf(0x0a);
115
- if (lastNewline < 0) {
116
- // A line larger than the batch cap is malformed for this protocol; skip
117
- // this chunk so one sender cannot block draining indefinitely.
118
- return {
119
- records: [],
120
- nextOffset: bytesRead === MAX_OUTBOX_READ_BYTES ? offset + bytesRead : offset,
121
- diagnostics: ["malformed or unterminated record was consumed"],
122
- };
123
- }
124
- const complete = unread.subarray(0, lastNewline).toString("utf-8");
125
- const records: unknown[] = [];
126
- const diagnostics: string[] = [];
127
- for (const line of complete.split("\n")) {
128
- if (!line.trim()) continue;
129
- try {
130
- records.push(JSON.parse(line));
131
- } catch {
132
- diagnostics.push("malformed JSON record was consumed");
133
- }
134
- }
135
- return { records, nextOffset: offset + lastNewline + 1, diagnostics };
136
- } catch {
137
- return { records: [], nextOffset: 0, diagnostics: [] };
138
- } finally {
139
- if (fd !== undefined) fs.closeSync(fd);
140
- }
141
- }
142
-
143
81
  // ── Report outboxes ───────────────────────────────────────────────
144
82
 
145
83
  /** Per-teammate append-only report log. Its filename cannot escape the dir. */
146
84
  export function workerOutboxPath(stateFile: string, workerName: string, spawnId: string): string {
147
- return path.join(path.dirname(stateFile), "events", `${safeName(workerName)}.${safeName(spawnId)}.jsonl`);
85
+ return path.join(path.dirname(stateFile), "events", `${safeFileName(workerName)}.${safeFileName(spawnId)}.jsonl`);
148
86
  }
149
87
 
150
88
  /** Delete a drained per-spawn outbox after its final snapshot is published. */
@@ -154,161 +92,7 @@ export function removeWorkerOutbox(stateFile: string, workerName: string, spawnI
154
92
 
155
93
  /** Append one teammate report event. Workers never replace leader state. */
156
94
  export function appendWorkerEvent(file: string, event: WorkerEvent): void {
157
- const record = `${JSON.stringify(event)}\n`;
158
- if (Buffer.byteLength(record, "utf-8") > MAX_WORKER_EVENT_BYTES) {
159
- throw new Error(`Worker event exceeds ${MAX_WORKER_EVENT_BYTES} bytes.`);
160
- }
161
- fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
162
- fs.appendFileSync(file, record, { encoding: "utf-8", mode: 0o600 });
163
- }
164
-
165
- // ── Persistent task board ─────────────────────────────────────────
166
-
167
- /** Root of all per-session board dirs (`~/.pi/agent/tasks/`). */
168
- export function tasksRoot(): string {
169
- return path.join(getAgentDir(), "tasks");
170
- }
171
-
172
- export function boardDir(sessionFile: string | undefined, cwd: string): string {
173
- return path.join(tasksRoot(), sessionKey(sessionFile, cwd));
174
- }
175
-
176
- export function boardFilePath(sessionFile: string | undefined, cwd: string): string {
177
- return path.join(boardDir(sessionFile, cwd), "board.json");
178
- }
179
-
180
- export function claimsDir(boardDirectory: string): string {
181
- return path.join(boardDirectory, "claims");
182
- }
183
-
184
- export function submissionsDir(boardDirectory: string): string {
185
- return path.join(boardDirectory, "submissions");
186
- }
187
-
188
- export function readBoardFile(file: string): { tasks: Record<string, import("./types").BoardTask> } | undefined {
189
- let raw: string;
190
- try {
191
- raw = fs.readFileSync(file, "utf-8");
192
- } catch (error) {
193
- // An absent board is a fresh session, not a data-loss event.
194
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
195
- throw new Error(`Agent Teams Work snapshot at ${file} could not be read: ${error instanceof Error ? error.message : String(error)}`);
196
- }
197
- let parsed: { runtimeVersion?: number; tasks?: Record<string, import("./types").BoardTask> };
198
- try {
199
- parsed = JSON.parse(raw) as { runtimeVersion?: number; tasks?: Record<string, import("./types").BoardTask> };
200
- } catch {
201
- // A present but unreadable board must never be treated as an empty board:
202
- // callers archive the preserved file instead of silently losing work.
203
- throw new Error(`Agent Teams Work snapshot at ${file} is not valid JSON; refusing to treat it as an empty board.`);
204
- }
205
- if (parsed.tasks && parsed.runtimeVersion !== TEAM_RUNTIME_VERSION) {
206
- throw new Error(`Incompatible Agent Teams runtime snapshot version ${String(parsed.runtimeVersion)}; expected ${TEAM_RUNTIME_VERSION}.`);
207
- }
208
- return parsed.tasks ? { tasks: parsed.tasks } : undefined;
209
- }
210
-
211
- export function writeBoardFile(file: string, tasks: Record<string, import("./types").BoardTask>): void {
212
- fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
213
- writeJsonAtomic(file, { runtimeVersion: TEAM_RUNTIME_VERSION, tasks });
214
- }
215
-
216
- /**
217
- * Express a board intent through an exclusive-create marker file. Returns
218
- * true exactly when this caller won the race for the taskId.
219
- *
220
- * Publication is atomic: the record is fully written to a private temp file
221
- * and then hard-linked into place, so `link()` still fails with EEXIST for a
222
- * loser while a concurrent reader can never observe a partial record.
223
- */
224
- export function createTaskIntent(dir: string, taskId: string, intent: TaskIntent): boolean {
225
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
226
- const file = path.join(dir, `${safeName(taskId)}.json`);
227
- const payload = JSON.stringify(intent);
228
- if (Buffer.byteLength(payload, "utf-8") > MAX_INTENT_BYTES) {
229
- throw new Error(`Task intent exceeds ${MAX_INTENT_BYTES} bytes.`);
230
- }
231
- const tmp = path.join(dir, `.${safeName(taskId)}.${process.pid}.${crypto.randomUUID()}.tmp`);
232
- try {
233
- fs.writeFileSync(tmp, payload, { encoding: "utf-8", mode: 0o600, flag: "wx" });
234
- fs.linkSync(tmp, file);
235
- return true;
236
- } catch (error) {
237
- if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
238
- throw error;
239
- } finally {
240
- fs.rmSync(tmp, { force: true });
241
- }
242
- }
243
-
244
- /** An unparseable intent younger than this may still be mid-publish elsewhere. */
245
- export const INTENT_PUBLISH_GRACE_MS = 30_000;
246
-
247
- function olderThan(file: string, milliseconds: number): boolean {
248
- try {
249
- return Date.now() - fs.statSync(file).mtimeMs > milliseconds;
250
- } catch {
251
- return true;
252
- }
253
- }
254
-
255
- /**
256
- * Drain one pending intent file. Malformed records are consumed and reported
257
- * so one broken file can never block the queue — but a record that cannot be
258
- * parsed yet is retried while it is younger than the publish grace, because a
259
- * destroyed in-flight intent would leave its author waiting forever.
260
- */
261
- export function takeTaskIntent(dir: string): { intent?: TaskIntent; diagnostic?: string } {
262
- let entries: string[] = [];
263
- try {
264
- entries = fs.readdirSync(dir).filter((name) => name.endsWith(".json")).sort();
265
- } catch {
266
- return {};
267
- }
268
- for (const name of entries) {
269
- const file = path.join(dir, name);
270
- let raw: string;
271
- try {
272
- raw = fs.readFileSync(file, "utf-8");
273
- } catch {
274
- // Raced with a concurrent publish or removal; the next tick retries.
275
- continue;
276
- }
277
- let parsed: Partial<TaskIntent>;
278
- try {
279
- parsed = JSON.parse(raw) as Partial<TaskIntent>;
280
- } catch {
281
- if (!olderThan(file, INTENT_PUBLISH_GRACE_MS)) continue;
282
- fs.rmSync(file, { force: true });
283
- return { diagnostic: `unreadable task intent "${name}" was consumed` };
284
- }
285
- fs.rmSync(file, { force: true });
286
- if (
287
- typeof parsed.taskId !== "string" || parsed.taskId.trim() === ""
288
- || typeof parsed.worker !== "string" || parsed.worker.trim() === ""
289
- || typeof parsed.spawnId !== "string" || parsed.spawnId.trim() === ""
290
- || typeof parsed.timestamp !== "number" || !Number.isFinite(parsed.timestamp)
291
- ) {
292
- return { diagnostic: `malformed task intent "${name}" was consumed (requires non-empty taskId/worker/spawnId and finite timestamp)` };
293
- }
294
- if (parsed.status !== undefined && parsed.status !== "completed" && parsed.status !== "failed") {
295
- return { diagnostic: `malformed task intent "${name}" was consumed (invalid submission status)` };
296
- }
297
- if (typeof parsed.result !== "undefined" && typeof parsed.result !== "string") {
298
- return { diagnostic: `malformed task intent "${name}" was consumed (invalid submission result)` };
299
- }
300
- return { intent: parsed as TaskIntent };
301
- }
302
- return {};
303
- }
304
-
305
- // ── Atomic JSON IO ────────────────────────────────────────────────
306
-
307
- function writeJsonAtomic(file: string, value: unknown): void {
308
- fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
309
- const tmp = `${file}.${process.pid}.tmp`;
310
- fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 });
311
- fs.renameSync(tmp, file);
95
+ appendJsonlLine(file, event, { label: "Worker event" });
312
96
  }
313
97
 
314
98
  // ── Expired runtime-dir cleanup ───────────────────────────────────
@@ -324,7 +108,7 @@ export function removeSessionStateDir(sessionFile: string | undefined, cwd: stri
324
108
  }
325
109
 
326
110
  /**
327
- * Sweep runtime dirs whose last write is older than maxAgeMs. Board dirs are
111
+ * Sweep runtime dirs whose last write is older than maxAgeMs. Tasks dirs are
328
112
  * never swept. Called on session_start so abandoned sessions never pile up.
329
113
  */
330
114
  export function cleanupExpiredStateDirs(maxAgeMs: number): number {