agents-pod 0.3.5 → 0.3.6
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 +114 -0
- package/README.md +87 -19
- package/package.json +1 -1
- package/src/admission.ts +102 -0
- package/src/cadence.ts +67 -0
- package/src/cli.ts +378 -50
- package/src/doctor.ts +132 -3
- package/src/extension/handoff.ts +17 -6
- package/src/extension/management.ts +19 -3
- package/src/extension/self.ts +8 -5
- package/src/registry.ts +57 -1
- 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,
|
|
@@ -57,7 +65,13 @@ import {
|
|
|
57
65
|
type AdapterFileState,
|
|
58
66
|
type AdapterSyncPlan,
|
|
59
67
|
} from "./adapters.ts";
|
|
60
|
-
import
|
|
68
|
+
import {
|
|
69
|
+
DEFAULT_WAKE_BUDGET_PER_DAY,
|
|
70
|
+
type Admission,
|
|
71
|
+
type PodRecord,
|
|
72
|
+
type WakePolicy,
|
|
73
|
+
type WakeUpEntry,
|
|
74
|
+
} from "./registry.ts";
|
|
61
75
|
import { readPods, withRegistryLock, writePods } from "./registry.ts";
|
|
62
76
|
import {
|
|
63
77
|
commitSourceCursors,
|
|
@@ -97,16 +111,19 @@ function launchWrapperDetached(podId: string, agentId?: string, warm = false): v
|
|
|
97
111
|
}
|
|
98
112
|
|
|
99
113
|
/**
|
|
100
|
-
* Flags may appear anywhere among the
|
|
101
|
-
*
|
|
102
|
-
*
|
|
114
|
+
* Flags may appear anywhere among the words (README's CLI surface), and the
|
|
115
|
+
* words that are not flags compose the goal. Every flag in `allowed` takes a
|
|
116
|
+
* value; one that arrives without a value is reported as `dangling` — left in
|
|
117
|
+
* `rest` it would become part of a goal (`spawn fix it --wake-budget`) and the
|
|
118
|
+
* command would proceed with the flag silently unset.
|
|
103
119
|
*/
|
|
104
120
|
function parseFlags(
|
|
105
121
|
words: string[],
|
|
106
122
|
allowed: readonly string[],
|
|
107
|
-
): { flags: Record<string, string>; rest: string[] } {
|
|
123
|
+
): { flags: Record<string, string>; rest: string[]; dangling: string[] } {
|
|
108
124
|
const flags: Record<string, string> = {};
|
|
109
125
|
const rest: string[] = [];
|
|
126
|
+
const dangling: string[] = [];
|
|
110
127
|
for (let index = 0; index < words.length; index += 1) {
|
|
111
128
|
const word = words[index]!;
|
|
112
129
|
const name = word.startsWith("--") ? word.slice(2) : undefined;
|
|
@@ -115,10 +132,11 @@ function parseFlags(
|
|
|
115
132
|
flags[name] = value;
|
|
116
133
|
index += 1;
|
|
117
134
|
} else {
|
|
135
|
+
if (name !== undefined && allowed.includes(name)) dangling.push(`--${name}`);
|
|
118
136
|
rest.push(word);
|
|
119
137
|
}
|
|
120
138
|
}
|
|
121
|
-
return { flags, rest };
|
|
139
|
+
return { flags, rest, dangling };
|
|
122
140
|
}
|
|
123
141
|
|
|
124
142
|
/** Exponential backoff after `failed`/`lost`: 1m ×2, capped at 15m (ADR 0011). */
|
|
@@ -126,6 +144,69 @@ function backoffMs(streak: number): number {
|
|
|
126
144
|
return Math.min(60_000 * 2 ** (streak - 1), 900_000);
|
|
127
145
|
}
|
|
128
146
|
|
|
147
|
+
/**
|
|
148
|
+
* One outcome's effect on the record's policy (ADR 0031): the single place that
|
|
149
|
+
* decides when the *next* wake-up may happen, and the reason it takes the owner.
|
|
150
|
+
*
|
|
151
|
+
* **An outcome never schedules a wake-up its owner would not have made.** `more`
|
|
152
|
+
* means "the goal needs another wake-up"; for a Pod the tick owns that is "on the
|
|
153
|
+
* next pass", and for a Pod a Listener feeds or an outside clock wakes, the next
|
|
154
|
+
* wake-up is its owner's decision — a `nextWakeAt` written here would be a second,
|
|
155
|
+
* invisible owner, which is how a goal re-armed itself at 41 wake-ups in five
|
|
156
|
+
* hours (issue #31, #34). `cancelled` says nothing about the schedule: a human
|
|
157
|
+
* stopped a window, not the work.
|
|
158
|
+
*
|
|
159
|
+
* The failure streak is *not* owner-dependent: park obeys it, and park is about
|
|
160
|
+
* money, not about who wakes the Pod.
|
|
161
|
+
*/
|
|
162
|
+
function applyOutcome(
|
|
163
|
+
policy: WakePolicy,
|
|
164
|
+
owner: Admission,
|
|
165
|
+
wake: WakeUpEntry,
|
|
166
|
+
now: number,
|
|
167
|
+
): void {
|
|
168
|
+
// `quiet` (ADR 0011 amendment, #36): the window is satisfied and nothing is
|
|
169
|
+
// scheduled. It is a clean exit — the streak resets, the batch it was admitted
|
|
170
|
+
// for was served — and the difference from `more` is that it asks for nothing:
|
|
171
|
+
// no clock, and the tick stops admitting the Pod until work arrives.
|
|
172
|
+
if (wake.outcome === "quiet") {
|
|
173
|
+
policy.failureStreak = 0;
|
|
174
|
+
delete policy.nextWakeAt;
|
|
175
|
+
delete policy.intakePending;
|
|
176
|
+
policy.awaiting = true;
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (wake.outcome === "more" || wake.outcome === "done") {
|
|
180
|
+
policy.failureStreak = 0;
|
|
181
|
+
delete policy.nextWakeAt; // `self`: due on the next pass, at once
|
|
182
|
+
// A clean exit served the batch it was woken for, so the debt is paid
|
|
183
|
+
// (ADR 0026 amendment).
|
|
184
|
+
delete policy.intakePending;
|
|
185
|
+
// Another wake-up is the work again, so a previous "nothing scheduled" is
|
|
186
|
+
// spent: the Pod is wake-eligible by its owner's ordinary rule.
|
|
187
|
+
delete policy.awaiting;
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
// Anything not named here is recorded and ignored, never counted as a failure:
|
|
191
|
+
// `cancelled` (a human stopped a window, not the work) and any outcome a later
|
|
192
|
+
// version adds must not park a Pod by falling through (ADR 0031 — #36's `quiet`
|
|
193
|
+
// is exactly such a value).
|
|
194
|
+
if (wake.outcome !== "failed" && wake.outcome !== "lost") return;
|
|
195
|
+
// Only the tick's own Pod is put on a clock; an intake Pod is re-admitted by
|
|
196
|
+
// the batch it still owes, and an `external` one by its own wake — both
|
|
197
|
+
// bounded by the streak and the park below, not by this field.
|
|
198
|
+
policy.failureStreak += 1;
|
|
199
|
+
// A window that ran and failed was not satisfied by its own account, so the
|
|
200
|
+
// "nothing scheduled" claim is spent too — the streak machinery decides from
|
|
201
|
+
// here, and a parked Pod is skipped before admission is even asked.
|
|
202
|
+
delete policy.awaiting;
|
|
203
|
+
if (owner === "self") {
|
|
204
|
+
policy.nextWakeAt = now + backoffMs(policy.failureStreak);
|
|
205
|
+
} else {
|
|
206
|
+
delete policy.nextWakeAt;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
129
210
|
/**
|
|
130
211
|
* Signal a pod's process group — never our own. The pgid comes from store
|
|
131
212
|
* data only (wrapper.json, ADR 0003), and the guards make a corrupt record a
|
|
@@ -324,6 +405,18 @@ async function reconcilePass(
|
|
|
324
405
|
const pods = await readPods();
|
|
325
406
|
const agents = await readAgents();
|
|
326
407
|
const now = Date.now();
|
|
408
|
+
// The owner is resolved per Pod below from the bindings as this pass begins.
|
|
409
|
+
// A `listener add` from another process can still land during the Pod loop,
|
|
410
|
+
// and the derivation would then read that Pod as the tick's — which is why the
|
|
411
|
+
// wake phase reads the bindings again (see wakeDuePods) and why `nextWakeAt`,
|
|
412
|
+
// the one field such a stale answer could write, gates nothing but a `self`
|
|
413
|
+
// Pod. A wrong answer here can delay nothing; a wrong answer there would wake
|
|
414
|
+
// a Pod that had just been bound.
|
|
415
|
+
const intakeBound = new Set(
|
|
416
|
+
(await readListeners()).flatMap((listener) =>
|
|
417
|
+
listener.mode === "pod" && listener.podId !== undefined ? [listener.podId] : [],
|
|
418
|
+
),
|
|
419
|
+
);
|
|
327
420
|
let changed = false;
|
|
328
421
|
const pending: PodNotification[] = [];
|
|
329
422
|
|
|
@@ -348,7 +441,13 @@ async function reconcilePass(
|
|
|
348
441
|
const facts = await livenessFacts(pod);
|
|
349
442
|
if (facts.quadrant === "lost") {
|
|
350
443
|
const n = facts.wrapper?.wakeup ?? (await maxWakeupNumber(pod)) ?? 1;
|
|
351
|
-
pod.wakeUps.push({
|
|
444
|
+
pod.wakeUps.push({
|
|
445
|
+
n,
|
|
446
|
+
outcome: "lost",
|
|
447
|
+
detail: lostDetail(facts),
|
|
448
|
+
at: now,
|
|
449
|
+
...(pod.admitted === undefined ? {} : { trigger: pod.admitted }),
|
|
450
|
+
});
|
|
352
451
|
pod.state = "idle";
|
|
353
452
|
delete pod.wedgedAt; // a lost wake-up spends any wedged observation
|
|
354
453
|
changed = true;
|
|
@@ -390,6 +489,8 @@ async function reconcilePass(
|
|
|
390
489
|
detail: trusted
|
|
391
490
|
? "wedged wrapper killed by reconcile"
|
|
392
491
|
: "wedged wrapper signal withheld (recorded process is gone)",
|
|
492
|
+
at: now,
|
|
493
|
+
...(pod.admitted === undefined ? {} : { trigger: pod.admitted }),
|
|
393
494
|
});
|
|
394
495
|
pod.state = "idle";
|
|
395
496
|
delete pod.wedgedAt;
|
|
@@ -425,6 +526,8 @@ async function reconcilePass(
|
|
|
425
526
|
n: wrapper.wakeup,
|
|
426
527
|
outcome: "failed",
|
|
427
528
|
detail: PROGRESSLESS_DETAIL,
|
|
529
|
+
at: now,
|
|
530
|
+
...(pod.admitted === undefined ? {} : { trigger: pod.admitted }),
|
|
428
531
|
});
|
|
429
532
|
pod.state = "idle";
|
|
430
533
|
changed = true;
|
|
@@ -438,28 +541,21 @@ async function reconcilePass(
|
|
|
438
541
|
// two-event notification inventory (ADRs 0021, 0025).
|
|
439
542
|
|
|
440
543
|
// 2. Policy derivation from outcome facts the wrapper (or step 1) recorded.
|
|
544
|
+
// The owner is resolved once here and handed to the derivation, so a new
|
|
545
|
+
// outcome cannot reach the schedule without answering "who wakes this Pod?"
|
|
546
|
+
// (ADR 0031).
|
|
547
|
+
const owner = admissionOf(pod, intakeBound);
|
|
441
548
|
const fresh = pod.wakeUps.slice(pod.policy.appliedOutcomes);
|
|
442
549
|
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;
|
|
550
|
+
applyOutcome(pod.policy, owner, wake, now);
|
|
551
|
+
// Backoff without a stop is a money pit: streak 3 parks the pod, and only
|
|
552
|
+
// a human wake lifts it (ADR 0011). Park is policy data, not a state, and
|
|
553
|
+
// it holds for every owner — a stop is about money, not about scheduling.
|
|
554
|
+
const failed = wake.outcome === "failed" || wake.outcome === "lost";
|
|
555
|
+
if (failed && pod.policy.failureStreak >= 3 && !pod.policy.parked) {
|
|
556
|
+
pod.policy.parked = true;
|
|
557
|
+
pod.policy.parkedBy = "failures";
|
|
558
|
+
announcePark();
|
|
463
559
|
}
|
|
464
560
|
pod.policy.appliedOutcomes += 1;
|
|
465
561
|
changed = true;
|
|
@@ -467,6 +563,23 @@ async function reconcilePass(
|
|
|
467
563
|
|
|
468
564
|
const goalOpen = pod.state !== "done" && pod.state !== "cancelled";
|
|
469
565
|
|
|
566
|
+
// 2b. The daily ceiling (ADR 0032). The one defence against a loop nobody
|
|
567
|
+
// predicted: the storm's own round 41 set `parked` by hand, and this is that
|
|
568
|
+
// manoeuvre promoted to machinery. Park is reused rather than invented, so
|
|
569
|
+
// exactly one notification is sent (ADR 0021) and only a human `wake`
|
|
570
|
+
// lifts it (ADR 0011).
|
|
571
|
+
const budget = pod.policy.wakeBudgetPerDay ?? DEFAULT_WAKE_BUDGET_PER_DAY;
|
|
572
|
+
const today = inWindow(pod.wakeUps, now, DAY_MS).length;
|
|
573
|
+
if (goalOpen && !pod.policy.parked && today > budget) {
|
|
574
|
+
pod.policy.parked = true;
|
|
575
|
+
// A budget stop is not the failure stop: `wake` lifts the latter freely
|
|
576
|
+
// (ADR 0011) but must not lift this one, or an outside clock re-opens the
|
|
577
|
+
// ceiling on every tick and every re-park announces itself (ADR 0032).
|
|
578
|
+
pod.policy.parkedBy = "budget";
|
|
579
|
+
announcePark(`${today} wake-ups in 24 h — over the budget of ${budget} (ADR 0032)`);
|
|
580
|
+
changed = true;
|
|
581
|
+
}
|
|
582
|
+
|
|
470
583
|
// 3. Terminal observation (ADR 0021): done is announced once, however many
|
|
471
584
|
// passes later find the record. The marker is what makes it once.
|
|
472
585
|
if (
|
|
@@ -494,6 +607,7 @@ async function reconcilePass(
|
|
|
494
607
|
if (!agentKnown && goalOpen && pod.state !== "active") {
|
|
495
608
|
if (!pod.policy.parked) {
|
|
496
609
|
pod.policy.parked = true;
|
|
610
|
+
pod.policy.parkedBy = "agent";
|
|
497
611
|
announcePark(`agent "${agentId}" no longer resolves (ADR 0012)`);
|
|
498
612
|
changed = true;
|
|
499
613
|
}
|
|
@@ -528,6 +642,19 @@ async function reconcilePass(
|
|
|
528
642
|
* inbox burned one whole agent run per tick — the shape that got the first
|
|
529
643
|
* router pod cancelled. An unbound pod has no inbox to check, so ADR 0011's
|
|
530
644
|
* optimistic rule stays its whole policy.
|
|
645
|
+
*
|
|
646
|
+
* ADR 0030 makes the owner a recorded fact with three values: `intake` (the debt
|
|
647
|
+
* gate below), `external` (never launched here — an outside clock or a human
|
|
648
|
+
* calls `wake`), `self` (the tick, for a goal that reaches `done`). A Listener
|
|
649
|
+
* binding outranks the record, because binding *is* an intake declaration, and
|
|
650
|
+
* an unrecorded owner falls back to `self` — the old rule, now named and
|
|
651
|
+
* reported by `doctor` rather than silently assumed.
|
|
652
|
+
*
|
|
653
|
+
* The bindings are read here, not handed down from the top of the pass: a
|
|
654
|
+
* `listener add` can land while a pass spawns pods, retries outboxes, and
|
|
655
|
+
* drains sources, and a set read seconds earlier would let this phase tick-wake
|
|
656
|
+
* a pod that had just been bound — the storm's own unit, reintroduced as a race.
|
|
657
|
+
* The derivation reads its own copy for the same reason, closest to its use.
|
|
531
658
|
*/
|
|
532
659
|
async function wakeDuePods(now: number): Promise<void> {
|
|
533
660
|
const pods = await readPods();
|
|
@@ -538,20 +665,47 @@ async function wakeDuePods(now: number): Promise<void> {
|
|
|
538
665
|
),
|
|
539
666
|
);
|
|
540
667
|
let changed = false;
|
|
668
|
+
const toLaunch: Array<{ id: string; agent: string }> = [];
|
|
669
|
+
const lines: string[] = [];
|
|
541
670
|
for (const pod of pods) {
|
|
542
671
|
if (pod.state !== "idle" || pod.policy?.parked) continue;
|
|
543
|
-
|
|
544
|
-
|
|
672
|
+
// `quiet`: the window said it was satisfied and scheduled nothing (#36).
|
|
673
|
+
// No owner admits it until work arrives — a new intake batch or a human
|
|
674
|
+
// `wake`, both of which clear the flag.
|
|
675
|
+
if (pod.policy?.awaiting) continue;
|
|
676
|
+
// Admission first (ADR 0030): the tick is one owner among three, and it
|
|
677
|
+
// reads the owner from the record instead of inferring it. An `external`
|
|
678
|
+
// Pod is somebody else's to wake at every tick of every day.
|
|
679
|
+
const owner = admissionOf(pod, intakeBound);
|
|
680
|
+
if (owner === "external") continue;
|
|
681
|
+
if (owner === "intake" && !pod.policy?.intakePending) continue;
|
|
682
|
+
// A backoff is the tick's own delay, written only for its own Pod (ADR 0031):
|
|
683
|
+
// letting it gate an `intake` Pod would be a second owner again — a record
|
|
684
|
+
// that failed before it was bound, or one written before ADR 0031, would
|
|
685
|
+
// hold a batch back for up to fifteen minutes.
|
|
686
|
+
if (owner === "self" && pod.policy?.nextWakeAt !== undefined && pod.policy.nextWakeAt > now) {
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
545
689
|
if (pod.after !== undefined) {
|
|
546
690
|
const upstream = pods.find((candidate) => candidate.id === pod.after);
|
|
547
691
|
if (upstream?.state !== "done") continue;
|
|
548
692
|
}
|
|
549
693
|
if (!projectHasCapacity(pod.project, pods, projects)) continue;
|
|
550
|
-
|
|
694
|
+
pod.admitted = "tick";
|
|
551
695
|
pod.state = "active";
|
|
696
|
+
toLaunch.push({ id: pod.id, agent: pod.policy?.agent ?? "default" });
|
|
697
|
+
lines.push(`wake ${pod.id} tick (admission ${owner})`);
|
|
552
698
|
changed = true;
|
|
553
699
|
}
|
|
700
|
+
// Record, then launch (ADR 0032). A crash between the two leaves a ghost the
|
|
701
|
+
// next pass adjudicates as `lost` — recoverable; a launch nobody recorded is
|
|
702
|
+
// the same Pod woken twice.
|
|
554
703
|
if (changed) await writePods(pods);
|
|
704
|
+
for (const launch of toLaunch) launchWrapperDetached(launch.id, launch.agent);
|
|
705
|
+
// The storm left no trace a single command could read (issue #35): every
|
|
706
|
+
// launch now says which launcher it was, next to the sources and listeners
|
|
707
|
+
// that already write here (ADR 0032).
|
|
708
|
+
for (const line of lines) await appendCronLog(line);
|
|
555
709
|
}
|
|
556
710
|
|
|
557
711
|
/** One Pod's state as the registry holds it now, read without any lock (ADR 0025). */
|
|
@@ -736,12 +890,12 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
|
|
|
736
890
|
materialized = true;
|
|
737
891
|
continue;
|
|
738
892
|
}
|
|
893
|
+
// A template Pod is spawned per Inbox Item and exists to finish it: the
|
|
894
|
+
// tick's own rule is its owner (ADR 0030), and `done` is its end.
|
|
739
895
|
const record = await spawnPodRecord(
|
|
740
896
|
rendered.goal,
|
|
741
897
|
listener.project ?? project,
|
|
742
|
-
"default",
|
|
743
|
-
undefined,
|
|
744
|
-
undefined,
|
|
898
|
+
{ agentId: "default", admission: "self" },
|
|
745
899
|
true,
|
|
746
900
|
);
|
|
747
901
|
claim.spawnedPodId = record.id;
|
|
@@ -812,6 +966,9 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
|
|
|
812
966
|
// leaves the count standing, so the next pass is the retry.
|
|
813
967
|
pod.policy ??= { failureStreak: 0, appliedOutcomes: 0 };
|
|
814
968
|
pod.policy.intakePending = (pod.policy.intakePending ?? 0) + batch.length;
|
|
969
|
+
// Work arrived, so a window that had declared itself satisfied is over
|
|
970
|
+
// (ADR 0011 amendment, #36) — the debt below is what admits the wake.
|
|
971
|
+
delete pod.policy.awaiting;
|
|
815
972
|
await writePods(pods);
|
|
816
973
|
appended = true;
|
|
817
974
|
}
|
|
@@ -934,20 +1091,68 @@ async function statusCmd(
|
|
|
934
1091
|
const last = pod.wakeUps.at(-1);
|
|
935
1092
|
out(pod.id);
|
|
936
1093
|
out(` goal: ${goalHeadline(pod.goal)}`);
|
|
937
|
-
|
|
1094
|
+
// The suffix qualifies a resting pod: `awaiting` says why the tick leaves it
|
|
1095
|
+
// alone, and a live wake-up is a different answer to the same question.
|
|
1096
|
+
const awaiting = pod.policy?.awaiting === true && pod.state === "idle";
|
|
1097
|
+
out(` state: ${pod.state}${awaiting ? " (awaiting)" : ""}`);
|
|
1098
|
+
// Who admits this Pod (ADR 0030) — the fact that decides whether the tick
|
|
1099
|
+
// wakes it at all, so `status` says it before it says anything about liveness.
|
|
1100
|
+
const bound = new Set(
|
|
1101
|
+
(await readListeners()).flatMap((listener) =>
|
|
1102
|
+
listener.mode === "pod" && listener.podId !== undefined ? [listener.podId] : [],
|
|
1103
|
+
),
|
|
1104
|
+
);
|
|
1105
|
+
const owner = admissionOf(pod, bound);
|
|
1106
|
+
out(
|
|
1107
|
+
` admission: ${owner}${
|
|
1108
|
+
pod.admission === undefined
|
|
1109
|
+
? " (derived: no owner recorded)"
|
|
1110
|
+
: pod.admission !== owner
|
|
1111
|
+
? ` (a listener is bound: intake wins over ${pod.admission})`
|
|
1112
|
+
: ""
|
|
1113
|
+
}`,
|
|
1114
|
+
);
|
|
938
1115
|
if (pod.after !== undefined) {
|
|
939
1116
|
const upstream = (await readPods()).find((candidate) => candidate.id === pod.after);
|
|
940
1117
|
out(` after: ${pod.after} (${upstream?.state ?? "missing"})`);
|
|
941
1118
|
}
|
|
942
1119
|
out(` liveness: ${await livenessOf(pod)}`);
|
|
943
1120
|
if (pod.policy?.parked) {
|
|
944
|
-
|
|
1121
|
+
// Three stops, three sentences (ADR 0011, 0012, 0032): a park that reads
|
|
1122
|
+
// "consecutive failures: 0" tells the operator nothing true.
|
|
1123
|
+
const budget = pod.policy.wakeBudgetPerDay ?? DEFAULT_WAKE_BUDGET_PER_DAY;
|
|
1124
|
+
const today = inWindow(pod.wakeUps, Date.now(), DAY_MS).length;
|
|
1125
|
+
out(
|
|
1126
|
+
pod.policy.parkedBy === "budget"
|
|
1127
|
+
? ` parked (over the daily budget: ${today} in 24h, budget ${budget}; agents-pod wake ${pod.id} --over-budget)`
|
|
1128
|
+
: pod.policy.parkedBy === "agent"
|
|
1129
|
+
? ` parked (agent "${pod.policy.agent ?? "default"}" no longer resolves; agents-pod agent add ${pod.policy.agent ?? "default"} …)`
|
|
1130
|
+
: ` parked (consecutive failures: ${pod.policy.failureStreak})`,
|
|
1131
|
+
);
|
|
945
1132
|
}
|
|
946
1133
|
out(` agent: ${resolveAgentBinary(process.env)}`);
|
|
947
1134
|
out(` wake-ups: ${pod.wakeUps.length} (last outcome: ${last?.outcome ?? "none"})`);
|
|
1135
|
+
out(` cadence: ${cadenceLine(pod, Date.now())}`);
|
|
948
1136
|
return 0;
|
|
949
1137
|
}
|
|
950
1138
|
|
|
1139
|
+
/**
|
|
1140
|
+
* The cadence, in the words a storm is recognised by (ADR 0032): how many ran
|
|
1141
|
+
* recently, how tight the rhythm was, and who admitted the last one. A record
|
|
1142
|
+
* written before provenance existed says so instead of showing an invented zero.
|
|
1143
|
+
*/
|
|
1144
|
+
function cadenceLine(pod: PodRecord, now: number): string {
|
|
1145
|
+
const timed = stamped(pod.wakeUps);
|
|
1146
|
+
if (timed.length === 0) {
|
|
1147
|
+
return "not recorded (this Pod's wake-ups predate provenance, ADR 0032)";
|
|
1148
|
+
}
|
|
1149
|
+
const day = inWindow(pod.wakeUps, now, DAY_MS).length;
|
|
1150
|
+
const median = medianIntervalMs(pod.wakeUps);
|
|
1151
|
+
const rhythm = median === undefined ? "interval n/a (fewer than two timed)" : `median interval ${duration(median)}`;
|
|
1152
|
+
const trigger = pod.wakeUps.at(-1)?.trigger ?? "unknown";
|
|
1153
|
+
return `${day} in 24h, ${rhythm}, last trigger ${trigger}`;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
951
1156
|
/**
|
|
952
1157
|
* The attention ruler's first use (DESIGN Part 1): a healthy pod takes one
|
|
953
1158
|
* quiet line at the bottom; the pods that need a human lead. Read-only.
|
|
@@ -1045,11 +1250,13 @@ async function wakeCmd(
|
|
|
1045
1250
|
err: (line: string) => void,
|
|
1046
1251
|
): Promise<number> {
|
|
1047
1252
|
const { flags, rest } = parseFlags(words, ["agent"]);
|
|
1048
|
-
// `--warm`
|
|
1253
|
+
// `--warm` and `--over-budget` carry no value, so parseFlags leaves them among
|
|
1254
|
+
// the words.
|
|
1049
1255
|
const warm = rest.includes("--warm");
|
|
1256
|
+
const overBudget = rest.includes("--over-budget");
|
|
1050
1257
|
const podId = rest.find((word) => !word.startsWith("--"));
|
|
1051
1258
|
if (!podId) {
|
|
1052
|
-
err("Usage: agents-pod wake <podId> [--agent <id>] [--warm]");
|
|
1259
|
+
err("Usage: agents-pod wake <podId> [--agent <id>] [--warm] [--over-budget]");
|
|
1053
1260
|
return 2;
|
|
1054
1261
|
}
|
|
1055
1262
|
const pods = await readPods();
|
|
@@ -1068,6 +1275,21 @@ async function wakeCmd(
|
|
|
1068
1275
|
return 0;
|
|
1069
1276
|
}
|
|
1070
1277
|
|
|
1278
|
+
// The human override lifts park and backoff (ADR 0011) — except the daily
|
|
1279
|
+
// ceiling, which exists precisely because some launcher keeps firing. Lifting
|
|
1280
|
+
// it silently made a cron-driven Pod re-park and re-announce on every pass;
|
|
1281
|
+
// one more window is still available, but it has to be asked for (ADR 0032).
|
|
1282
|
+
if (pod.policy?.parked && pod.policy.parkedBy === "budget" && !overBudget) {
|
|
1283
|
+
const budget = pod.policy.wakeBudgetPerDay ?? DEFAULT_WAKE_BUDGET_PER_DAY;
|
|
1284
|
+
const today = inWindow(pod.wakeUps, Date.now(), DAY_MS).length;
|
|
1285
|
+
err(
|
|
1286
|
+
`Pod ${pod.id} is parked over its daily budget (${today} wake-ups in 24 h, budget ${budget})`,
|
|
1287
|
+
);
|
|
1288
|
+
err(` one more window, deliberately: agents-pod wake ${pod.id} --over-budget`);
|
|
1289
|
+
err(" or spawn the work again with a ceiling that fits: --wake-budget <n>");
|
|
1290
|
+
return 2;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1071
1293
|
// Only a crash has a session worth resuming (ADR 0011): a `failed` wake-up
|
|
1072
1294
|
// ended cleanly, and a fresh window is the honest continuation for it.
|
|
1073
1295
|
const last = pod.wakeUps.at(-1);
|
|
@@ -1129,13 +1351,18 @@ async function wakeCmd(
|
|
|
1129
1351
|
if (target.policy) {
|
|
1130
1352
|
target.policy.failureStreak = 0;
|
|
1131
1353
|
delete target.policy.nextWakeAt;
|
|
1354
|
+
// A human wake is the other answer to "wake me when work arrives" (#36).
|
|
1355
|
+
delete target.policy.awaiting;
|
|
1132
1356
|
delete target.policy.parked;
|
|
1357
|
+
delete target.policy.parkedBy;
|
|
1133
1358
|
}
|
|
1134
1359
|
delete target.notifiedAt;
|
|
1135
1360
|
}
|
|
1136
|
-
|
|
1361
|
+
target.admitted = "wake";
|
|
1137
1362
|
target.state = "active";
|
|
1138
1363
|
await writePods(current);
|
|
1364
|
+
// Recorded first, launched second (ADR 0032): see wakeDuePods.
|
|
1365
|
+
launchWrapperDetached(target.id, agentId, warm);
|
|
1139
1366
|
return true;
|
|
1140
1367
|
});
|
|
1141
1368
|
if (!woke) {
|
|
@@ -1191,29 +1418,42 @@ async function cancelCmd(
|
|
|
1191
1418
|
* The one spawn path: record, workspace, then wake admission (ADR 0022). An
|
|
1192
1419
|
* admitted pod gets its detached wrapper; a blocked one waits idle. `spawn`
|
|
1193
1420
|
* and a listener's template materialization both walk it (ADR 0007).
|
|
1421
|
+
*
|
|
1422
|
+
* The owner is written into the record here (ADR 0030), always: a Pod whose
|
|
1423
|
+
* owner nobody declared is a Pod the tick will guess about.
|
|
1194
1424
|
*/
|
|
1425
|
+
export interface SpawnOptions {
|
|
1426
|
+
agentId: string;
|
|
1427
|
+
admission: Admission;
|
|
1428
|
+
model?: string;
|
|
1429
|
+
after?: string;
|
|
1430
|
+
wakeBudgetPerDay?: number;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1195
1433
|
async function spawnPodRecord(
|
|
1196
1434
|
goal: string,
|
|
1197
1435
|
project: string,
|
|
1198
|
-
|
|
1199
|
-
model?: string,
|
|
1200
|
-
after?: string,
|
|
1436
|
+
options: SpawnOptions,
|
|
1201
1437
|
registryHeld = false,
|
|
1202
1438
|
): Promise<PodRecord> {
|
|
1439
|
+
const { agentId, admission, model, after, wakeBudgetPerDay } = options;
|
|
1203
1440
|
const record: PodRecord = {
|
|
1204
1441
|
id: newPodId(),
|
|
1205
1442
|
goal,
|
|
1206
1443
|
state: "spawning",
|
|
1207
1444
|
project: resolve(project),
|
|
1445
|
+
admission,
|
|
1208
1446
|
...(after === undefined ? {} : { after }),
|
|
1209
1447
|
wakeUps: [],
|
|
1210
1448
|
// The policy names the agent and the optional model pin; `--model` beats
|
|
1211
|
-
// the definition's model, which beats the adapter default (ADR 0012).
|
|
1449
|
+
// the definition's model, which beats the adapter default (ADR 0012). The
|
|
1450
|
+
// daily ceiling rides here too (ADR 0032).
|
|
1212
1451
|
policy: {
|
|
1213
1452
|
failureStreak: 0,
|
|
1214
1453
|
appliedOutcomes: 0,
|
|
1215
1454
|
agent: agentId,
|
|
1216
1455
|
...(model === undefined ? {} : { model }),
|
|
1456
|
+
...(wakeBudgetPerDay === undefined ? {} : { wakeBudgetPerDay }),
|
|
1217
1457
|
},
|
|
1218
1458
|
};
|
|
1219
1459
|
await createPodWorkspace(record);
|
|
@@ -1235,6 +1475,7 @@ async function spawnPodRecord(
|
|
|
1235
1475
|
return record;
|
|
1236
1476
|
}
|
|
1237
1477
|
|
|
1478
|
+
record.admitted = "spawn";
|
|
1238
1479
|
await writePods(pods);
|
|
1239
1480
|
launchWrapperDetached(record.id, agentId);
|
|
1240
1481
|
record.state = "active";
|
|
@@ -1254,12 +1495,68 @@ async function spawnCmd(
|
|
|
1254
1495
|
out: (line: string) => void,
|
|
1255
1496
|
err: (line: string) => void,
|
|
1256
1497
|
): Promise<number> {
|
|
1257
|
-
const { flags, rest } = parseFlags(words, [
|
|
1258
|
-
|
|
1498
|
+
const { flags, rest, dangling } = parseFlags(words, [
|
|
1499
|
+
"agent",
|
|
1500
|
+
"model",
|
|
1501
|
+
"cwd",
|
|
1502
|
+
"project",
|
|
1503
|
+
"after",
|
|
1504
|
+
"admission",
|
|
1505
|
+
"wake-budget",
|
|
1506
|
+
]);
|
|
1507
|
+
// `--external` carries no value, so parseFlags leaves it among the words: it
|
|
1508
|
+
// is the shorthand ADR 0030 documents for `--admission external`, and it is
|
|
1509
|
+
// taken out before the remaining words compose the goal.
|
|
1510
|
+
const external = rest.includes("--external");
|
|
1511
|
+
const goal = rest.filter((word) => word !== "--external").join(" ").trim();
|
|
1512
|
+
if (dangling.length > 0) {
|
|
1513
|
+
// A flag with no value would otherwise compose the goal and leave its own
|
|
1514
|
+
// setting at the default — a typo that silently disables a guardrail is not
|
|
1515
|
+
// a guardrail (ADR 0032).
|
|
1516
|
+
err(`${dangling[0]} needs a value`);
|
|
1517
|
+
err("Usage: agents-pod spawn <goal prompt> [--agent <id>] [--model <id>] [--cwd <dir>] [--after <podId>]");
|
|
1518
|
+
err(" [--admission intake|external|self | --external] [--wake-budget <n>]");
|
|
1519
|
+
return 2;
|
|
1520
|
+
}
|
|
1259
1521
|
if (!goal) {
|
|
1260
1522
|
err("Usage: agents-pod spawn <goal prompt> [--agent <id>] [--model <id>] [--cwd <dir>] [--after <podId>]");
|
|
1523
|
+
err(" [--admission intake|external|self | --external] [--wake-budget <n>]");
|
|
1524
|
+
return 2;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
// The daily ceiling is a guardrail, so its value is validated like any other
|
|
1528
|
+
// surface flag: a typo must not silently disable it (ADR 0032).
|
|
1529
|
+
let wakeBudgetPerDay: number | undefined;
|
|
1530
|
+
if (flags["wake-budget"] !== undefined) {
|
|
1531
|
+
const parsed = Number(flags["wake-budget"]);
|
|
1532
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
1533
|
+
err(`--wake-budget must be a positive integer, got ${flags["wake-budget"]}`);
|
|
1534
|
+
return 2;
|
|
1535
|
+
}
|
|
1536
|
+
wakeBudgetPerDay = parsed;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
// Admission is decided before anything is written (ADR 0030): who may wake
|
|
1540
|
+
// this Pod is the fact the incident was missing, and a refused spawn leaves
|
|
1541
|
+
// no record, no workspace, and no wake-up behind.
|
|
1542
|
+
if (flags.admission !== undefined && !isAdmission(flags.admission)) {
|
|
1543
|
+
err(`Unknown admission ${flags.admission}: one of ${ADMISSIONS.join(" | ")}`);
|
|
1544
|
+
return 2;
|
|
1545
|
+
}
|
|
1546
|
+
if (external && flags.admission !== undefined && flags.admission !== "external") {
|
|
1547
|
+
err(`--external means --admission external; not ${flags.admission}`);
|
|
1261
1548
|
return 2;
|
|
1262
1549
|
}
|
|
1550
|
+
const declared: Admission | undefined = flags.admission ?? (external ? "external" : undefined);
|
|
1551
|
+
// A goal that names a clock under the tick's own rule is the incident: every
|
|
1552
|
+
// `more` re-arms it, so the clock fires as fast as the passes do. The clock
|
|
1553
|
+
// belongs to a schedule; an `external` or `intake` owner makes it context.
|
|
1554
|
+
const clock = clockPhrase(goal);
|
|
1555
|
+
if (clock !== undefined && declared !== "external" && declared !== "intake") {
|
|
1556
|
+
for (const line of clockRefusal(clock)) err(line);
|
|
1557
|
+
return 2;
|
|
1558
|
+
}
|
|
1559
|
+
const admission: Admission = declared ?? "self";
|
|
1263
1560
|
|
|
1264
1561
|
// Fail fast at the surface (ADR 0012): an unknown id is an exit-2
|
|
1265
1562
|
// configuration error, before any pod record or workspace exists. An
|
|
@@ -1285,13 +1582,13 @@ async function spawnCmd(
|
|
|
1285
1582
|
}
|
|
1286
1583
|
}
|
|
1287
1584
|
|
|
1288
|
-
const record = await spawnPodRecord(
|
|
1289
|
-
goal,
|
|
1290
|
-
flags.project ?? flags.cwd ?? deps.cwd ?? process.cwd(),
|
|
1585
|
+
const record = await spawnPodRecord(goal, flags.project ?? flags.cwd ?? deps.cwd ?? process.cwd(), {
|
|
1291
1586
|
agentId,
|
|
1292
|
-
|
|
1293
|
-
flags.
|
|
1294
|
-
|
|
1587
|
+
admission,
|
|
1588
|
+
...(flags.model === undefined ? {} : { model: flags.model }),
|
|
1589
|
+
...(flags.after === undefined ? {} : { after: flags.after }),
|
|
1590
|
+
...(wakeBudgetPerDay === undefined ? {} : { wakeBudgetPerDay }),
|
|
1591
|
+
});
|
|
1295
1592
|
out(record.id);
|
|
1296
1593
|
return 0;
|
|
1297
1594
|
}
|
|
@@ -1989,6 +2286,19 @@ async function listenerCmd(
|
|
|
1989
2286
|
err(`Unknown pod ${flags.pod}`);
|
|
1990
2287
|
return 2;
|
|
1991
2288
|
}
|
|
2289
|
+
// Binding is an intake declaration (ADR 0030): a Pod recorded as
|
|
2290
|
+
// `external` is woken from outside, so binding it would silently drop
|
|
2291
|
+
// that owner — and a Pod that owes a batch *and* an outside clock has
|
|
2292
|
+
// two owners, which is zero owners when both fire.
|
|
2293
|
+
if (pod.admission === "external") {
|
|
2294
|
+
err(
|
|
2295
|
+
`Pod ${pod.id} is admitted from outside (--admission external): a listener cannot bind it`,
|
|
2296
|
+
);
|
|
2297
|
+
err(
|
|
2298
|
+
" bind a Pod spawned with --admission intake, or spawn a new one for this inbox",
|
|
2299
|
+
);
|
|
2300
|
+
return 2;
|
|
2301
|
+
}
|
|
1992
2302
|
listener.mode = "pod";
|
|
1993
2303
|
listener.podId = pod.id;
|
|
1994
2304
|
}
|
|
@@ -2025,10 +2335,28 @@ async function listenerCmd(
|
|
|
2025
2335
|
}
|
|
2026
2336
|
const removed = await withRegistryLock(async () => {
|
|
2027
2337
|
const listeners = await readListeners();
|
|
2338
|
+
const gone = listeners.find((listener) => listener.id === id);
|
|
2028
2339
|
const remaining = listeners.filter((listener) => listener.id !== id);
|
|
2029
2340
|
if (remaining.length === listeners.length) return false;
|
|
2030
2341
|
// Claims go with the record: re-adding is a fresh claim namespace (ADR 0013).
|
|
2031
2342
|
await writeListeners(remaining);
|
|
2343
|
+
// A `quiet` window's promise is made to a Listener ("wake me when work
|
|
2344
|
+
// arrives"), so removing that Listener removes the claimant: the Pod
|
|
2345
|
+
// falls back to the tick, which would otherwise skip it forever and say
|
|
2346
|
+
// nothing (ADR 0011 amendment, #36).
|
|
2347
|
+
const stillBound = new Set(
|
|
2348
|
+
remaining.flatMap((listener) =>
|
|
2349
|
+
listener.mode === "pod" && listener.podId !== undefined ? [listener.podId] : [],
|
|
2350
|
+
),
|
|
2351
|
+
);
|
|
2352
|
+
if (gone?.mode === "pod" && gone.podId !== undefined && !stillBound.has(gone.podId)) {
|
|
2353
|
+
const pods = await readPods();
|
|
2354
|
+
const pod = pods.find((candidate) => candidate.id === gone.podId);
|
|
2355
|
+
if (pod?.policy?.awaiting) {
|
|
2356
|
+
delete pod.policy.awaiting;
|
|
2357
|
+
await writePods(pods);
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2032
2360
|
return true;
|
|
2033
2361
|
});
|
|
2034
2362
|
if (removed === undefined) {
|