agents-pod 0.1.0 → 0.2.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 +28 -0
- package/README.md +209 -22
- package/package.json +1 -1
- package/src/cli.ts +399 -47
- package/src/inbox.ts +75 -9
- package/src/listeners.ts +11 -5
- package/src/outbox.ts +294 -0
- package/src/paths.ts +10 -0
- package/src/sources.ts +278 -0
- package/src/workspace.ts +11 -4
- package/src/wrapper.ts +71 -22
package/src/cli.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
+
import { constants } from "node:fs";
|
|
3
4
|
import {
|
|
5
|
+
access,
|
|
4
6
|
appendFile,
|
|
5
7
|
mkdir,
|
|
6
8
|
readFile,
|
|
@@ -31,7 +33,8 @@ import { acquireLock, readLock, releaseLock, sleep } from "./locks.ts";
|
|
|
31
33
|
import { loadNotifyConfig } from "./notify/config.ts";
|
|
32
34
|
import { dispatchNotification, shouldNotify } from "./notify/dispatch.ts";
|
|
33
35
|
import type { NotificationsConfig, PodNotification } from "./notify/types.ts";
|
|
34
|
-
import { cronLogPath, podDir, reconcileLockPath } from "./paths.ts";
|
|
36
|
+
import { cronLogPath, delivererFile, podDir, reconcileLockPath } from "./paths.ts";
|
|
37
|
+
import { readDeliverer, retryOutbox, writeDeliverer } from "./outbox.ts";
|
|
35
38
|
import {
|
|
36
39
|
projectHasCapacity,
|
|
37
40
|
projectMaxActive,
|
|
@@ -40,6 +43,14 @@ import {
|
|
|
40
43
|
} from "./projects.ts";
|
|
41
44
|
import type { PodRecord } from "./registry.ts";
|
|
42
45
|
import { readPods, withRegistryLock, writePods } from "./registry.ts";
|
|
46
|
+
import {
|
|
47
|
+
commitSourceCursors,
|
|
48
|
+
drainSources,
|
|
49
|
+
pruneSourceClaims,
|
|
50
|
+
readSources,
|
|
51
|
+
writeSources,
|
|
52
|
+
type SourceDrain,
|
|
53
|
+
} from "./sources.ts";
|
|
43
54
|
import {
|
|
44
55
|
HEARTBEAT_STALE_MS,
|
|
45
56
|
WEDGED_ESCALATE_MS,
|
|
@@ -225,20 +236,46 @@ async function acquireReconcileLock(): Promise<boolean> {
|
|
|
225
236
|
* queued while policy is derived, recorded in `notifiedAt`, and delivered
|
|
226
237
|
* after the locks are gone — a slow channel never holds the registry.
|
|
227
238
|
*
|
|
228
|
-
*
|
|
229
|
-
* there, so each transition is a compare-and-swap
|
|
230
|
-
* now. The single-flight lock above is a courtesy;
|
|
239
|
+
* Sources drain before the body takes the registry lock (ADR 0024). The body
|
|
240
|
+
* then re-reads pods.json there, so each transition is a compare-and-swap
|
|
241
|
+
* against the record as it is now. The single-flight lock above is a courtesy;
|
|
242
|
+
* this is the correctness.
|
|
231
243
|
*/
|
|
232
244
|
async function reconcileCmd(deps: CliDeps = {}): Promise<number> {
|
|
233
245
|
if (!(await acquireReconcileLock())) return 0;
|
|
234
246
|
const warnings: string[] = [];
|
|
235
247
|
const notify = await loadNotifyConfig(undefined, (message) => warnings.push(message));
|
|
236
|
-
// Listener-spawned pods
|
|
237
|
-
//
|
|
248
|
+
// Listener-spawned pods default to the tick's project; `listener add
|
|
249
|
+
// --project` overrides that destination per Listener (ADR 0007, ADR 0004).
|
|
238
250
|
const project = deps.cwd ?? process.cwd();
|
|
239
251
|
let pending: PodNotification[] = [];
|
|
240
252
|
try {
|
|
241
|
-
|
|
253
|
+
// A slow drain holds no registry lock and runs before any Pod is touched.
|
|
254
|
+
const drained = await drainSources(project, await readListeners());
|
|
255
|
+
for (const result of drained) {
|
|
256
|
+
if (result.error !== undefined) {
|
|
257
|
+
await appendCronLog(
|
|
258
|
+
`source ${result.name}: drain failed (${result.error}); no items, cursor unchanged`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
for (const bad of result.bad) {
|
|
262
|
+
await appendCronLog(
|
|
263
|
+
`source ${result.name}: line ${bad.line}: ${bad.reason} (skipped, unclaimed)`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// Delivery is the drain's mirror: network-bound, so outside the lock
|
|
268
|
+
// (ADRs 0024, 0025). Every Pod that is not active gets the Wrapper's own
|
|
269
|
+
// bounded flush; the state is re-read per Pod so a wake that started
|
|
270
|
+
// meanwhile keeps its Wrapper the only writer over that outbox.
|
|
271
|
+
for (const pod of await readPods()) {
|
|
272
|
+
if (pod.state === "active") continue;
|
|
273
|
+
const fresh = await podState(pod.id);
|
|
274
|
+
if (fresh === undefined || fresh === "active") continue;
|
|
275
|
+
await retryOutbox(pod.id, pod.project);
|
|
276
|
+
}
|
|
277
|
+
pending =
|
|
278
|
+
(await withRegistryLock(() => reconcilePass(notify, project, drained))) ?? [];
|
|
242
279
|
} finally {
|
|
243
280
|
await releaseLock(reconcileLockPath());
|
|
244
281
|
}
|
|
@@ -259,6 +296,7 @@ async function reconcileCmd(deps: CliDeps = {}): Promise<number> {
|
|
|
259
296
|
async function reconcilePass(
|
|
260
297
|
notify: NotificationsConfig,
|
|
261
298
|
project: string,
|
|
299
|
+
drained: SourceDrain[],
|
|
262
300
|
): Promise<PodNotification[]> {
|
|
263
301
|
const pods = await readPods();
|
|
264
302
|
const agents = await readAgents();
|
|
@@ -372,6 +410,10 @@ async function reconcilePass(
|
|
|
372
410
|
}
|
|
373
411
|
}
|
|
374
412
|
|
|
413
|
+
// Delivery retries ran outside this lock, before the pass (ADR 0025).
|
|
414
|
+
// Delivery failure writes only cron.log, never Pod policy or the
|
|
415
|
+
// two-event notification inventory (ADRs 0021, 0025).
|
|
416
|
+
|
|
375
417
|
// 2. Policy derivation from outcome facts the wrapper (or step 1) recorded.
|
|
376
418
|
const fresh = pod.wakeUps.slice(pod.policy.appliedOutcomes);
|
|
377
419
|
for (const wake of fresh) {
|
|
@@ -434,7 +476,7 @@ async function reconcilePass(
|
|
|
434
476
|
// pod in this pass must be served by this pass's wake — one batch, one wake,
|
|
435
477
|
// appended and marked before anything is launched. Claim-before-touch still
|
|
436
478
|
// holds (ADR 0007): the claims are written before a document is opened.
|
|
437
|
-
await listenersPass(project);
|
|
479
|
+
await listenersPass(project, drained);
|
|
438
480
|
|
|
439
481
|
// 4. The wake phase (ADR 0011): the guarded idle → active transition, read
|
|
440
482
|
// fresh because intake may have claimed, appended, or spawned since the pass
|
|
@@ -468,6 +510,12 @@ async function wakeDuePods(now: number): Promise<void> {
|
|
|
468
510
|
if (changed) await writePods(pods);
|
|
469
511
|
}
|
|
470
512
|
|
|
513
|
+
/** One Pod's state as the registry holds it now, read without any lock (ADR 0025). */
|
|
514
|
+
async function podState(podId: string): Promise<PodRecord["state"] | undefined> {
|
|
515
|
+
const pods = await readPods();
|
|
516
|
+
return pods.find((candidate) => candidate.id === podId)?.state;
|
|
517
|
+
}
|
|
518
|
+
|
|
471
519
|
/** Non-terminal materializations occupy a listener's bound, even while capacity-waiting. */
|
|
472
520
|
function livePod(claimedBy: string, pods: PodRecord[]): boolean {
|
|
473
521
|
const pod = pods.find((candidate) => candidate.id === claimedBy);
|
|
@@ -489,7 +537,7 @@ function matchesListener(rule: string | undefined, item: InboxItem): boolean {
|
|
|
489
537
|
* every pass until their writer fixes them. Claims then reach their target: a
|
|
490
538
|
* template spawns a pod, a bound standing pod takes them as `## Inbox` blocks.
|
|
491
539
|
*/
|
|
492
|
-
async function listenersPass(project: string): Promise<void> {
|
|
540
|
+
async function listenersPass(project: string, drained: SourceDrain[]): Promise<void> {
|
|
493
541
|
const listeners = await readListeners();
|
|
494
542
|
if (listeners.length === 0) return;
|
|
495
543
|
|
|
@@ -507,13 +555,27 @@ async function listenersPass(project: string): Promise<void> {
|
|
|
507
555
|
|
|
508
556
|
// Phase 1: scan, hold the stability memory, and claim. Nothing is spawned yet.
|
|
509
557
|
for (const listener of listeners) {
|
|
510
|
-
const
|
|
511
|
-
|
|
558
|
+
const origin =
|
|
559
|
+
listener.source === undefined ? listener.inbox! : `source:${listener.source}`;
|
|
560
|
+
const sourceDrain = drained.find((candidate) => candidate.name === listener.source);
|
|
561
|
+
const scan =
|
|
562
|
+
listener.source === undefined
|
|
563
|
+
? await scanInbox(listener.inbox!)
|
|
564
|
+
: {
|
|
565
|
+
items: sourceDrain?.items ?? [],
|
|
566
|
+
bad: [],
|
|
567
|
+
...(sourceDrain?.error === undefined ? {} : { error: sourceDrain.error }),
|
|
568
|
+
};
|
|
569
|
+
const byId = new Map<string, InboxItem>();
|
|
570
|
+
for (const item of scan.items) if (!byId.has(item.id)) byId.set(item.id, item);
|
|
571
|
+
itemsByListener.set(listener.id, byId);
|
|
512
572
|
if (scan.error !== undefined) {
|
|
513
573
|
unscannable.add(listener.id);
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
574
|
+
if (listener.source === undefined) {
|
|
575
|
+
await appendCronLog(
|
|
576
|
+
`listener ${listener.id}: inbox ${listener.inbox} unreadable (${scan.error})`,
|
|
577
|
+
);
|
|
578
|
+
}
|
|
517
579
|
continue;
|
|
518
580
|
}
|
|
519
581
|
for (const bad of scan.bad) {
|
|
@@ -522,24 +584,27 @@ async function listenersPass(project: string): Promise<void> {
|
|
|
522
584
|
);
|
|
523
585
|
}
|
|
524
586
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
587
|
+
let stable: InboxItem[] = scan.items;
|
|
588
|
+
if (listener.source === undefined) {
|
|
589
|
+
const seen = new Map(listener.seen.map((fact) => [fact.item, fact]));
|
|
590
|
+
for (const known of [...seen.keys()]) {
|
|
591
|
+
if (!scan.items.some((item) => item.id === known)) {
|
|
592
|
+
seen.delete(known); // gone from the inbox; the inbox is the writer's
|
|
593
|
+
changed = true;
|
|
594
|
+
}
|
|
530
595
|
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
596
|
+
stable = [];
|
|
597
|
+
for (const item of scan.items) {
|
|
598
|
+
const before = seen.get(item.id);
|
|
599
|
+
if (before !== undefined && before.mtimeMs === item.mtimeMs && before.size === item.size) {
|
|
600
|
+
stable.push(item);
|
|
601
|
+
} else {
|
|
602
|
+
seen.set(item.id, { item: item.id, mtimeMs: item.mtimeMs, size: item.size });
|
|
603
|
+
changed = true;
|
|
604
|
+
}
|
|
540
605
|
}
|
|
606
|
+
listener.seen = [...seen.values()];
|
|
541
607
|
}
|
|
542
|
-
listener.seen = [...seen.values()];
|
|
543
608
|
|
|
544
609
|
const pendingClaims = listener.claimed.filter(
|
|
545
610
|
(claim) => claim.spawnedPodId === undefined && claim.blocked === undefined,
|
|
@@ -549,20 +614,29 @@ async function listenersPass(project: string): Promise<void> {
|
|
|
549
614
|
(claim) => claim.spawnedPodId !== undefined && livePod(claim.spawnedPodId, pods),
|
|
550
615
|
).length;
|
|
551
616
|
// A bound pod is served by a queue (ADR 0014), so only template spawns
|
|
552
|
-
// compete for its slots — and a pending claim already holds one.
|
|
617
|
+
// compete for its slots — and a pending claim already holds one. A Source
|
|
618
|
+
// claim is unbounded too: its items exist only in this drain's output and
|
|
619
|
+
// the cursor commits behind the claims, so an unclaimed item would be lost
|
|
620
|
+
// rather than re-scanned. The bound then defers materialization, below.
|
|
553
621
|
let room =
|
|
554
|
-
listener.mode === "pod"
|
|
622
|
+
listener.mode === "pod" || listener.source !== undefined
|
|
555
623
|
? Number.POSITIVE_INFINITY
|
|
556
624
|
: Math.max(0, listener.maxConcurrent - live - pendingClaims);
|
|
557
625
|
|
|
558
626
|
for (const item of stable) {
|
|
559
627
|
if (room <= 0) break;
|
|
560
|
-
const key = claimKey(
|
|
628
|
+
const key = claimKey(origin, item.id);
|
|
561
629
|
if (claimedInInbox.has(key) || !matchesListener(listener.match, item)) continue;
|
|
562
630
|
// One claim shape for both targets (ADR 0013): a template materializes a
|
|
563
631
|
// pod from it later, a bound standing pod takes it as a document in
|
|
564
632
|
// phase 3 (ADR 0014). Claim first, always.
|
|
565
|
-
listener.claimed.push({
|
|
633
|
+
listener.claimed.push({
|
|
634
|
+
inbox: origin,
|
|
635
|
+
item: item.id,
|
|
636
|
+
...(sourceDrain?.drainedAt === undefined
|
|
637
|
+
? {}
|
|
638
|
+
: { claimedAt: sourceDrain.drainedAt }),
|
|
639
|
+
});
|
|
566
640
|
claimedInInbox.add(key);
|
|
567
641
|
changed = true;
|
|
568
642
|
if (room !== Number.POSITIVE_INFINITY) room -= 1;
|
|
@@ -575,7 +649,7 @@ async function listenersPass(project: string): Promise<void> {
|
|
|
575
649
|
// Phase 2: materialize what is claimed and still unserved.
|
|
576
650
|
let materialized = false;
|
|
577
651
|
for (const listener of listeners) {
|
|
578
|
-
if (listener.mode !== "template") continue;
|
|
652
|
+
if (listener.mode !== "template" || unscannable.has(listener.id)) continue;
|
|
579
653
|
const pending = () =>
|
|
580
654
|
listener.claimed.filter(
|
|
581
655
|
(claim) => claim.spawnedPodId === undefined && claim.blocked === undefined,
|
|
@@ -620,7 +694,7 @@ async function listenersPass(project: string): Promise<void> {
|
|
|
620
694
|
}
|
|
621
695
|
const record = await spawnPodRecord(
|
|
622
696
|
rendered.goal,
|
|
623
|
-
project,
|
|
697
|
+
listener.project ?? project,
|
|
624
698
|
"default",
|
|
625
699
|
undefined,
|
|
626
700
|
undefined,
|
|
@@ -693,6 +767,35 @@ async function listenersPass(project: string): Promise<void> {
|
|
|
693
767
|
// launch anything, so a crash anywhere between claim and wake repairs on the
|
|
694
768
|
// next pass (ADR 0014). The wake itself is the pass's ordinary transition.
|
|
695
769
|
if (appended) await writeListeners(listeners);
|
|
770
|
+
|
|
771
|
+
// Two stores, in this order: claims first, then each successful cursor in
|
|
772
|
+
// the source store's own atomic write. A bound standing pod adds one gate:
|
|
773
|
+
// its claims must also be appended, because their bodies exist only in this
|
|
774
|
+
// drain's output. While a wake-up holds the append back, the unchanged cursor
|
|
775
|
+
// makes the next pass drain the same items again. A crash between writes
|
|
776
|
+
// replays into id and document dedupe rather than repeating work (ADR 0024).
|
|
777
|
+
// A Listener removed while its drain was running no longer licenses a cursor.
|
|
778
|
+
const consumed = new Set(listeners.flatMap((listener) => listener.source ?? []));
|
|
779
|
+
const waitingForAppend = new Set(
|
|
780
|
+
listeners.flatMap((listener) =>
|
|
781
|
+
listener.mode === "pod" &&
|
|
782
|
+
listener.source !== undefined &&
|
|
783
|
+
listener.claimed.some(
|
|
784
|
+
(claim) =>
|
|
785
|
+
claim.inbox === `source:${listener.source}` &&
|
|
786
|
+
claim.blocked === undefined &&
|
|
787
|
+
claim.appended !== true,
|
|
788
|
+
)
|
|
789
|
+
? [listener.source]
|
|
790
|
+
: [],
|
|
791
|
+
),
|
|
792
|
+
);
|
|
793
|
+
const committable = drained.filter(
|
|
794
|
+
(drain) => consumed.has(drain.name) && !waitingForAppend.has(drain.name),
|
|
795
|
+
);
|
|
796
|
+
const pruning = pruneSourceClaims(listeners, committable);
|
|
797
|
+
if (pruning.changed) await writeListeners(pruning.listeners);
|
|
798
|
+
await commitSourceCursors(committable);
|
|
696
799
|
}
|
|
697
800
|
|
|
698
801
|
export interface CliDeps {
|
|
@@ -1324,6 +1427,209 @@ async function agentCmd(
|
|
|
1324
1427
|
}
|
|
1325
1428
|
}
|
|
1326
1429
|
|
|
1430
|
+
/** Sources are named operator commands; configuration errors fail fast (ADR 0024). */
|
|
1431
|
+
async function sourceCmd(
|
|
1432
|
+
words: string[],
|
|
1433
|
+
deps: CliDeps,
|
|
1434
|
+
out: (line: string) => void,
|
|
1435
|
+
err: (line: string) => void,
|
|
1436
|
+
): Promise<number> {
|
|
1437
|
+
const [sub, ...rest] = words;
|
|
1438
|
+
switch (sub) {
|
|
1439
|
+
case "list": {
|
|
1440
|
+
const sources = await readSources();
|
|
1441
|
+
if (rest.includes("--json")) {
|
|
1442
|
+
out(
|
|
1443
|
+
JSON.stringify(
|
|
1444
|
+
sources.map((source) => ({
|
|
1445
|
+
name: source.name,
|
|
1446
|
+
cmd: source.cmd,
|
|
1447
|
+
timeoutMs: source.timeoutMs,
|
|
1448
|
+
cursor: source.cursor ?? null,
|
|
1449
|
+
})),
|
|
1450
|
+
),
|
|
1451
|
+
);
|
|
1452
|
+
return 0;
|
|
1453
|
+
}
|
|
1454
|
+
if (sources.length === 0) {
|
|
1455
|
+
out("No sources");
|
|
1456
|
+
return 0;
|
|
1457
|
+
}
|
|
1458
|
+
for (const source of sources) {
|
|
1459
|
+
out(
|
|
1460
|
+
`${source.name} ${source.cmd} timeout ${source.timeoutMs / 1000}s cursor ${source.cursor ?? "-"}`,
|
|
1461
|
+
);
|
|
1462
|
+
}
|
|
1463
|
+
return 0;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
case "add": {
|
|
1467
|
+
const { flags, rest: positional } = parseFlags(rest, ["cmd", "timeout"]);
|
|
1468
|
+
const name = positional[0];
|
|
1469
|
+
if (positional.length !== 1 || name === undefined || flags.cmd === undefined) {
|
|
1470
|
+
err("Usage: agents-pod source add <name> --cmd <path> [--timeout <s>]");
|
|
1471
|
+
return 2;
|
|
1472
|
+
}
|
|
1473
|
+
const timeoutSeconds = flags.timeout === undefined ? 10 : Number(flags.timeout);
|
|
1474
|
+
const timeoutMs = timeoutSeconds * 1000;
|
|
1475
|
+
if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0 || !Number.isFinite(timeoutMs)) {
|
|
1476
|
+
err(`--timeout must be a positive number, got ${flags.timeout}`);
|
|
1477
|
+
return 2;
|
|
1478
|
+
}
|
|
1479
|
+
const cmd = resolve(deps.cwd ?? process.cwd(), flags.cmd);
|
|
1480
|
+
const info = await stat(cmd).catch(() => undefined);
|
|
1481
|
+
if (info === undefined || !info.isFile()) {
|
|
1482
|
+
err(`Drain command ${cmd} is missing or not a regular file`);
|
|
1483
|
+
return 2;
|
|
1484
|
+
}
|
|
1485
|
+
if (!(await access(cmd, constants.X_OK).then(() => true, () => false))) {
|
|
1486
|
+
err(`Drain command ${cmd} is not executable`);
|
|
1487
|
+
return 2;
|
|
1488
|
+
}
|
|
1489
|
+
const added = await withRegistryLock(async () => {
|
|
1490
|
+
const sources = await readSources();
|
|
1491
|
+
if (sources.some((source) => source.name === name)) return false;
|
|
1492
|
+
sources.push({ name, cmd, timeoutMs });
|
|
1493
|
+
await writeSources(sources);
|
|
1494
|
+
return true;
|
|
1495
|
+
});
|
|
1496
|
+
if (added === undefined) {
|
|
1497
|
+
err("registry lock unavailable: source not added");
|
|
1498
|
+
return 2;
|
|
1499
|
+
}
|
|
1500
|
+
if (!added) {
|
|
1501
|
+
err(`Source ${name} already exists`);
|
|
1502
|
+
return 2;
|
|
1503
|
+
}
|
|
1504
|
+
return 0;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
case "rm": {
|
|
1508
|
+
const name = rest.find((word) => !word.startsWith("--"));
|
|
1509
|
+
if (name === undefined) {
|
|
1510
|
+
err("Usage: agents-pod source rm <name>");
|
|
1511
|
+
return 2;
|
|
1512
|
+
}
|
|
1513
|
+
const removed = await withRegistryLock(async () => {
|
|
1514
|
+
const sources = await readSources();
|
|
1515
|
+
if (!sources.some((source) => source.name === name)) return "missing" as const;
|
|
1516
|
+
const listeners = await readListeners();
|
|
1517
|
+
if (listeners.some((listener) => listener.source === name)) return "in-use" as const;
|
|
1518
|
+
await writeSources(sources.filter((source) => source.name !== name));
|
|
1519
|
+
return "removed" as const;
|
|
1520
|
+
});
|
|
1521
|
+
if (removed === undefined) {
|
|
1522
|
+
err("registry lock unavailable: source not removed");
|
|
1523
|
+
return 2;
|
|
1524
|
+
}
|
|
1525
|
+
if (removed === "missing") {
|
|
1526
|
+
err(`Unknown source ${name}`);
|
|
1527
|
+
return 2;
|
|
1528
|
+
}
|
|
1529
|
+
if (removed === "in-use") {
|
|
1530
|
+
err(`Source ${name} is still consumed by a Listener`);
|
|
1531
|
+
return 2;
|
|
1532
|
+
}
|
|
1533
|
+
return 0;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
default:
|
|
1537
|
+
err(`Unknown source command: ${sub ?? "(none)"}. Try: add | list | rm`);
|
|
1538
|
+
return 2;
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
/** P0 has one outbound delivery command; configuration errors fail fast (ADR 0025). */
|
|
1543
|
+
async function deliverCmd(
|
|
1544
|
+
words: string[],
|
|
1545
|
+
deps: CliDeps,
|
|
1546
|
+
out: (line: string) => void,
|
|
1547
|
+
err: (line: string) => void,
|
|
1548
|
+
): Promise<number> {
|
|
1549
|
+
const [sub, ...rest] = words;
|
|
1550
|
+
switch (sub) {
|
|
1551
|
+
case "list": {
|
|
1552
|
+
const deliverer = await readDeliverer();
|
|
1553
|
+
if (rest.includes("--json")) {
|
|
1554
|
+
out(JSON.stringify(deliverer === undefined ? [] : [deliverer]));
|
|
1555
|
+
return 0;
|
|
1556
|
+
}
|
|
1557
|
+
if (deliverer === undefined) {
|
|
1558
|
+
out("No deliverer");
|
|
1559
|
+
return 0;
|
|
1560
|
+
}
|
|
1561
|
+
out(`${deliverer.name} ${deliverer.cmd} timeout ${deliverer.timeoutMs / 1000}s`);
|
|
1562
|
+
return 0;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
case "add": {
|
|
1566
|
+
const { flags, rest: positional } = parseFlags(rest, ["cmd", "timeout"]);
|
|
1567
|
+
const name = positional[0];
|
|
1568
|
+
if (positional.length !== 1 || name === undefined || flags.cmd === undefined) {
|
|
1569
|
+
err("Usage: agents-pod deliver add <name> --cmd <path> [--timeout <s>]");
|
|
1570
|
+
return 2;
|
|
1571
|
+
}
|
|
1572
|
+
const timeoutSeconds = flags.timeout === undefined ? 10 : Number(flags.timeout);
|
|
1573
|
+
const timeoutMs = timeoutSeconds * 1000;
|
|
1574
|
+
if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0 || !Number.isFinite(timeoutMs)) {
|
|
1575
|
+
err(`--timeout must be a positive number, got ${flags.timeout}`);
|
|
1576
|
+
return 2;
|
|
1577
|
+
}
|
|
1578
|
+
const cmd = resolve(deps.cwd ?? process.cwd(), flags.cmd);
|
|
1579
|
+
const info = await stat(cmd).catch(() => undefined);
|
|
1580
|
+
if (info === undefined || !info.isFile()) {
|
|
1581
|
+
err(`Delivery command ${cmd} is missing or not a regular file`);
|
|
1582
|
+
return 2;
|
|
1583
|
+
}
|
|
1584
|
+
if (!(await access(cmd, constants.X_OK).then(() => true, () => false))) {
|
|
1585
|
+
err(`Delivery command ${cmd} is not executable`);
|
|
1586
|
+
return 2;
|
|
1587
|
+
}
|
|
1588
|
+
const added = await withRegistryLock(async () => {
|
|
1589
|
+
if ((await readDeliverer()) !== undefined) return false;
|
|
1590
|
+
await writeDeliverer({ name, cmd, timeoutMs });
|
|
1591
|
+
return true;
|
|
1592
|
+
});
|
|
1593
|
+
if (added === undefined) {
|
|
1594
|
+
err("registry lock unavailable: deliverer not added");
|
|
1595
|
+
return 2;
|
|
1596
|
+
}
|
|
1597
|
+
if (!added) {
|
|
1598
|
+
err("A deliverer is already configured: remove it before adding another");
|
|
1599
|
+
return 2;
|
|
1600
|
+
}
|
|
1601
|
+
return 0;
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
case "rm": {
|
|
1605
|
+
const name = rest.find((word) => !word.startsWith("--"));
|
|
1606
|
+
if (name === undefined) {
|
|
1607
|
+
err("Usage: agents-pod deliver rm <name>");
|
|
1608
|
+
return 2;
|
|
1609
|
+
}
|
|
1610
|
+
const removed = await withRegistryLock(async () => {
|
|
1611
|
+
const deliverer = await readDeliverer();
|
|
1612
|
+
if (deliverer?.name !== name) return false;
|
|
1613
|
+
await rm(delivererFile(), { force: true });
|
|
1614
|
+
return true;
|
|
1615
|
+
});
|
|
1616
|
+
if (removed === undefined) {
|
|
1617
|
+
err("registry lock unavailable: deliverer not removed");
|
|
1618
|
+
return 2;
|
|
1619
|
+
}
|
|
1620
|
+
if (!removed) {
|
|
1621
|
+
err(`Unknown deliverer ${name}`);
|
|
1622
|
+
return 2;
|
|
1623
|
+
}
|
|
1624
|
+
return 0;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
default:
|
|
1628
|
+
err(`Unknown deliver command: ${sub ?? "(none)"}. Try: add | list | rm`);
|
|
1629
|
+
return 2;
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1327
1633
|
/**
|
|
1328
1634
|
* `agents-pod listener` is add / list / rm (ADR 0007): a listener is a registry
|
|
1329
1635
|
* record, never a process. Bad references are exit-2 configuration errors
|
|
@@ -1345,10 +1651,12 @@ async function listenerCmd(
|
|
|
1345
1651
|
JSON.stringify(
|
|
1346
1652
|
listeners.map((listener) => ({
|
|
1347
1653
|
id: listener.id,
|
|
1348
|
-
inbox: listener.inbox,
|
|
1654
|
+
inbox: listener.inbox ?? null,
|
|
1655
|
+
source: listener.source ?? null,
|
|
1349
1656
|
match: listener.match ?? null,
|
|
1350
1657
|
mode: listener.mode,
|
|
1351
1658
|
template: listener.template ?? null,
|
|
1659
|
+
project: listener.project ?? null,
|
|
1352
1660
|
podId: listener.podId ?? null,
|
|
1353
1661
|
maxConcurrent: listener.maxConcurrent,
|
|
1354
1662
|
claimed: listener.claimed ?? [],
|
|
@@ -1366,19 +1674,26 @@ async function listenerCmd(
|
|
|
1366
1674
|
listener.mode === "template"
|
|
1367
1675
|
? `template ${listener.template}`
|
|
1368
1676
|
: `pod ${listener.podId}`;
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
);
|
|
1677
|
+
const origin = listener.source === undefined ? listener.inbox : `source:${listener.source}`;
|
|
1678
|
+
out(`${listener.id} ${target} ${origin} claimed ${listener.claimed?.length ?? 0}`);
|
|
1372
1679
|
}
|
|
1373
1680
|
return 0;
|
|
1374
1681
|
}
|
|
1375
1682
|
|
|
1376
1683
|
case "add": {
|
|
1377
|
-
const { flags } = parseFlags(rest, [
|
|
1684
|
+
const { flags } = parseFlags(rest, [
|
|
1685
|
+
"inbox",
|
|
1686
|
+
"source",
|
|
1687
|
+
"template",
|
|
1688
|
+
"pod",
|
|
1689
|
+
"match",
|
|
1690
|
+
"max-concurrent",
|
|
1691
|
+
"project",
|
|
1692
|
+
]);
|
|
1378
1693
|
const usage =
|
|
1379
|
-
"Usage: agents-pod listener add --inbox <dir|file>
|
|
1380
|
-
if (flags.inbox === undefined) {
|
|
1381
|
-
err(usage);
|
|
1694
|
+
"Usage: agents-pod listener add (--inbox <dir|file> | --source <name>) (--template <file> | --pod <podId>) [--match <substr>] [--max-concurrent <n>] [--project <dir>]";
|
|
1695
|
+
if ((flags.inbox === undefined) === (flags.source === undefined)) {
|
|
1696
|
+
err(`listener add takes exactly one of --inbox or --source. ${usage}`);
|
|
1382
1697
|
return 2;
|
|
1383
1698
|
}
|
|
1384
1699
|
// A listener has one target, exactly (ADR 0007): a fresh pod per item, or
|
|
@@ -1389,11 +1704,33 @@ async function listenerCmd(
|
|
|
1389
1704
|
}
|
|
1390
1705
|
|
|
1391
1706
|
const cwd = deps.cwd ?? process.cwd();
|
|
1392
|
-
|
|
1393
|
-
|
|
1707
|
+
// Only a template materializes a Pod, so only a template has a destination
|
|
1708
|
+
// project; a bound standing Pod already lives where it lives (ADR 0007).
|
|
1709
|
+
if (flags.project !== undefined && flags.pod !== undefined) {
|
|
1710
|
+
err(`--project needs --template: a bound standing Pod keeps its own project. ${usage}`);
|
|
1711
|
+
return 2;
|
|
1712
|
+
}
|
|
1713
|
+
const targetProject =
|
|
1714
|
+
flags.project === undefined ? undefined : resolve(cwd, flags.project);
|
|
1715
|
+
if (targetProject !== undefined) {
|
|
1716
|
+
const info = await stat(targetProject).catch(() => undefined);
|
|
1717
|
+
if (info === undefined || !info.isDirectory()) {
|
|
1718
|
+
err(`Project ${targetProject} does not exist or is not a directory`);
|
|
1719
|
+
return 2;
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
const inbox = flags.inbox === undefined ? undefined : resolve(cwd, flags.inbox);
|
|
1723
|
+
if (inbox !== undefined && !(await exists(inbox))) {
|
|
1394
1724
|
err(`Inbox ${inbox} does not exist: pass a directory or a JSONL file`);
|
|
1395
1725
|
return 2;
|
|
1396
1726
|
}
|
|
1727
|
+
if (
|
|
1728
|
+
flags.source !== undefined &&
|
|
1729
|
+
!(await readSources()).some((source) => source.name === flags.source)
|
|
1730
|
+
) {
|
|
1731
|
+
err(`Unknown source ${flags.source}`);
|
|
1732
|
+
return 2;
|
|
1733
|
+
}
|
|
1397
1734
|
|
|
1398
1735
|
let maxConcurrent = 1;
|
|
1399
1736
|
if (flags["max-concurrent"] !== undefined) {
|
|
@@ -1407,8 +1744,9 @@ async function listenerCmd(
|
|
|
1407
1744
|
|
|
1408
1745
|
const listener: ListenerRecord = {
|
|
1409
1746
|
id: newListenerId(),
|
|
1410
|
-
inbox,
|
|
1747
|
+
...(inbox === undefined ? { source: flags.source! } : { inbox }),
|
|
1411
1748
|
mode: "template",
|
|
1749
|
+
...(targetProject === undefined ? {} : { project: targetProject }),
|
|
1412
1750
|
maxConcurrent,
|
|
1413
1751
|
claimed: [],
|
|
1414
1752
|
seen: [],
|
|
@@ -1435,6 +1773,12 @@ async function listenerCmd(
|
|
|
1435
1773
|
}
|
|
1436
1774
|
|
|
1437
1775
|
const added = await withRegistryLock(async () => {
|
|
1776
|
+
if (
|
|
1777
|
+
listener.source !== undefined &&
|
|
1778
|
+
!(await readSources()).some((source) => source.name === listener.source)
|
|
1779
|
+
) {
|
|
1780
|
+
return false;
|
|
1781
|
+
}
|
|
1438
1782
|
const listeners = await readListeners();
|
|
1439
1783
|
listeners.push(listener);
|
|
1440
1784
|
await writeListeners(listeners);
|
|
@@ -1444,6 +1788,10 @@ async function listenerCmd(
|
|
|
1444
1788
|
err("registry lock unavailable: listener not added");
|
|
1445
1789
|
return 2;
|
|
1446
1790
|
}
|
|
1791
|
+
if (!added) {
|
|
1792
|
+
err(`Unknown source ${listener.source}`);
|
|
1793
|
+
return 2;
|
|
1794
|
+
}
|
|
1447
1795
|
out(listener.id);
|
|
1448
1796
|
return 0;
|
|
1449
1797
|
}
|
|
@@ -1503,10 +1851,14 @@ export async function main(argv: string[], deps: CliDeps = {}): Promise<number>
|
|
|
1503
1851
|
return agentCmd(rest, out, err);
|
|
1504
1852
|
case "listener":
|
|
1505
1853
|
return listenerCmd(rest, deps, out, err);
|
|
1854
|
+
case "source":
|
|
1855
|
+
return sourceCmd(rest, deps, out, err);
|
|
1856
|
+
case "deliver":
|
|
1857
|
+
return deliverCmd(rest, deps, out, err);
|
|
1506
1858
|
case "project":
|
|
1507
1859
|
return projectCmd(rest, deps, out, err);
|
|
1508
1860
|
default:
|
|
1509
|
-
err(`Unknown command: ${cmd ?? "(none)"}. Try: spawn | list | status | logs | wake | cancel | reconcile | agent | listener | project`);
|
|
1861
|
+
err(`Unknown command: ${cmd ?? "(none)"}. Try: spawn | list | status | logs | wake | cancel | reconcile | agent | listener | source | deliver | project`);
|
|
1510
1862
|
return 2;
|
|
1511
1863
|
}
|
|
1512
1864
|
}
|