@fradser/pi-agent-teams 0.5.3 → 0.6.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,32 +1,44 @@
1
1
  /**
2
- * Shared session files for the leader-owned debug snapshot and worker outboxes.
2
+ * Shared session files for the leader-owned snapshot, teammate outboxes and
3
+ * inboxes, and the persistent task board.
3
4
  *
4
- * Layout: `~/.pi/agent/teammate/<sessionKey>/state.json`, sessionKey derived
5
- * from the session file (or cwd for ephemeral sessions) so different sessions
6
- * never share a board file.
5
+ * Runtime layout (removed at session shutdown):
6
+ * ~/.pi/agent/teammate/<sessionKey>/state.json leader-owned snapshot
7
+ * ~/.pi/agent/teammate/<sessionKey>/events/*.jsonl per-spawn report outboxes
8
+ * ~/.pi/agent/teammate/<sessionKey>/mail/*.jsonl peer inbox files
9
+ * ~/.pi/agent/teammate/<sessionKey>/roster.json living teammates, worker-readable
7
10
  *
8
- * Concurrency: the parent is the sole writer of state.json (atomic tmp+rename).
9
- * Workers append requests only to their own JSONL outbox; the state snapshot is
10
- * not a worker inbox or a communication channel.
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
17
+ * (atomic tmp+rename). Teammates append only to their own outbox, append to
18
+ * recipient inboxes, and express board intent through exclusive-create files.
11
19
  */
12
20
 
13
21
  import * as crypto from "node:crypto";
14
22
  import * as fs from "node:fs";
15
- import * as os from "node:os";
16
23
  import * as path from "node:path";
17
- import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
18
- import type { TeammateState, WorkerEvent } from "./types";
24
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
25
+ import type { TaskIntent, TeamState, WorkerEvent } from "./types.ts";
19
26
 
20
27
  const MAX_WORKER_EVENT_BYTES = 64 * 1024;
28
+ const MAX_INBOX_MESSAGE_BYTES = 64 * 1024;
21
29
  const MAX_OUTBOX_READ_BYTES = 256 * 1024;
30
+ const MAX_INTENT_BYTES = 16 * 1024;
22
31
 
