agents-pod 0.1.0 → 0.3.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/CHANGELOG.md +68 -0
- package/README.md +283 -25
- package/examples/feishu/.env.example +23 -0
- package/examples/feishu/README.md +92 -0
- package/examples/feishu/feishu_client.py +220 -0
- package/examples/feishu/feishu_deliver.py +136 -0
- package/examples/feishu/feishu_drain.py +305 -0
- package/package.json +2 -1
- package/src/cli.ts +472 -47
- package/src/doctor.ts +551 -0
- package/src/inbox.ts +75 -9
- package/src/listeners.ts +11 -5
- package/src/outbox.ts +294 -0
- package/src/paths.ts +15 -0
- package/src/sources.ts +312 -0
- package/src/workspace.ts +11 -4
- package/src/wrapper.ts +71 -22
package/src/workspace.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import type
|
|
4
|
+
import { deliveryAddress, type InboxItem } from "./inbox.ts";
|
|
5
5
|
import { podDir } from "./paths.ts";
|
|
6
6
|
import type { PodRecord } from "./registry.ts";
|
|
7
7
|
|
|
@@ -48,7 +48,7 @@ const TEMPLATES: Record<string, string> = {
|
|
|
48
48
|
"One line per decision, with its reason. Append-only.",
|
|
49
49
|
"",
|
|
50
50
|
].join("\n"),
|
|
51
|
-
"TASKS.md": ["# Tasks", "", "## Plan", "", "## Inbox", ""].join("\n"),
|
|
51
|
+
"TASKS.md": ["# Tasks", "", "## Plan", "", "## Waiting", "", "## Inbox", ""].join("\n"),
|
|
52
52
|
};
|
|
53
53
|
|
|
54
54
|
/** The one section reconcile may write (ADR 0008, 0014). */
|
|
@@ -58,6 +58,9 @@ export async function createPodWorkspace(pod: PodRecord): Promise<string> {
|
|
|
58
58
|
const dir = podDir(pod.project, pod.id);
|
|
59
59
|
const docs = join(dir, "docs");
|
|
60
60
|
await mkdir(docs, { recursive: true });
|
|
61
|
+
// The outbox is beside the three-slot Document Store, never a fourth slot
|
|
62
|
+
// (ADRs 0008, 0025).
|
|
63
|
+
await mkdir(join(dir, "outbox"), { recursive: true });
|
|
61
64
|
for (const [name, body] of Object.entries(TEMPLATES)) {
|
|
62
65
|
await writeFile(join(docs, name), body);
|
|
63
66
|
}
|
|
@@ -70,9 +73,13 @@ export async function createPodWorkspace(pod: PodRecord): Promise<string> {
|
|
|
70
73
|
* audit have an anchor; the body is copied because the source file belongs to
|
|
71
74
|
* its writer and may be deleted once the item is claimed.
|
|
72
75
|
*/
|
|
73
|
-
export function inboxBlock(
|
|
76
|
+
export function inboxBlock(
|
|
77
|
+
item: Pick<InboxItem, "id" | "title" | "body" | "chatId" | "rootId" | "threadId">,
|
|
78
|
+
): string {
|
|
74
79
|
const title = item.title.split("\n", 1)[0]!.trim();
|
|
75
|
-
const
|
|
80
|
+
const address = deliveryAddress(item);
|
|
81
|
+
const suffix = address === "" ? "" : ` — to: ${address}`;
|
|
82
|
+
const lines = [`- [ ] ${item.id} — ${title}${suffix}`];
|
|
76
83
|
const body = item.body.replace(/\r\n/g, "\n").replace(/\n+$/, "");
|
|
77
84
|
if (body.length > 0) {
|
|
78
85
|
for (const line of body.split("\n")) {
|
package/src/wrapper.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import {
|
|
4
|
+
appendFile,
|
|
4
5
|
copyFile,
|
|
5
6
|
mkdir,
|
|
6
7
|
open,
|
|
@@ -20,7 +21,15 @@ import {
|
|
|
20
21
|
type WrapperRecord,
|
|
21
22
|
} from "./liveness.ts";
|
|
22
23
|
import { podDir } from "./paths.ts";
|
|
23
|
-
import {
|
|
24
|
+
import { flushOutbox } from "./outbox.ts";
|
|
25
|
+
import { sleep } from "./locks.ts";
|
|
26
|
+
import {
|
|
27
|
+
readPods,
|
|
28
|
+
withRegistryLock,
|
|
29
|
+
writePods,
|
|
30
|
+
type PodRecord,
|
|
31
|
+
type WakeUpOutcome,
|
|
32
|
+
} from "./registry.ts";
|
|
24
33
|
import { snapshotDocs } from "./workspace.ts";
|
|
25
34
|
|
|
26
35
|
/** The heartbeat interval: the mtime is the liveness signal (ADR 0017). */
|
|
@@ -213,17 +222,52 @@ function instructionsHash(instructions: string): string {
|
|
|
213
222
|
return createHash("sha256").update(instructions).digest("hex").slice(0, 12);
|
|
214
223
|
}
|
|
215
224
|
|
|
225
|
+
/**
|
|
226
|
+
* One pod's read-modify-write belongs wholly inside the registry lock (ADR
|
|
227
|
+
* 0018). A wrapper that cannot record its result must fail loudly and keep its
|
|
228
|
+
* identity: reconcile may call that wake-up lost, but its outcome is never
|
|
229
|
+
* silently discarded.
|
|
230
|
+
*/
|
|
231
|
+
async function updatePodRecord(
|
|
232
|
+
podId: string,
|
|
233
|
+
podRoot: string,
|
|
234
|
+
n: number,
|
|
235
|
+
mutate: (record: PodRecord) => void,
|
|
236
|
+
): Promise<boolean> {
|
|
237
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
238
|
+
const recorded = await withRegistryLock(async () => {
|
|
239
|
+
const pods = await readPods();
|
|
240
|
+
const record = pods.find((candidate) => candidate.id === podId);
|
|
241
|
+
if (record) {
|
|
242
|
+
mutate(record);
|
|
243
|
+
await writePods(pods);
|
|
244
|
+
}
|
|
245
|
+
return true;
|
|
246
|
+
});
|
|
247
|
+
if (recorded !== undefined) return true;
|
|
248
|
+
if (attempt < 2) await sleep(100 * 2 ** attempt);
|
|
249
|
+
}
|
|
250
|
+
await appendFile(
|
|
251
|
+
join(podRoot, "output.log"),
|
|
252
|
+
`wrapper: registry lock unavailable; wake-up ${n} outcome not recorded\n`,
|
|
253
|
+
);
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
|
|
216
257
|
/**
|
|
217
258
|
* Records a wake-up that never reached an agent. The outcome is a fact; the
|
|
218
259
|
* policy that follows from it — backoff, streak, park — stays reconcile's.
|
|
219
260
|
*/
|
|
220
|
-
async function recordUnresolved(
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
261
|
+
async function recordUnresolved(
|
|
262
|
+
podId: string,
|
|
263
|
+
podRoot: string,
|
|
264
|
+
n: number,
|
|
265
|
+
what: string,
|
|
266
|
+
): Promise<boolean> {
|
|
267
|
+
return updatePodRecord(podId, podRoot, n, (record) => {
|
|
268
|
+
record.wakeUps.push({ n, outcome: "failed", detail: what });
|
|
269
|
+
record.state = "idle";
|
|
270
|
+
});
|
|
227
271
|
}
|
|
228
272
|
|
|
229
273
|
export async function runWrapper(argv: string[]): Promise<number> {
|
|
@@ -255,8 +299,7 @@ export async function runWrapper(argv: string[]): Promise<number> {
|
|
|
255
299
|
const agentId = agentWord ?? pod.policy?.agent ?? "default";
|
|
256
300
|
const agent = resolveAgentDefinition(await readAgents(), agentId);
|
|
257
301
|
if (!agent) {
|
|
258
|
-
await recordUnresolved(podId, n, "agent resolution failed");
|
|
259
|
-
return 0;
|
|
302
|
+
return (await recordUnresolved(podId, podRoot, n, "agent resolution failed")) ? 0 : 1;
|
|
260
303
|
}
|
|
261
304
|
|
|
262
305
|
// The second seat of the Pi-only guard (ADR 0023, agents-store.ts): the CLI refuses an
|
|
@@ -266,8 +309,14 @@ export async function runWrapper(argv: string[]): Promise<number> {
|
|
|
266
309
|
// from there; it never silently runs pi in claude's name.
|
|
267
310
|
const refusal = agentTypeRefusal(agent.type);
|
|
268
311
|
if (refusal !== undefined) {
|
|
269
|
-
await recordUnresolved(
|
|
270
|
-
|
|
312
|
+
return (await recordUnresolved(
|
|
313
|
+
podId,
|
|
314
|
+
podRoot,
|
|
315
|
+
n,
|
|
316
|
+
`agent ${agent.id} has type ${agent.type}: ${refusal}`,
|
|
317
|
+
))
|
|
318
|
+
? 0
|
|
319
|
+
: 1;
|
|
271
320
|
}
|
|
272
321
|
|
|
273
322
|
const wakeupDir = join(podRoot, "wakeups", String(n));
|
|
@@ -437,9 +486,7 @@ export async function runWrapper(argv: string[]): Promise<number> {
|
|
|
437
486
|
// owner, and its detail is the wind-down, not the agent's last words. The
|
|
438
487
|
// pod is terminal, so reconcile never walks it back to a wake (ADR 0011).
|
|
439
488
|
if (cancelRequested) {
|
|
440
|
-
const
|
|
441
|
-
const record = pods.find((p) => p.id === podId);
|
|
442
|
-
if (record) {
|
|
489
|
+
const recorded = await updatePodRecord(podId, podRoot, n, (record) => {
|
|
443
490
|
// A reconcile kill already adjudicated this wake-up (ADRs 0017, 0019):
|
|
444
491
|
// its intent was written before the signal, so the wrapper adds no
|
|
445
492
|
// second, contradictory outcome — whether the cause was progressless or
|
|
@@ -452,9 +499,9 @@ export async function runWrapper(argv: string[]): Promise<number> {
|
|
|
452
499
|
: { n, outcome: "cancelled" },
|
|
453
500
|
);
|
|
454
501
|
record.state = "cancelled";
|
|
455
|
-
await writePods(pods);
|
|
456
502
|
}
|
|
457
|
-
}
|
|
503
|
+
});
|
|
504
|
+
if (!recorded) return 1;
|
|
458
505
|
// The identity outlives its purpose only until the outcome is recorded.
|
|
459
506
|
await rm(wrapperRecordFile, { force: true });
|
|
460
507
|
return 0;
|
|
@@ -466,13 +513,15 @@ export async function runWrapper(argv: string[]): Promise<number> {
|
|
|
466
513
|
() => undefined,
|
|
467
514
|
);
|
|
468
515
|
const { outcome, detail } = await adjudicate(docsDir, wakeStart, eventsFile, code);
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
516
|
+
// Delivery holds the active state until its bounded flush is over; a
|
|
517
|
+
// concurrent pass must not wake a second wrapper over the same outbox
|
|
518
|
+
// (ADR 0025). Failures are logged inside the flush and never change outcome.
|
|
519
|
+
await flushOutbox(podId, pod.project, n);
|
|
520
|
+
const recorded = await updatePodRecord(podId, podRoot, n, (record) => {
|
|
472
521
|
record.wakeUps.push(detail === undefined ? { n, outcome } : { n, outcome, detail });
|
|
473
522
|
record.state = outcome === "done" ? "done" : "idle";
|
|
474
|
-
|
|
475
|
-
|
|
523
|
+
});
|
|
524
|
+
if (!recorded) return 1;
|
|
476
525
|
// The identity outlives its purpose only until the outcome is recorded.
|
|
477
526
|
await rm(wrapperRecordFile, { force: true });
|
|
478
527
|
return 0;
|