@fradser/pi-agent-teams 0.8.2 → 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/.memory/MEMORY.md +1 -1
- package/.memory/follow-up-queue.md +8 -11
- package/.memory/team-centric-design.md +18 -14
- package/README.md +119 -161
- package/package.json +2 -2
- package/references/agent-roles.md +57 -12
- package/src/activity.ts +12 -33
- package/src/agent-actions.ts +101 -0
- package/src/agents.ts +1 -6
- package/src/guidance.ts +114 -199
- package/src/index.ts +117 -101
- package/src/leader-reports.ts +92 -0
- package/src/recipient.ts +44 -0
- package/src/spawner.ts +293 -38
- package/src/state.ts +250 -69
- package/src/statefile.ts +74 -31
- package/src/team-machine.ts +984 -424
- package/src/tool-copy.ts +374 -0
- package/src/tool-render.ts +47 -22
- package/src/tools.ts +182 -160
- package/src/types.ts +104 -65
- package/src/ui.ts +32 -56
- package/src/work-context.ts +88 -0
- package/src/worker-tools.ts +4 -0
- package/src/worker.ts +251 -184
- package/src/follow-up-queue.ts +0 -301
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
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|