agents-pod 0.0.0-stage → 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 +83 -0
- package/LICENSE +661 -0
- package/README.md +530 -2
- package/bin/agents-pod.mjs +15 -0
- package/bin/ts-loader.mjs +66 -0
- package/bin/wrapper.mjs +12 -0
- package/index.mjs +11 -0
- package/package.json +39 -4
- package/src/agents-store.ts +88 -0
- package/src/cli.ts +1864 -0
- package/src/extension/handoff.ts +57 -0
- package/src/extension/index.ts +34 -0
- package/src/extension/management.ts +149 -0
- package/src/extension/self.ts +80 -0
- package/src/inbox.ts +221 -0
- package/src/listeners.ts +84 -0
- package/src/liveness.ts +191 -0
- package/src/locks.ts +74 -0
- package/src/notify/config.ts +125 -0
- package/src/notify/dispatch.ts +63 -0
- package/src/notify/email.ts +72 -0
- package/src/notify/format.ts +42 -0
- package/src/notify/system.ts +79 -0
- package/src/notify/types.ts +96 -0
- package/src/notify/webhook.ts +39 -0
- package/src/outbox.ts +294 -0
- package/src/paths.ts +67 -0
- package/src/projects.ts +49 -0
- package/src/registry.ts +103 -0
- package/src/sources.ts +278 -0
- package/src/workspace.ts +131 -0
- package/src/wrapper.ts +533 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,1864 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import {
|
|
5
|
+
access,
|
|
6
|
+
appendFile,
|
|
7
|
+
mkdir,
|
|
8
|
+
readFile,
|
|
9
|
+
readdir,
|
|
10
|
+
rm,
|
|
11
|
+
stat,
|
|
12
|
+
unlink,
|
|
13
|
+
writeFile,
|
|
14
|
+
} from "node:fs/promises";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { dirname, join, resolve } from "node:path";
|
|
17
|
+
import {
|
|
18
|
+
agentTypeRefusal,
|
|
19
|
+
isAgentType,
|
|
20
|
+
readAgents,
|
|
21
|
+
resolveAgentDefinition,
|
|
22
|
+
writeAgents,
|
|
23
|
+
} from "./agents-store.ts";
|
|
24
|
+
import { renderTemplate, scanInbox, type InboxItem } from "./inbox.ts";
|
|
25
|
+
import {
|
|
26
|
+
claimKey,
|
|
27
|
+
newListenerId,
|
|
28
|
+
readListeners,
|
|
29
|
+
writeListeners,
|
|
30
|
+
type ListenerRecord,
|
|
31
|
+
} from "./listeners.ts";
|
|
32
|
+
import { acquireLock, readLock, releaseLock, sleep } from "./locks.ts";
|
|
33
|
+
import { loadNotifyConfig } from "./notify/config.ts";
|
|
34
|
+
import { dispatchNotification, shouldNotify } from "./notify/dispatch.ts";
|
|
35
|
+
import type { NotificationsConfig, PodNotification } from "./notify/types.ts";
|
|
36
|
+
import { cronLogPath, delivererFile, podDir, reconcileLockPath } from "./paths.ts";
|
|
37
|
+
import { readDeliverer, retryOutbox, writeDeliverer } from "./outbox.ts";
|
|
38
|
+
import {
|
|
39
|
+
projectHasCapacity,
|
|
40
|
+
projectMaxActive,
|
|
41
|
+
readProjects,
|
|
42
|
+
writeProjects,
|
|
43
|
+
} from "./projects.ts";
|
|
44
|
+
import type { PodRecord } from "./registry.ts";
|
|
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";
|
|
54
|
+
import {
|
|
55
|
+
HEARTBEAT_STALE_MS,
|
|
56
|
+
WEDGED_ESCALATE_MS,
|
|
57
|
+
livenessFacts,
|
|
58
|
+
pidDead,
|
|
59
|
+
readWrapperRecord,
|
|
60
|
+
verifyProcess,
|
|
61
|
+
wrapperJsonPath,
|
|
62
|
+
wrapperVerifiablyDead,
|
|
63
|
+
type LivenessFacts,
|
|
64
|
+
} from "./liveness.ts";
|
|
65
|
+
import { appendInbox, createPodWorkspace, snapshotDocs, type DocFacts } from "./workspace.ts";
|
|
66
|
+
import { resolveAgentBinary, wrapperScript } from "./wrapper.ts";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Spawn and wake name their agent explicitly; the wrapper resolves it (ADR
|
|
70
|
+
* 0012). `--warm` is a wrapper argument, not an agent one (ADR 0011): the
|
|
71
|
+
* wrapper parses it and points the agent at the lost wake-up's session.
|
|
72
|
+
*/
|
|
73
|
+
function launchWrapperDetached(podId: string, agentId?: string, warm = false): void {
|
|
74
|
+
const args = [wrapperScript(), podId, ...(agentId ? [agentId] : []), ...(warm ? ["--warm"] : [])];
|
|
75
|
+
const child = spawn(process.execPath, args, {
|
|
76
|
+
detached: true,
|
|
77
|
+
stdio: "ignore",
|
|
78
|
+
});
|
|
79
|
+
child.unref();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Flags may appear anywhere among the goal words (README's CLI surface), and
|
|
84
|
+
* the words that are not flags compose the goal. A flag without a value is
|
|
85
|
+
* left unset and surfaces through the command's own validation.
|
|
86
|
+
*/
|
|
87
|
+
function parseFlags(
|
|
88
|
+
words: string[],
|
|
89
|
+
allowed: readonly string[],
|
|
90
|
+
): { flags: Record<string, string>; rest: string[] } {
|
|
91
|
+
const flags: Record<string, string> = {};
|
|
92
|
+
const rest: string[] = [];
|
|
93
|
+
for (let index = 0; index < words.length; index += 1) {
|
|
94
|
+
const word = words[index]!;
|
|
95
|
+
const name = word.startsWith("--") ? word.slice(2) : undefined;
|
|
96
|
+
const value = words[index + 1];
|
|
97
|
+
if (name !== undefined && allowed.includes(name) && value !== undefined) {
|
|
98
|
+
flags[name] = value;
|
|
99
|
+
index += 1;
|
|
100
|
+
} else {
|
|
101
|
+
rest.push(word);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { flags, rest };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Exponential backoff after `failed`/`lost`: 1m ×2, capped at 15m (ADR 0011). */
|
|
108
|
+
function backoffMs(streak: number): number {
|
|
109
|
+
return Math.min(60_000 * 2 ** (streak - 1), 900_000);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Signal a pod's process group — never our own. The pgid comes from store
|
|
114
|
+
* data only (wrapper.json, ADR 0003), and the guards make a corrupt record a
|
|
115
|
+
* no-op instead of a self-kill.
|
|
116
|
+
*/
|
|
117
|
+
function signalGroup(pgid: number, signal: NodeJS.Signals): boolean {
|
|
118
|
+
if (!Number.isInteger(pgid) || pgid <= 1 || pgid === process.pid) return false;
|
|
119
|
+
try {
|
|
120
|
+
process.kill(-pgid, signal);
|
|
121
|
+
return true;
|
|
122
|
+
} catch {
|
|
123
|
+
return false; // the group is already gone
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The lost detail names the fact that condemned the wake-up (ADR 0002, 0020). */
|
|
128
|
+
function lostDetail(facts: LivenessFacts): string {
|
|
129
|
+
switch (facts.lostCause) {
|
|
130
|
+
case "ghost":
|
|
131
|
+
return "ghost record (no wrapper.json)";
|
|
132
|
+
case "reboot":
|
|
133
|
+
return "machine rebooted";
|
|
134
|
+
default:
|
|
135
|
+
return "wrapper gone, heartbeat stale";
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The highest wake-up number on disk: a ghost is numbered by its directory. */
|
|
140
|
+
async function maxWakeupNumber(pod: PodRecord): Promise<number | undefined> {
|
|
141
|
+
const dir = join(podDir(pod.project, pod.id), "wakeups");
|
|
142
|
+
const entries = await readdir(dir).catch(() => []);
|
|
143
|
+
const numbers = entries
|
|
144
|
+
.map((name) => Number.parseInt(name, 10))
|
|
145
|
+
.filter((n) => Number.isInteger(n));
|
|
146
|
+
return numbers.length > 0 ? Math.max(...numbers) : undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function livenessOf(pod: PodRecord): Promise<string> {
|
|
150
|
+
return (await livenessFacts(pod)).quadrant;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Staleness is a TTL of five minutes of wall clock, never pid liveness
|
|
155
|
+
* (ADR 0018): a live-but-wedged holder must not wedge every later tick, and a
|
|
156
|
+
* reused pid must not read as alive.
|
|
157
|
+
*/
|
|
158
|
+
const LOCK_STALE_MS = 300_000;
|
|
159
|
+
|
|
160
|
+
/** The progress window: 60 minutes of a live wake-up with no document change (ADR 0019). */
|
|
161
|
+
const PROGRESS_WINDOW_MS = 3_600_000;
|
|
162
|
+
|
|
163
|
+
/** The verdict a progress kill records, in reconcile's own word (ADR 0019). */
|
|
164
|
+
const PROGRESSLESS_DETAIL = "progressless (killed by reconcile)";
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* ADR 0019's only progress signal: any of the three documents moving mtime or
|
|
168
|
+
* size. A file appearing or vanishing counts; the pod log is never read — a
|
|
169
|
+
* looping printer is the thief this rule exists to catch.
|
|
170
|
+
*/
|
|
171
|
+
async function documentsMoved(
|
|
172
|
+
docsDir: string,
|
|
173
|
+
baseline: Record<string, DocFacts>,
|
|
174
|
+
): Promise<boolean> {
|
|
175
|
+
const current = await snapshotDocs(docsDir);
|
|
176
|
+
for (const name of new Set([...Object.keys(baseline), ...Object.keys(current)])) {
|
|
177
|
+
const before = baseline[name];
|
|
178
|
+
const after = current[name];
|
|
179
|
+
if (before === undefined || after === undefined) return true; // appeared or vanished
|
|
180
|
+
if (before.mtimeMs !== after.mtimeMs || before.size !== after.size) return true;
|
|
181
|
+
}
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function exists(file: string): Promise<boolean> {
|
|
186
|
+
return stat(file).then(
|
|
187
|
+
() => true,
|
|
188
|
+
() => false,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Overlap is audited, not silent: one line per steal (ADR 0018). */
|
|
193
|
+
async function appendCronLog(line: string): Promise<void> {
|
|
194
|
+
const file = cronLogPath();
|
|
195
|
+
await mkdir(dirname(file), { recursive: true });
|
|
196
|
+
await appendFile(file, `${line}\n`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The single-flight acquire (ADR 0018). `false` means a fresh holder owns the
|
|
201
|
+
* pass: the tick exits 0 and loses nothing, because passes are idempotent. A
|
|
202
|
+
* lock past its TTL is stolen and the steal recorded. Two stealers both
|
|
203
|
+
* unlink and both race the exclusive create — one wins, the other re-reads a
|
|
204
|
+
* fresh lock and skips; no arbitration needed.
|
|
205
|
+
*/
|
|
206
|
+
async function acquireReconcileLock(): Promise<boolean> {
|
|
207
|
+
const file = reconcileLockPath();
|
|
208
|
+
const now = Date.now();
|
|
209
|
+
const held = await readLock(file);
|
|
210
|
+
if (held !== undefined && now - held.startedAt < LOCK_STALE_MS) return false;
|
|
211
|
+
if (held === undefined && !(await exists(file))) {
|
|
212
|
+
return acquireLock(file, { pid: process.pid, startedAt: now });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// A lock file whose contents cannot be parsed is stale by definition:
|
|
216
|
+
// nothing about it can prove the holder is recent.
|
|
217
|
+
await unlink(file).catch(() => undefined);
|
|
218
|
+
const stolen = await acquireLock(file, { pid: process.pid, startedAt: now });
|
|
219
|
+
if (stolen) {
|
|
220
|
+
await appendCronLog(
|
|
221
|
+
held === undefined
|
|
222
|
+
? "reconcile lock stolen from pid unknown (unreadable lock)"
|
|
223
|
+
: `reconcile lock stolen from pid ${held.pid} (${Math.floor((now - held.startedAt) / 1000)}s stale)`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
return stolen;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Reconcile is a stateless, idempotent pass (ADR 0002): it derives policy from
|
|
231
|
+
* outcome facts the wrapper recorded, then walks the single wake path —
|
|
232
|
+
* idle + goal open + not parked + now ≥ nextWakeAt (ADR 0011). The wrapper
|
|
233
|
+
* records facts; the pass writes policy; neither does the other's job.
|
|
234
|
+
*
|
|
235
|
+
* The pass also consumes this episode's notification (ADR 0021): the moment is
|
|
236
|
+
* queued while policy is derived, recorded in `notifiedAt`, and delivered
|
|
237
|
+
* after the locks are gone — a slow channel never holds the registry.
|
|
238
|
+
*
|
|
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.
|
|
243
|
+
*/
|
|
244
|
+
async function reconcileCmd(deps: CliDeps = {}): Promise<number> {
|
|
245
|
+
if (!(await acquireReconcileLock())) return 0;
|
|
246
|
+
const warnings: string[] = [];
|
|
247
|
+
const notify = await loadNotifyConfig(undefined, (message) => warnings.push(message));
|
|
248
|
+
// Listener-spawned pods default to the tick's project; `listener add
|
|
249
|
+
// --project` overrides that destination per Listener (ADR 0007, ADR 0004).
|
|
250
|
+
const project = deps.cwd ?? process.cwd();
|
|
251
|
+
let pending: PodNotification[] = [];
|
|
252
|
+
try {
|
|
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))) ?? [];
|
|
279
|
+
} finally {
|
|
280
|
+
await releaseLock(reconcileLockPath());
|
|
281
|
+
}
|
|
282
|
+
// A bad config degrades, it never kills the pass (ADR 0021): the warnings
|
|
283
|
+
// and any delivery failure are cron.log lines, and nothing is retried.
|
|
284
|
+
for (const warning of warnings) await appendCronLog(`notify: ${warning}`);
|
|
285
|
+
// Independent pods are announced in parallel: one hung channel spends one
|
|
286
|
+
// ten-second budget, not one per parked pod (ADR 0021).
|
|
287
|
+
const deliveries = await Promise.all(
|
|
288
|
+
pending.map((notification) => dispatchNotification(notify, notification)),
|
|
289
|
+
);
|
|
290
|
+
for (const notes of deliveries) {
|
|
291
|
+
for (const note of notes) await appendCronLog(`notify: ${note}`);
|
|
292
|
+
}
|
|
293
|
+
return 0;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function reconcilePass(
|
|
297
|
+
notify: NotificationsConfig,
|
|
298
|
+
project: string,
|
|
299
|
+
drained: SourceDrain[],
|
|
300
|
+
): Promise<PodNotification[]> {
|
|
301
|
+
const pods = await readPods();
|
|
302
|
+
const agents = await readAgents();
|
|
303
|
+
const now = Date.now();
|
|
304
|
+
let changed = false;
|
|
305
|
+
const pending: PodNotification[] = [];
|
|
306
|
+
|
|
307
|
+
for (const pod of pods) {
|
|
308
|
+
pod.policy ??= { failureStreak: 0, appliedOutcomes: 0 };
|
|
309
|
+
|
|
310
|
+
// Once per episode (ADR 0021): the flip announces, the marker records.
|
|
311
|
+
// A human wake clears the marker, so the next park is a new episode.
|
|
312
|
+
const announcePark = (reason?: string): void => {
|
|
313
|
+
if (pod.notifiedAt !== undefined) return;
|
|
314
|
+
if (!shouldNotify(notify.on, "park")) return;
|
|
315
|
+
pending.push(parkNotification(pod, reason));
|
|
316
|
+
pod.notifiedAt = now;
|
|
317
|
+
changed = true;
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
// 1. Liveness adjudication for active pods first (ADR 0002, 0017): a
|
|
321
|
+
// ghost or crash becomes a `lost` outcome this pass records, so the
|
|
322
|
+
// policy derivation below sees it and backoff applies before the wake
|
|
323
|
+
// path could touch the pod again.
|
|
324
|
+
if (pod.state === "active") {
|
|
325
|
+
const facts = await livenessFacts(pod);
|
|
326
|
+
if (facts.quadrant === "lost") {
|
|
327
|
+
const n = facts.wrapper?.wakeup ?? (await maxWakeupNumber(pod)) ?? 1;
|
|
328
|
+
pod.wakeUps.push({ n, outcome: "lost", detail: lostDetail(facts) });
|
|
329
|
+
pod.state = "idle";
|
|
330
|
+
delete pod.wedgedAt; // a lost wake-up spends any wedged observation
|
|
331
|
+
changed = true;
|
|
332
|
+
// Orphans are reaped, never adopted (ADR 0003): if the agent's group
|
|
333
|
+
// survives its wrapper, the group goes — nothing here can `wait()` it.
|
|
334
|
+
// The reap is three-verified too (ADR 0020): only a wrapper that is
|
|
335
|
+
// verifiably dead on this same boot licenses a group signal, so a
|
|
336
|
+
// post-reboot pgid — a stale number — is never touched.
|
|
337
|
+
if (facts.wrapper !== undefined && (await wrapperVerifiablyDead(facts.wrapper))) {
|
|
338
|
+
signalGroup(facts.wrapper.pgid, "SIGKILL");
|
|
339
|
+
}
|
|
340
|
+
} else if (facts.quadrant === "wedged-pending") {
|
|
341
|
+
// The two-pass rule (ADR 0017): the first pass records a stably stale
|
|
342
|
+
// observation; the confirming pass runs cancel's signal path, records
|
|
343
|
+
// `lost`, and removes the identity. No grace wait here — a wedged
|
|
344
|
+
// wrapper's trap may be dead, and the next pass collects the group.
|
|
345
|
+
if (pod.wedgedAt === undefined) {
|
|
346
|
+
pod.wedgedAt = now;
|
|
347
|
+
changed = true;
|
|
348
|
+
} else if (
|
|
349
|
+
facts.wrapper !== undefined &&
|
|
350
|
+
(facts.beatAgeMs === undefined || facts.beatAgeMs >= WEDGED_ESCALATE_MS)
|
|
351
|
+
) {
|
|
352
|
+
// Three-verified (ADR 0020): a live pid whose start time is not the
|
|
353
|
+
// recorded one is somebody else. The wake-up is still lost — the
|
|
354
|
+
// record cannot be trusted — but nothing is signalled. The intent
|
|
355
|
+
// lands first so a live trap defers to this verdict instead of
|
|
356
|
+
// overwriting it with `cancelled` (the same deference ADR 0019
|
|
357
|
+
// gives the progress kill — one mechanism, two causes).
|
|
358
|
+
const trusted = await verifyProcess(facts.wrapper);
|
|
359
|
+
if (trusted) {
|
|
360
|
+
pod.killIntent = { n: facts.wrapper.wakeup, at: now, cause: "wedged" };
|
|
361
|
+
await writePods(pods);
|
|
362
|
+
signalGroup(facts.wrapper.pgid, "SIGTERM");
|
|
363
|
+
}
|
|
364
|
+
pod.wakeUps.push({
|
|
365
|
+
n: facts.wrapper.wakeup,
|
|
366
|
+
outcome: "lost",
|
|
367
|
+
detail: trusted
|
|
368
|
+
? "wedged wrapper killed by reconcile"
|
|
369
|
+
: "wedged wrapper signal withheld (recorded process is gone)",
|
|
370
|
+
});
|
|
371
|
+
pod.state = "idle";
|
|
372
|
+
delete pod.wedgedAt;
|
|
373
|
+
await rm(wrapperJsonPath(pod.project, pod.id), { force: true });
|
|
374
|
+
changed = true;
|
|
375
|
+
}
|
|
376
|
+
} else if (facts.quadrant === "healthy") {
|
|
377
|
+
// Beats resumed: the wake-from-sleep race closes (ADR 0017).
|
|
378
|
+
if (pod.wedgedAt !== undefined) {
|
|
379
|
+
delete pod.wedgedAt;
|
|
380
|
+
changed = true;
|
|
381
|
+
}
|
|
382
|
+
// Liveness outranks progress (ADR 0019): only a healthy wake-up — pid
|
|
383
|
+
// live, heartbeat fresh — is judged on its documents at all.
|
|
384
|
+
const wrapper = facts.wrapper;
|
|
385
|
+
if (
|
|
386
|
+
wrapper !== undefined &&
|
|
387
|
+
wrapper.docs !== undefined &&
|
|
388
|
+
now - wrapper.startedAt >= PROGRESS_WINDOW_MS &&
|
|
389
|
+
!(await documentsMoved(join(podDir(pod.project, pod.id), "docs"), wrapper.docs))
|
|
390
|
+
) {
|
|
391
|
+
// Three-verified (ADR 0020): a live pid whose start time is not the
|
|
392
|
+
// recorded one is somebody else — nothing to judge, nothing to kill.
|
|
393
|
+
if (await verifyProcess(wrapper)) {
|
|
394
|
+
// Intent first (ADR 0019): the registry-lock write lands before the
|
|
395
|
+
// signal, so the verdict is on disk before it is acted on, and the
|
|
396
|
+
// wrapper sees it and records no second outcome. The pass waits for
|
|
397
|
+
// nothing — the wrapper owns the wind-down (ADR 0017).
|
|
398
|
+
pod.killIntent = { n: wrapper.wakeup, at: now, cause: "progressless" };
|
|
399
|
+
await writePods(pods);
|
|
400
|
+
signalGroup(wrapper.pgid, "SIGTERM");
|
|
401
|
+
pod.wakeUps.push({
|
|
402
|
+
n: wrapper.wakeup,
|
|
403
|
+
outcome: "failed",
|
|
404
|
+
detail: PROGRESSLESS_DETAIL,
|
|
405
|
+
});
|
|
406
|
+
pod.state = "idle";
|
|
407
|
+
changed = true;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
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
|
+
|
|
417
|
+
// 2. Policy derivation from outcome facts the wrapper (or step 1) recorded.
|
|
418
|
+
const fresh = pod.wakeUps.slice(pod.policy.appliedOutcomes);
|
|
419
|
+
for (const wake of fresh) {
|
|
420
|
+
if (wake.outcome === "failed" || wake.outcome === "lost") {
|
|
421
|
+
pod.policy.failureStreak += 1;
|
|
422
|
+
pod.policy.nextWakeAt = now + backoffMs(pod.policy.failureStreak);
|
|
423
|
+
// Backoff without a stop is a money pit: streak 3 parks the pod, and
|
|
424
|
+
// only a human wake lifts it (ADR 0011). Park is policy data, not a state.
|
|
425
|
+
if (pod.policy.failureStreak >= 3 && !pod.policy.parked) {
|
|
426
|
+
pod.policy.parked = true;
|
|
427
|
+
announcePark();
|
|
428
|
+
}
|
|
429
|
+
} else if (wake.outcome === "more" || wake.outcome === "done") {
|
|
430
|
+
pod.policy.failureStreak = 0;
|
|
431
|
+
delete pod.policy.nextWakeAt;
|
|
432
|
+
}
|
|
433
|
+
pod.policy.appliedOutcomes += 1;
|
|
434
|
+
changed = true;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const goalOpen = pod.state !== "done" && pod.state !== "cancelled";
|
|
438
|
+
|
|
439
|
+
// 3. Terminal observation (ADR 0021): done is announced once, however many
|
|
440
|
+
// passes later find the record. The marker is what makes it once.
|
|
441
|
+
if (
|
|
442
|
+
pod.state === "done" &&
|
|
443
|
+
pod.notifiedAt === undefined &&
|
|
444
|
+
shouldNotify(notify.on, "done")
|
|
445
|
+
) {
|
|
446
|
+
const handoff = await handoffStatusLine(pod);
|
|
447
|
+
pending.push({
|
|
448
|
+
event: "done",
|
|
449
|
+
podId: pod.id,
|
|
450
|
+
goal: goalHeadline(pod.goal),
|
|
451
|
+
...(handoff ? { handoffStatus: handoff } : {}),
|
|
452
|
+
});
|
|
453
|
+
pod.notifiedAt = now;
|
|
454
|
+
changed = true;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Deleting a definition an idle pod still references is not an error at
|
|
458
|
+
// `agent rm`: the orphan fails resolution here and parks (ADR 0012). An
|
|
459
|
+
// active pod is the wrapper's to fail — the streak machinery takes over.
|
|
460
|
+
// The park it causes is a park episode too, so it notifies (ADR 0021).
|
|
461
|
+
const agentId = pod.policy.agent ?? "default";
|
|
462
|
+
const agentKnown = resolveAgentDefinition(agents, agentId) !== undefined;
|
|
463
|
+
if (!agentKnown && goalOpen && pod.state !== "active") {
|
|
464
|
+
if (!pod.policy.parked) {
|
|
465
|
+
pod.policy.parked = true;
|
|
466
|
+
announcePark(`agent "${agentId}" no longer resolves (ADR 0012)`);
|
|
467
|
+
changed = true;
|
|
468
|
+
}
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (changed) await writePods(pods);
|
|
474
|
+
|
|
475
|
+
// Intake comes before the wake phase (ADR 0014): a batch appended to an idle
|
|
476
|
+
// pod in this pass must be served by this pass's wake — one batch, one wake,
|
|
477
|
+
// appended and marked before anything is launched. Claim-before-touch still
|
|
478
|
+
// holds (ADR 0007): the claims are written before a document is opened.
|
|
479
|
+
await listenersPass(project, drained);
|
|
480
|
+
|
|
481
|
+
// 4. The wake phase (ADR 0011): the guarded idle → active transition, read
|
|
482
|
+
// fresh because intake may have claimed, appended, or spawned since the pass
|
|
483
|
+
// began. A pod intake already woke is active here and is not woken twice.
|
|
484
|
+
await wakeDuePods(now);
|
|
485
|
+
return pending;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* The wake phase: every idle, goal-open, unparked pod whose backoff expired is
|
|
490
|
+
* woken. It runs after intake so an item that just landed in a pod's `## Inbox`
|
|
491
|
+
* is served by the same pass's wake (ADR 0014), and the read is the registry's
|
|
492
|
+
* current state — the same compare-and-swap ADR 0018 relies on, one write.
|
|
493
|
+
*/
|
|
494
|
+
async function wakeDuePods(now: number): Promise<void> {
|
|
495
|
+
const pods = await readPods();
|
|
496
|
+
const projects = await readProjects();
|
|
497
|
+
let changed = false;
|
|
498
|
+
for (const pod of pods) {
|
|
499
|
+
if (pod.state !== "idle" || pod.policy?.parked) continue;
|
|
500
|
+
if (pod.policy?.nextWakeAt !== undefined && pod.policy.nextWakeAt > now) continue;
|
|
501
|
+
if (pod.after !== undefined) {
|
|
502
|
+
const upstream = pods.find((candidate) => candidate.id === pod.after);
|
|
503
|
+
if (upstream?.state !== "done") continue;
|
|
504
|
+
}
|
|
505
|
+
if (!projectHasCapacity(pod.project, pods, projects)) continue;
|
|
506
|
+
launchWrapperDetached(pod.id, pod.policy?.agent ?? "default");
|
|
507
|
+
pod.state = "active";
|
|
508
|
+
changed = true;
|
|
509
|
+
}
|
|
510
|
+
if (changed) await writePods(pods);
|
|
511
|
+
}
|
|
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
|
+
|
|
519
|
+
/** Non-terminal materializations occupy a listener's bound, even while capacity-waiting. */
|
|
520
|
+
function livePod(claimedBy: string, pods: PodRecord[]): boolean {
|
|
521
|
+
const pod = pods.find((candidate) => candidate.id === claimedBy);
|
|
522
|
+
return pod !== undefined && pod.state !== "done" && pod.state !== "cancelled";
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** P0's match rule: a plain substring over title and body; absent means all. */
|
|
526
|
+
function matchesListener(rule: string | undefined, item: InboxItem): boolean {
|
|
527
|
+
if (rule === undefined || rule === "") return true;
|
|
528
|
+
return `${item.title}\n${item.body}`.includes(rule);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* The listener pass (ADR 0007, 0013, 0014). The inbox is read-only; a claim is
|
|
533
|
+
* registry data written here — inside the registry lock, before any pod is
|
|
534
|
+
* touched — so a crash between claim and materialization is repaired by the
|
|
535
|
+
* next pass. Directory items are claimable only once their mtime and size held
|
|
536
|
+
* across two consecutive scans; JSONL malformed lines are skipped and warned
|
|
537
|
+
* every pass until their writer fixes them. Claims then reach their target: a
|
|
538
|
+
* template spawns a pod, a bound standing pod takes them as `## Inbox` blocks.
|
|
539
|
+
*/
|
|
540
|
+
async function listenersPass(project: string, drained: SourceDrain[]): Promise<void> {
|
|
541
|
+
const listeners = await readListeners();
|
|
542
|
+
if (listeners.length === 0) return;
|
|
543
|
+
|
|
544
|
+
/** The inbox, not the listener, is the claim namespace (ADR 0013). */
|
|
545
|
+
const claimedInInbox = new Set<string>();
|
|
546
|
+
for (const listener of listeners) {
|
|
547
|
+
listener.claimed ??= [];
|
|
548
|
+
listener.seen ??= [];
|
|
549
|
+
for (const claim of listener.claimed) claimedInInbox.add(claimKey(claim.inbox, claim.item));
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const itemsByListener = new Map<string, Map<string, InboxItem>>();
|
|
553
|
+
const unscannable = new Set<string>();
|
|
554
|
+
let changed = false;
|
|
555
|
+
|
|
556
|
+
// Phase 1: scan, hold the stability memory, and claim. Nothing is spawned yet.
|
|
557
|
+
for (const listener of listeners) {
|
|
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);
|
|
572
|
+
if (scan.error !== undefined) {
|
|
573
|
+
unscannable.add(listener.id);
|
|
574
|
+
if (listener.source === undefined) {
|
|
575
|
+
await appendCronLog(
|
|
576
|
+
`listener ${listener.id}: inbox ${listener.inbox} unreadable (${scan.error})`,
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
for (const bad of scan.bad) {
|
|
582
|
+
await appendCronLog(
|
|
583
|
+
`listener ${listener.id}: ${listener.inbox} line ${bad.line}: ${bad.reason} (skipped, unclaimed)`,
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
|
|
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
|
+
}
|
|
595
|
+
}
|
|
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
|
+
}
|
|
605
|
+
}
|
|
606
|
+
listener.seen = [...seen.values()];
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const pendingClaims = listener.claimed.filter(
|
|
610
|
+
(claim) => claim.spawnedPodId === undefined && claim.blocked === undefined,
|
|
611
|
+
).length;
|
|
612
|
+
const pods = await readPods();
|
|
613
|
+
const live = listener.claimed.filter(
|
|
614
|
+
(claim) => claim.spawnedPodId !== undefined && livePod(claim.spawnedPodId, pods),
|
|
615
|
+
).length;
|
|
616
|
+
// A bound pod is served by a queue (ADR 0014), so only template spawns
|
|
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.
|
|
621
|
+
let room =
|
|
622
|
+
listener.mode === "pod" || listener.source !== undefined
|
|
623
|
+
? Number.POSITIVE_INFINITY
|
|
624
|
+
: Math.max(0, listener.maxConcurrent - live - pendingClaims);
|
|
625
|
+
|
|
626
|
+
for (const item of stable) {
|
|
627
|
+
if (room <= 0) break;
|
|
628
|
+
const key = claimKey(origin, item.id);
|
|
629
|
+
if (claimedInInbox.has(key) || !matchesListener(listener.match, item)) continue;
|
|
630
|
+
// One claim shape for both targets (ADR 0013): a template materializes a
|
|
631
|
+
// pod from it later, a bound standing pod takes it as a document in
|
|
632
|
+
// phase 3 (ADR 0014). Claim first, always.
|
|
633
|
+
listener.claimed.push({
|
|
634
|
+
inbox: origin,
|
|
635
|
+
item: item.id,
|
|
636
|
+
...(sourceDrain?.drainedAt === undefined
|
|
637
|
+
? {}
|
|
638
|
+
: { claimedAt: sourceDrain.drainedAt }),
|
|
639
|
+
});
|
|
640
|
+
claimedInInbox.add(key);
|
|
641
|
+
changed = true;
|
|
642
|
+
if (room !== Number.POSITIVE_INFINITY) room -= 1;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// The claim is on disk before any pod is touched (ADR 0007).
|
|
647
|
+
if (changed) await writeListeners(listeners);
|
|
648
|
+
|
|
649
|
+
// Phase 2: materialize what is claimed and still unserved.
|
|
650
|
+
let materialized = false;
|
|
651
|
+
for (const listener of listeners) {
|
|
652
|
+
if (listener.mode !== "template" || unscannable.has(listener.id)) continue;
|
|
653
|
+
const pending = () =>
|
|
654
|
+
listener.claimed.filter(
|
|
655
|
+
(claim) => claim.spawnedPodId === undefined && claim.blocked === undefined,
|
|
656
|
+
);
|
|
657
|
+
if (pending().length === 0) continue;
|
|
658
|
+
|
|
659
|
+
const body = await readFile(listener.template ?? "", "utf8").catch(() => undefined);
|
|
660
|
+
if (body === undefined) {
|
|
661
|
+
for (const claim of pending()) {
|
|
662
|
+
claim.blocked = `template ${listener.template} unreadable`;
|
|
663
|
+
await appendCronLog(
|
|
664
|
+
`listener ${listener.id}: item ${claim.item} materialization failed: template ${listener.template} unreadable (claim consumed, ADR 0013)`,
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
materialized = true;
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const pods = await readPods();
|
|
672
|
+
let live = listener.claimed.filter(
|
|
673
|
+
(claim) => claim.spawnedPodId !== undefined && livePod(claim.spawnedPodId, pods),
|
|
674
|
+
).length;
|
|
675
|
+
for (const claim of pending()) {
|
|
676
|
+
if (live >= listener.maxConcurrent) break; // bound: the claim waits a pass
|
|
677
|
+
const item = itemsByListener.get(listener.id)?.get(claim.item);
|
|
678
|
+
if (item === undefined) {
|
|
679
|
+
claim.blocked = "item vanished before materialization";
|
|
680
|
+
await appendCronLog(
|
|
681
|
+
`listener ${listener.id}: item ${claim.item} materialization failed: item vanished (claim consumed, ADR 0013)`,
|
|
682
|
+
);
|
|
683
|
+
materialized = true;
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
const rendered = renderTemplate(body, item);
|
|
687
|
+
if ("error" in rendered) {
|
|
688
|
+
claim.blocked = rendered.error;
|
|
689
|
+
await appendCronLog(
|
|
690
|
+
`listener ${listener.id}: item ${claim.item} materialization failed: ${rendered.error} (claim consumed, ADR 0013)`,
|
|
691
|
+
);
|
|
692
|
+
materialized = true;
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
const record = await spawnPodRecord(
|
|
696
|
+
rendered.goal,
|
|
697
|
+
listener.project ?? project,
|
|
698
|
+
"default",
|
|
699
|
+
undefined,
|
|
700
|
+
undefined,
|
|
701
|
+
true,
|
|
702
|
+
);
|
|
703
|
+
claim.spawnedPodId = record.id;
|
|
704
|
+
live += 1;
|
|
705
|
+
materialized = true;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
if (materialized) await writeListeners(listeners);
|
|
709
|
+
|
|
710
|
+
// Phase 3: a bound standing pod takes the claimed batch as documents
|
|
711
|
+
// (ADR 0014). Nothing is written while a wake-up is live — TASKS.md is the
|
|
712
|
+
// one file both sides write, so one writer at a time, and a parked pod's
|
|
713
|
+
// items accumulate in the registry until a human lifts the park.
|
|
714
|
+
let appended = false;
|
|
715
|
+
for (const listener of listeners) {
|
|
716
|
+
if (listener.mode !== "pod" || listener.podId === undefined) continue;
|
|
717
|
+
if (unscannable.has(listener.id)) continue; // no bodies to copy this pass
|
|
718
|
+
const waiting = listener.claimed.filter(
|
|
719
|
+
(claim) => claim.blocked === undefined && claim.appended !== true,
|
|
720
|
+
);
|
|
721
|
+
if (waiting.length === 0) continue;
|
|
722
|
+
|
|
723
|
+
const pods = await readPods();
|
|
724
|
+
const pod = pods.find((candidate) => candidate.id === listener.podId);
|
|
725
|
+
if (pod === undefined) {
|
|
726
|
+
await appendCronLog(
|
|
727
|
+
`listener ${listener.id}: bound pod ${listener.podId} is not in the registry (claim waits)`,
|
|
728
|
+
);
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
if (pod.state !== "idle" || pod.policy?.parked) continue;
|
|
732
|
+
|
|
733
|
+
// The body is copied at append time (ADR 0014), so the item must still be
|
|
734
|
+
// readable: a writer that deleted it first leaves nothing to copy.
|
|
735
|
+
const scanned = itemsByListener.get(listener.id);
|
|
736
|
+
const batch: InboxItem[] = [];
|
|
737
|
+
for (const claim of waiting) {
|
|
738
|
+
const item = scanned?.get(claim.item);
|
|
739
|
+
if (item === undefined) {
|
|
740
|
+
claim.blocked = "item vanished before append";
|
|
741
|
+
await appendCronLog(
|
|
742
|
+
`listener ${listener.id}: item ${claim.item} vanished before append (claim consumed, ADR 0014)`,
|
|
743
|
+
);
|
|
744
|
+
appended = true;
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
batch.push(item);
|
|
748
|
+
}
|
|
749
|
+
if (batch.length === 0) continue;
|
|
750
|
+
|
|
751
|
+
try {
|
|
752
|
+
await appendInbox(join(podDir(pod.project, pod.id), "docs"), batch);
|
|
753
|
+
} catch (error) {
|
|
754
|
+
// The claims stay unappended: the next pass retries the same batch.
|
|
755
|
+
await appendCronLog(
|
|
756
|
+
`listener ${listener.id}: bound pod ${pod.id} Inbox append failed (${(error as Error).message})`,
|
|
757
|
+
);
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
for (const item of batch) {
|
|
761
|
+
const claim = waiting.find((candidate) => candidate.item === item.id);
|
|
762
|
+
if (claim !== undefined) claim.appended = true;
|
|
763
|
+
}
|
|
764
|
+
appended = true;
|
|
765
|
+
}
|
|
766
|
+
// Append first, then mark: the marker is on disk before the wake phase can
|
|
767
|
+
// launch anything, so a crash anywhere between claim and wake repairs on the
|
|
768
|
+
// next pass (ADR 0014). The wake itself is the pass's ordinary transition.
|
|
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);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
export interface CliDeps {
|
|
802
|
+
cwd?: string;
|
|
803
|
+
stdout?: (line: string) => void;
|
|
804
|
+
stderr?: (line: string) => void;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function newPodId(): string {
|
|
808
|
+
return `pod-${randomBytes(3).toString("hex")}`;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** A goal is standing text; its first line is the one-line handle (ADR 0011). */
|
|
812
|
+
function goalHeadline(goal: string): string {
|
|
813
|
+
return goal.split("\n", 1)[0]!.trim();
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* The consecutive failed/lost run that condemned the pod, in wake-up order.
|
|
818
|
+
* The streak is policy's count; this is the evidence behind it (ADR 0021).
|
|
819
|
+
*/
|
|
820
|
+
function failureTrail(pod: PodRecord): Array<{ n: number; outcome: string; detail?: string }> {
|
|
821
|
+
const trail: Array<{ n: number; outcome: string; detail?: string }> = [];
|
|
822
|
+
for (let index = pod.wakeUps.length - 1; index >= 0; index -= 1) {
|
|
823
|
+
const wake = pod.wakeUps[index]!;
|
|
824
|
+
if (wake.outcome !== "failed" && wake.outcome !== "lost") break;
|
|
825
|
+
trail.unshift(wake);
|
|
826
|
+
}
|
|
827
|
+
return trail;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/** The park verdict: id, goal headline, the failures and their details (ADR 0021). */
|
|
831
|
+
function parkNotification(pod: PodRecord, reason?: string): PodNotification {
|
|
832
|
+
const failures = failureTrail(pod);
|
|
833
|
+
return {
|
|
834
|
+
event: "park",
|
|
835
|
+
podId: pod.id,
|
|
836
|
+
goal: goalHeadline(pod.goal),
|
|
837
|
+
consecutiveFailures: pod.policy?.failureStreak ?? failures.length,
|
|
838
|
+
...(failures.length > 0 ? { failures } : {}),
|
|
839
|
+
...(reason ? { reason } : {}),
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* The handoff's status line, from the wrapper's snapshot (ADR 0009): a done
|
|
845
|
+
* notification carries the agent's own word, not a reconstruction.
|
|
846
|
+
*/
|
|
847
|
+
async function handoffStatusLine(pod: PodRecord): Promise<string | undefined> {
|
|
848
|
+
const wakeup = pod.wakeUps.at(-1)?.n;
|
|
849
|
+
if (wakeup === undefined) return undefined;
|
|
850
|
+
const file = join(podDir(pod.project, pod.id), "wakeups", String(wakeup), "handoff.md");
|
|
851
|
+
const body = await readFile(file, "utf8").catch(() => undefined);
|
|
852
|
+
const line = (body?.split("\n", 1)[0] ?? "").trim();
|
|
853
|
+
return line || undefined;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
async function requirePod(
|
|
857
|
+
podId: string | undefined,
|
|
858
|
+
command: string,
|
|
859
|
+
err: (line: string) => void,
|
|
860
|
+
): Promise<PodRecord | undefined> {
|
|
861
|
+
if (!podId) {
|
|
862
|
+
err(`Usage: agents-pod ${command} <podId>`);
|
|
863
|
+
return undefined;
|
|
864
|
+
}
|
|
865
|
+
const pod = (await readPods()).find((candidate) => candidate.id === podId);
|
|
866
|
+
if (!pod) {
|
|
867
|
+
err(`Unknown pod ${podId}`);
|
|
868
|
+
return undefined;
|
|
869
|
+
}
|
|
870
|
+
return pod;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/** A read-only view: status never writes the store (ADR 0011, ADR 0015). */
|
|
874
|
+
async function statusCmd(
|
|
875
|
+
rest: string[],
|
|
876
|
+
out: (line: string) => void,
|
|
877
|
+
err: (line: string) => void,
|
|
878
|
+
): Promise<number> {
|
|
879
|
+
const pod = await requirePod(rest[0], "status", err);
|
|
880
|
+
if (!pod) return 2;
|
|
881
|
+
|
|
882
|
+
const last = pod.wakeUps.at(-1);
|
|
883
|
+
out(pod.id);
|
|
884
|
+
out(` goal: ${goalHeadline(pod.goal)}`);
|
|
885
|
+
out(` state: ${pod.state}`);
|
|
886
|
+
if (pod.after !== undefined) {
|
|
887
|
+
const upstream = (await readPods()).find((candidate) => candidate.id === pod.after);
|
|
888
|
+
out(` after: ${pod.after} (${upstream?.state ?? "missing"})`);
|
|
889
|
+
}
|
|
890
|
+
out(` liveness: ${await livenessOf(pod)}`);
|
|
891
|
+
if (pod.policy?.parked) {
|
|
892
|
+
out(` parked (consecutive failures: ${pod.policy.failureStreak})`);
|
|
893
|
+
}
|
|
894
|
+
out(` agent: ${resolveAgentBinary(process.env)}`);
|
|
895
|
+
out(` wake-ups: ${pod.wakeUps.length} (last outcome: ${last?.outcome ?? "none"})`);
|
|
896
|
+
return 0;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* The attention ruler's first use (DESIGN Part 1): a healthy pod takes one
|
|
901
|
+
* quiet line at the bottom; the pods that need a human lead. Read-only.
|
|
902
|
+
*/
|
|
903
|
+
async function listCmd(out: (line: string) => void): Promise<number> {
|
|
904
|
+
const pods = await readPods();
|
|
905
|
+
if (pods.length === 0) {
|
|
906
|
+
out("No pods");
|
|
907
|
+
return 0;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const rows = await Promise.all(
|
|
911
|
+
pods.map(async (pod) => ({ pod, liveness: await livenessOf(pod) })),
|
|
912
|
+
);
|
|
913
|
+
const needsHuman = (row: (typeof rows)[number]) =>
|
|
914
|
+
row.pod.policy?.parked === true || row.liveness !== "healthy";
|
|
915
|
+
const ordered = [...rows.filter(needsHuman), ...rows.filter((row) => !needsHuman(row))];
|
|
916
|
+
|
|
917
|
+
for (const { pod } of ordered) {
|
|
918
|
+
const parked = pod.policy?.parked ? " (parked)" : "";
|
|
919
|
+
out(`${pod.id} ${pod.state}${parked} ${goalHeadline(pod.goal)}`);
|
|
920
|
+
}
|
|
921
|
+
return 0;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* `logs` is the human's window into the wrapper's dual log (ADR 0009):
|
|
926
|
+
* output.log carries the wake-up separators and the assistant's text. A tail
|
|
927
|
+
* is deliberately boring — a 50 ms stat poll, no fs.watch (a watch is an
|
|
928
|
+
* inotify edge to miss, a poll is a fact to re-read).
|
|
929
|
+
*/
|
|
930
|
+
const FOLLOW_POLL_MS = 50;
|
|
931
|
+
|
|
932
|
+
async function logsCmd(
|
|
933
|
+
words: string[],
|
|
934
|
+
out: (line: string) => void,
|
|
935
|
+
err: (line: string) => void,
|
|
936
|
+
): Promise<number> {
|
|
937
|
+
// `--follow` carries no value, so parseFlags leaves it among the words; the
|
|
938
|
+
// pod id is the only non-flag word (README's `logs <podId> [--follow]`).
|
|
939
|
+
const { rest } = parseFlags(words, []);
|
|
940
|
+
const follow = rest.includes("--follow");
|
|
941
|
+
const pod = await requirePod(
|
|
942
|
+
rest.find((word) => !word.startsWith("--")),
|
|
943
|
+
"logs",
|
|
944
|
+
err,
|
|
945
|
+
);
|
|
946
|
+
if (!pod) return 2;
|
|
947
|
+
|
|
948
|
+
const file = join(podDir(pod.project, pod.id), "output.log");
|
|
949
|
+
if (!(await exists(file))) return 0; // a pod that never logged is not an error
|
|
950
|
+
|
|
951
|
+
// Complete lines only: bytes past the last newline wait for the rest, so a
|
|
952
|
+
// half-written line is never printed twice or torn in half.
|
|
953
|
+
let offset = 0;
|
|
954
|
+
const drain = async (): Promise<void> => {
|
|
955
|
+
const body = await readFile(file).catch(() => undefined);
|
|
956
|
+
if (body === undefined) return;
|
|
957
|
+
const lastNewline = body.lastIndexOf(0x0a);
|
|
958
|
+
if (lastNewline < offset) return;
|
|
959
|
+
const chunk = body.subarray(offset, lastNewline + 1).toString("utf8");
|
|
960
|
+
offset = lastNewline + 1;
|
|
961
|
+
for (const line of chunk.slice(0, -1).split("\n")) out(line);
|
|
962
|
+
};
|
|
963
|
+
|
|
964
|
+
await drain();
|
|
965
|
+
if (!follow) {
|
|
966
|
+
const body = await readFile(file).catch(() => undefined);
|
|
967
|
+
if (body !== undefined && body.length > offset) {
|
|
968
|
+
out(body.subarray(offset).toString("utf8"));
|
|
969
|
+
}
|
|
970
|
+
return 0;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// The tail never returns: the follower is ended by a signal, not by an exit.
|
|
974
|
+
for (;;) {
|
|
975
|
+
await sleep(FOLLOW_POLL_MS);
|
|
976
|
+
await drain();
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* The human override (ADR 0011): one rule, no `--force`. A wake on an idle,
|
|
982
|
+
* goal-open pod always succeeds and clears parked, failureStreak, and
|
|
983
|
+
* nextWakeAt; terminal is terminal; a running pod has nothing to wake.
|
|
984
|
+
*
|
|
985
|
+
* `--warm` is crash recovery, not an override (ADR 0011, 0020): it is legal
|
|
986
|
+
* only when the last outcome is `lost`, still counts as wake-up n+1 with its
|
|
987
|
+
* own wrapper and outcome, and neither clears nor advances the streak — so it
|
|
988
|
+
* must not walk through the policy-clearing branch below.
|
|
989
|
+
*/
|
|
990
|
+
async function wakeCmd(
|
|
991
|
+
words: string[],
|
|
992
|
+
out: (line: string) => void,
|
|
993
|
+
err: (line: string) => void,
|
|
994
|
+
): Promise<number> {
|
|
995
|
+
const { flags, rest } = parseFlags(words, ["agent"]);
|
|
996
|
+
// `--warm` carries no value, so parseFlags leaves it among the words.
|
|
997
|
+
const warm = rest.includes("--warm");
|
|
998
|
+
const podId = rest.find((word) => !word.startsWith("--"));
|
|
999
|
+
if (!podId) {
|
|
1000
|
+
err("Usage: agents-pod wake <podId> [--agent <id>] [--warm]");
|
|
1001
|
+
return 2;
|
|
1002
|
+
}
|
|
1003
|
+
const pods = await readPods();
|
|
1004
|
+
const pod = pods.find((candidate) => candidate.id === podId);
|
|
1005
|
+
if (!pod) {
|
|
1006
|
+
err(`Unknown pod ${podId}`);
|
|
1007
|
+
return 2;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
if (pod.state === "done" || pod.state === "cancelled") {
|
|
1011
|
+
err(`Pod ${pod.id} is ${pod.state}: terminal is terminal — spawn a new pod instead`);
|
|
1012
|
+
return 2;
|
|
1013
|
+
}
|
|
1014
|
+
if (pod.state === "active" || pod.state === "spawning") {
|
|
1015
|
+
err(`Pod ${pod.id} is ${pod.state}: nothing to wake`);
|
|
1016
|
+
return 0;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// Only a crash has a session worth resuming (ADR 0011): a `failed` wake-up
|
|
1020
|
+
// ended cleanly, and a fresh window is the honest continuation for it.
|
|
1021
|
+
const last = pod.wakeUps.at(-1);
|
|
1022
|
+
if (warm && last?.outcome !== "lost") {
|
|
1023
|
+
err(
|
|
1024
|
+
`Pod ${pod.id}: --warm is valid only when the last outcome is lost (last: ${last?.outcome ?? "none"})`,
|
|
1025
|
+
);
|
|
1026
|
+
return 2;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
if (pod.after !== undefined) {
|
|
1030
|
+
const upstream = (await readPods()).find((candidate) => candidate.id === pod.after);
|
|
1031
|
+
if (upstream?.state !== "done") {
|
|
1032
|
+
err(`Pod ${pod.id} is waiting for pod ${pod.after} (${upstream?.state ?? "missing"})`);
|
|
1033
|
+
return 2;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
const projects = await readProjects();
|
|
1037
|
+
if (!projectHasCapacity(pod.project, pods, projects)) {
|
|
1038
|
+
const active = pods.filter(
|
|
1039
|
+
(candidate) =>
|
|
1040
|
+
resolve(candidate.project) === resolve(pod.project) &&
|
|
1041
|
+
(candidate.state === "spawning" || candidate.state === "active"),
|
|
1042
|
+
).length;
|
|
1043
|
+
err(`Pod ${pod.id} is waiting for project capacity (${active}/${projectMaxActive(pod.project, projects)})`);
|
|
1044
|
+
return 2;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// A one-off override: it serves this wake-up only, never the pod's policy
|
|
1048
|
+
// (ADR 0012). An unknown id is a configuration error at the surface.
|
|
1049
|
+
const agentId = flags.agent ?? pod.policy?.agent ?? "default";
|
|
1050
|
+
const agent = resolveAgentDefinition(await readAgents(), agentId);
|
|
1051
|
+
if (!agent) {
|
|
1052
|
+
err(`Unknown agent ${agentId}: define it with agents-pod agent add`);
|
|
1053
|
+
return 2;
|
|
1054
|
+
}
|
|
1055
|
+
const refusal = agentTypeRefusal(agent.type);
|
|
1056
|
+
if (refusal !== undefined) {
|
|
1057
|
+
err(`Agent ${agent.id} has type ${agent.type}: ${refusal}`);
|
|
1058
|
+
return 2;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// The wake is a read-modify-write compare-and-swap (ADR 0018): the record is
|
|
1062
|
+
// re-read inside the registry lock and the condition re-verified, so a pass
|
|
1063
|
+
// that woke the pod between the check above and here wins and this skips.
|
|
1064
|
+
const woke = await withRegistryLock(async () => {
|
|
1065
|
+
const current = await readPods();
|
|
1066
|
+
const target = current.find((candidate) => candidate.id === pod.id);
|
|
1067
|
+
if (!target || target.state !== "idle") return false;
|
|
1068
|
+
if (target.after !== undefined) {
|
|
1069
|
+
const upstream = current.find((candidate) => candidate.id === target.after);
|
|
1070
|
+
if (upstream?.state !== "done") return false;
|
|
1071
|
+
}
|
|
1072
|
+
if (!projectHasCapacity(target.project, current, await readProjects())) return false;
|
|
1073
|
+
// Warm is crash recovery, not the human override: policy is the streak's
|
|
1074
|
+
// and stays exactly where reconcile left it (ADR 0011). A non-warm wake is
|
|
1075
|
+
// a new episode: the park-clearing also clears notifiedAt (ADR 0021).
|
|
1076
|
+
if (!warm) {
|
|
1077
|
+
if (target.policy) {
|
|
1078
|
+
target.policy.failureStreak = 0;
|
|
1079
|
+
delete target.policy.nextWakeAt;
|
|
1080
|
+
delete target.policy.parked;
|
|
1081
|
+
}
|
|
1082
|
+
delete target.notifiedAt;
|
|
1083
|
+
}
|
|
1084
|
+
launchWrapperDetached(target.id, agentId, warm);
|
|
1085
|
+
target.state = "active";
|
|
1086
|
+
await writePods(current);
|
|
1087
|
+
return true;
|
|
1088
|
+
});
|
|
1089
|
+
if (!woke) {
|
|
1090
|
+
// The loser's CAS saw a pod someone else already woke: skip, not an error.
|
|
1091
|
+
err(`Pod ${pod.id} is already running: nothing to wake`);
|
|
1092
|
+
return 0;
|
|
1093
|
+
}
|
|
1094
|
+
out(`woke ${pod.id}${warm ? " (warm)" : ""}`);
|
|
1095
|
+
return 0;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* The human's stop (ADR 0003, 0017): signal the wake-up's whole process group
|
|
1100
|
+
* and return — the wrapper owns the wind-down (trap, forward, grace, kill) and
|
|
1101
|
+
* the `cancelled` record. Refusing a non-active pod is a no-op, not an error.
|
|
1102
|
+
*/
|
|
1103
|
+
async function cancelCmd(
|
|
1104
|
+
rest: string[],
|
|
1105
|
+
out: (line: string) => void,
|
|
1106
|
+
err: (line: string) => void,
|
|
1107
|
+
): Promise<number> {
|
|
1108
|
+
const pod = await requirePod(rest[0], "cancel", err);
|
|
1109
|
+
if (!pod) return 2;
|
|
1110
|
+
|
|
1111
|
+
if (pod.state !== "active") {
|
|
1112
|
+
err(`pod ${pod.id} is ${pod.state}: nothing to cancel`);
|
|
1113
|
+
return 0;
|
|
1114
|
+
}
|
|
1115
|
+
const wrapper = await readWrapperRecord(pod);
|
|
1116
|
+
if (wrapper === undefined) {
|
|
1117
|
+
// A ghost record: nothing to signal; reconcile owns that verdict (ADR 0002).
|
|
1118
|
+
err(`pod ${pod.id} has no wrapper record: nothing to cancel`);
|
|
1119
|
+
return 0;
|
|
1120
|
+
}
|
|
1121
|
+
// Three-verified (ADR 0020): the recorded pid is live, its start time
|
|
1122
|
+
// matches, and the boot time is this boot's. Otherwise the record points at
|
|
1123
|
+
// a stranger — a recycled pid, or any pid at all after a reboot — and the
|
|
1124
|
+
// pod's verdict stays reconcile's.
|
|
1125
|
+
if (!(await verifyProcess(wrapper))) {
|
|
1126
|
+
err(`pod ${pod.id} recorded process is gone: nothing to signal`);
|
|
1127
|
+
return 0;
|
|
1128
|
+
}
|
|
1129
|
+
if (!signalGroup(wrapper.pgid, "SIGTERM")) {
|
|
1130
|
+
err(`pod ${pod.id} has no live wrapper: nothing to cancel`);
|
|
1131
|
+
return 0;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
out(`cancel signalled ${pod.id}`);
|
|
1135
|
+
return 0;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* The one spawn path: record, workspace, then wake admission (ADR 0022). An
|
|
1140
|
+
* admitted pod gets its detached wrapper; a blocked one waits idle. `spawn`
|
|
1141
|
+
* and a listener's template materialization both walk it (ADR 0007).
|
|
1142
|
+
*/
|
|
1143
|
+
async function spawnPodRecord(
|
|
1144
|
+
goal: string,
|
|
1145
|
+
project: string,
|
|
1146
|
+
agentId: string,
|
|
1147
|
+
model?: string,
|
|
1148
|
+
after?: string,
|
|
1149
|
+
registryHeld = false,
|
|
1150
|
+
): Promise<PodRecord> {
|
|
1151
|
+
const record: PodRecord = {
|
|
1152
|
+
id: newPodId(),
|
|
1153
|
+
goal,
|
|
1154
|
+
state: "spawning",
|
|
1155
|
+
project: resolve(project),
|
|
1156
|
+
...(after === undefined ? {} : { after }),
|
|
1157
|
+
wakeUps: [],
|
|
1158
|
+
// The policy names the agent and the optional model pin; `--model` beats
|
|
1159
|
+
// the definition's model, which beats the adapter default (ADR 0012).
|
|
1160
|
+
policy: {
|
|
1161
|
+
failureStreak: 0,
|
|
1162
|
+
appliedOutcomes: 0,
|
|
1163
|
+
agent: agentId,
|
|
1164
|
+
...(model === undefined ? {} : { model }),
|
|
1165
|
+
},
|
|
1166
|
+
};
|
|
1167
|
+
await createPodWorkspace(record);
|
|
1168
|
+
|
|
1169
|
+
const admit = async (): Promise<PodRecord> => {
|
|
1170
|
+
const pods = await readPods();
|
|
1171
|
+
const projects = await readProjects();
|
|
1172
|
+
const upstream = after === undefined
|
|
1173
|
+
? undefined
|
|
1174
|
+
: pods.find((candidate) => candidate.id === after);
|
|
1175
|
+
const blocked =
|
|
1176
|
+
(after !== undefined && upstream?.state !== "done") ||
|
|
1177
|
+
!projectHasCapacity(record.project, pods, projects);
|
|
1178
|
+
pods.push(record);
|
|
1179
|
+
|
|
1180
|
+
if (blocked) {
|
|
1181
|
+
record.state = "idle";
|
|
1182
|
+
await writePods(pods);
|
|
1183
|
+
return record;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
await writePods(pods);
|
|
1187
|
+
launchWrapperDetached(record.id, agentId);
|
|
1188
|
+
record.state = "active";
|
|
1189
|
+
await writePods(pods);
|
|
1190
|
+
return record;
|
|
1191
|
+
};
|
|
1192
|
+
|
|
1193
|
+
if (registryHeld) return admit();
|
|
1194
|
+
const spawned = await withRegistryLock(admit);
|
|
1195
|
+
if (spawned === undefined) throw new Error("registry lock unavailable: pod not spawned");
|
|
1196
|
+
return spawned;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
async function spawnCmd(
|
|
1200
|
+
words: string[],
|
|
1201
|
+
deps: CliDeps,
|
|
1202
|
+
out: (line: string) => void,
|
|
1203
|
+
err: (line: string) => void,
|
|
1204
|
+
): Promise<number> {
|
|
1205
|
+
const { flags, rest } = parseFlags(words, ["agent", "model", "cwd", "project", "after"]);
|
|
1206
|
+
const goal = rest.join(" ").trim();
|
|
1207
|
+
if (!goal) {
|
|
1208
|
+
err("Usage: agents-pod spawn <goal prompt> [--agent <id>] [--model <id>] [--cwd <dir>] [--after <podId>]");
|
|
1209
|
+
return 2;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// Fail fast at the surface (ADR 0012): an unknown id is an exit-2
|
|
1213
|
+
// configuration error, before any pod record or workspace exists. An
|
|
1214
|
+
// unsupported adapter type is the same kind of error, and refusing it here
|
|
1215
|
+
// keeps a pod from being born into a wake-up that can only fail.
|
|
1216
|
+
const agentId = flags.agent ?? "default";
|
|
1217
|
+
const agent = resolveAgentDefinition(await readAgents(), agentId);
|
|
1218
|
+
if (!agent) {
|
|
1219
|
+
err(`Unknown agent ${agentId}: define it with agents-pod agent add`);
|
|
1220
|
+
return 2;
|
|
1221
|
+
}
|
|
1222
|
+
const refusal = agentTypeRefusal(agent.type);
|
|
1223
|
+
if (refusal !== undefined) {
|
|
1224
|
+
err(`Agent ${agent.id} has type ${agent.type}: ${refusal}`);
|
|
1225
|
+
return 2;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
if (flags.after !== undefined) {
|
|
1229
|
+
const upstream = (await readPods()).find((candidate) => candidate.id === flags.after);
|
|
1230
|
+
if (upstream === undefined) {
|
|
1231
|
+
err(`Unknown pod ${flags.after}`);
|
|
1232
|
+
return 2;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
const record = await spawnPodRecord(
|
|
1237
|
+
goal,
|
|
1238
|
+
flags.project ?? flags.cwd ?? deps.cwd ?? process.cwd(),
|
|
1239
|
+
agentId,
|
|
1240
|
+
flags.model,
|
|
1241
|
+
flags.after,
|
|
1242
|
+
);
|
|
1243
|
+
out(record.id);
|
|
1244
|
+
return 0;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/** Per-project wake-up capacity: a small persistent guardrail, not a scheduler. */
|
|
1248
|
+
async function projectCmd(
|
|
1249
|
+
words: string[],
|
|
1250
|
+
deps: CliDeps,
|
|
1251
|
+
out: (line: string) => void,
|
|
1252
|
+
err: (line: string) => void,
|
|
1253
|
+
): Promise<number> {
|
|
1254
|
+
const [sub, ...rest] = words;
|
|
1255
|
+
const { flags, rest: positional } = parseFlags(rest, ["project"]);
|
|
1256
|
+
const project = resolve(flags.project ?? deps.cwd ?? process.cwd());
|
|
1257
|
+
if (sub !== "cap" || positional.length > 1) {
|
|
1258
|
+
err("Usage: agents-pod project cap [n|unlimited] [--project <dir>]");
|
|
1259
|
+
return 2;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
if (positional.length === 0) {
|
|
1263
|
+
const maxActive = projectMaxActive(project, await readProjects());
|
|
1264
|
+
out(`${project} ${maxActive === undefined ? "unlimited" : maxActive}`);
|
|
1265
|
+
return 0;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
const value = positional[0]!;
|
|
1269
|
+
const maxActive = value === "unlimited" ? undefined : Number(value);
|
|
1270
|
+
if (maxActive !== undefined && (!Number.isInteger(maxActive) || maxActive < 1)) {
|
|
1271
|
+
err("Project capacity must be a positive integer or unlimited");
|
|
1272
|
+
return 2;
|
|
1273
|
+
}
|
|
1274
|
+
const updated = await withRegistryLock(async () => {
|
|
1275
|
+
const projects = await readProjects();
|
|
1276
|
+
const remaining = projects.filter((candidate) => resolve(candidate.project) !== project);
|
|
1277
|
+
if (maxActive !== undefined) remaining.push({ project, maxActive });
|
|
1278
|
+
await writeProjects(remaining);
|
|
1279
|
+
return true;
|
|
1280
|
+
});
|
|
1281
|
+
if (updated === undefined) {
|
|
1282
|
+
err("registry lock unavailable: project capacity not changed");
|
|
1283
|
+
return 2;
|
|
1284
|
+
}
|
|
1285
|
+
out(`${project} ${maxActive === undefined ? "unlimited" : maxActive}`);
|
|
1286
|
+
return 0;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
/** The first line of role instructions is the list's one-line handle. */
|
|
1290
|
+
function instructionsHeadline(instructions: string): string {
|
|
1291
|
+
return instructions.split("\n", 1)[0]!.trim() || "(no role instructions)";
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
/**
|
|
1295
|
+
* `agents-pod agent` is add / edit / rm / list (ADR 0012): one machine-global
|
|
1296
|
+
* store, no version numbers, no pin flags, no `--force` ceremony. Definitions
|
|
1297
|
+
* are the operator's worker identities, so the store is a file a human reads.
|
|
1298
|
+
*/
|
|
1299
|
+
async function agentCmd(
|
|
1300
|
+
words: string[],
|
|
1301
|
+
out: (line: string) => void,
|
|
1302
|
+
err: (line: string) => void,
|
|
1303
|
+
): Promise<number> {
|
|
1304
|
+
const [sub, ...rest] = words;
|
|
1305
|
+
switch (sub) {
|
|
1306
|
+
case "list": {
|
|
1307
|
+
const agents = await readAgents();
|
|
1308
|
+
if (rest.includes("--json")) {
|
|
1309
|
+
out(
|
|
1310
|
+
JSON.stringify(
|
|
1311
|
+
agents.map((agent) => ({
|
|
1312
|
+
id: agent.id,
|
|
1313
|
+
type: agent.type,
|
|
1314
|
+
model: agent.model ?? null,
|
|
1315
|
+
instructions: agent.instructions,
|
|
1316
|
+
displayName: agent.displayName ?? null,
|
|
1317
|
+
})),
|
|
1318
|
+
),
|
|
1319
|
+
);
|
|
1320
|
+
return 0;
|
|
1321
|
+
}
|
|
1322
|
+
for (const agent of agents) {
|
|
1323
|
+
out(
|
|
1324
|
+
`${agent.id} ${agent.type} ${agent.model ?? "-"} ${instructionsHeadline(agent.instructions)}`,
|
|
1325
|
+
);
|
|
1326
|
+
}
|
|
1327
|
+
return 0;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
case "add": {
|
|
1331
|
+
const { flags, rest: positional } = parseFlags(rest, [
|
|
1332
|
+
"type",
|
|
1333
|
+
"model",
|
|
1334
|
+
"instructions",
|
|
1335
|
+
]);
|
|
1336
|
+
const id = positional[0];
|
|
1337
|
+
if (!id || flags.type === undefined) {
|
|
1338
|
+
err("Usage: agents-pod agent add <id> --type <pi|claude|codex> [--model <id>] [--instructions <file>]");
|
|
1339
|
+
return 2;
|
|
1340
|
+
}
|
|
1341
|
+
if (!isAgentType(flags.type)) {
|
|
1342
|
+
err(`Unknown agent type ${flags.type}: expected pi, claude, or codex`);
|
|
1343
|
+
return 2;
|
|
1344
|
+
}
|
|
1345
|
+
const refusal = agentTypeRefusal(flags.type);
|
|
1346
|
+
if (refusal !== undefined) {
|
|
1347
|
+
err(`Cannot add agent ${id}: --type ${flags.type} — ${refusal}`);
|
|
1348
|
+
return 2;
|
|
1349
|
+
}
|
|
1350
|
+
const agents = await readAgents();
|
|
1351
|
+
if (agents.some((agent) => agent.id === id)) {
|
|
1352
|
+
err(`Agent ${id} already exists: use agents-pod agent edit ${id}`);
|
|
1353
|
+
return 2;
|
|
1354
|
+
}
|
|
1355
|
+
let instructions = "";
|
|
1356
|
+
if (flags.instructions !== undefined) {
|
|
1357
|
+
// Read once, store the text, never the path (ADR 0012).
|
|
1358
|
+
const body = await readFile(flags.instructions, "utf8").catch(() => undefined);
|
|
1359
|
+
if (body === undefined) {
|
|
1360
|
+
err(`Cannot read instructions file ${flags.instructions}`);
|
|
1361
|
+
return 2;
|
|
1362
|
+
}
|
|
1363
|
+
instructions = body;
|
|
1364
|
+
}
|
|
1365
|
+
agents.push({
|
|
1366
|
+
id,
|
|
1367
|
+
type: flags.type,
|
|
1368
|
+
instructions,
|
|
1369
|
+
...(flags.model === undefined ? {} : { model: flags.model }),
|
|
1370
|
+
});
|
|
1371
|
+
await writeAgents(agents);
|
|
1372
|
+
return 0;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
case "edit": {
|
|
1376
|
+
const id = rest.find((word) => !word.startsWith("--"));
|
|
1377
|
+
if (!id) {
|
|
1378
|
+
err("Usage: agents-pod agent edit <id>");
|
|
1379
|
+
return 2;
|
|
1380
|
+
}
|
|
1381
|
+
const agents = await readAgents();
|
|
1382
|
+
const agent = agents.find((candidate) => candidate.id === id);
|
|
1383
|
+
if (!agent) {
|
|
1384
|
+
err(`Unknown agent ${id}`);
|
|
1385
|
+
return 2;
|
|
1386
|
+
}
|
|
1387
|
+
// $EDITOR on the stored text, written back on a clean exit (ADR 0012).
|
|
1388
|
+
const draft = join(tmpdir(), `agents-pod-agent-${randomBytes(6).toString("hex")}.md`);
|
|
1389
|
+
await writeFile(draft, agent.instructions);
|
|
1390
|
+
const editor = process.env.EDITOR?.trim() || "vi";
|
|
1391
|
+
const code = await new Promise<number>((resolve) => {
|
|
1392
|
+
const child = spawn(editor, [draft], { stdio: "inherit" });
|
|
1393
|
+
child.on("error", () => resolve(-1));
|
|
1394
|
+
child.on("close", (exitCode) => resolve(exitCode ?? -1));
|
|
1395
|
+
});
|
|
1396
|
+
const saved = code === 0 ? await readFile(draft, "utf8") : undefined;
|
|
1397
|
+
await unlink(draft).catch(() => undefined);
|
|
1398
|
+
if (saved === undefined) {
|
|
1399
|
+
err(`Editor ${editor} exited ${code}: ${id} is unchanged`);
|
|
1400
|
+
return 2;
|
|
1401
|
+
}
|
|
1402
|
+
agent.instructions = saved;
|
|
1403
|
+
await writeAgents(agents);
|
|
1404
|
+
return 0;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
case "rm": {
|
|
1408
|
+
const id = rest.find((word) => !word.startsWith("--"));
|
|
1409
|
+
if (!id) {
|
|
1410
|
+
err("Usage: agents-pod agent rm <id>");
|
|
1411
|
+
return 2;
|
|
1412
|
+
}
|
|
1413
|
+
const agents = await readAgents();
|
|
1414
|
+
const remaining = agents.filter((agent) => agent.id !== id);
|
|
1415
|
+
if (remaining.length === agents.length) {
|
|
1416
|
+
err(`Unknown agent ${id}`);
|
|
1417
|
+
return 2;
|
|
1418
|
+
}
|
|
1419
|
+
// No --force: pods still referencing it park at the next reconcile (ADR 0012).
|
|
1420
|
+
await writeAgents(remaining);
|
|
1421
|
+
return 0;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
default:
|
|
1425
|
+
err(`Unknown agent command: ${sub ?? "(none)"}. Try: add | edit | rm | list`);
|
|
1426
|
+
return 2;
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
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
|
+
|
|
1633
|
+
/**
|
|
1634
|
+
* `agents-pod listener` is add / list / rm (ADR 0007): a listener is a registry
|
|
1635
|
+
* record, never a process. Bad references are exit-2 configuration errors
|
|
1636
|
+
* (ADR 0012's fail-fast habit), and add/rm take the registry lock because a
|
|
1637
|
+
* pass rewrites listeners.json wholesale — an unlocked add could be overwritten.
|
|
1638
|
+
*/
|
|
1639
|
+
async function listenerCmd(
|
|
1640
|
+
words: string[],
|
|
1641
|
+
deps: CliDeps,
|
|
1642
|
+
out: (line: string) => void,
|
|
1643
|
+
err: (line: string) => void,
|
|
1644
|
+
): Promise<number> {
|
|
1645
|
+
const [sub, ...rest] = words;
|
|
1646
|
+
switch (sub) {
|
|
1647
|
+
case "list": {
|
|
1648
|
+
const listeners = await readListeners();
|
|
1649
|
+
if (rest.includes("--json")) {
|
|
1650
|
+
out(
|
|
1651
|
+
JSON.stringify(
|
|
1652
|
+
listeners.map((listener) => ({
|
|
1653
|
+
id: listener.id,
|
|
1654
|
+
inbox: listener.inbox ?? null,
|
|
1655
|
+
source: listener.source ?? null,
|
|
1656
|
+
match: listener.match ?? null,
|
|
1657
|
+
mode: listener.mode,
|
|
1658
|
+
template: listener.template ?? null,
|
|
1659
|
+
project: listener.project ?? null,
|
|
1660
|
+
podId: listener.podId ?? null,
|
|
1661
|
+
maxConcurrent: listener.maxConcurrent,
|
|
1662
|
+
claimed: listener.claimed ?? [],
|
|
1663
|
+
})),
|
|
1664
|
+
),
|
|
1665
|
+
);
|
|
1666
|
+
return 0;
|
|
1667
|
+
}
|
|
1668
|
+
if (listeners.length === 0) {
|
|
1669
|
+
out("No listeners");
|
|
1670
|
+
return 0;
|
|
1671
|
+
}
|
|
1672
|
+
for (const listener of listeners) {
|
|
1673
|
+
const target =
|
|
1674
|
+
listener.mode === "template"
|
|
1675
|
+
? `template ${listener.template}`
|
|
1676
|
+
: `pod ${listener.podId}`;
|
|
1677
|
+
const origin = listener.source === undefined ? listener.inbox : `source:${listener.source}`;
|
|
1678
|
+
out(`${listener.id} ${target} ${origin} claimed ${listener.claimed?.length ?? 0}`);
|
|
1679
|
+
}
|
|
1680
|
+
return 0;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
case "add": {
|
|
1684
|
+
const { flags } = parseFlags(rest, [
|
|
1685
|
+
"inbox",
|
|
1686
|
+
"source",
|
|
1687
|
+
"template",
|
|
1688
|
+
"pod",
|
|
1689
|
+
"match",
|
|
1690
|
+
"max-concurrent",
|
|
1691
|
+
"project",
|
|
1692
|
+
]);
|
|
1693
|
+
const 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}`);
|
|
1697
|
+
return 2;
|
|
1698
|
+
}
|
|
1699
|
+
// A listener has one target, exactly (ADR 0007): a fresh pod per item, or
|
|
1700
|
+
// a bound standing pod's queue.
|
|
1701
|
+
if ((flags.template === undefined) === (flags.pod === undefined)) {
|
|
1702
|
+
err(`listener add takes exactly one of --template <file> or --pod <podId>. ${usage}`);
|
|
1703
|
+
return 2;
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
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))) {
|
|
1724
|
+
err(`Inbox ${inbox} does not exist: pass a directory or a JSONL file`);
|
|
1725
|
+
return 2;
|
|
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
|
+
}
|
|
1734
|
+
|
|
1735
|
+
let maxConcurrent = 1;
|
|
1736
|
+
if (flags["max-concurrent"] !== undefined) {
|
|
1737
|
+
const parsed = Number(flags["max-concurrent"]);
|
|
1738
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
1739
|
+
err(`--max-concurrent must be a positive integer, got ${flags["max-concurrent"]}`);
|
|
1740
|
+
return 2;
|
|
1741
|
+
}
|
|
1742
|
+
maxConcurrent = parsed;
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
const listener: ListenerRecord = {
|
|
1746
|
+
id: newListenerId(),
|
|
1747
|
+
...(inbox === undefined ? { source: flags.source! } : { inbox }),
|
|
1748
|
+
mode: "template",
|
|
1749
|
+
...(targetProject === undefined ? {} : { project: targetProject }),
|
|
1750
|
+
maxConcurrent,
|
|
1751
|
+
claimed: [],
|
|
1752
|
+
seen: [],
|
|
1753
|
+
};
|
|
1754
|
+
if (flags.match !== undefined) listener.match = flags.match;
|
|
1755
|
+
|
|
1756
|
+
if (flags.template !== undefined) {
|
|
1757
|
+
// Read once to prove the reference, store the path (ADR 0007): the
|
|
1758
|
+
// template is the operator's file, edited in place between passes.
|
|
1759
|
+
const template = resolve(cwd, flags.template);
|
|
1760
|
+
if ((await readFile(template, "utf8").catch(() => undefined)) === undefined) {
|
|
1761
|
+
err(`Cannot read template file ${template}`);
|
|
1762
|
+
return 2;
|
|
1763
|
+
}
|
|
1764
|
+
listener.template = template;
|
|
1765
|
+
} else {
|
|
1766
|
+
const pod = (await readPods()).find((candidate) => candidate.id === flags.pod);
|
|
1767
|
+
if (!pod) {
|
|
1768
|
+
err(`Unknown pod ${flags.pod}`);
|
|
1769
|
+
return 2;
|
|
1770
|
+
}
|
|
1771
|
+
listener.mode = "pod";
|
|
1772
|
+
listener.podId = pod.id;
|
|
1773
|
+
}
|
|
1774
|
+
|
|
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
|
+
}
|
|
1782
|
+
const listeners = await readListeners();
|
|
1783
|
+
listeners.push(listener);
|
|
1784
|
+
await writeListeners(listeners);
|
|
1785
|
+
return true;
|
|
1786
|
+
});
|
|
1787
|
+
if (added === undefined) {
|
|
1788
|
+
err("registry lock unavailable: listener not added");
|
|
1789
|
+
return 2;
|
|
1790
|
+
}
|
|
1791
|
+
if (!added) {
|
|
1792
|
+
err(`Unknown source ${listener.source}`);
|
|
1793
|
+
return 2;
|
|
1794
|
+
}
|
|
1795
|
+
out(listener.id);
|
|
1796
|
+
return 0;
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
case "rm": {
|
|
1800
|
+
const id = rest.find((word) => !word.startsWith("--"));
|
|
1801
|
+
if (!id) {
|
|
1802
|
+
err("Usage: agents-pod listener rm <listenerId>");
|
|
1803
|
+
return 2;
|
|
1804
|
+
}
|
|
1805
|
+
const removed = await withRegistryLock(async () => {
|
|
1806
|
+
const listeners = await readListeners();
|
|
1807
|
+
const remaining = listeners.filter((listener) => listener.id !== id);
|
|
1808
|
+
if (remaining.length === listeners.length) return false;
|
|
1809
|
+
// Claims go with the record: re-adding is a fresh claim namespace (ADR 0013).
|
|
1810
|
+
await writeListeners(remaining);
|
|
1811
|
+
return true;
|
|
1812
|
+
});
|
|
1813
|
+
if (removed === undefined) {
|
|
1814
|
+
err("registry lock unavailable: listener not removed");
|
|
1815
|
+
return 2;
|
|
1816
|
+
}
|
|
1817
|
+
if (!removed) {
|
|
1818
|
+
err(`Unknown listener ${id}`);
|
|
1819
|
+
return 2;
|
|
1820
|
+
}
|
|
1821
|
+
return 0;
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
default:
|
|
1825
|
+
err(`Unknown listener command: ${sub ?? "(none)"}. Try: add | list | rm`);
|
|
1826
|
+
return 2;
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
export async function main(argv: string[], deps: CliDeps = {}): Promise<number> {
|
|
1831
|
+
const out = deps.stdout ?? ((line: string) => console.log(line));
|
|
1832
|
+
const err = deps.stderr ?? ((line: string) => console.error(line));
|
|
1833
|
+
const [cmd, ...rest] = argv;
|
|
1834
|
+
|
|
1835
|
+
switch (cmd) {
|
|
1836
|
+
case "spawn":
|
|
1837
|
+
return spawnCmd(rest, deps, out, err);
|
|
1838
|
+
case "status":
|
|
1839
|
+
return statusCmd(rest, out, err);
|
|
1840
|
+
case "list":
|
|
1841
|
+
return listCmd(out);
|
|
1842
|
+
case "logs":
|
|
1843
|
+
return logsCmd(rest, out, err);
|
|
1844
|
+
case "wake":
|
|
1845
|
+
return wakeCmd(rest, out, err);
|
|
1846
|
+
case "cancel":
|
|
1847
|
+
return cancelCmd(rest, out, err);
|
|
1848
|
+
case "reconcile":
|
|
1849
|
+
return reconcileCmd(deps);
|
|
1850
|
+
case "agent":
|
|
1851
|
+
return agentCmd(rest, out, err);
|
|
1852
|
+
case "listener":
|
|
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);
|
|
1858
|
+
case "project":
|
|
1859
|
+
return projectCmd(rest, deps, out, err);
|
|
1860
|
+
default:
|
|
1861
|
+
err(`Unknown command: ${cmd ?? "(none)"}. Try: spawn | list | status | logs | wake | cancel | reconcile | agent | listener | source | deliver | project`);
|
|
1862
|
+
return 2;
|
|
1863
|
+
}
|
|
1864
|
+
}
|