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/src/doctor.ts CHANGED
@@ -14,7 +14,9 @@ import {
14
14
  reconcileLockPath,
15
15
  storeRoot,
16
16
  } from "./paths.ts";
17
- import { readPods, type PodRecord } from "./registry.ts";
17
+ import { admissionOf, clockPhrase, openEnded } from "./admission.ts";
18
+ import { HOUR_MS, duration, inWindow, medianIntervalMs } from "./cadence.ts";
19
+ import { readPods, type Admission, type PodRecord } from "./registry.ts";
18
20
  import { readSources, type SourceRecord } from "./sources.ts";
19
21
 
20
22
  /**
@@ -63,6 +65,8 @@ const PROBE_OUTPUT_CAP = 8 * 1024;
63
65
  const PROBE_LINES = 6;
64
66
  /** A pass lock older than this is what the next pass steals (ADR 0018). */
65
67
  const LOCK_STALE_MS = 5 * 60_000;
68
+ /** A Pod above this many wake-ups in an hour is worth a line (ADR 0032). */
69
+ const WAKE_RATE_CEILING_PER_HOUR = 6;
66
70
 
67
71
  export async function doctor(options: DoctorOptions = {}): Promise<DoctorReport> {
68
72
  const now = options.now ?? Date.now();
@@ -77,13 +81,24 @@ export async function doctor(options: DoctorOptions = {}): Promise<DoctorReport>
77
81
  const notify = await loadNotifyConfig(undefined, (message) => warnings.push(message));
78
82
  const lastPass = await readLastPass();
79
83
 
84
+ // The first run is where a defaulted owner is most likely (nothing else is
85
+ // configured yet), so admission is reported before the recipe can swallow it.
86
+ const sections: DoctorSection[] = [];
87
+ if (registry.error === undefined && registry.pods.length > 0) {
88
+ sections.push({ title: "wake", lines: wakeLines(registry.pods, listeners, now) });
89
+ }
90
+
80
91
  const firstRun =
81
92
  sources.length === 0 && deliverer === undefined && notify.channels.length === 0;
82
93
  if (firstRun) {
83
- return { store: root, sections: [], recipe: recipeFor(sources, listeners, deliverer, notify, lastPass), healthy: false };
94
+ return {
95
+ store: root,
96
+ sections,
97
+ recipe: recipeFor(sources, listeners, deliverer, notify, lastPass),
98
+ healthy: false,
99
+ };
84
100
  }
85
101
 
86
- const sections: DoctorSection[] = [];
87
102
  const storeLines: DoctorLine[] = [];
88
103
  if (registry.error !== undefined) {
89
104
  storeLines.push({ mark: "bad", text: `pods.json is unreadable: ${registry.error}` });
@@ -204,6 +219,120 @@ async function passLines(
204
219
  return [{ mark: "ok", text: `last pass ${age(now - lastPass)} ago` }];
205
220
  }
206
221
 
222
+ /**
223
+ * Admission, and the two shapes that cost money (ADR 0030, issue #31): a Pod on
224
+ * the tick whose goal names a clock is re-armed after every `more`, and a Pod on
225
+ * the tick whose goal is open-ended is woken with nothing new to work on. Both
226
+ * are legal records and broken intent, so they are named here rather than
227
+ * rewritten: the fix is an owner, and only the operator knows which one.
228
+ */
229
+ function wakeLines(pods: PodRecord[], listeners: ListenerRecord[], now: number): DoctorLine[] {
230
+ const bound = new Set(
231
+ listeners.flatMap((listener) =>
232
+ listener.mode === "pod" && listener.podId !== undefined ? [listener.podId] : [],
233
+ ),
234
+ );
235
+ const counts: Record<Admission, number> = { intake: 0, external: 0, self: 0 };
236
+ const findings: DoctorLine[] = [];
237
+ for (const pod of pods) {
238
+ if (pod.state === "done" || pod.state === "cancelled") continue;
239
+ const owner = admissionOf(pod, bound);
240
+ counts[owner] += 1;
241
+ // The mirror of `external` (ADR 0030): a Pod owned by intake with no
242
+ // Listener bound to it can never be given work — nothing writes the debt.
243
+ if (owner === "intake" && !bound.has(pod.id)) {
244
+ findings.push({
245
+ mark: "bad",
246
+ text: `pod ${pod.id}: admission intake but no listener is bound to it — nothing will ever give it work`,
247
+ detail: [`next: agents-pod listener add --inbox <dir> --pod ${pod.id}`],
248
+ });
249
+ continue;
250
+ }
251
+ if (owner !== "self") continue;
252
+ // A Pod that declared itself satisfied and scheduled nothing is not a Pod
253
+ // the tick loops on (ADR 0011 amendment, #36): `awaiting` is the honest
254
+ // shape of a standing goal, and naming it as a problem would teach exactly
255
+ // the wrong lesson. But a satisfied Pod with nothing that can deliver work
256
+ // has stopped for good — "wake me when work arrives" with no arrival path
257
+ // is the one silence this report exists to break.
258
+ if (pod.policy?.awaiting) {
259
+ if (!bound.has(pod.id)) {
260
+ findings.push({
261
+ mark: "note",
262
+ text: `pod ${pod.id}: awaiting — its last window was satisfied and nothing can deliver work to it`,
263
+ detail: [
264
+ ` bind an inbox: agents-pod listener add --inbox <dir> --pod ${pod.id}`,
265
+ ` or wake it by hand: agents-pod wake ${pod.id}`,
266
+ ],
267
+ });
268
+ }
269
+ continue;
270
+ }
271
+ const clock = clockPhrase(pod.goal);
272
+ if (clock !== undefined) {
273
+ findings.push({
274
+ mark: "bad",
275
+ text: `pod ${pod.id}: its goal names a clock ("${clock}") and the tick admits it — every handoff re-arms it at the speed of the passes`,
276
+ detail: [
277
+ `next: spawn the work again with --admission external (an outside clock calls \`agents-pod wake\`)`,
278
+ " or move the clock to schedule_task and let the schedule hand the work over",
279
+ ],
280
+ });
281
+ continue;
282
+ }
283
+ if (openEnded(pod.goal)) {
284
+ findings.push({
285
+ mark: "bad",
286
+ text: `pod ${pod.id}: admission self with an open-ended goal — the tick admits it after every handoff, with nothing new to work on`,
287
+ detail: [
288
+ ` if a listener feeds it: agents-pod listener add --inbox <dir> --pod ${pod.id}`,
289
+ " if an outside clock wakes it: a Pod spawned with --admission external (ADR 0030)",
290
+ ],
291
+ });
292
+ }
293
+ }
294
+ // The rate line (ADR 0032): a Pod the tick re-arms shows up as a rhythm, not
295
+ // as a single failure, and the storm was 41 wake-ups in five hours with no
296
+ // line anywhere that said so.
297
+ for (const pod of pods) {
298
+ if (pod.state === "done" || pod.state === "cancelled") continue;
299
+ const hour = inWindow(pod.wakeUps, now, HOUR_MS).length;
300
+ if (hour <= WAKE_RATE_CEILING_PER_HOUR) continue;
301
+ const median = medianIntervalMs(pod.wakeUps);
302
+ const rhythm = median === undefined ? "" : `, median interval ${duration(median)}`;
303
+ const clock = clockPhrase(pod.goal);
304
+ const owner = admissionOf(pod, bound);
305
+ findings.push({
306
+ mark: "note",
307
+ text: `pod ${pod.id}: ${hour} wake-ups in the last hour${rhythm} — above the ${WAKE_RATE_CEILING_PER_HOUR}/h ceiling`,
308
+ detail:
309
+ clock === undefined
310
+ ? [
311
+ ` the goal names no clock: check who admits it (agents-pod status ${pod.id})`,
312
+ " and its daily budget (agents-pod spawn --wake-budget, ADR 0032)",
313
+ ]
314
+ : owner === "self"
315
+ ? [
316
+ ` its goal names a clock ("${clock}") and the tick admits it — that is the #31 shape`,
317
+ " next: agents-pod spawn … --admission external, or move the clock to schedule_task",
318
+ ]
319
+ : [
320
+ ` its goal names a clock ("${clock}") and ${owner} admits it — the rhythm is that clock's`,
321
+ ` next: check the schedule that calls agents-pod wake ${pod.id}`,
322
+ ],
323
+ });
324
+ }
325
+
326
+ const census = `${counts.intake} intake, ${counts.external} external, ${counts.self} self`;
327
+ return [
328
+ {
329
+ mark: findings.length > 0 ? "note" : "ok",
330
+ text: `admission by owner: ${census}`,
331
+ },
332
+ ...findings,
333
+ ];
334
+ }
335
+
207
336
  async function intakeLines(
208
337
  sources: SourceRecord[],
209
338
  listeners: ListenerRecord[],
@@ -268,6 +397,19 @@ async function intakeLines(
268
397
  }
269
398
  lines.push({ mark: "ok", text: `source ${source.name}: ${source.cmd}` });
270
399
  lines.push({ mark: "ok", text: ` ${drained}, ${cursor}` });
400
+ // Routing leaves items behind on purpose (ADR 0034), so the count is a
401
+ // question and not a failure: a group nobody listens to is legitimate, a
402
+ // group nobody wanted is what the operator has to see.
403
+ if ((source.lastDrainUnrouted ?? 0) > 0) {
404
+ lines.push({
405
+ mark: "note",
406
+ text: `source ${source.name}: ${source.lastDrainUnrouted} item(s) of the last drain matched no listener — unrouted`,
407
+ detail: [
408
+ ` the cursor passed them; a route added now applies to what arrives next`,
409
+ `next: agents-pod listener add --source ${source.name} --chat <chat_id> --template <file> [--project <dir>]`,
410
+ ],
411
+ });
412
+ }
271
413
  }
272
414
  if (sources.length === 0) {
273
415
  lines.push({
@@ -1,13 +1,18 @@
1
1
  /**
2
2
  * The Handoff document, rendered once and written once (ADR 0008, 0010).
3
3
  *
4
- * The only machine surface is the first line — `status: rotating` or
5
- * `status: done` — because outcome classification greps it and the wrapper's
6
- * `freshDeclaration()` refuses anything else. Everything after it is free
4
+ * The only machine surface is the first line — `status: rotating`, `status:
5
+ * quiet`, or `status: done` — because outcome classification greps it and the
6
+ * wrapper's `freshDeclaration()` refuses anything else. Everything after it is free
7
7
  * markdown for the successor, so the sections are the ones CONTEXT.md names:
8
8
  * goal restated, decisions and reasons, done, next, pointers.
9
9
  */
10
- export type HandoffStatus = "rotating" | "done";
10
+ /**
11
+ * `rotating` — the goal is open and the next wake-up is the work; `quiet` — the
12
+ * window is satisfied, nothing is scheduled, wake me when work arrives (#36);
13
+ * `done` — the goal is reached and the Pod ends.
14
+ */
15
+ export type HandoffStatus = "rotating" | "quiet" | "done";
11
16
 
12
17
  export interface HandoffInput {
13
18
  status: HandoffStatus;
@@ -44,12 +49,18 @@ export function renderHandoff(input: HandoffInput, now: Date = new Date()): stri
44
49
  lines.push(...section("## Decisions and reasons", input.decisions));
45
50
  lines.push(...section("## Done", input.done));
46
51
  // Next is the one section a successor cannot live without, so it is always
47
- // present — an empty one says so instead of disappearing.
52
+ // present — an empty one says so instead of disappearing, and the two legal
53
+ // "nothing to do" answers say why they are empty, because the difference
54
+ // between them is the difference between an ended goal and a waiting one.
48
55
  lines.push(
49
56
  ...section(
50
57
  "## Next",
51
58
  input.next,
52
- input.status === "done" ? "(nothing — the goal is reached)" : "(unstated)",
59
+ input.status === "done"
60
+ ? "(nothing — the goal is reached)"
61
+ : input.status === "quiet"
62
+ ? "(nothing scheduled — the next window starts when work arrives)"
63
+ : "(unstated)",
53
64
  ),
54
65
  );
55
66
  lines.push(...section("## Pointers", input.pointers));
@@ -49,6 +49,7 @@ const POD_HELP = [
49
49
  "/pod spawn <goal…> spawn a pod in this project",
50
50
  "/pod wake <podId> wake an idle pod now",
51
51
  "/pod cancel <podId> cancel a live wake-up",
52
+ "/pod rm <podId> discard a pod that no wake-up owns, with its workspace",
52
53
  "/pod agents named agents",
53
54
  "/pod listeners listeners and their claims",
54
55
  ].join("\n");
@@ -67,7 +68,7 @@ export function registerManagementCommands(pi: ExtensionAPI): void {
67
68
  ctx.ui.notify(POD_HELP, "info");
68
69
  return;
69
70
  }
70
- const needsPodId = ["status", "logs", "wake", "cancel", "spawn"].includes(action);
71
+ const needsPodId = ["status", "logs", "wake", "cancel", "rm", "spawn"].includes(action);
71
72
  if (needsPodId && rest.length === 0) {
72
73
  ctx.ui.notify(POD_HELP, "info");
73
74
  return;
@@ -93,17 +94,26 @@ export function registerManagementCommands(pi: ExtensionAPI): void {
93
94
  * pod, and the guardrails that make fan-out safe — the per-project cap and the
94
95
  * `--after` dependency — are the same ones the CLI enforces, because it is the
95
96
  * CLI doing the enforcing.
97
+ *
98
+ * The model-facing text is also where a hybrid ask is decomposed (ADR 0033):
99
+ * trigger × work give three shapes, the middle one is a schedule that hands over
100
+ * to a pod, and the rewrite is stated in one line — naming both units and the
101
+ * admission owner — before the first tool call, because the tie-break it
102
+ * replaces is what put a clock inside a pod's goal (issue #31).
96
103
  */
97
104
  export function registerManagementTools(pi: ExtensionAPI): void {
98
105
  pi.registerTool({
99
106
  name: "spawn_pod",
100
107
  label: "Spawn Pod",
101
108
  description:
102
- "Spawn a pod: a standing unit that outlives this session and wakes fresh agents inside it, one context window per wake-up, for as long as its goal stays open. A pod does not finish on its own — cancel it, or retire the goal, to end it: a router, a watcher, an inbox server, a multi-day refactor are all one pod. A pod is fed from a chat or an inbox (a source, e.g. feishu, plus a listener bound to it), sleeps between batches, and routes work to other pods by writing inbox items — a pod never spawns pods. A pod has NO CLOCK: it never runs at 9:00 or every 30 minutes, it wakes when it has work. Returns the pod id.",
109
+ "Spawn a pod: a standing unit that outlives this session and wakes fresh agents inside it, one context window per wake-up, for as long as its goal stays open. A pod does not finish on its own — cancel it, or retire the goal, to end it: a router, a watcher, an inbox server, a multi-day refactor are all one pod. A pod is fed from a chat or an inbox (a source, e.g. feishu, plus a listener bound to it), sleeps between batches, and routes work to other pods by writing inbox items — a pod never spawns pods. A pod has NO CLOCK: a time rule belongs to schedule_task (agents-cron), so an ask that has both a clock and memory across firings is a schedule that hands the work over to a pod, never a pod that reads the clock. Returns the pod id.",
103
110
  promptSnippet: "Spawn a standing pod for work that outlives this session",
104
111
  promptGuidelines: [
105
- "Decide by the signal. A TIME rule — 每天, 每小时, 每 30 分钟, 到点, 明天提醒我 — is a schedule: use schedule_task / agents-cron, which owns when work starts. A MESSAGE or a ROLE that must stay alive — 常驻, 一直跑, 监听, 收消息, 路由, 服务某个 inbox, 直到我说停, one job spanning days — is a pod: call spawn_pod as your FIRST tool call, without surveying the repo or the plugin first.",
106
- "Never put a clock in a pod's goal. If the ask needs 09:00, the schedule owns the time and a pod owns the work it hands over; a pod is woken by intake, never by the clock.",
112
+ "Read two axes before you call anything, because one ask can carry both signals, and a trigger alone does not decide — `time` without memory is a schedule (agents-cron / `schedule_task`), `message` or `role alive` is a pod (`spawn_pod`), and only the work axis separates the hybrid. trigger: `time` — 每天, 每小时, 每 30 分钟, 到点, 明天提醒我, 09:00; `message` — 收消息, 服务某个 inbox, 有活就干; `role alive` — 常驻, 监听, 路由, 直到我说停, or one job that spans days. work: `stateless` — one firing produces one answer and keeps nothing; `memory` — it needs what an earlier firing learned (跨天对比, 累计, 记得上次, 一份持续更新的报告).",
113
+ "Three shapes fall out of those two axes, and there is no fourth. (1) time + stateless → `schedule_task` alone: agents-cron owns the clock and the work, and a pod here is the incident. (2) time + memory → `schedule_task` owns the clock and hands the work over, `spawn_pod` owns it: bind the pod to the inbox the task appends to (`agents-pod listener add --inbox <file> --pod <podId>`) and give it `admission: intake`, or — when no batch has to be queued — let the task call `agents-pod wake <podId>` with `admission: external`. Spawn it with `dormant: true`: the record exists and nothing runs, so the clock owns every firing including the first, and the handover's first batch or `wake` is wake-up 1. Never a pod alone, never `self`, and never a spawn wake-up the clock did not order. (3) message / role alive → one pod via `spawn_pod`, `intake` when a listener or an inbox feeds it and `self` only when the goal reaches done, with no clock anywhere in its goal — call it as your FIRST tool call, without surveying the repo or the plugin first.",
114
+ "Say the rewrite in one line before the first tool call, naming every unit the ask actually has — and, whenever a pod is involved, its admission owner: 「这句我拆成:09:00 由 schedule_task 写 ci-daily.jsonl → pod X 被 intake 唤醒;pod X 负责汇总与跨天对比」. A schedule-only ask names no pod. If your line comes out as a pod plus a hand-written `crontab wake`, as a clock inside the pod's goal, or as a pod on `self`, that is the illegal combination (#31): say so and fix the shape instead of proceeding.",
115
+ "Never put a clock in a pod's goal: the phrases the guard recognises — 09:00, 每天, 每 30 分钟, 9 点, every 30 minutes, daily, 到点 — are refused at spawn when the pod's owner is the tick, and a pod re-armed on every pass is the storm. The clock belongs to the schedule; a pod is woken by its admission owner — a listener's batch, an outside wake, or the tick — never by a time rule written inside the goal.",
116
+ "Say who admits the pod's wake-ups (`admission`), because the tick is only one of three owners and it is the wrong one for a standing pod: `intake` when a listener or inbox feeds it (the router/watcher/inbox-server shape), `external` when an outside clock or a human wakes it with `agents-pod wake`, `self` only for a bounded goal the tick may re-wake until done. Left unstated it defaults to `self`, which wakes a standing pod every tick — the incident #31 exists to prevent.",
107
117
  "A pod is the right shape for a role, not only for a big job: a router that judges incoming messages, a watcher, an inbox server.",
108
118
  "A pod's agents share documents, not this conversation: put everything the successor must know into the goal text.",
109
119
  "spawn_pod returns a pod id; report that id back to the user so they can run /pod status on it. The pod owns the work from there — do not start doing its work in this session.",
@@ -122,6 +132,18 @@ export function registerManagementTools(pi: ExtensionAPI): void {
122
132
  cwd: Type.Optional(
123
133
  Type.String({ description: "Working directory for wake-ups, relative to this project." }),
124
134
  ),
135
+ admission: Type.Optional(
136
+ Type.Union([Type.Literal("intake"), Type.Literal("external"), Type.Literal("self")], {
137
+ description:
138
+ "Who admits this pod's wake-ups (ADR 0030): `intake` — a listener feeds it (the standing/inbox shape); `external` — an outside clock or a human calls `agents-pod wake`; `self` — the tick, legal only for a bounded goal that reaches done. Default is `self`; a standing pod left there is woken on every tick.",
139
+ }),
140
+ ),
141
+ dormant: Type.Optional(
142
+ Type.Boolean({
143
+ description:
144
+ "Write the pod and launch nothing, so its first wake-up belongs to its owner (ADR 0033 amendment, #43). Required for the hybrid shape: a schedule owns every firing including the first one. Needs `admission: intake` or `external` — with `self` the tick would wake it on its next pass, so the flag is refused there.",
145
+ }),
146
+ ),
125
147
  }),
126
148
  async execute(_toolCallId, params, _signal, _onUpdate, ctx: ExtensionContext) {
127
149
  const argv = ["spawn", params.goal, "--project", ctx.cwd];
@@ -129,6 +151,8 @@ export function registerManagementTools(pi: ExtensionAPI): void {
129
151
  if (params.model !== undefined) argv.push("--model", params.model);
130
152
  if (params.after !== undefined) argv.push("--after", params.after);
131
153
  if (params.cwd !== undefined) argv.push("--cwd", params.cwd);
154
+ if (params.admission !== undefined) argv.push("--admission", params.admission);
155
+ if (params.dormant === true) argv.push("--dormant");
132
156
 
133
157
  const outcome = await runCli(argv, ctx.cwd);
134
158
  const podId = outcome.lines.join("\n").trim();
@@ -141,10 +165,13 @@ export function registerManagementTools(pi: ExtensionAPI): void {
141
165
  content: [
142
166
  {
143
167
  type: "text" as const,
144
- text: `Spawned pod ${podId}. Wake-ups run detached; /pod status ${podId} shows progress.`,
168
+ text:
169
+ params.dormant === true
170
+ ? `Spawned pod ${podId} dormant: the record exists and no wake-up has run, so its first window belongs to its owner. The first handover starts it — an inbox item for \`intake\`, or \`agents-pod wake ${podId}\` for \`external\`. /pod status ${podId} shows it as "dormant: no wake-up yet".`
171
+ : `Spawned pod ${podId}. Wake-ups run detached; /pod status ${podId} shows progress.`,
145
172
  },
146
173
  ],
147
- details: { podId },
174
+ details: { podId, ...(params.dormant === true ? { dormant: true } : {}) },
148
175
  };
149
176
  },
150
177
  });
@@ -10,9 +10,9 @@ import { renderHandoff, type HandoffStatus } from "./handoff.ts";
10
10
  export const DOCS_DIR_ENV = "AGENTS_POD_DOCS_DIR";
11
11
 
12
12
  const handoffSchema = Type.Object({
13
- status: StringEnum(["rotating", "done"] as const, {
13
+ status: StringEnum(["rotating", "quiet", "done"] as const, {
14
14
  description:
15
- "rotating: the goal is open, a successor continues. done: the goal is reached and the pod ends.",
15
+ "rotating: the goal is open and the next wake-up is the work. quiet: this window is satisfied, nothing is scheduled — wake me when work arrives (the honest ending for a standing goal). done: the goal is reached and the pod ends.",
16
16
  }),
17
17
  goal: Type.Optional(Type.String({ description: "The goal as this wake-up understood it." })),
18
18
  decisions: Type.Optional(
@@ -47,7 +47,7 @@ export function registerSelf(pi: ExtensionAPI): void {
47
47
  promptSnippet: "Write the pod handoff and end this wake-up",
48
48
  promptGuidelines: [
49
49
  "Call pod_handoff as the last action of a wake-up, after the work is recorded in the documents.",
50
- "Use pod_handoff with status done only when the pod's goal is reached; rotating means the goal is open and the next wake-up continues from the handoff.",
50
+ "Use pod_handoff with status done only when the pod's goal is reached; rotating means the goal is open and the next wake-up continues from the handoff; quiet means this window is satisfied and nothing is scheduled — the honest ending for a standing goal, which must not say rotating to stay alive.",
51
51
  "An exit without a fresh pod_handoff is classified as failed — pod_handoff is the clean exit, not a summary you may skip.",
52
52
  ],
53
53
  parameters: handoffSchema,
@@ -59,9 +59,12 @@ export function registerSelf(pi: ExtensionAPI): void {
59
59
  throw new Error(`pod_handoff requires ${DOCS_DIR_ENV}: this session is not a pod wake-up`);
60
60
  }
61
61
  const file = join(docsDir, "HANDOFF.md");
62
- // The schema admits exactly these two words; narrowing here keeps the
62
+ // The schema admits exactly these three words; narrowing here keeps the
63
63
  // hard line legal even if a provider reorders the enum.
64
- const status: HandoffStatus = params.status === "done" ? "done" : "rotating";
64
+ const status: HandoffStatus =
65
+ params.status === "done" || params.status === "quiet"
66
+ ? (params.status as HandoffStatus)
67
+ : "rotating";
65
68
  const body = renderHandoff({ ...params, status });
66
69
  // The agent may be editing HANDOFF.md in the same turn: one queue per file.
67
70
  await withFileMutationQueue(file, () => writeFile(file, body));
package/src/listeners.ts CHANGED
@@ -17,13 +17,19 @@ export type ListenerMode = "template" | "pod";
17
17
  * `blocked` is a reason materialization will not happen
18
18
  * (a configuration error, or an item that vanished). `appended` is ADR 0014's
19
19
  * one boolean for a bound standing pod: the block is in the pod's `## Inbox`.
20
- * Beyond that the document is the truth — there is no per-item lifecycle.
20
+ * The item copy lets a Source claim outlive the drain that produced it (ADR
21
+ * 0034). Beyond that the document is the truth — there is no per-item lifecycle.
21
22
  */
22
23
  export interface ClaimRecord {
23
24
  inbox: string;
24
25
  item: string;
25
- /** Source claims retain the current and previous successful drain (ADR 0024). */
26
+ /** The successful drain that made the claim; re-emission, not age, is its horizon (ADR 0034). */
26
27
  claimedAt?: number;
28
+ title?: string;
29
+ body?: string;
30
+ chatId?: string;
31
+ rootId?: string;
32
+ threadId?: string;
27
33
  spawnedPodId?: string;
28
34
  blocked?: string;
29
35
  appended?: boolean;
@@ -43,6 +49,12 @@ export interface ListenerRecord {
43
49
  source?: string;
44
50
  /** P0's rule: a plain substring; absent means every item matches. */
45
51
  match?: string;
52
+ /**
53
+ * Routing filter (ADR 0034): the groups this Listener claims, by the item's
54
+ * `chat_id`. Absent means every group. A filter is ANDed with `match`, and the
55
+ * first Listener in registry order that holds claims the item.
56
+ */
57
+ chats?: string[];
46
58
  mode: ListenerMode;
47
59
  /** Template mode: the goal template file. */
48
60
  template?: string;
package/src/registry.ts CHANGED
@@ -10,12 +10,39 @@ import { podsFile, registryLockPath } from "./paths.ts";
10
10
  */
11
11
  export type PodState = "spawning" | "active" | "idle" | "done" | "cancelled";
12
12
 
13
- export type WakeUpOutcome = "more" | "done" | "failed" | "lost" | "cancelled";
13
+ /**
14
+ * `more` — the goal needs another wake-up now; `quiet` — this window is
15
+ * satisfied and nothing is scheduled (ADR 0011 amendment, #36); `done` — the
16
+ * goal is reached; the rest are failures of the window, not of the Pod.
17
+ */
18
+ export type WakeUpOutcome = "more" | "quiet" | "done" | "failed" | "lost" | "cancelled";
19
+
20
+ /**
21
+ * Who may wake this Pod (ADR 0030): `intake` — the batch a Listener owes;
22
+ * `external` — an outside clock or a human (`wake`); `self` — the tick. Absent
23
+ * on a record written before ADR 0030, where the listener binding was the only
24
+ * thing that could say it (src/admission.ts derives that answer, never writes it).
25
+ */
26
+ export type Admission = "intake" | "external" | "self";
27
+
28
+ /**
29
+ * What admitted a wake-up (ADR 0032). Three launchers existed and were
30
+ * indistinguishable after the fact; a storm of 41 wake-ups could not be
31
+ * attributed to any of them from the record.
32
+ */
33
+ export type WakeTrigger = "spawn" | "tick" | "wake";
34
+
35
+ /** The default daily ceiling (ADR 0032): the storm ran at 41 in five hours. */
36
+ export const DEFAULT_WAKE_BUDGET_PER_DAY = 24;
14
37
 
15
38
  export interface WakeUpEntry {
16
39
  n: number;
17
40
  outcome: WakeUpOutcome;
18
41
  detail?: string;
42
+ /** When the outcome was recorded, epoch ms. Absent on records written before ADR 0032. */
43
+ at?: number;
44
+ /** What admitted this wake-up; absent on records written before ADR 0032. */
45
+ trigger?: WakeTrigger;
19
46
  }
20
47
 
21
48
  export interface PodRecord {
@@ -26,6 +53,15 @@ export interface PodRecord {
26
53
  project: string;
27
54
  /** The upstream Pod whose done state admits this Pod's first wake-up (ADR 0022). */
28
55
  after?: string;
56
+ /** Decided at spawn, one of three (ADR 0030); the tick reads it before it launches. */
57
+ admission?: Admission;
58
+ /**
59
+ * What admitted the wake-up that is live now (ADR 0032). Written by whichever
60
+ * launcher ran under the registry lock, read when the outcome is recorded, so
61
+ * every entry can say who woke it — including the ones reconcile records on a
62
+ * wrapper's behalf (`lost`, wedged, progressless).
63
+ */
64
+ admitted?: WakeTrigger;
29
65
  wakeUps: WakeUpEntry[];
30
66
  policy?: WakePolicy;
31
67
  /** When a pass first observed a wedged wrapper (ADR 0017's two-pass rule). */
@@ -49,6 +85,13 @@ export interface WakePolicy {
49
85
  failureStreak: number;
50
86
  nextWakeAt?: number;
51
87
  parked?: boolean;
88
+ /**
89
+ * Which stop parked it (ADR 0032): three different facts that read
90
+ * identically as `parked`. Only `failures` is lifted by an ordinary `wake`;
91
+ * a `budget` stop needs `wake --over-budget`, and an `agent` stop needs the
92
+ * definition back. Absent on records written before ADR 0032.
93
+ */
94
+ parkedBy?: "failures" | "budget" | "agent";
52
95
  /** How many wake-up outcomes the policy has already taken into account. */
53
96
  appliedOutcomes: number;
54
97
  /**
@@ -59,10 +102,23 @@ export interface WakePolicy {
59
102
  * wake — the tick does not (ADR 0011 stays the rule for unbound pods).
60
103
  */
61
104
  intakePending?: number;
105
+ /**
106
+ * The window declared itself satisfied with nothing scheduled (`quiet`). The
107
+ * tick stops admitting it; a new intake batch or a human `wake` lifts it
108
+ * (ADR 0011 amendment, #36). Redundant for a Pod nobody else wakes, and
109
+ * visible in `status` where a standing goal used to be forced into `more`.
110
+ */
111
+ awaiting?: boolean;
62
112
  /** The agent every wake-up resolves to unless one wake names another (ADR 0012). */
63
113
  agent?: string;
64
114
  /** The pod's model pin: it beats the definition's model (ADR 0012). */
65
115
  model?: string;
116
+ /**
117
+ * The daily ceiling on wake-ups (ADR 0032), from `spawn --wake-budget`;
118
+ * `DEFAULT_WAKE_BUDGET_PER_DAY` when unset. Exceeding it parks the Pod, which
119
+ * is the one notification path that already exists (ADR 0021).
120
+ */
121
+ wakeBudgetPerDay?: number;
66
122
  }
67
123
 
68
124
  export async function readPods(): Promise<PodRecord[]> {
package/src/sources.ts CHANGED
@@ -3,7 +3,7 @@ import { randomBytes } from "node:crypto";
3
3
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
4
4
  import { dirname } from "node:path";
5
5
  import type { BadInboxLine, InboxItem } from "./inbox.ts";
6
- import type { ListenerRecord } from "./listeners.ts";
6
+ import type { ClaimRecord, ListenerRecord } from "./listeners.ts";
7
7
  import { sourcesFile } from "./paths.ts";
8
8
 
9
9
  /** A Source outlives its Listeners; its opaque cursor lives beside them (ADR 0024). */
@@ -12,13 +12,19 @@ export interface SourceRecord {
12
12
  cmd: string;
13
13
  timeoutMs: number;
14
14
  cursor?: string;
15
- /** The previous successful drain is the claim-retention horizon (ADR 0024). */
15
+ /** The last successful drain's timestamp; the cursor, not its age, bounds claims (ADR 0034). */
16
16
  lastDrainAt?: number;
17
17
  /**
18
18
  * That drain's yield (ADR 0027): zero is a fact, not a gap — a pull that runs
19
19
  * and delivers nothing is the shape of a conversation that never reaches it.
20
20
  */
21
21
  lastDrainItems?: number;
22
+ /**
23
+ * How many of that drain's items no Listener claimed (ADR 0034). A group nobody
24
+ * listens to is legitimate; a group being drained into nothing is the number an
25
+ * operator needs, and `doctor` prints it as a question rather than a failure.
26
+ */
27
+ lastDrainUnrouted?: number;
22
28
  /** A failed drain's fact (ADR 0027): a report cannot say `ok` while every pass fails. */
23
29
  lastFailureAt?: number;
24
30
  lastError?: string;
@@ -29,9 +35,10 @@ export interface SourceDrain {
29
35
  items: InboxItem[];
30
36
  bad: BadInboxLine[];
31
37
  cursor?: string;
32
- /** Successful drains carry their generation and the preceding one's boundary. */
38
+ /** A successful drain's timestamp, retained for its Source and claim records. */
33
39
  drainedAt?: number;
34
- previousDrainAt?: number;
40
+ /** Items this drain delivered that no Listener claimed (ADR 0034). */
41
+ unrouted?: number;
35
42
  /** A failed drain contributes nothing and leaves the cursor untouched. */
36
43
  error?: string;
37
44
  }
@@ -202,13 +209,12 @@ async function drainOne(source: SourceRecord, project: string): Promise<SourceDr
202
209
  });
203
210
  });
204
211
  if (result.error !== undefined) return { ...empty, error: result.error };
205
- // Distinct generations need distinct boundaries even when two local drains
206
- // finish within one wall-clock millisecond.
212
+ // Distinct drains need distinct timestamps even when two local drains finish
213
+ // within one wall-clock millisecond.
207
214
  const drainedAt = Math.max(Date.now(), (source.lastDrainAt ?? 0) + 1);
208
215
  return {
209
216
  ...parseDrainOutput(source.name, result.stdout ?? ""),
210
217
  drainedAt,
211
- ...(source.lastDrainAt === undefined ? {} : { previousDrainAt: source.lastDrainAt }),
212
218
  };
213
219
  }
214
220
 
@@ -232,30 +238,39 @@ export async function drainSources(
232
238
  }
233
239
 
234
240
  /**
235
- * Two generations are enough: after this drain, anything older than the prior
236
- * successful one is behind the committed cursor and cannot arrive again. The
237
- * store is transformed without I/O; the caller writes it inside the registry
238
- * lock before advancing the Source records (ADR 0024). Legacy claims without a
239
- * timestamp are retained because their generation cannot be proven.
241
+ * A successful drain is the claim's horizon (ADRs 0024, 0034): an id it did not
242
+ * re-emit is behind the cursor, while an id it keeps re-emitting stays claimed
243
+ * for any number of passes. Failed drains and other inbox kinds have no say.
244
+ * Legacy claims without `claimedAt` stay because their provenance is unproven.
245
+ *
246
+ * Claims that began this pass unserved are sheltered until the next one. Their
247
+ * own item copy must get a chance to materialize, and a legacy miss must remain
248
+ * `blocked` long enough to be observed; pruning either here would recreate the
249
+ * silent loss this rule is meant to end. The caller writes the transformed store
250
+ * inside the registry lock before advancing the Source records.
240
251
  */
241
252
  export function pruneSourceClaims(
242
253
  listeners: ListenerRecord[],
243
254
  drained: SourceDrain[],
255
+ deferredClaims: ReadonlySet<ClaimRecord> = new Set(),
244
256
  ): { listeners: ListenerRecord[]; changed: boolean } {
245
- const horizons = new Map<string, number>();
257
+ const reemitted = new Map<string, Set<string>>();
246
258
  for (const drain of drained) {
247
- if (drain.error === undefined && drain.previousDrainAt !== undefined) {
248
- horizons.set(`source:${drain.name}`, drain.previousDrainAt);
259
+ if (drain.error === undefined) {
260
+ reemitted.set(`source:${drain.name}`, new Set(drain.items.map((item) => item.id)));
249
261
  }
250
262
  }
251
- if (horizons.size === 0) return { listeners, changed: false };
263
+ if (reemitted.size === 0) return { listeners, changed: false };
252
264
 
253
265
  let changed = false;
254
266
  const pruned = listeners.map((listener) => {
255
267
  const claimed = listener.claimed.filter((claim) => {
256
- const horizon = horizons.get(claim.inbox);
268
+ const emitted = reemitted.get(claim.inbox);
257
269
  return (
258
- horizon === undefined || claim.claimedAt === undefined || claim.claimedAt >= horizon
270
+ emitted === undefined ||
271
+ claim.claimedAt === undefined ||
272
+ deferredClaims.has(claim) ||
273
+ emitted.has(claim.item)
259
274
  );
260
275
  });
261
276
  if (claimed.length === listener.claimed.length) return listener;
@@ -265,7 +280,7 @@ export function pruneSourceClaims(
265
280
  return { listeners: pruned, changed };
266
281
  }
267
282
 
268
- /** Claims and pruning land before this cursor/retention-horizon write (ADR 0024). */
283
+ /** Claims and pruning land before this cursor write (ADRs 0024, 0034). */
269
284
  export async function commitSourceCursors(drained: SourceDrain[]): Promise<void> {
270
285
  const sources = await readSources();
271
286
  let changed = false;
@@ -287,6 +302,12 @@ export async function commitSourceCursors(drained: SourceDrain[]): Promise<void>
287
302
  source.lastDrainItems = drain.items.length;
288
303
  changed = true;
289
304
  }
305
+ // What the filters left behind is a fact of this drain (ADR 0034), recorded
306
+ // whether it is zero or not for the same reason the yield is.
307
+ if (drain.unrouted !== undefined && source.lastDrainUnrouted !== drain.unrouted) {
308
+ source.lastDrainUnrouted = drain.unrouted;
309
+ changed = true;
310
+ }
290
311
  // A success clears the failure it disproved (ADR 0027).
291
312
  if (source.lastError !== undefined) {
292
313
  delete source.lastError;