23
- export function sessionStateDir(sessionFile: string | undefined, cwd: string): string {
24
- const key = crypto
32
+ export function sessionKey(sessionFile: string | undefined, cwd: string): string {
33
+ return crypto
25
34
  .createHash("sha256")
26
35
  .update(sessionFile ?? cwd)
27
36
  .digest("hex")
28
37
  .slice(0, 16);
29
- return path.join(os.homedir(), CONFIG_DIR_NAME, "agent", "teammate", key);
38
+ }
39
+
40
+ export function sessionStateDir(sessionFile: string | undefined, cwd: string): string {
41
+ return path.join(getAgentDir(), "teammate", sessionKey(sessionFile, cwd));
30
42
  }
31
43
 
32
44
  export function stateFilePath(sessionFile: string | undefined, cwd: string): string {
@@ -34,85 +46,236 @@ export function stateFilePath(sessionFile: string | undefined, cwd: string): str
34
46
  }
35
47
 
36
48
  /** Write the leader-owned debug snapshot atomically (tmp + rename). */
37
- export function writeStateFile(file: string, state: TeammateState): void {
38
- fs.mkdirSync(path.dirname(file), { recursive: true });
39
- const tmp = `${file}.${process.pid}.tmp`;
40
- fs.writeFileSync(tmp, JSON.stringify(state), { mode: 0o600 });
41
- fs.renameSync(tmp, file);
49
+ export function writeStateFile(file: string, state: TeamState): void {
50
+ writeJsonAtomic(file, state);
42
51
  }
43
52
 
44
- /** Per-run append-only event log. Its filename cannot escape the session dir. */
45
- export function workerOutboxPath(stateFile: string, workerName: string, runId: string): string {
46
- return path.join(path.dirname(stateFile), "events", `${encodeURIComponent(workerName)}.${encodeURIComponent(runId)}.jsonl`);
53
+ // ── Peer mail ─────────────────────────────────────────────────────
54
+
55
+ function safeName(name: string): string {
56
+ return encodeURIComponent(name);
47
57
  }
48
58
 
49
- /** Delete a drained per-run outbox after its final snapshot is safely published. */
50
- export function removeWorkerOutbox(stateFile: string, workerName: string, runId: string): void {
51
- fs.rmSync(workerOutboxPath(stateFile, workerName, runId), { force: true });
59
+ export function mailDir(stateFile: string): string {
60
+ return path.join(path.dirname(stateFile), "mail");
52
61
  }
53
62
 
54
- /** Append one worker event. Workers never replace the leader-owned state snapshot. */
55
- export function appendWorkerEvent(file: string, event: WorkerEvent): void {
56
- const record = `${JSON.stringify(event)}\n`;
57
- if (Buffer.byteLength(record, "utf-8") > MAX_WORKER_EVENT_BYTES) {
58
- throw new Error(`Worker event exceeds ${MAX_WORKER_EVENT_BYTES} bytes.`);
63
+ export function inboxPath(stateFile: string, teammateName: string): string {
64
+ return path.join(mailDir(stateFile), `inbox-${safeName(teammateName)}.jsonl`);
65
+ }
66
+
67
+ export function rosterPath(stateFile: string): string {
68
+ return path.join(path.dirname(stateFile), "roster.json");
69
+ }
70
+
71
+ /** Append one message to a teammate inbox. Sent means this write succeeded. */
72
+ 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.`);
59
76
  }
60
77
  fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
61
78
  fs.appendFileSync(file, record, { encoding: "utf-8", mode: 0o600 });
62
79
  }
63
80
 
64
- /** Read a bounded batch of complete JSONL records after byteOffset. */
65
- export function readWorkerEvents(file: string, byteOffset: number): { events: unknown[]; nextOffset: number } {
81
+ /** Publish the worker-readable roster of living teammates. */
82
+ export function writeRoster(file: string, teammates: Array<{ name: string; agent: string; status: string }>): void {
83
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
84
+ writeJsonAtomic(file, { teammates });
85
+ }
86
+
87
+ /** Read the roster from inside a teammate process; unknown roster = empty. */
88
+ export function readRoster(file: string): Array<{ name: string; agent: string; status: string }> {
89
+ try {
90
+ const parsed = JSON.parse(fs.readFileSync(file, "utf-8")) as { teammates?: Array<{ name: string; agent: string; status: string }> };
91
+ return Array.isArray(parsed.teammates) ? parsed.teammates : [];
92
+ } catch {
93
+ return [];
94
+ }
95
+ }
96
+
97
+ /** Read complete JSONL records after byteOffset (shared by outboxes and inboxes). */
98
+ export function readJsonlBatch(file: string, byteOffset: number): {
99
+ records: unknown[];
100
+ nextOffset: number;
101
+ diagnostics: string[];
102
+ } {
66
103
  let fd: number | undefined;
67
104
  try {
68
105
  fd = fs.openSync(file, "r");
69
106
  const size = fs.fstatSync(fd).size;
70
- // A truncated/recreated outbox starts at zero; event IDs make replay safe.
107
+ // A truncated/recreated file starts at zero; message ids make replay safe.
71
108
  const offset = byteOffset > size ? 0 : Math.max(0, byteOffset);
72
109
  const toRead = Math.min(MAX_OUTBOX_READ_BYTES, size - offset);
73
- if (toRead === 0) return { events: [], nextOffset: offset };
110
+ if (toRead === 0) return { records: [], nextOffset: offset, diagnostics: [] };
74
111
  const raw = Buffer.allocUnsafe(toRead);
75
112
  const bytesRead = fs.readSync(fd, raw, 0, toRead, offset);
76
113
  const unread = raw.subarray(0, bytesRead);
77
114
  const lastNewline = unread.lastIndexOf(0x0a);
78
115
  if (lastNewline < 0) {
79
116
  // A line larger than the batch cap is malformed for this protocol; skip
80
- // this chunk so one worker cannot block event draining indefinitely.
81
- return { events: [], nextOffset: bytesRead === MAX_OUTBOX_READ_BYTES ? offset + bytesRead : offset };
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
+ };
82
123
  }
83
124
  const complete = unread.subarray(0, lastNewline).toString("utf-8");
84
- const events: unknown[] = [];
125
+ const records: unknown[] = [];
126
+ const diagnostics: string[] = [];
85
127
  for (const line of complete.split("\n")) {
86
128
  if (!line.trim()) continue;
87
129
  try {
88
- events.push(JSON.parse(line));
130
+ records.push(JSON.parse(line));
89
131
  } catch {
90
- // Malformed records are consumed, not retried forever.
132
+ diagnostics.push("malformed JSON record was consumed");
91
133
  }
92
134
  }
93
- return { events, nextOffset: offset + lastNewline + 1 };
135
+ return { records, nextOffset: offset + lastNewline + 1, diagnostics };
94
136
  } catch {
95
- return { events: [], nextOffset: 0 };
137
+ return { records: [], nextOffset: 0, diagnostics: [] };
96
138
  } finally {
97
139
  if (fd !== undefined) fs.closeSync(fd);
98
140
  }
99
141
  }
100
142
 
101
- // ── Expired state-dir cleanup ───────────────────────────────────────
143
+ // ── Report outboxes ───────────────────────────────────────────────
144
+
145
+ /** Per-teammate append-only report log. Its filename cannot escape the dir. */
146
+ export function workerOutboxPath(stateFile: string, workerName: string, spawnId: string): string {
147
+ return path.join(path.dirname(stateFile), "events", `${safeName(workerName)}.${safeName(spawnId)}.jsonl`);
148
+ }
149
+
150
+ /** Delete a drained per-spawn outbox after its final snapshot is published. */
151
+ export function removeWorkerOutbox(stateFile: string, workerName: string, spawnId: string): void {
152
+ fs.rmSync(workerOutboxPath(stateFile, workerName, spawnId), { force: true });
153
+ }
154
+
155
+ /** Append one teammate report event. Workers never replace leader state. */
156
+ 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
+ try {
190
+ const parsed = JSON.parse(fs.readFileSync(file, "utf-8")) as { tasks?: Record<string, import("./types").BoardTask> };
191
+ return parsed.tasks ? { tasks: parsed.tasks } : undefined;
192
+ } catch {
193
+ return undefined;
194
+ }
195
+ }
196
+
197
+ export function writeBoardFile(file: string, tasks: Record<string, import("./types").BoardTask>): void {
198
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
199
+ writeJsonAtomic(file, { tasks });
200
+ }
201
+
202
+ /**
203
+ * Express a board intent through an exclusive-create marker file. Returns
204
+ * true exactly when this caller won the race for the taskId.
205
+ */
206
+ export function createTaskIntent(dir: string, taskId: string, intent: TaskIntent): boolean {
207
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
208
+ const file = path.join(dir, `${safeName(taskId)}.json`);
209
+ const payload = JSON.stringify(intent);
210
+ if (Buffer.byteLength(payload, "utf-8") > MAX_INTENT_BYTES) {
211
+ throw new Error(`Task intent exceeds ${MAX_INTENT_BYTES} bytes.`);
212
+ }
213
+ try {
214
+ fs.writeFileSync(file, payload, { encoding: "utf-8", mode: 0o600, flag: "wx" });
215
+ return true;
216
+ } catch (error) {
217
+ const code = (error as NodeJS.ErrnoException).code;
218
+ if (code === "EEXIST") return false;
219
+ throw error;
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Drain one pending intent file. Malformed records are consumed and reported
225
+ * so one broken file can never block the queue.
226
+ */
227
+ export function takeTaskIntent(dir: string): { intent?: TaskIntent; diagnostic?: string } {
228
+ let entries: string[] = [];
229
+ try {
230
+ entries = fs.readdirSync(dir).filter((name) => name.endsWith(".json")).sort();
231
+ } catch {
232
+ return {};
233
+ }
234
+ for (const name of entries) {
235
+ const file = path.join(dir, name);
236
+ try {
237
+ const parsed = JSON.parse(fs.readFileSync(file, "utf-8")) as Partial<TaskIntent>;
238
+ fs.rmSync(file, { force: true });
239
+ if (
240
+ typeof parsed.taskId !== "string"
241
+ || typeof parsed.worker !== "string"
242
+ || typeof parsed.spawnId !== "string"
243
+ ) {
244
+ return { diagnostic: `malformed task intent "${name}" was consumed (missing taskId/worker/spawnId)` };
245
+ }
246
+ return { intent: parsed as TaskIntent };
247
+ } catch {
248
+ fs.rmSync(file, { force: true });
249
+ return { diagnostic: `unreadable task intent "${name}" was consumed` };
250
+ }
251
+ }
252
+ return {};
253
+ }
254
+
255
+ // ── Atomic JSON IO ────────────────────────────────────────────────
256
+
257
+ function writeJsonAtomic(file: string, value: unknown): void {
258
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
259
+ const tmp = `${file}.${process.pid}.tmp`;
260
+ fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 });
261
+ fs.renameSync(tmp, file);
262
+ }
263
+
264
+ // ── Expired runtime-dir cleanup ───────────────────────────────────
102
265
 
103
- /** Root of all per-session teammate state dirs (`~/.pi/agent/teammate/`). */
266
+ /** Root of all per-session teammate runtime dirs (`~/.pi/agent/teammate/`). */
104
267
  export function stateDirsRoot(): string {
105
- return path.join(os.homedir(), CONFIG_DIR_NAME, "agent", "teammate");
268
+ return path.join(getAgentDir(), "teammate");
106
269
  }
107
270
 
108
- /** Remove the current session's state dir (called on session_shutdown). */
271
+ /** Remove the current session's runtime dir (called on session_shutdown). */
109
272
  export function removeSessionStateDir(sessionFile: string | undefined, cwd: string): void {
110
273
  fs.rmSync(sessionStateDir(sessionFile, cwd), { recursive: true, force: true });
111
274
  }
112
275
 
113
276
  /**
114
- * Sweep state dirs whose last write is older than maxAgeMs. Returns how many
115
- * were removed. Called on session_start so abandoned sessions never pile up.
277
+ * Sweep runtime dirs whose last write is older than maxAgeMs. Board dirs are
278
+ * never swept. Called on session_start so abandoned sessions never pile up.
116
279
  */
117
280
  export function cleanupExpiredStateDirs(maxAgeMs: number): number {
118
281
  const root = stateDirsRoot();