@fradser/pi-agent-teams 0.8.3 → 0.9.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
@@ -22,7 +22,7 @@ import * as crypto from "node:crypto";
22
22
  import * as fs from "node:fs";
23
23
  import * as path from "node:path";
24
24
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
25
- import type { TaskIntent, TeamState, WorkerEvent } from "./types.ts";
25
+ import { TEAM_RUNTIME_VERSION, type TaskIntent, type TeamState, type WorkerEvent } from "./types.ts";
26
26
 
27
27
  const MAX_WORKER_EVENT_BYTES = 64 * 1024;
28
28
  const MAX_INBOX_MESSAGE_BYTES = 64 * 1024;
@@ -79,15 +79,15 @@ export function appendInboxMessage(file: string, message: { id: string; from: st
79
79
  }
80
80
 
81
81
  /** Publish the worker-readable roster of living teammates. */
82
- export function writeRoster(file: string, teammates: Array<{ name: string; agent: string; status: string; tools?: string[]; currentTaskId?: string; assignment?: import("./types.ts").WorkerAssignment }>): void {
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 {
83
83
  fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
84
84
  writeJsonAtomic(file, { teammates });
85
85
  }
86
86
 
87
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; tools?: string[]; currentTaskId?: string; assignment?: import("./types.ts").WorkerAssignment }> {
88
+ export function readRoster(file: string): Array<{ name: string; agent: string; spawnId?: string; status: string; tools?: string[]; currentTaskId?: string; assignment?: import("./types.ts").WorkerAssignment }> {
89
89
  try {
90
- const parsed = JSON.parse(fs.readFileSync(file, "utf-8")) as { teammates?: Array<{ name: string; agent: string; status: string; tools?: string[]; currentTaskId?: string; assignment?: import("./types.ts").WorkerAssignment }> };
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
91
  return Array.isArray(parsed.teammates) ? parsed.teammates : [];
92
92
  } catch {
93
93
  return [];
@@ -95,7 +95,7 @@ export function readRoster(file: string): Array<{ name: string; agent: string; s
95
95
  }
96
96
 
97
97
  /** Read complete JSONL records after byteOffset (shared by outboxes and inboxes). */
98
- export function readJsonlBatch(file: string, byteOffset: number): {
98
+ export function readJsonlBatch(file: string, byteOffset: number, endOffset = Number.POSITIVE_INFINITY): {
99
99
  records: unknown[];
100
100
  nextOffset: number;
101
101
  diagnostics: string[];
@@ -106,7 +106,7 @@ export function readJsonlBatch(file: string, byteOffset: number): {
106
106
  const size = fs.fstatSync(fd).size;
107
107
  // A truncated/recreated file starts at zero; message ids make replay safe.
108
108
  const offset = byteOffset > size ? 0 : Math.max(0, byteOffset);
109
- const toRead = Math.min(MAX_OUTBOX_READ_BYTES, size - offset);
109
+ const toRead = Math.max(0, Math.min(MAX_OUTBOX_READ_BYTES, Math.min(size, endOffset) - offset));
110
110
  if (toRead === 0) return { records: [], nextOffset: offset, diagnostics: [] };
111
111
  const raw = Buffer.allocUnsafe(toRead);
112
112
  const bytesRead = fs.readSync(fd, raw, 0, toRead, offset);
@@ -186,22 +186,40 @@ export function submissionsDir(boardDirectory: string): string {
186
186
  }
187
187
 
188
188
  export function readBoardFile(file: string): { tasks: Record<string, import("./types").BoardTask> } | undefined {
189
+ let raw: string;
189
190
  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;
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> };
192
200
  } catch {
193
- return undefined;
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.`);
194
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;
195
209
  }
196
210
 
197
211
  export function writeBoardFile(file: string, tasks: Record<string, import("./types").BoardTask>): void {
198
212
  fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
199
- writeJsonAtomic(file, { tasks });
213
+ writeJsonAtomic(file, { runtimeVersion: TEAM_RUNTIME_VERSION, tasks });
200
214
  }
201
215
 
202
216
  /**
203
217
  * Express a board intent through an exclusive-create marker file. Returns
204
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.
205
223
  */
206
224
  export function createTaskIntent(dir: string, taskId: string, intent: TaskIntent): boolean {
207
225
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -210,19 +228,35 @@ export function createTaskIntent(dir: string, taskId: string, intent: TaskIntent
210
228
  if (Buffer.byteLength(payload, "utf-8") > MAX_INTENT_BYTES) {
211
229
  throw new Error(`Task intent exceeds ${MAX_INTENT_BYTES} bytes.`);
212
230
  }
231
+ const tmp = path.join(dir, `.${safeName(taskId)}.${process.pid}.${crypto.randomUUID()}.tmp`);
213
232
  try {
214
- fs.writeFileSync(file, payload, { encoding: "utf-8", mode: 0o600, flag: "wx" });
233
+ fs.writeFileSync(tmp, payload, { encoding: "utf-8", mode: 0o600, flag: "wx" });
234
+ fs.linkSync(tmp, file);
215
235
  return true;
216
236
  } catch (error) {
217
- const code = (error as NodeJS.ErrnoException).code;
218
- if (code === "EEXIST") return false;
237
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
219
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;
220
252
  }
221
253
  }
222
254
 
223
255
  /**
224
256
  * Drain one pending intent file. Malformed records are consumed and reported
225
- * so one broken file can never block the queue.
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.
226
260
  */
227
261
  export function takeTaskIntent(dir: string): { intent?: TaskIntent; diagnostic?: string } {
228
262
  let entries: string[] = [];
@@ -233,28 +267,37 @@ export function takeTaskIntent(dir: string): { intent?: TaskIntent; diagnostic?:
233
267
  }
234
268
  for (const name of entries) {
235
269
  const file = path.join(dir, name);
270
+ let raw: string;
236
271
  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" || parsed.taskId.trim() === ""
241
- || typeof parsed.worker !== "string" || parsed.worker.trim() === ""
242
- || typeof parsed.spawnId !== "string" || parsed.spawnId.trim() === ""
243
- || typeof parsed.timestamp !== "number" || !Number.isFinite(parsed.timestamp)
244
- ) {
245
- return { diagnostic: `malformed task intent "${name}" was consumed (requires non-empty taskId/worker/spawnId and finite timestamp)` };
246
- }
247
- if (parsed.status !== undefined && parsed.status !== "completed" && parsed.status !== "failed") {
248
- return { diagnostic: `malformed task intent "${name}" was consumed (invalid submission status)` };
249
- }
250
- if (typeof parsed.result !== "undefined" && typeof parsed.result !== "string") {
251
- return { diagnostic: `malformed task intent "${name}" was consumed (invalid submission result)` };
252
- }
253
- return { intent: parsed as TaskIntent };
272
+ raw = fs.readFileSync(file, "utf-8");
254
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;
255
282
  fs.rmSync(file, { force: true });
256
283
  return { diagnostic: `unreadable task intent "${name}" was consumed` };
257
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 };
258
301
  }
259
302
  return {};
260
303
  }