agents-pod 0.3.5 → 0.3.7
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 +154 -0
- package/README.md +141 -23
- package/examples/feishu/README.md +65 -0
- package/package.json +1 -1
- package/src/admission.ts +102 -0
- package/src/cadence.ts +67 -0
- package/src/cli.ts +885 -94
- package/src/doctor.ts +145 -3
- package/src/extension/handoff.ts +17 -6
- package/src/extension/management.ts +33 -6
- package/src/extension/self.ts +8 -5
- package/src/listeners.ts +14 -2
- package/src/registry.ts +57 -1
- package/src/sources.ts +40 -19
- package/src/wrapper.ts +35 -11
package/src/cli.ts
CHANGED
|
@@ -14,6 +14,14 @@ import {
|
|
|
14
14
|
} from "node:fs/promises";
|
|
15
15
|
import { tmpdir } from "node:os";
|
|
16
16
|
import { dirname, join, resolve } from "node:path";
|
|
17
|
+
import {
|
|
18
|
+
ADMISSIONS,
|
|
19
|
+
admissionOf,
|
|
20
|
+
clockPhrase,
|
|
21
|
+
clockRefusal,
|
|
22
|
+
isAdmission,
|
|
23
|
+
} from "./admission.ts";
|
|
24
|
+
import { DAY_MS, duration, inWindow, medianIntervalMs, stamped } from "./cadence.ts";
|
|
17
25
|
import {
|
|
18
26
|
agentTypeRefusal,
|
|
19
27
|
isAgentType,
|
|
@@ -32,6 +40,7 @@ import {
|
|
|
32
40
|
newListenerId,
|
|
33
41
|
readListeners,
|
|
34
42
|
writeListeners,
|
|
43
|
+
type ClaimRecord,
|
|
35
44
|
type ListenerRecord,
|
|
36
45
|
} from "./listeners.ts";
|
|
37
46
|
import { acquireLock, readLock, releaseLock, sleep } from "./locks.ts";
|
|
@@ -57,7 +66,13 @@ import {
|
|
|
57
66
|
type AdapterFileState,
|
|
58
67
|
type AdapterSyncPlan,
|
|
59
68
|
} from "./adapters.ts";
|
|
60
|
-
import
|
|
69
|
+
import {
|
|
70
|
+
DEFAULT_WAKE_BUDGET_PER_DAY,
|
|
71
|
+
type Admission,
|
|
72
|
+
type PodRecord,
|
|
73
|
+
type WakePolicy,
|
|
74
|
+
type WakeUpEntry,
|
|
75
|
+
} from "./registry.ts";
|
|
61
76
|
import { readPods, withRegistryLock, writePods } from "./registry.ts";
|
|
62
77
|
import {
|
|
63
78
|
commitSourceCursors,
|
|
@@ -97,28 +112,64 @@ function launchWrapperDetached(podId: string, agentId?: string, warm = false): v
|
|
|
97
112
|
}
|
|
98
113
|
|
|
99
114
|
/**
|
|
100
|
-
* Flags may appear anywhere among the
|
|
101
|
-
*
|
|
102
|
-
*
|
|
115
|
+
* Flags may appear anywhere among the words (README's CLI surface), and the
|
|
116
|
+
* words that are not flags compose the goal. Every flag in `allowed` takes a
|
|
117
|
+
* value; one that arrives without a value is reported as `dangling` — left in
|
|
118
|
+
* `rest` it would become part of a goal (`spawn fix it --wake-budget`) and the
|
|
119
|
+
* command would proceed with the flag silently unset.
|
|
120
|
+
*
|
|
121
|
+
* A token that begins with `--` is the *next* flag's name, never this flag's
|
|
122
|
+
* value: `spawn … --model --dormant` used to set `model` to the literal string
|
|
123
|
+
* `--dormant`, drop the dormancy and launch the wake-up the flag was there to
|
|
124
|
+
* prevent (#43 review). No flag's value is a flag name — an agent id, a model
|
|
125
|
+
* id, a path, a pod id and a count never start with `--` — so the token stays
|
|
126
|
+
* unconsumed and this flag is reported dangling, exactly as if it had arrived
|
|
127
|
+
* last. A single leading `-` is still a value (`--wake-budget -3` must reach the
|
|
128
|
+
* integer check, not the usage error).
|
|
103
129
|
*/
|
|
104
130
|
function parseFlags(
|
|
105
131
|
words: string[],
|
|
106
132
|
allowed: readonly string[],
|
|
107
|
-
|
|
133
|
+
multi: readonly string[] = [],
|
|
134
|
+
): {
|
|
135
|
+
flags: Record<string, string>;
|
|
136
|
+
multiFlags: Record<string, string[]>;
|
|
137
|
+
rest: string[];
|
|
138
|
+
dangling: string[];
|
|
139
|
+
} {
|
|
108
140
|
const flags: Record<string, string> = {};
|
|
141
|
+
const multiFlags: Record<string, string[]> = {};
|
|
109
142
|
const rest: string[] = [];
|
|
143
|
+
const dangling: string[] = [];
|
|
110
144
|
for (let index = 0; index < words.length; index += 1) {
|
|
111
145
|
const word = words[index]!;
|
|
112
146
|
const name = word.startsWith("--") ? word.slice(2) : undefined;
|
|
113
|
-
const
|
|
147
|
+
const next = words[index + 1];
|
|
148
|
+
const value = next !== undefined && !next.startsWith("--") ? next : undefined;
|
|
114
149
|
if (name !== undefined && allowed.includes(name) && value !== undefined) {
|
|
115
|
-
|
|
150
|
+
// A flag that may repeat keeps every value (ADR 0034's `--chat`): a scalar
|
|
151
|
+
// would silently drop all but the last group.
|
|
152
|
+
if (multi.includes(name)) (multiFlags[name] ??= []).push(value);
|
|
153
|
+
else flags[name] = value;
|
|
116
154
|
index += 1;
|
|
117
155
|
} else {
|
|
156
|
+
if (name !== undefined && allowed.includes(name)) dangling.push(`--${name}`);
|
|
118
157
|
rest.push(word);
|
|
119
158
|
}
|
|
120
159
|
}
|
|
121
|
-
return { flags, rest };
|
|
160
|
+
return { flags, multiFlags, rest, dangling };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The routing filter as one rule (ADR 0034): the groups an item's `chat_id` must
|
|
165
|
+
* be among, ANDed with the text substring. An item with no routing field can
|
|
166
|
+
* never satisfy a filter; that is a fact about directory items, and `listener
|
|
167
|
+
* add` refuses to write a filter they could never match.
|
|
168
|
+
*/
|
|
169
|
+
function routingChats(values: readonly string[] | undefined): string[] | undefined {
|
|
170
|
+
if (values === undefined) return undefined;
|
|
171
|
+
const chats = values.map((value) => value.trim()).filter((value) => value !== "");
|
|
172
|
+
return chats.length === 0 ? undefined : chats;
|
|
122
173
|
}
|
|
123
174
|
|
|
124
175
|
/** Exponential backoff after `failed`/`lost`: 1m ×2, capped at 15m (ADR 0011). */
|
|
@@ -126,6 +177,69 @@ function backoffMs(streak: number): number {
|
|
|
126
177
|
return Math.min(60_000 * 2 ** (streak - 1), 900_000);
|
|
127
178
|
}
|
|
128
179
|
|
|
180
|
+
/**
|
|
181
|
+
* One outcome's effect on the record's policy (ADR 0031): the single place that
|
|
182
|
+
* decides when the *next* wake-up may happen, and the reason it takes the owner.
|
|
183
|
+
*
|
|
184
|
+
* **An outcome never schedules a wake-up its owner would not have made.** `more`
|
|
185
|
+
* means "the goal needs another wake-up"; for a Pod the tick owns that is "on the
|
|
186
|
+
* next pass", and for a Pod a Listener feeds or an outside clock wakes, the next
|
|
187
|
+
* wake-up is its owner's decision — a `nextWakeAt` written here would be a second,
|
|
188
|
+
* invisible owner, which is how a goal re-armed itself at 41 wake-ups in five
|
|
189
|
+
* hours (issue #31, #34). `cancelled` says nothing about the schedule: a human
|
|
190
|
+
* stopped a window, not the work.
|
|
191
|
+
*
|
|
192
|
+
* The failure streak is *not* owner-dependent: park obeys it, and park is about
|
|
193
|
+
* money, not about who wakes the Pod.
|
|
194
|
+
*/
|
|
195
|
+
function applyOutcome(
|
|
196
|
+
policy: WakePolicy,
|
|
197
|
+
owner: Admission,
|
|
198
|
+
wake: WakeUpEntry,
|
|
199
|
+
now: number,
|
|
200
|
+
): void {
|
|
201
|
+
// `quiet` (ADR 0011 amendment, #36): the window is satisfied and nothing is
|
|
202
|
+
// scheduled. It is a clean exit — the streak resets, the batch it was admitted
|
|
203
|
+
// for was served — and the difference from `more` is that it asks for nothing:
|
|
204
|
+
// no clock, and the tick stops admitting the Pod until work arrives.
|
|
205
|
+
if (wake.outcome === "quiet") {
|
|
206
|
+
policy.failureStreak = 0;
|
|
207
|
+
delete policy.nextWakeAt;
|
|
208
|
+
delete policy.intakePending;
|
|
209
|
+
policy.awaiting = true;
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (wake.outcome === "more" || wake.outcome === "done") {
|
|
213
|
+
policy.failureStreak = 0;
|
|
214
|
+
delete policy.nextWakeAt; // `self`: due on the next pass, at once
|
|
215
|
+
// A clean exit served the batch it was woken for, so the debt is paid
|
|
216
|
+
// (ADR 0026 amendment).
|
|
217
|
+
delete policy.intakePending;
|
|
218
|
+
// Another wake-up is the work again, so a previous "nothing scheduled" is
|
|
219
|
+
// spent: the Pod is wake-eligible by its owner's ordinary rule.
|
|
220
|
+
delete policy.awaiting;
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
// Anything not named here is recorded and ignored, never counted as a failure:
|
|
224
|
+
// `cancelled` (a human stopped a window, not the work) and any outcome a later
|
|
225
|
+
// version adds must not park a Pod by falling through (ADR 0031 — #36's `quiet`
|
|
226
|
+
// is exactly such a value).
|
|
227
|
+
if (wake.outcome !== "failed" && wake.outcome !== "lost") return;
|
|
228
|
+
// Only the tick's own Pod is put on a clock; an intake Pod is re-admitted by
|
|
229
|
+
// the batch it still owes, and an `external` one by its own wake — both
|
|
230
|
+
// bounded by the streak and the park below, not by this field.
|
|
231
|
+
policy.failureStreak += 1;
|
|
232
|
+
// A window that ran and failed was not satisfied by its own account, so the
|
|
233
|
+
// "nothing scheduled" claim is spent too — the streak machinery decides from
|
|
234
|
+
// here, and a parked Pod is skipped before admission is even asked.
|
|
235
|
+
delete policy.awaiting;
|
|
236
|
+
if (owner === "self") {
|
|
237
|
+
policy.nextWakeAt = now + backoffMs(policy.failureStreak);
|
|
238
|
+
} else {
|
|
239
|
+
delete policy.nextWakeAt;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
129
243
|
/**
|
|
130
244
|
* Signal a pod's process group — never our own. The pgid comes from store
|
|
131
245
|
* data only (wrapper.json, ADR 0003), and the guards make a corrupt record a
|
|
@@ -324,6 +438,18 @@ async function reconcilePass(
|
|
|
324
438
|
const pods = await readPods();
|
|
325
439
|
const agents = await readAgents();
|
|
326
440
|
const now = Date.now();
|
|
441
|
+
// The owner is resolved per Pod below from the bindings as this pass begins.
|
|
442
|
+
// A `listener add` from another process can still land during the Pod loop,
|
|
443
|
+
// and the derivation would then read that Pod as the tick's — which is why the
|
|
444
|
+
// wake phase reads the bindings again (see wakeDuePods) and why `nextWakeAt`,
|
|
445
|
+
// the one field such a stale answer could write, gates nothing but a `self`
|
|
446
|
+
// Pod. A wrong answer here can delay nothing; a wrong answer there would wake
|
|
447
|
+
// a Pod that had just been bound.
|
|
448
|
+
const intakeBound = new Set(
|
|
449
|
+
(await readListeners()).flatMap((listener) =>
|
|
450
|
+
listener.mode === "pod" && listener.podId !== undefined ? [listener.podId] : [],
|
|
451
|
+
),
|
|
452
|
+
);
|
|
327
453
|
let changed = false;
|
|
328
454
|
const pending: PodNotification[] = [];
|
|
329
455
|
|
|
@@ -348,7 +474,13 @@ async function reconcilePass(
|
|
|
348
474
|
const facts = await livenessFacts(pod);
|
|
349
475
|
if (facts.quadrant === "lost") {
|
|
350
476
|
const n = facts.wrapper?.wakeup ?? (await maxWakeupNumber(pod)) ?? 1;
|
|
351
|
-
pod.wakeUps.push({
|
|
477
|
+
pod.wakeUps.push({
|
|
478
|
+
n,
|
|
479
|
+
outcome: "lost",
|
|
480
|
+
detail: lostDetail(facts),
|
|
481
|
+
at: now,
|
|
482
|
+
...(pod.admitted === undefined ? {} : { trigger: pod.admitted }),
|
|
483
|
+
});
|
|
352
484
|
pod.state = "idle";
|
|
353
485
|
delete pod.wedgedAt; // a lost wake-up spends any wedged observation
|
|
354
486
|
changed = true;
|
|
@@ -390,6 +522,8 @@ async function reconcilePass(
|
|
|
390
522
|
detail: trusted
|
|
391
523
|
? "wedged wrapper killed by reconcile"
|
|
392
524
|
: "wedged wrapper signal withheld (recorded process is gone)",
|
|
525
|
+
at: now,
|
|
526
|
+
...(pod.admitted === undefined ? {} : { trigger: pod.admitted }),
|
|
393
527
|
});
|
|
394
528
|
pod.state = "idle";
|
|
395
529
|
delete pod.wedgedAt;
|
|
@@ -425,6 +559,8 @@ async function reconcilePass(
|
|
|
425
559
|
n: wrapper.wakeup,
|
|
426
560
|
outcome: "failed",
|
|
427
561
|
detail: PROGRESSLESS_DETAIL,
|
|
562
|
+
at: now,
|
|
563
|
+
...(pod.admitted === undefined ? {} : { trigger: pod.admitted }),
|
|
428
564
|
});
|
|
429
565
|
pod.state = "idle";
|
|
430
566
|
changed = true;
|
|
@@ -438,28 +574,21 @@ async function reconcilePass(
|
|
|
438
574
|
// two-event notification inventory (ADRs 0021, 0025).
|
|
439
575
|
|
|
440
576
|
// 2. Policy derivation from outcome facts the wrapper (or step 1) recorded.
|
|
577
|
+
// The owner is resolved once here and handed to the derivation, so a new
|
|
578
|
+
// outcome cannot reach the schedule without answering "who wakes this Pod?"
|
|
579
|
+
// (ADR 0031).
|
|
580
|
+
const owner = admissionOf(pod, intakeBound);
|
|
441
581
|
const fresh = pod.wakeUps.slice(pod.policy.appliedOutcomes);
|
|
442
582
|
for (const wake of fresh) {
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
} else if (wake.outcome === "more" || wake.outcome === "done") {
|
|
453
|
-
pod.policy.failureStreak = 0;
|
|
454
|
-
delete pod.policy.nextWakeAt;
|
|
455
|
-
}
|
|
456
|
-
// A clean exit served the batch it was woken for, so the debt is paid
|
|
457
|
-
// (ADR 0026 amendment). `failed` and `lost` leave it standing: the next
|
|
458
|
-
// admitted wake-up is the retry, and it is bounded by backoff and park
|
|
459
|
-
// exactly like any other failure. `cancelled` leaves it too — a human
|
|
460
|
-
// stopped a window, not the work.
|
|
461
|
-
if (wake.outcome === "more" || wake.outcome === "done") {
|
|
462
|
-
delete pod.policy.intakePending;
|
|
583
|
+
applyOutcome(pod.policy, owner, wake, now);
|
|
584
|
+
// Backoff without a stop is a money pit: streak 3 parks the pod, and only
|
|
585
|
+
// a human wake lifts it (ADR 0011). Park is policy data, not a state, and
|
|
586
|
+
// it holds for every owner — a stop is about money, not about scheduling.
|
|
587
|
+
const failed = wake.outcome === "failed" || wake.outcome === "lost";
|
|
588
|
+
if (failed && pod.policy.failureStreak >= 3 && !pod.policy.parked) {
|
|
589
|
+
pod.policy.parked = true;
|
|
590
|
+
pod.policy.parkedBy = "failures";
|
|
591
|
+
announcePark();
|
|
463
592
|
}
|
|
464
593
|
pod.policy.appliedOutcomes += 1;
|
|
465
594
|
changed = true;
|
|
@@ -467,6 +596,23 @@ async function reconcilePass(
|
|
|
467
596
|
|
|
468
597
|
const goalOpen = pod.state !== "done" && pod.state !== "cancelled";
|
|
469
598
|
|
|
599
|
+
// 2b. The daily ceiling (ADR 0032). The one defence against a loop nobody
|
|
600
|
+
// predicted: the storm's own round 41 set `parked` by hand, and this is that
|
|
601
|
+
// manoeuvre promoted to machinery. Park is reused rather than invented, so
|
|
602
|
+
// exactly one notification is sent (ADR 0021) and only a human `wake`
|
|
603
|
+
// lifts it (ADR 0011).
|
|
604
|
+
const budget = pod.policy.wakeBudgetPerDay ?? DEFAULT_WAKE_BUDGET_PER_DAY;
|
|
605
|
+
const today = inWindow(pod.wakeUps, now, DAY_MS).length;
|
|
606
|
+
if (goalOpen && !pod.policy.parked && today > budget) {
|
|
607
|
+
pod.policy.parked = true;
|
|
608
|
+
// A budget stop is not the failure stop: `wake` lifts the latter freely
|
|
609
|
+
// (ADR 0011) but must not lift this one, or an outside clock re-opens the
|
|
610
|
+
// ceiling on every tick and every re-park announces itself (ADR 0032).
|
|
611
|
+
pod.policy.parkedBy = "budget";
|
|
612
|
+
announcePark(`${today} wake-ups in 24 h — over the budget of ${budget} (ADR 0032)`);
|
|
613
|
+
changed = true;
|
|
614
|
+
}
|
|
615
|
+
|
|
470
616
|
// 3. Terminal observation (ADR 0021): done is announced once, however many
|
|
471
617
|
// passes later find the record. The marker is what makes it once.
|
|
472
618
|
if (
|
|
@@ -494,6 +640,7 @@ async function reconcilePass(
|
|
|
494
640
|
if (!agentKnown && goalOpen && pod.state !== "active") {
|
|
495
641
|
if (!pod.policy.parked) {
|
|
496
642
|
pod.policy.parked = true;
|
|
643
|
+
pod.policy.parkedBy = "agent";
|
|
497
644
|
announcePark(`agent "${agentId}" no longer resolves (ADR 0012)`);
|
|
498
645
|
changed = true;
|
|
499
646
|
}
|
|
@@ -528,6 +675,19 @@ async function reconcilePass(
|
|
|
528
675
|
* inbox burned one whole agent run per tick — the shape that got the first
|
|
529
676
|
* router pod cancelled. An unbound pod has no inbox to check, so ADR 0011's
|
|
530
677
|
* optimistic rule stays its whole policy.
|
|
678
|
+
*
|
|
679
|
+
* ADR 0030 makes the owner a recorded fact with three values: `intake` (the debt
|
|
680
|
+
* gate below), `external` (never launched here — an outside clock or a human
|
|
681
|
+
* calls `wake`), `self` (the tick, for a goal that reaches `done`). A Listener
|
|
682
|
+
* binding outranks the record, because binding *is* an intake declaration, and
|
|
683
|
+
* an unrecorded owner falls back to `self` — the old rule, now named and
|
|
684
|
+
* reported by `doctor` rather than silently assumed.
|
|
685
|
+
*
|
|
686
|
+
* The bindings are read here, not handed down from the top of the pass: a
|
|
687
|
+
* `listener add` can land while a pass spawns pods, retries outboxes, and
|
|
688
|
+
* drains sources, and a set read seconds earlier would let this phase tick-wake
|
|
689
|
+
* a pod that had just been bound — the storm's own unit, reintroduced as a race.
|
|
690
|
+
* The derivation reads its own copy for the same reason, closest to its use.
|
|
531
691
|
*/
|
|
532
692
|
async function wakeDuePods(now: number): Promise<void> {
|
|
533
693
|
const pods = await readPods();
|
|
@@ -538,20 +698,47 @@ async function wakeDuePods(now: number): Promise<void> {
|
|
|
538
698
|
),
|
|
539
699
|
);
|
|
540
700
|
let changed = false;
|
|
701
|
+
const toLaunch: Array<{ id: string; agent: string }> = [];
|
|
702
|
+
const lines: string[] = [];
|
|
541
703
|
for (const pod of pods) {
|
|
542
704
|
if (pod.state !== "idle" || pod.policy?.parked) continue;
|
|
543
|
-
|
|
544
|
-
|
|
705
|
+
// `quiet`: the window said it was satisfied and scheduled nothing (#36).
|
|
706
|
+
// No owner admits it until work arrives — a new intake batch or a human
|
|
707
|
+
// `wake`, both of which clear the flag.
|
|
708
|
+
if (pod.policy?.awaiting) continue;
|
|
709
|
+
// Admission first (ADR 0030): the tick is one owner among three, and it
|
|
710
|
+
// reads the owner from the record instead of inferring it. An `external`
|
|
711
|
+
// Pod is somebody else's to wake at every tick of every day.
|
|
712
|
+
const owner = admissionOf(pod, intakeBound);
|
|
713
|
+
if (owner === "external") continue;
|
|
714
|
+
if (owner === "intake" && !pod.policy?.intakePending) continue;
|
|
715
|
+
// A backoff is the tick's own delay, written only for its own Pod (ADR 0031):
|
|
716
|
+
// letting it gate an `intake` Pod would be a second owner again — a record
|
|
717
|
+
// that failed before it was bound, or one written before ADR 0031, would
|
|
718
|
+
// hold a batch back for up to fifteen minutes.
|
|
719
|
+
if (owner === "self" && pod.policy?.nextWakeAt !== undefined && pod.policy.nextWakeAt > now) {
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
545
722
|
if (pod.after !== undefined) {
|
|
546
723
|
const upstream = pods.find((candidate) => candidate.id === pod.after);
|
|
547
724
|
if (upstream?.state !== "done") continue;
|
|
548
725
|
}
|
|
549
726
|
if (!projectHasCapacity(pod.project, pods, projects)) continue;
|
|
550
|
-
|
|
727
|
+
pod.admitted = "tick";
|
|
551
728
|
pod.state = "active";
|
|
729
|
+
toLaunch.push({ id: pod.id, agent: pod.policy?.agent ?? "default" });
|
|
730
|
+
lines.push(`wake ${pod.id} tick (admission ${owner})`);
|
|
552
731
|
changed = true;
|
|
553
732
|
}
|
|
733
|
+
// Record, then launch (ADR 0032). A crash between the two leaves a ghost the
|
|
734
|
+
// next pass adjudicates as `lost` — recoverable; a launch nobody recorded is
|
|
735
|
+
// the same Pod woken twice.
|
|
554
736
|
if (changed) await writePods(pods);
|
|
737
|
+
for (const launch of toLaunch) launchWrapperDetached(launch.id, launch.agent);
|
|
738
|
+
// The storm left no trace a single command could read (issue #35): every
|
|
739
|
+
// launch now says which launcher it was, next to the sources and listeners
|
|
740
|
+
// that already write here (ADR 0032).
|
|
741
|
+
for (const line of lines) await appendCronLog(line);
|
|
555
742
|
}
|
|
556
743
|
|
|
557
744
|
/** One Pod's state as the registry holds it now, read without any lock (ADR 0025). */
|
|
@@ -566,10 +753,47 @@ function livePod(claimedBy: string, pods: PodRecord[]): boolean {
|
|
|
566
753
|
return pod !== undefined && pod.state !== "done" && pod.state !== "cancelled";
|
|
567
754
|
}
|
|
568
755
|
|
|
569
|
-
/**
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
756
|
+
/**
|
|
757
|
+
* A Listener's match rule (ADRs 0007, 0034): a routing filter over the item's
|
|
758
|
+
* `chat_id`, ANDed with a plain substring over title and body. Either half alone
|
|
759
|
+
* is a rule; both together must hold.
|
|
760
|
+
*/
|
|
761
|
+
function matchesListener(
|
|
762
|
+
rule: Pick<ListenerRecord, "match" | "chats">,
|
|
763
|
+
item: InboxItem,
|
|
764
|
+
): boolean {
|
|
765
|
+
if (rule.chats !== undefined && rule.chats.length > 0) {
|
|
766
|
+
if (item.chatId === undefined || !rule.chats.includes(item.chatId)) return false;
|
|
767
|
+
}
|
|
768
|
+
if (rule.match === undefined || rule.match === "") return true;
|
|
769
|
+
return `${item.title}\n${item.body}`.includes(rule.match);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* A Source claim owns its item after the drain has moved on (ADR 0034). Claims
|
|
774
|
+
* written before that decision fall back to this pass's scan. Filesystem inbox
|
|
775
|
+
* claims carry no copy at all: `{{path}}` and deletion are meaningful there, and
|
|
776
|
+
* the file itself is the truth.
|
|
777
|
+
*/
|
|
778
|
+
function itemForClaim(
|
|
779
|
+
listener: ListenerRecord,
|
|
780
|
+
claim: ClaimRecord,
|
|
781
|
+
scanned: InboxItem | undefined,
|
|
782
|
+
): InboxItem | undefined {
|
|
783
|
+
if (listener.source === undefined || claim.title === undefined || claim.body === undefined) {
|
|
784
|
+
return scanned;
|
|
785
|
+
}
|
|
786
|
+
return {
|
|
787
|
+
id: claim.item,
|
|
788
|
+
title: claim.title,
|
|
789
|
+
body: claim.body,
|
|
790
|
+
path: "",
|
|
791
|
+
mtimeMs: 0,
|
|
792
|
+
size: 0,
|
|
793
|
+
...(claim.chatId === undefined ? {} : { chatId: claim.chatId }),
|
|
794
|
+
...(claim.rootId === undefined ? {} : { rootId: claim.rootId }),
|
|
795
|
+
...(claim.threadId === undefined ? {} : { threadId: claim.threadId }),
|
|
796
|
+
};
|
|
573
797
|
}
|
|
574
798
|
|
|
575
799
|
/**
|
|
@@ -587,10 +811,20 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
|
|
|
587
811
|
|
|
588
812
|
/** The inbox, not the listener, is the claim namespace (ADR 0013). */
|
|
589
813
|
const claimedInInbox = new Set<string>();
|
|
814
|
+
const deferredClaims = new Set<ClaimRecord>();
|
|
590
815
|
for (const listener of listeners) {
|
|
591
816
|
listener.claimed ??= [];
|
|
592
817
|
listener.seen ??= [];
|
|
593
|
-
for (const claim of listener.claimed)
|
|
818
|
+
for (const claim of listener.claimed) {
|
|
819
|
+
claimedInInbox.add(claimKey(claim.inbox, claim.item));
|
|
820
|
+
if (
|
|
821
|
+
claim.spawnedPodId === undefined &&
|
|
822
|
+
claim.blocked === undefined &&
|
|
823
|
+
claim.appended !== true
|
|
824
|
+
) {
|
|
825
|
+
deferredClaims.add(claim);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
594
828
|
}
|
|
595
829
|
|
|
596
830
|
const itemsByListener = new Map<string, Map<string, InboxItem>>();
|
|
@@ -659,9 +893,9 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
|
|
|
659
893
|
).length;
|
|
660
894
|
// A bound pod is served by a queue (ADR 0014), so only template spawns
|
|
661
895
|
// compete for its slots — and a pending claim already holds one. A Source
|
|
662
|
-
// claim is unbounded too:
|
|
663
|
-
//
|
|
664
|
-
//
|
|
896
|
+
// claim is unbounded too: the cursor commits behind the claims, so an
|
|
897
|
+
// unclaimed item would be lost rather than re-scanned. Its item copy lets
|
|
898
|
+
// the bound defer only materialization, not the content itself (ADR 0034).
|
|
665
899
|
let room =
|
|
666
900
|
listener.mode === "pod" || listener.source !== undefined
|
|
667
901
|
? Number.POSITIVE_INFINITY
|
|
@@ -670,16 +904,28 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
|
|
|
670
904
|
for (const item of stable) {
|
|
671
905
|
if (room <= 0) break;
|
|
672
906
|
const key = claimKey(origin, item.id);
|
|
673
|
-
if (claimedInInbox.has(key) || !matchesListener(listener
|
|
907
|
+
if (claimedInInbox.has(key) || !matchesListener(listener, item)) continue;
|
|
674
908
|
// One claim shape for both targets (ADR 0013): a template materializes a
|
|
675
909
|
// pod from it later, a bound standing pod takes it as a document in
|
|
676
|
-
// phase 3 (ADR 0014). Claim first, always.
|
|
910
|
+
// phase 3 (ADR 0014). Claim first, always. Only a Source claim copies its
|
|
911
|
+
// item (ADR 0034): a filesystem item is still on disk and the document is
|
|
912
|
+
// the truth there, so a copy would only swell `listeners.json` with
|
|
913
|
+
// bodies nothing ever reads.
|
|
677
914
|
listener.claimed.push({
|
|
678
915
|
inbox: origin,
|
|
679
916
|
item: item.id,
|
|
680
917
|
...(sourceDrain?.drainedAt === undefined
|
|
681
918
|
? {}
|
|
682
919
|
: { claimedAt: sourceDrain.drainedAt }),
|
|
920
|
+
...(listener.source === undefined
|
|
921
|
+
? {}
|
|
922
|
+
: {
|
|
923
|
+
title: item.title,
|
|
924
|
+
body: item.body,
|
|
925
|
+
...(item.chatId === undefined ? {} : { chatId: item.chatId }),
|
|
926
|
+
...(item.rootId === undefined ? {} : { rootId: item.rootId }),
|
|
927
|
+
...(item.threadId === undefined ? {} : { threadId: item.threadId }),
|
|
928
|
+
}),
|
|
683
929
|
});
|
|
684
930
|
claimedInInbox.add(key);
|
|
685
931
|
changed = true;
|
|
@@ -687,6 +933,18 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
|
|
|
687
933
|
}
|
|
688
934
|
}
|
|
689
935
|
|
|
936
|
+
// Routing makes "nobody wants this group" an ordinary state, so the pass counts
|
|
937
|
+
// it (ADR 0034): unique ids only, because the overlap re-emits the same message
|
|
938
|
+
// pass after pass, and a filter's whole job is to leave some of them unclaimed.
|
|
939
|
+
for (const drain of drained) {
|
|
940
|
+
if (drain.error !== undefined) continue;
|
|
941
|
+
let unrouted = 0;
|
|
942
|
+
for (const id of new Set(drain.items.map((item) => item.id))) {
|
|
943
|
+
if (!claimedInInbox.has(claimKey(`source:${drain.name}`, id))) unrouted += 1;
|
|
944
|
+
}
|
|
945
|
+
drain.unrouted = unrouted;
|
|
946
|
+
}
|
|
947
|
+
|
|
690
948
|
// The claim is on disk before any pod is touched (ADR 0007).
|
|
691
949
|
if (changed) await writeListeners(listeners);
|
|
692
950
|
|
|
@@ -718,7 +976,11 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
|
|
|
718
976
|
).length;
|
|
719
977
|
for (const claim of pending()) {
|
|
720
978
|
if (live >= listener.maxConcurrent) break; // bound: the claim waits a pass
|
|
721
|
-
const item =
|
|
979
|
+
const item = itemForClaim(
|
|
980
|
+
listener,
|
|
981
|
+
claim,
|
|
982
|
+
itemsByListener.get(listener.id)?.get(claim.item),
|
|
983
|
+
);
|
|
722
984
|
if (item === undefined) {
|
|
723
985
|
claim.blocked = "item vanished before materialization";
|
|
724
986
|
await appendCronLog(
|
|
@@ -736,12 +998,12 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
|
|
|
736
998
|
materialized = true;
|
|
737
999
|
continue;
|
|
738
1000
|
}
|
|
1001
|
+
// A template Pod is spawned per Inbox Item and exists to finish it: the
|
|
1002
|
+
// tick's own rule is its owner (ADR 0030), and `done` is its end.
|
|
739
1003
|
const record = await spawnPodRecord(
|
|
740
1004
|
rendered.goal,
|
|
741
1005
|
listener.project ?? project,
|
|
742
|
-
"default",
|
|
743
|
-
undefined,
|
|
744
|
-
undefined,
|
|
1006
|
+
{ agentId: "default", admission: "self" },
|
|
745
1007
|
true,
|
|
746
1008
|
);
|
|
747
1009
|
claim.spawnedPodId = record.id;
|
|
@@ -758,7 +1020,7 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
|
|
|
758
1020
|
let appended = false;
|
|
759
1021
|
for (const listener of listeners) {
|
|
760
1022
|
if (listener.mode !== "pod" || listener.podId === undefined) continue;
|
|
761
|
-
if (unscannable.has(listener.id)) continue; //
|
|
1023
|
+
if (unscannable.has(listener.id)) continue; // a failed scan still waits for a successful retry
|
|
762
1024
|
const waiting = listener.claimed.filter(
|
|
763
1025
|
(claim) => claim.blocked === undefined && claim.appended !== true,
|
|
764
1026
|
);
|
|
@@ -774,12 +1036,12 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
|
|
|
774
1036
|
}
|
|
775
1037
|
if (pod.state !== "idle" || pod.policy?.parked) continue;
|
|
776
1038
|
|
|
777
|
-
// The body is copied at append time (ADR 0014)
|
|
778
|
-
//
|
|
1039
|
+
// The body is copied at append time (ADR 0014). A Source claim supplies its
|
|
1040
|
+
// own copy; a legacy claim or filesystem inbox still needs the current scan.
|
|
779
1041
|
const scanned = itemsByListener.get(listener.id);
|
|
780
1042
|
const batch: InboxItem[] = [];
|
|
781
1043
|
for (const claim of waiting) {
|
|
782
|
-
const item = scanned?.get(claim.item);
|
|
1044
|
+
const item = itemForClaim(listener, claim, scanned?.get(claim.item));
|
|
783
1045
|
if (item === undefined) {
|
|
784
1046
|
claim.blocked = "item vanished before append";
|
|
785
1047
|
await appendCronLog(
|
|
@@ -812,6 +1074,9 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
|
|
|
812
1074
|
// leaves the count standing, so the next pass is the retry.
|
|
813
1075
|
pod.policy ??= { failureStreak: 0, appliedOutcomes: 0 };
|
|
814
1076
|
pod.policy.intakePending = (pod.policy.intakePending ?? 0) + batch.length;
|
|
1077
|
+
// Work arrived, so a window that had declared itself satisfied is over
|
|
1078
|
+
// (ADR 0011 amendment, #36) — the debt below is what admits the wake.
|
|
1079
|
+
delete pod.policy.awaiting;
|
|
815
1080
|
await writePods(pods);
|
|
816
1081
|
appended = true;
|
|
817
1082
|
}
|
|
@@ -822,10 +1087,11 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
|
|
|
822
1087
|
|
|
823
1088
|
// Two stores, in this order: claims first, then each successful cursor in
|
|
824
1089
|
// the source store's own atomic write. A bound standing pod adds one gate:
|
|
825
|
-
// its claims must also be appended,
|
|
826
|
-
//
|
|
827
|
-
//
|
|
828
|
-
//
|
|
1090
|
+
// its claims must also be appended, preserving ADR 0014's document handoff.
|
|
1091
|
+
// While a wake-up holds the append back, the unchanged cursor makes the next
|
|
1092
|
+
// pass drain the same items again; their snapshots now cover a drain that
|
|
1093
|
+
// does not (ADR 0034). A crash between writes replays into id and document
|
|
1094
|
+
// dedupe rather than repeating work (ADR 0024).
|
|
829
1095
|
// A Listener removed while its drain was running no longer licenses a cursor.
|
|
830
1096
|
const consumed = new Set(listeners.flatMap((listener) => listener.source ?? []));
|
|
831
1097
|
const waitingForAppend = new Set(
|
|
@@ -845,8 +1111,18 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
|
|
|
845
1111
|
const committable = drained.filter(
|
|
846
1112
|
(drain) => consumed.has(drain.name) && !waitingForAppend.has(drain.name),
|
|
847
1113
|
);
|
|
848
|
-
const pruning = pruneSourceClaims(listeners, committable);
|
|
1114
|
+
const pruning = pruneSourceClaims(listeners, committable, deferredClaims);
|
|
849
1115
|
if (pruning.changed) await writeListeners(pruning.listeners);
|
|
1116
|
+
// The count is a fact of the pass that committed it, printed once: a group an
|
|
1117
|
+
// operator never aimed a Listener at is legitimate, a group nothing wants is
|
|
1118
|
+
// what they need told (ADR 0034).
|
|
1119
|
+
for (const drain of committable) {
|
|
1120
|
+
if ((drain.unrouted ?? 0) > 0) {
|
|
1121
|
+
await appendCronLog(
|
|
1122
|
+
`source ${drain.name}: ${drain.unrouted} item(s) matched no listener (unrouted, the cursor passed them)`,
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
850
1126
|
await commitSourceCursors(committable);
|
|
851
1127
|
}
|
|
852
1128
|
|
|
@@ -934,33 +1210,125 @@ async function statusCmd(
|
|
|
934
1210
|
const last = pod.wakeUps.at(-1);
|
|
935
1211
|
out(pod.id);
|
|
936
1212
|
out(` goal: ${goalHeadline(pod.goal)}`);
|
|
937
|
-
|
|
1213
|
+
// Who admits this Pod (ADR 0030) — the fact that decides whether the tick
|
|
1214
|
+
// wakes it at all, so the record's owner is read before the state line, which
|
|
1215
|
+
// now also depends on it.
|
|
1216
|
+
const bound = new Set(
|
|
1217
|
+
(await readListeners()).flatMap((listener) =>
|
|
1218
|
+
listener.mode === "pod" && listener.podId !== undefined ? [listener.podId] : [],
|
|
1219
|
+
),
|
|
1220
|
+
);
|
|
1221
|
+
const owner = admissionOf(pod, bound);
|
|
1222
|
+
// The suffix qualifies a resting pod: `awaiting` says why the tick leaves it
|
|
1223
|
+
// alone, and a live wake-up is a different answer to the same question.
|
|
1224
|
+
const awaiting = pod.policy?.awaiting === true && pod.state === "idle";
|
|
1225
|
+
// `dormant` (#43): the record exists and no wake-up has ever run, so the first
|
|
1226
|
+
// one still belongs to the owner — the fact a spawn wrote and a hybrid ask
|
|
1227
|
+
// depends on. Derived, never stored: the empty wake-up list is the fact, and a
|
|
1228
|
+
// marker would have to be cleared by three launchers. A Pod the tick owns is
|
|
1229
|
+
// never reported this way; the tick is on its way to it.
|
|
1230
|
+
const dormant = pod.state === "idle" && pod.wakeUps.length === 0 && owner !== "self";
|
|
1231
|
+
out(
|
|
1232
|
+
` state: ${pod.state}${awaiting ? " (awaiting)" : ""}${
|
|
1233
|
+
dormant ? " (dormant: no wake-up yet)" : ""
|
|
1234
|
+
}`,
|
|
1235
|
+
);
|
|
1236
|
+
// The owner line comes before anything about liveness (ADR 0030).
|
|
1237
|
+
out(
|
|
1238
|
+
` admission: ${owner}${
|
|
1239
|
+
pod.admission === undefined
|
|
1240
|
+
? " (derived: no owner recorded)"
|
|
1241
|
+
: pod.admission !== owner
|
|
1242
|
+
? ` (a listener is bound: intake wins over ${pod.admission})`
|
|
1243
|
+
: ""
|
|
1244
|
+
}`,
|
|
1245
|
+
);
|
|
938
1246
|
if (pod.after !== undefined) {
|
|
939
1247
|
const upstream = (await readPods()).find((candidate) => candidate.id === pod.after);
|
|
940
1248
|
out(` after: ${pod.after} (${upstream?.state ?? "missing"})`);
|
|
941
1249
|
}
|
|
942
1250
|
out(` liveness: ${await livenessOf(pod)}`);
|
|
943
1251
|
if (pod.policy?.parked) {
|
|
944
|
-
|
|
1252
|
+
// Three stops, three sentences (ADR 0011, 0012, 0032): a park that reads
|
|
1253
|
+
// "consecutive failures: 0" tells the operator nothing true.
|
|
1254
|
+
const budget = pod.policy.wakeBudgetPerDay ?? DEFAULT_WAKE_BUDGET_PER_DAY;
|
|
1255
|
+
const today = inWindow(pod.wakeUps, Date.now(), DAY_MS).length;
|
|
1256
|
+
out(
|
|
1257
|
+
pod.policy.parkedBy === "budget"
|
|
1258
|
+
? ` parked (over the daily budget: ${today} in 24h, budget ${budget}; agents-pod wake ${pod.id} --over-budget)`
|
|
1259
|
+
: pod.policy.parkedBy === "agent"
|
|
1260
|
+
? ` parked (agent "${pod.policy.agent ?? "default"}" no longer resolves; agents-pod agent add ${pod.policy.agent ?? "default"} …)`
|
|
1261
|
+
: ` parked (consecutive failures: ${pod.policy.failureStreak})`,
|
|
1262
|
+
);
|
|
945
1263
|
}
|
|
946
1264
|
out(` agent: ${resolveAgentBinary(process.env)}`);
|
|
947
1265
|
out(` wake-ups: ${pod.wakeUps.length} (last outcome: ${last?.outcome ?? "none"})`);
|
|
1266
|
+
out(` cadence: ${cadenceLine(pod, Date.now())}`);
|
|
948
1267
|
return 0;
|
|
949
1268
|
}
|
|
950
1269
|
|
|
1270
|
+
/**
|
|
1271
|
+
* The cadence, in the words a storm is recognised by (ADR 0032): how many ran
|
|
1272
|
+
* recently, how tight the rhythm was, and who admitted the last one. A record
|
|
1273
|
+
* written before provenance existed says so instead of showing an invented zero.
|
|
1274
|
+
*/
|
|
1275
|
+
function cadenceLine(pod: PodRecord, now: number): string {
|
|
1276
|
+
const timed = stamped(pod.wakeUps);
|
|
1277
|
+
if (timed.length === 0) {
|
|
1278
|
+
return "not recorded (this Pod's wake-ups predate provenance, ADR 0032)";
|
|
1279
|
+
}
|
|
1280
|
+
const day = inWindow(pod.wakeUps, now, DAY_MS).length;
|
|
1281
|
+
const median = medianIntervalMs(pod.wakeUps);
|
|
1282
|
+
const rhythm = median === undefined ? "interval n/a (fewer than two timed)" : `median interval ${duration(median)}`;
|
|
1283
|
+
const trigger = pod.wakeUps.at(-1)?.trigger ?? "unknown";
|
|
1284
|
+
return `${day} in 24h, ${rhythm}, last trigger ${trigger}`;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/** Terminal states (ADR 0006): what `list` hides by default and `wake` refuses. */
|
|
1288
|
+
function isTerminal(pod: PodRecord): boolean {
|
|
1289
|
+
return pod.state === "done" || pod.state === "cancelled";
|
|
1290
|
+
}
|
|
1291
|
+
|
|
951
1292
|
/**
|
|
952
1293
|
* The attention ruler's first use (DESIGN Part 1): a healthy pod takes one
|
|
953
1294
|
* quiet line at the bottom; the pods that need a human lead. Read-only.
|
|
1295
|
+
*
|
|
1296
|
+
* Terminal pods are the exception (ADR 0035): a retired Pod asks nothing of a
|
|
1297
|
+
* human, and a wall of `cancelled` records buries the ones that do. They stay
|
|
1298
|
+
* one flag away, and the footer says how many are hidden — silence about them
|
|
1299
|
+
* would make the registry look smaller than it is.
|
|
954
1300
|
*/
|
|
955
|
-
async function listCmd(
|
|
1301
|
+
async function listCmd(
|
|
1302
|
+
words: string[],
|
|
1303
|
+
out: (line: string) => void,
|
|
1304
|
+
err: (line: string) => void,
|
|
1305
|
+
): Promise<number> {
|
|
1306
|
+
const unknown = words.filter((word) => word !== "--all" && word !== "--json");
|
|
1307
|
+
if (unknown.length > 0) {
|
|
1308
|
+
err(`Unknown list argument: ${unknown[0]}. Usage: agents-pod list [--all] [--json]`);
|
|
1309
|
+
return 2;
|
|
1310
|
+
}
|
|
1311
|
+
const all = words.includes("--all");
|
|
956
1312
|
const pods = await readPods();
|
|
957
|
-
|
|
958
|
-
|
|
1313
|
+
const visible = all ? pods : pods.filter((pod) => !isTerminal(pod));
|
|
1314
|
+
const hidden = pods.length - visible.length;
|
|
1315
|
+
|
|
1316
|
+
if (visible.length === 0) {
|
|
1317
|
+
out(
|
|
1318
|
+
hidden === 0
|
|
1319
|
+
? "No pods"
|
|
1320
|
+
: `No live pods (${hidden} terminal: agents-pod list --all)`,
|
|
1321
|
+
);
|
|
1322
|
+
return 0;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
if (words.includes("--json")) {
|
|
1326
|
+
out(JSON.stringify(visible));
|
|
959
1327
|
return 0;
|
|
960
1328
|
}
|
|
961
1329
|
|
|
962
1330
|
const rows = await Promise.all(
|
|
963
|
-
|
|
1331
|
+
visible.map(async (pod) => ({ pod, liveness: await livenessOf(pod) })),
|
|
964
1332
|
);
|
|
965
1333
|
const needsHuman = (row: (typeof rows)[number]) =>
|
|
966
1334
|
row.pod.policy?.parked === true || row.liveness !== "healthy";
|
|
@@ -970,6 +1338,9 @@ async function listCmd(out: (line: string) => void): Promise<number> {
|
|
|
970
1338
|
const parked = pod.policy?.parked ? " (parked)" : "";
|
|
971
1339
|
out(`${pod.id} ${pod.state}${parked} ${goalHeadline(pod.goal)}`);
|
|
972
1340
|
}
|
|
1341
|
+
if (hidden > 0) {
|
|
1342
|
+
out(`${hidden} terminal pod${hidden === 1 ? "" : "s"} hidden (agents-pod list --all)`);
|
|
1343
|
+
}
|
|
973
1344
|
return 0;
|
|
974
1345
|
}
|
|
975
1346
|
|
|
@@ -1045,11 +1416,13 @@ async function wakeCmd(
|
|
|
1045
1416
|
err: (line: string) => void,
|
|
1046
1417
|
): Promise<number> {
|
|
1047
1418
|
const { flags, rest } = parseFlags(words, ["agent"]);
|
|
1048
|
-
// `--warm`
|
|
1419
|
+
// `--warm` and `--over-budget` carry no value, so parseFlags leaves them among
|
|
1420
|
+
// the words.
|
|
1049
1421
|
const warm = rest.includes("--warm");
|
|
1422
|
+
const overBudget = rest.includes("--over-budget");
|
|
1050
1423
|
const podId = rest.find((word) => !word.startsWith("--"));
|
|
1051
1424
|
if (!podId) {
|
|
1052
|
-
err("Usage: agents-pod wake <podId> [--agent <id>] [--warm]");
|
|
1425
|
+
err("Usage: agents-pod wake <podId> [--agent <id>] [--warm] [--over-budget]");
|
|
1053
1426
|
return 2;
|
|
1054
1427
|
}
|
|
1055
1428
|
const pods = await readPods();
|
|
@@ -1061,6 +1434,7 @@ async function wakeCmd(
|
|
|
1061
1434
|
|
|
1062
1435
|
if (pod.state === "done" || pod.state === "cancelled") {
|
|
1063
1436
|
err(`Pod ${pod.id} is ${pod.state}: terminal is terminal — spawn a new pod instead`);
|
|
1437
|
+
err(` or forget it: agents-pod rm ${pod.id}`);
|
|
1064
1438
|
return 2;
|
|
1065
1439
|
}
|
|
1066
1440
|
if (pod.state === "active" || pod.state === "spawning") {
|
|
@@ -1068,6 +1442,21 @@ async function wakeCmd(
|
|
|
1068
1442
|
return 0;
|
|
1069
1443
|
}
|
|
1070
1444
|
|
|
1445
|
+
// The human override lifts park and backoff (ADR 0011) — except the daily
|
|
1446
|
+
// ceiling, which exists precisely because some launcher keeps firing. Lifting
|
|
1447
|
+
// it silently made a cron-driven Pod re-park and re-announce on every pass;
|
|
1448
|
+
// one more window is still available, but it has to be asked for (ADR 0032).
|
|
1449
|
+
if (pod.policy?.parked && pod.policy.parkedBy === "budget" && !overBudget) {
|
|
1450
|
+
const budget = pod.policy.wakeBudgetPerDay ?? DEFAULT_WAKE_BUDGET_PER_DAY;
|
|
1451
|
+
const today = inWindow(pod.wakeUps, Date.now(), DAY_MS).length;
|
|
1452
|
+
err(
|
|
1453
|
+
`Pod ${pod.id} is parked over its daily budget (${today} wake-ups in 24 h, budget ${budget})`,
|
|
1454
|
+
);
|
|
1455
|
+
err(` one more window, deliberately: agents-pod wake ${pod.id} --over-budget`);
|
|
1456
|
+
err(" or spawn the work again with a ceiling that fits: --wake-budget <n>");
|
|
1457
|
+
return 2;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1071
1460
|
// Only a crash has a session worth resuming (ADR 0011): a `failed` wake-up
|
|
1072
1461
|
// ended cleanly, and a fresh window is the honest continuation for it.
|
|
1073
1462
|
const last = pod.wakeUps.at(-1);
|
|
@@ -1129,13 +1518,18 @@ async function wakeCmd(
|
|
|
1129
1518
|
if (target.policy) {
|
|
1130
1519
|
target.policy.failureStreak = 0;
|
|
1131
1520
|
delete target.policy.nextWakeAt;
|
|
1521
|
+
// A human wake is the other answer to "wake me when work arrives" (#36).
|
|
1522
|
+
delete target.policy.awaiting;
|
|
1132
1523
|
delete target.policy.parked;
|
|
1524
|
+
delete target.policy.parkedBy;
|
|
1133
1525
|
}
|
|
1134
1526
|
delete target.notifiedAt;
|
|
1135
1527
|
}
|
|
1136
|
-
|
|
1528
|
+
target.admitted = "wake";
|
|
1137
1529
|
target.state = "active";
|
|
1138
1530
|
await writePods(current);
|
|
1531
|
+
// Recorded first, launched second (ADR 0032): see wakeDuePods.
|
|
1532
|
+
launchWrapperDetached(target.id, agentId, warm);
|
|
1139
1533
|
return true;
|
|
1140
1534
|
});
|
|
1141
1535
|
if (!woke) {
|
|
@@ -1161,7 +1555,12 @@ async function cancelCmd(
|
|
|
1161
1555
|
if (!pod) return 2;
|
|
1162
1556
|
|
|
1163
1557
|
if (pod.state !== "active") {
|
|
1164
|
-
err(
|
|
1558
|
+
err(
|
|
1559
|
+
`pod ${pod.id} is ${pod.state}: nothing to cancel (` +
|
|
1560
|
+
(isTerminal(pod)
|
|
1561
|
+
? `agents-pod rm ${pod.id} removes the record)`
|
|
1562
|
+
: `agents-pod rm ${pod.id} discards it)`),
|
|
1563
|
+
);
|
|
1165
1564
|
return 0;
|
|
1166
1565
|
}
|
|
1167
1566
|
const wrapper = await readWrapperRecord(pod);
|
|
@@ -1187,33 +1586,154 @@ async function cancelCmd(
|
|
|
1187
1586
|
return 0;
|
|
1188
1587
|
}
|
|
1189
1588
|
|
|
1589
|
+
/** Undelivered entries a removal would delete with the workspace (ADR 0025, 0035). */
|
|
1590
|
+
async function pendingOutbox(pod: PodRecord): Promise<number> {
|
|
1591
|
+
const dir = join(podDir(pod.project, pod.id), "outbox");
|
|
1592
|
+
const names = await readdir(dir).catch(() => []);
|
|
1593
|
+
// A `.dead` entry is already a verdict, not a message waiting to go out.
|
|
1594
|
+
return names.filter((name) => !name.startsWith(".") && !name.endsWith(".dead")).length;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
/**
|
|
1598
|
+
* The record's end (ADR 0035): remove a Pod that no live wake-up owns — its
|
|
1599
|
+
* record and its workspace, so the registry and the disk agree again. This is
|
|
1600
|
+
* garbage collection, not a stop: `cancel` still owns every running process,
|
|
1601
|
+
* and a live pod is refused here rather than deleted under its wrapper.
|
|
1602
|
+
*
|
|
1603
|
+
* Refusals are the references that would be left dangling: a bound Listener
|
|
1604
|
+
* (its claims would wait forever), a dependent Pod (`after` could never be
|
|
1605
|
+
* satisfied), and an undelivered Outbox entry (a message a wake-up already
|
|
1606
|
+
* decided to send). A Listener claim's `spawnedPodId` is deliberately *not* a
|
|
1607
|
+
* reference: it is intake's history, and a missing pod simply reads as not
|
|
1608
|
+
* live (ADR 0034).
|
|
1609
|
+
*/
|
|
1610
|
+
async function removeCmd(
|
|
1611
|
+
rest: string[],
|
|
1612
|
+
out: (line: string) => void,
|
|
1613
|
+
err: (line: string) => void,
|
|
1614
|
+
): Promise<number> {
|
|
1615
|
+
const unknown = rest.filter((word) => word.startsWith("--"));
|
|
1616
|
+
if (unknown.length > 0) {
|
|
1617
|
+
err(`Unknown rm argument: ${unknown[0]}. Usage: agents-pod rm <podId>`);
|
|
1618
|
+
return 2;
|
|
1619
|
+
}
|
|
1620
|
+
const pod = await requirePod(rest[0], "rm", err);
|
|
1621
|
+
if (!pod) return 2;
|
|
1622
|
+
|
|
1623
|
+
if (pod.state === "active" || pod.state === "spawning") {
|
|
1624
|
+
err(`pod ${pod.id} is ${pod.state}: a live wake-up owns it — agents-pod cancel ${pod.id} first`);
|
|
1625
|
+
return 2;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
const bound = (await readListeners()).filter(
|
|
1629
|
+
(listener) => listener.mode === "pod" && listener.podId === pod.id,
|
|
1630
|
+
);
|
|
1631
|
+
if (bound.length > 0) {
|
|
1632
|
+
err(
|
|
1633
|
+
`pod ${pod.id} is bound by ${bound.map((listener) => listener.id).join(", ")}: remove the listener first`,
|
|
1634
|
+
);
|
|
1635
|
+
return 2;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
const dependents = (await readPods()).filter((candidate) => candidate.after === pod.id);
|
|
1639
|
+
if (dependents.length > 0) {
|
|
1640
|
+
err(
|
|
1641
|
+
`pod ${pod.id} is the upstream of ${dependents.map((candidate) => candidate.id).join(", ")}: remove the dependent pod${dependents.length === 1 ? "" : "s"} first`,
|
|
1642
|
+
);
|
|
1643
|
+
return 2;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
const waiting = await pendingOutbox(pod);
|
|
1647
|
+
if (waiting > 0) {
|
|
1648
|
+
const dir = join(podDir(pod.project, pod.id), "outbox");
|
|
1649
|
+
err(
|
|
1650
|
+
`pod ${pod.id} has ${waiting} undelivered outbox entr${waiting === 1 ? "y" : "ies"}: deliver or delete ${dir} first`,
|
|
1651
|
+
);
|
|
1652
|
+
return 2;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
// The same CAS as every other transition (ADR 0018): the refusal checks above
|
|
1656
|
+
// ran outside the lock, so the state and the references are re-read inside it
|
|
1657
|
+
// and a wake, a spawn or a new dependent that landed meanwhile wins.
|
|
1658
|
+
const removed = await withRegistryLock(async () => {
|
|
1659
|
+
const current = await readPods();
|
|
1660
|
+
const target = current.find((candidate) => candidate.id === pod.id);
|
|
1661
|
+
if (!target || target.state === "active" || target.state === "spawning") return false;
|
|
1662
|
+
if (current.some((candidate) => candidate.after === pod.id)) return false;
|
|
1663
|
+
await writePods(current.filter((candidate) => candidate.id !== pod.id));
|
|
1664
|
+
return true;
|
|
1665
|
+
});
|
|
1666
|
+
if (removed === undefined) {
|
|
1667
|
+
err(`pod ${pod.id}: the registry is locked; nothing removed`);
|
|
1668
|
+
return 1;
|
|
1669
|
+
}
|
|
1670
|
+
if (!removed) {
|
|
1671
|
+
err(`pod ${pod.id} changed while removing it: nothing removed`);
|
|
1672
|
+
return 2;
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
// The record goes first: it is what every read surface shows, and a crash
|
|
1676
|
+
// between the two leaves an invisible directory, not a pod that looks alive.
|
|
1677
|
+
const dir = podDir(pod.project, pod.id);
|
|
1678
|
+
const line = `removed ${pod.id} (${pod.state}): ${goalHeadline(pod.goal)}`;
|
|
1679
|
+
try {
|
|
1680
|
+
await rm(dir, { recursive: true, force: true });
|
|
1681
|
+
} catch (error) {
|
|
1682
|
+
out(line);
|
|
1683
|
+
err(`workspace ${dir} could not be deleted (${(error as Error).message}); remove it by hand`);
|
|
1684
|
+
return 1;
|
|
1685
|
+
}
|
|
1686
|
+
out(line);
|
|
1687
|
+
return 0;
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1190
1690
|
/**
|
|
1191
1691
|
* The one spawn path: record, workspace, then wake admission (ADR 0022). An
|
|
1192
1692
|
* admitted pod gets its detached wrapper; a blocked one waits idle. `spawn`
|
|
1193
1693
|
* and a listener's template materialization both walk it (ADR 0007).
|
|
1694
|
+
*
|
|
1695
|
+
* The owner is written into the record here (ADR 0030), always: a Pod whose
|
|
1696
|
+
* owner nobody declared is a Pod the tick will guess about.
|
|
1194
1697
|
*/
|
|
1698
|
+
export interface SpawnOptions {
|
|
1699
|
+
agentId: string;
|
|
1700
|
+
admission: Admission;
|
|
1701
|
+
model?: string;
|
|
1702
|
+
after?: string;
|
|
1703
|
+
wakeBudgetPerDay?: number;
|
|
1704
|
+
/**
|
|
1705
|
+
* Write the record and launch nothing (#43, ADR 0033 amendment). A hybrid
|
|
1706
|
+
* Pod's first firing belongs to the handover, not to the spawn that created
|
|
1707
|
+
* it; the blocked path below already writes exactly this record, so dormancy
|
|
1708
|
+
* is the absence of a launch rather than a state of its own.
|
|
1709
|
+
*/
|
|
1710
|
+
dormant?: boolean;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1195
1713
|
async function spawnPodRecord(
|
|
1196
1714
|
goal: string,
|
|
1197
1715
|
project: string,
|
|
1198
|
-
|
|
1199
|
-
model?: string,
|
|
1200
|
-
after?: string,
|
|
1716
|
+
options: SpawnOptions,
|
|
1201
1717
|
registryHeld = false,
|
|
1202
1718
|
): Promise<PodRecord> {
|
|
1719
|
+
const { agentId, admission, model, after, wakeBudgetPerDay, dormant } = options;
|
|
1203
1720
|
const record: PodRecord = {
|
|
1204
1721
|
id: newPodId(),
|
|
1205
1722
|
goal,
|
|
1206
1723
|
state: "spawning",
|
|
1207
1724
|
project: resolve(project),
|
|
1725
|
+
admission,
|
|
1208
1726
|
...(after === undefined ? {} : { after }),
|
|
1209
1727
|
wakeUps: [],
|
|
1210
1728
|
// The policy names the agent and the optional model pin; `--model` beats
|
|
1211
|
-
// the definition's model, which beats the adapter default (ADR 0012).
|
|
1729
|
+
// the definition's model, which beats the adapter default (ADR 0012). The
|
|
1730
|
+
// daily ceiling rides here too (ADR 0032).
|
|
1212
1731
|
policy: {
|
|
1213
1732
|
failureStreak: 0,
|
|
1214
1733
|
appliedOutcomes: 0,
|
|
1215
1734
|
agent: agentId,
|
|
1216
1735
|
...(model === undefined ? {} : { model }),
|
|
1736
|
+
...(wakeBudgetPerDay === undefined ? {} : { wakeBudgetPerDay }),
|
|
1217
1737
|
},
|
|
1218
1738
|
};
|
|
1219
1739
|
await createPodWorkspace(record);
|
|
@@ -1235,6 +1755,17 @@ async function spawnPodRecord(
|
|
|
1235
1755
|
return record;
|
|
1236
1756
|
}
|
|
1237
1757
|
|
|
1758
|
+
// Dormant (#43): the record, the workspace and the owner exist; the first
|
|
1759
|
+
// wake-up does not. Nothing in the store says "dormant" — zero wake-ups on
|
|
1760
|
+
// an idle Pod is the fact, and a marker would have to be cleared by three
|
|
1761
|
+
// launchers.
|
|
1762
|
+
if (dormant === true) {
|
|
1763
|
+
record.state = "idle";
|
|
1764
|
+
await writePods(pods);
|
|
1765
|
+
return record;
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
record.admitted = "spawn";
|
|
1238
1769
|
await writePods(pods);
|
|
1239
1770
|
launchWrapperDetached(record.id, agentId);
|
|
1240
1771
|
record.state = "active";
|
|
@@ -1254,10 +1785,82 @@ async function spawnCmd(
|
|
|
1254
1785
|
out: (line: string) => void,
|
|
1255
1786
|
err: (line: string) => void,
|
|
1256
1787
|
): Promise<number> {
|
|
1257
|
-
const { flags, rest } = parseFlags(words, [
|
|
1258
|
-
|
|
1788
|
+
const { flags, rest, dangling } = parseFlags(words, [
|
|
1789
|
+
"agent",
|
|
1790
|
+
"model",
|
|
1791
|
+
"cwd",
|
|
1792
|
+
"project",
|
|
1793
|
+
"after",
|
|
1794
|
+
"admission",
|
|
1795
|
+
"wake-budget",
|
|
1796
|
+
]);
|
|
1797
|
+
// `--external` and `--dormant` carry no value, so parseFlags leaves them among
|
|
1798
|
+
// the words: the first is the shorthand ADR 0030 documents for `--admission
|
|
1799
|
+
// external`, the second declares the Pod without its spawn wake-up (#43).
|
|
1800
|
+
// Both are taken out before the remaining words compose the goal.
|
|
1801
|
+
const external = rest.includes("--external");
|
|
1802
|
+
const dormant = rest.includes("--dormant");
|
|
1803
|
+
const goal = rest
|
|
1804
|
+
.filter((word) => word !== "--external" && word !== "--dormant")
|
|
1805
|
+
.join(" ")
|
|
1806
|
+
.trim();
|
|
1807
|
+
if (dangling.length > 0) {
|
|
1808
|
+
// A flag with no value would otherwise compose the goal and leave its own
|
|
1809
|
+
// setting at the default — a typo that silently disables a guardrail is not
|
|
1810
|
+
// a guardrail (ADR 0032).
|
|
1811
|
+
err(`${dangling[0]} needs a value`);
|
|
1812
|
+
err("Usage: agents-pod spawn <goal prompt> [--agent <id>] [--model <id>] [--cwd <dir>] [--after <podId>]");
|
|
1813
|
+
err(" [--admission intake|external|self | --external] [--wake-budget <n>] [--dormant]");
|
|
1814
|
+
return 2;
|
|
1815
|
+
}
|
|
1259
1816
|
if (!goal) {
|
|
1260
1817
|
err("Usage: agents-pod spawn <goal prompt> [--agent <id>] [--model <id>] [--cwd <dir>] [--after <podId>]");
|
|
1818
|
+
err(" [--admission intake|external|self | --external] [--wake-budget <n>] [--dormant]");
|
|
1819
|
+
return 2;
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
// The daily ceiling is a guardrail, so its value is validated like any other
|
|
1823
|
+
// surface flag: a typo must not silently disable it (ADR 0032).
|
|
1824
|
+
let wakeBudgetPerDay: number | undefined;
|
|
1825
|
+
if (flags["wake-budget"] !== undefined) {
|
|
1826
|
+
const parsed = Number(flags["wake-budget"]);
|
|
1827
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
1828
|
+
err(`--wake-budget must be a positive integer, got ${flags["wake-budget"]}`);
|
|
1829
|
+
return 2;
|
|
1830
|
+
}
|
|
1831
|
+
wakeBudgetPerDay = parsed;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
// Admission is decided before anything is written (ADR 0030): who may wake
|
|
1835
|
+
// this Pod is the fact the incident was missing, and a refused spawn leaves
|
|
1836
|
+
// no record, no workspace, and no wake-up behind.
|
|
1837
|
+
if (flags.admission !== undefined && !isAdmission(flags.admission)) {
|
|
1838
|
+
err(`Unknown admission ${flags.admission}: one of ${ADMISSIONS.join(" | ")}`);
|
|
1839
|
+
return 2;
|
|
1840
|
+
}
|
|
1841
|
+
if (external && flags.admission !== undefined && flags.admission !== "external") {
|
|
1842
|
+
err(`--external means --admission external; not ${flags.admission}`);
|
|
1843
|
+
return 2;
|
|
1844
|
+
}
|
|
1845
|
+
const declared: Admission | undefined = flags.admission ?? (external ? "external" : undefined);
|
|
1846
|
+
// A goal that names a clock under the tick's own rule is the incident: every
|
|
1847
|
+
// `more` re-arms it, so the clock fires as fast as the passes do. The clock
|
|
1848
|
+
// belongs to a schedule; an `external` or `intake` owner makes it context.
|
|
1849
|
+
const clock = clockPhrase(goal);
|
|
1850
|
+
if (clock !== undefined && declared !== "external" && declared !== "intake") {
|
|
1851
|
+
for (const line of clockRefusal(clock)) err(line);
|
|
1852
|
+
return 2;
|
|
1853
|
+
}
|
|
1854
|
+
const admission: Admission = declared ?? "self";
|
|
1855
|
+
// Dormancy is not an owner, it is the absence of a first launch — and the tick
|
|
1856
|
+
// does not wait to launch. A `self` Pod is admitted on the very next pass, so
|
|
1857
|
+
// `--dormant` there would promise a stop nothing honours (#43, ADR 0033
|
|
1858
|
+
// amendment: refuse the flag where it cannot mean anything).
|
|
1859
|
+
if (dormant && admission === "self") {
|
|
1860
|
+
err("--dormant needs an owner that arrives later: a `self` pod is admitted by the tick on its next pass, so it would be dormant for a minute at most");
|
|
1861
|
+
err(" --admission intake a listener feeds it: the first batch is its first wake-up");
|
|
1862
|
+
err(" --admission external an outside clock or a human calls `agents-pod wake`");
|
|
1863
|
+
err("or drop --dormant and let its spawn wake-up run now");
|
|
1261
1864
|
return 2;
|
|
1262
1865
|
}
|
|
1263
1866
|
|
|
@@ -1285,13 +1888,14 @@ async function spawnCmd(
|
|
|
1285
1888
|
}
|
|
1286
1889
|
}
|
|
1287
1890
|
|
|
1288
|
-
const record = await spawnPodRecord(
|
|
1289
|
-
goal,
|
|
1290
|
-
flags.project ?? flags.cwd ?? deps.cwd ?? process.cwd(),
|
|
1891
|
+
const record = await spawnPodRecord(goal, flags.project ?? flags.cwd ?? deps.cwd ?? process.cwd(), {
|
|
1291
1892
|
agentId,
|
|
1292
|
-
|
|
1293
|
-
flags.
|
|
1294
|
-
|
|
1893
|
+
admission,
|
|
1894
|
+
...(flags.model === undefined ? {} : { model: flags.model }),
|
|
1895
|
+
...(flags.after === undefined ? {} : { after: flags.after }),
|
|
1896
|
+
...(wakeBudgetPerDay === undefined ? {} : { wakeBudgetPerDay }),
|
|
1897
|
+
...(dormant ? { dormant: true } : {}),
|
|
1898
|
+
});
|
|
1295
1899
|
out(record.id);
|
|
1296
1900
|
return 0;
|
|
1297
1901
|
}
|
|
@@ -1667,6 +2271,8 @@ async function sourceCmd(
|
|
|
1667
2271
|
cmd: source.cmd,
|
|
1668
2272
|
timeoutMs: source.timeoutMs,
|
|
1669
2273
|
cursor: source.cursor ?? null,
|
|
2274
|
+
lastDrainItems: source.lastDrainItems ?? null,
|
|
2275
|
+
unrouted: source.lastDrainUnrouted ?? null,
|
|
1670
2276
|
})),
|
|
1671
2277
|
),
|
|
1672
2278
|
);
|
|
@@ -1677,8 +2283,13 @@ async function sourceCmd(
|
|
|
1677
2283
|
return 0;
|
|
1678
2284
|
}
|
|
1679
2285
|
for (const source of sources) {
|
|
2286
|
+
// The unrouted count rides the line an operator already reads (ADR 0034):
|
|
2287
|
+
// a drain that delivers into nothing is otherwise indistinguishable from a
|
|
2288
|
+
// quiet chat, which is the state this number exists to separate.
|
|
2289
|
+
const unrouted =
|
|
2290
|
+
(source.lastDrainUnrouted ?? 0) === 0 ? "" : ` unrouted ${source.lastDrainUnrouted}`;
|
|
1680
2291
|
out(
|
|
1681
|
-
`${source.name} ${source.cmd} timeout ${source.timeoutMs / 1000}s cursor ${source.cursor ?? "-"}`,
|
|
2292
|
+
`${source.name} ${source.cmd} timeout ${source.timeoutMs / 1000}s cursor ${source.cursor ?? "-"}${unrouted}`,
|
|
1682
2293
|
);
|
|
1683
2294
|
}
|
|
1684
2295
|
return 0;
|
|
@@ -1754,8 +2365,40 @@ async function sourceCmd(
|
|
|
1754
2365
|
return 0;
|
|
1755
2366
|
}
|
|
1756
2367
|
|
|
2368
|
+
case "rewind": {
|
|
2369
|
+
// The one explicit way back past a cursor (ADR 0034): a batch an unrouted
|
|
2370
|
+
// drain swallowed, or a route fixed after the fact. Claims dedupe on id, so
|
|
2371
|
+
// a rewind re-serves exactly what was never claimed; `--to ""` clears the
|
|
2372
|
+
// cursor and makes the adapter decide where to start again.
|
|
2373
|
+
const { flags, rest: words } = parseFlags(rest, ["to"]);
|
|
2374
|
+
const name = words.find((word) => !word.startsWith("--"));
|
|
2375
|
+
if (name === undefined || flags.to === undefined) {
|
|
2376
|
+
err('Usage: agents-pod source rewind <name> --to <cursor|"">');
|
|
2377
|
+
return 2;
|
|
2378
|
+
}
|
|
2379
|
+
const rewound = await withRegistryLock(async () => {
|
|
2380
|
+
const sources = await readSources();
|
|
2381
|
+
const source = sources.find((candidate) => candidate.name === name);
|
|
2382
|
+
if (source === undefined) return false;
|
|
2383
|
+
if (flags.to === "") delete source.cursor;
|
|
2384
|
+
else source.cursor = flags.to;
|
|
2385
|
+
await writeSources(sources);
|
|
2386
|
+
return true;
|
|
2387
|
+
});
|
|
2388
|
+
if (rewound === undefined) {
|
|
2389
|
+
err("registry lock unavailable: cursor not rewound");
|
|
2390
|
+
return 2;
|
|
2391
|
+
}
|
|
2392
|
+
if (!rewound) {
|
|
2393
|
+
err(`Unknown source ${name}`);
|
|
2394
|
+
return 2;
|
|
2395
|
+
}
|
|
2396
|
+
out(flags.to === "" ? `${name} cursor cleared` : `${name} cursor ${flags.to}`);
|
|
2397
|
+
return 0;
|
|
2398
|
+
}
|
|
2399
|
+
|
|
1757
2400
|
default:
|
|
1758
|
-
err(`Unknown source command: ${sub ?? "(none)"}. Try: add | list | rm`);
|
|
2401
|
+
err(`Unknown source command: ${sub ?? "(none)"}. Try: add | list | rm | rewind`);
|
|
1759
2402
|
return 2;
|
|
1760
2403
|
}
|
|
1761
2404
|
}
|
|
@@ -1875,6 +2518,7 @@ async function listenerCmd(
|
|
|
1875
2518
|
inbox: listener.inbox ?? null,
|
|
1876
2519
|
source: listener.source ?? null,
|
|
1877
2520
|
match: listener.match ?? null,
|
|
2521
|
+
chats: listener.chats ?? null,
|
|
1878
2522
|
mode: listener.mode,
|
|
1879
2523
|
template: listener.template ?? null,
|
|
1880
2524
|
project: listener.project ?? null,
|
|
@@ -1896,23 +2540,32 @@ async function listenerCmd(
|
|
|
1896
2540
|
? `template ${listener.template}`
|
|
1897
2541
|
: `pod ${listener.podId}`;
|
|
1898
2542
|
const origin = listener.source === undefined ? listener.inbox : `source:${listener.source}`;
|
|
1899
|
-
|
|
2543
|
+
const filter =
|
|
2544
|
+
listener.chats === undefined ? "" : ` chats ${listener.chats.join(",")}`;
|
|
2545
|
+
out(
|
|
2546
|
+
`${listener.id} ${target} ${origin} claimed ${listener.claimed?.length ?? 0}${filter}`,
|
|
2547
|
+
);
|
|
1900
2548
|
}
|
|
1901
2549
|
return 0;
|
|
1902
2550
|
}
|
|
1903
2551
|
|
|
1904
2552
|
case "add": {
|
|
1905
|
-
const { flags } = parseFlags(
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
2553
|
+
const { flags, multiFlags } = parseFlags(
|
|
2554
|
+
rest,
|
|
2555
|
+
[
|
|
2556
|
+
"inbox",
|
|
2557
|
+
"source",
|
|
2558
|
+
"template",
|
|
2559
|
+
"pod",
|
|
2560
|
+
"match",
|
|
2561
|
+
"chat",
|
|
2562
|
+
"max-concurrent",
|
|
2563
|
+
"project",
|
|
2564
|
+
],
|
|
2565
|
+
["chat"],
|
|
2566
|
+
);
|
|
1914
2567
|
const usage =
|
|
1915
|
-
"Usage: agents-pod listener add (--inbox <dir|file> | --source <name>) (--template <file> | --pod <podId>) [--match <substr>] [--max-concurrent <n>] [--project <dir>]";
|
|
2568
|
+
"Usage: agents-pod listener add (--inbox <dir|file> | --source <name>) (--template <file> | --pod <podId>) [--match <substr>] [--chat <chat_id> ...] [--max-concurrent <n>] [--project <dir>]";
|
|
1916
2569
|
if ((flags.inbox === undefined) === (flags.source === undefined)) {
|
|
1917
2570
|
err(`listener add takes exactly one of --inbox or --source. ${usage}`);
|
|
1918
2571
|
return 2;
|
|
@@ -1953,6 +2606,18 @@ async function listenerCmd(
|
|
|
1953
2606
|
return 2;
|
|
1954
2607
|
}
|
|
1955
2608
|
|
|
2609
|
+
// A filter reads the item's group, and a directory item has none, so a rule
|
|
2610
|
+
// it could never satisfy is refused here rather than shipped (ADR 0034).
|
|
2611
|
+
const chats = routingChats(multiFlags.chat);
|
|
2612
|
+
if (chats !== undefined && inbox !== undefined) {
|
|
2613
|
+
const info = await stat(inbox).catch(() => undefined);
|
|
2614
|
+
if (info?.isDirectory()) {
|
|
2615
|
+
err(`--chat filters on a group, and a directory item carries none: ${inbox}`);
|
|
2616
|
+
err(" filter a Source, or a JSONL inbox whose items carry chat_id");
|
|
2617
|
+
return 2;
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2620
|
+
|
|
1956
2621
|
let maxConcurrent = 1;
|
|
1957
2622
|
if (flags["max-concurrent"] !== undefined) {
|
|
1958
2623
|
const parsed = Number(flags["max-concurrent"]);
|
|
@@ -1973,6 +2638,7 @@ async function listenerCmd(
|
|
|
1973
2638
|
seen: [],
|
|
1974
2639
|
};
|
|
1975
2640
|
if (flags.match !== undefined) listener.match = flags.match;
|
|
2641
|
+
if (chats !== undefined) listener.chats = chats;
|
|
1976
2642
|
|
|
1977
2643
|
if (flags.template !== undefined) {
|
|
1978
2644
|
// Read once to prove the reference, store the path (ADR 0007): the
|
|
@@ -1989,6 +2655,19 @@ async function listenerCmd(
|
|
|
1989
2655
|
err(`Unknown pod ${flags.pod}`);
|
|
1990
2656
|
return 2;
|
|
1991
2657
|
}
|
|
2658
|
+
// Binding is an intake declaration (ADR 0030): a Pod recorded as
|
|
2659
|
+
// `external` is woken from outside, so binding it would silently drop
|
|
2660
|
+
// that owner — and a Pod that owes a batch *and* an outside clock has
|
|
2661
|
+
// two owners, which is zero owners when both fire.
|
|
2662
|
+
if (pod.admission === "external") {
|
|
2663
|
+
err(
|
|
2664
|
+
`Pod ${pod.id} is admitted from outside (--admission external): a listener cannot bind it`,
|
|
2665
|
+
);
|
|
2666
|
+
err(
|
|
2667
|
+
" bind a Pod spawned with --admission intake, or spawn a new one for this inbox",
|
|
2668
|
+
);
|
|
2669
|
+
return 2;
|
|
2670
|
+
}
|
|
1992
2671
|
listener.mode = "pod";
|
|
1993
2672
|
listener.podId = pod.id;
|
|
1994
2673
|
}
|
|
@@ -2017,6 +2696,98 @@ async function listenerCmd(
|
|
|
2017
2696
|
return 0;
|
|
2018
2697
|
}
|
|
2019
2698
|
|
|
2699
|
+
case "edit": {
|
|
2700
|
+
// Claims live in the Listener record (ADR 0034), so re-creating one forgets
|
|
2701
|
+
// the ids it served and the replay window hands them back as new work. The
|
|
2702
|
+
// rule changes in place instead; the target and the claims do not move.
|
|
2703
|
+
const clearChats = rest.includes("--no-chat");
|
|
2704
|
+
const { flags, multiFlags, rest: words } = parseFlags(
|
|
2705
|
+
rest.filter((word) => word !== "--no-chat"),
|
|
2706
|
+
["match", "chat", "max-concurrent", "project"],
|
|
2707
|
+
["chat"],
|
|
2708
|
+
);
|
|
2709
|
+
const id = words.find((word) => !word.startsWith("--"));
|
|
2710
|
+
const usage =
|
|
2711
|
+
"Usage: agents-pod listener edit <listenerId> [--match <substr>] [--chat <chat_id> ... | --no-chat] [--max-concurrent <n>] [--project <dir>]";
|
|
2712
|
+
if (id === undefined) {
|
|
2713
|
+
err(usage);
|
|
2714
|
+
return 2;
|
|
2715
|
+
}
|
|
2716
|
+
if (
|
|
2717
|
+
flags.match === undefined &&
|
|
2718
|
+
multiFlags.chat === undefined &&
|
|
2719
|
+
!clearChats &&
|
|
2720
|
+
flags.project === undefined &&
|
|
2721
|
+
flags["max-concurrent"] === undefined
|
|
2722
|
+
) {
|
|
2723
|
+
err(`listener edit needs at least one rule. ${usage}`);
|
|
2724
|
+
return 2;
|
|
2725
|
+
}
|
|
2726
|
+
const found = (await readListeners()).find((candidate) => candidate.id === id);
|
|
2727
|
+
if (found === undefined) {
|
|
2728
|
+
err(`Unknown listener ${id}`);
|
|
2729
|
+
return 2;
|
|
2730
|
+
}
|
|
2731
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
2732
|
+
let targetProject: string | undefined;
|
|
2733
|
+
if (flags.project !== undefined) {
|
|
2734
|
+
if (found.mode !== "template") {
|
|
2735
|
+
err(`--project needs a template listener: a bound standing Pod keeps its own project. ${usage}`);
|
|
2736
|
+
return 2;
|
|
2737
|
+
}
|
|
2738
|
+
targetProject = resolve(cwd, flags.project);
|
|
2739
|
+
const info = await stat(targetProject).catch(() => undefined);
|
|
2740
|
+
if (info === undefined || !info.isDirectory()) {
|
|
2741
|
+
err(`Project ${targetProject} does not exist or is not a directory`);
|
|
2742
|
+
return 2;
|
|
2743
|
+
}
|
|
2744
|
+
}
|
|
2745
|
+
let maxConcurrent: number | undefined;
|
|
2746
|
+
if (flags["max-concurrent"] !== undefined) {
|
|
2747
|
+
const parsed = Number(flags["max-concurrent"]);
|
|
2748
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
2749
|
+
err(`--max-concurrent must be a positive integer, got ${flags["max-concurrent"]}`);
|
|
2750
|
+
return 2;
|
|
2751
|
+
}
|
|
2752
|
+
maxConcurrent = parsed;
|
|
2753
|
+
}
|
|
2754
|
+
const chats = clearChats ? [] : routingChats(multiFlags.chat);
|
|
2755
|
+
if (chats !== undefined && chats.length > 0 && found.inbox !== undefined) {
|
|
2756
|
+
const info = await stat(found.inbox).catch(() => undefined);
|
|
2757
|
+
if (info?.isDirectory()) {
|
|
2758
|
+
err(`--chat filters on a group, and a directory item carries none: ${found.inbox}`);
|
|
2759
|
+
return 2;
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2762
|
+
const edited = await withRegistryLock(async () => {
|
|
2763
|
+
const current = await readListeners();
|
|
2764
|
+
const listener = current.find((candidate) => candidate.id === id);
|
|
2765
|
+
if (listener === undefined) return false;
|
|
2766
|
+
if (flags.match !== undefined) {
|
|
2767
|
+
if (flags.match === "") delete listener.match;
|
|
2768
|
+
else listener.match = flags.match;
|
|
2769
|
+
}
|
|
2770
|
+
if (chats !== undefined) {
|
|
2771
|
+
if (chats.length === 0) delete listener.chats;
|
|
2772
|
+
else listener.chats = chats;
|
|
2773
|
+
}
|
|
2774
|
+
if (targetProject !== undefined) listener.project = targetProject;
|
|
2775
|
+
if (maxConcurrent !== undefined) listener.maxConcurrent = maxConcurrent;
|
|
2776
|
+
await writeListeners(current);
|
|
2777
|
+
return true;
|
|
2778
|
+
});
|
|
2779
|
+
if (edited === undefined) {
|
|
2780
|
+
err("registry lock unavailable: listener not edited");
|
|
2781
|
+
return 2;
|
|
2782
|
+
}
|
|
2783
|
+
if (!edited) {
|
|
2784
|
+
err(`Unknown listener ${id}`);
|
|
2785
|
+
return 2;
|
|
2786
|
+
}
|
|
2787
|
+
out(id);
|
|
2788
|
+
return 0;
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2020
2791
|
case "rm": {
|
|
2021
2792
|
const id = rest.find((word) => !word.startsWith("--"));
|
|
2022
2793
|
if (!id) {
|
|
@@ -2025,10 +2796,28 @@ async function listenerCmd(
|
|
|
2025
2796
|
}
|
|
2026
2797
|
const removed = await withRegistryLock(async () => {
|
|
2027
2798
|
const listeners = await readListeners();
|
|
2799
|
+
const gone = listeners.find((listener) => listener.id === id);
|
|
2028
2800
|
const remaining = listeners.filter((listener) => listener.id !== id);
|
|
2029
2801
|
if (remaining.length === listeners.length) return false;
|
|
2030
2802
|
// Claims go with the record: re-adding is a fresh claim namespace (ADR 0013).
|
|
2031
2803
|
await writeListeners(remaining);
|
|
2804
|
+
// A `quiet` window's promise is made to a Listener ("wake me when work
|
|
2805
|
+
// arrives"), so removing that Listener removes the claimant: the Pod
|
|
2806
|
+
// falls back to the tick, which would otherwise skip it forever and say
|
|
2807
|
+
// nothing (ADR 0011 amendment, #36).
|
|
2808
|
+
const stillBound = new Set(
|
|
2809
|
+
remaining.flatMap((listener) =>
|
|
2810
|
+
listener.mode === "pod" && listener.podId !== undefined ? [listener.podId] : [],
|
|
2811
|
+
),
|
|
2812
|
+
);
|
|
2813
|
+
if (gone?.mode === "pod" && gone.podId !== undefined && !stillBound.has(gone.podId)) {
|
|
2814
|
+
const pods = await readPods();
|
|
2815
|
+
const pod = pods.find((candidate) => candidate.id === gone.podId);
|
|
2816
|
+
if (pod?.policy?.awaiting) {
|
|
2817
|
+
delete pod.policy.awaiting;
|
|
2818
|
+
await writePods(pods);
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2032
2821
|
return true;
|
|
2033
2822
|
});
|
|
2034
2823
|
if (removed === undefined) {
|
|
@@ -2043,7 +2832,7 @@ async function listenerCmd(
|
|
|
2043
2832
|
}
|
|
2044
2833
|
|
|
2045
2834
|
default:
|
|
2046
|
-
err(`Unknown listener command: ${sub ?? "(none)"}. Try: add | list | rm`);
|
|
2835
|
+
err(`Unknown listener command: ${sub ?? "(none)"}. Try: add | edit | list | rm`);
|
|
2047
2836
|
return 2;
|
|
2048
2837
|
}
|
|
2049
2838
|
}
|
|
@@ -2107,7 +2896,7 @@ export async function main(argv: string[], deps: CliDeps = {}): Promise<number>
|
|
|
2107
2896
|
out("agents-pod — standing Pods across days, one cron-ticked pass");
|
|
2108
2897
|
out("");
|
|
2109
2898
|
out("start here agents-pod doctor what is missing on this machine, and the command that fixes it");
|
|
2110
|
-
out("pods spawn | list | status <id> | logs <id> | wake <id> | cancel <id>");
|
|
2899
|
+
out("pods spawn | list [--all] | status <id> | logs <id> | wake <id> | cancel <id> | rm <id>");
|
|
2111
2900
|
out("records agent | listener | source | deliver | adapter | project | reconcile");
|
|
2112
2901
|
return 0;
|
|
2113
2902
|
}
|
|
@@ -2118,13 +2907,15 @@ export async function main(argv: string[], deps: CliDeps = {}): Promise<number>
|
|
|
2118
2907
|
case "status":
|
|
2119
2908
|
return statusCmd(rest, out, err);
|
|
2120
2909
|
case "list":
|
|
2121
|
-
return listCmd(out);
|
|
2910
|
+
return listCmd(rest, out, err);
|
|
2122
2911
|
case "logs":
|
|
2123
2912
|
return logsCmd(rest, out, err);
|
|
2124
2913
|
case "wake":
|
|
2125
2914
|
return wakeCmd(rest, out, err);
|
|
2126
2915
|
case "cancel":
|
|
2127
2916
|
return cancelCmd(rest, out, err);
|
|
2917
|
+
case "rm":
|
|
2918
|
+
return removeCmd(rest, out, err);
|
|
2128
2919
|
case "reconcile":
|
|
2129
2920
|
return reconcileCmd(deps);
|
|
2130
2921
|
case "agent":
|
|
@@ -2142,7 +2933,7 @@ export async function main(argv: string[], deps: CliDeps = {}): Promise<number>
|
|
|
2142
2933
|
case "doctor":
|
|
2143
2934
|
return doctorCmd(rest, deps, out, err);
|
|
2144
2935
|
default:
|
|
2145
|
-
err(`Unknown command: ${cmd ?? "(none)"}. Try: doctor | spawn | list | status | logs | wake | cancel | reconcile | agent | listener | source | deliver | project`);
|
|
2936
|
+
err(`Unknown command: ${cmd ?? "(none)"}. Try: doctor | spawn | list | status | logs | wake | cancel | rm | reconcile | agent | listener | source | deliver | project`);
|
|
2146
2937
|
return 2;
|
|
2147
2938
|
}
|
|
2148
2939
|
}
|