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/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 type { PodRecord } from "./registry.ts";
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 goal words (README's CLI surface), and
101
- * the words that are not flags compose the goal. A flag without a value is
102
- * left unset and surfaces through the command's own validation.
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({ n, outcome: "lost", detail: lostDetail(facts) });
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
- if (wake.outcome === "failed" || wake.outcome === "lost") {
444
- pod.policy.failureStreak += 1;
445
- pod.policy.nextWakeAt = now + backoffMs(pod.policy.failureStreak);
446
- // Backoff without a stop is a money pit: streak 3 parks the pod, and
447
- // only a human wake lifts it (ADR 0011). Park is policy data, not a state.
448
- if (pod.policy.failureStreak >= 3 && !pod.policy.parked) {
449
- pod.policy.parked = true;
450
- announcePark();
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
- if (intakeBound.has(pod.id) && !pod.policy?.intakePending) continue;
544
- if (pod.policy?.nextWakeAt !== undefined && pod.policy.nextWakeAt > now) continue;
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
- launchWrapperDetached(pod.id, pod.policy?.agent ?? "default");
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
- out(` state: ${pod.state}`);
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
- out(` parked (consecutive failures: ${pod.policy.failureStreak})`);
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` carries no value, so parseFlags leaves it among the words.
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
- launchWrapperDetached(target.id, agentId, warm);
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
- agentId: string,
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, ["agent", "model", "cwd", "project", "after"]);
1258
- const goal = rest.join(" ").trim();
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
- flags.model,
1293
- flags.after,
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) {