agents-pod 0.3.0 → 0.3.2

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 CHANGED
@@ -4,6 +4,64 @@ All notable changes to agents-pod. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.3.2] — 2026-11-04
8
+
9
+ 0.3.1 taught `spawn_pod` to name standing work and got one word wrong: 每天. Two
10
+ surfaces answer "recurring" requests — this tool and agents-cron's scheduler
11
+ skill — and a skill body outweighs a guideline snippet, so the pod frame
12
+ absorbed the schedule's vocabulary and pods were asked to read the clock. The
13
+ fix is one sentence, stated on the side that decides: a TIME rule is a
14
+ schedule, a MESSAGE or a ROLE that must stay alive is a pod, and a pod never
15
+ carries a clock (issue #23, ADR 0005).
16
+
17
+ ### Fixed
18
+
19
+ - **A time rule is a schedule, not a pod** (issue #23). 0.3.1 listed 每天 among
20
+ `spawn_pod`'s triggers, so the scheduler skill's vocabulary was absorbed into
21
+ the pod frame: "每天早上 9 点汇总 CI" and "每 30 分钟检查 CI" both spawned a
22
+ pod whose goal carried a clock (one even instructed the pod to "register a
23
+ 09:00 cron trigger" — a pod has no clock). The guidelines now split on the
24
+ signal — a TIME rule (每天/每小时/到点/明天提醒我) is `schedule_task` /
25
+ agents-cron; a MESSAGE or a ROLE that must stay alive (常驻/监听/收消息/路由/
26
+ 服务某个 inbox/直到我说停) is `spawn_pod` — the description says NO CLOCK, and
27
+ the check now asserts both directions: a standing prompt must spawn first, a
28
+ scheduled one must not spawn at all.
29
+
30
+ ## [0.3.1] — 2026-11-04
31
+
32
+ Two measurements decided this patch: a standing pod with an empty inbox is no
33
+ longer woken, and `spawn_pod` finally says what it is for. The first is the gate
34
+ ADR 0026 deferred until "the ticking hurts" — it hurt, one whole agent run per
35
+ minute on a router with nothing to do (issue #20). The second is a discovery
36
+ failure measured headlessly: neither a router request nor "每天早上 9 点汇总 CI"
37
+ ever reached the tool, because its description promised a pod that ends and a
38
+ guideline made turn 1 a survey (issue #21).
39
+
40
+ ### Changed
41
+
42
+ - **`spawn_pod`'s model-facing text now names standing work** (issue #21).
43
+ Measured headless runs never called the tool — 18 turns and 31 tool calls for
44
+ a router request, 7 turns and 10 for "每天 9 点汇总 CI", zero spawns — because
45
+ the description promised a pod that ends "until its goal is done", said
46
+ nothing about intake, and a guideline made turn 1 a `/pod list` survey. The
47
+ description now says a pod stands while its goal is open, is fed from a
48
+ source or inbox, and routes by writing inbox items; the guidelines map
49
+ 常驻/路由/收消息/每天 onto `spawn_pod` as the first tool call. Both measured
50
+ prompts now reach `spawn_pod` in the first call, 2 turns
51
+ (`scripts/first-turn-check.sh`, which runs against a throwaway store).
52
+
53
+ ### Fixed
54
+
55
+ - **A standing pod bound by a listener sleeps until its `## Inbox` has
56
+ something** (issue #20). `wakeDuePods` woke every idle, goal-open pod on
57
+ every tick, so a pod that exists to serve an inbox burned one whole agent run
58
+ per minute with nothing to do — the shape that got the first router pod
59
+ cancelled. The intake phase now records the batch it owes
60
+ (`policy.intakePending`) and the wake phase admits an intake-driven pod on
61
+ that debt alone; a clean exit pays it, a `failed`/`lost` wake-up leaves it for
62
+ the retry, and a capacity-blocked launch keeps it for the next pass. An
63
+ unbound pod keeps ADR 0011's rule unchanged (ADR 0026 amendment).
64
+
7
65
  ## [0.3.0] — 2026-11-04
8
66
 
9
67
  A machine whose IM surface has never been exercised can now say so. Discovery
package/README.md CHANGED
@@ -223,6 +223,10 @@ policy, per record:
223
223
  pod's default agent — which agent, which model, after how much backoff,
224
224
  is pod-record policy, written by reconcile from outcomes
225
225
  ([ADR 0011](docs/adr/0011-minimal-wake-policy.md));
226
+ - idle + **bound by a listener** + nothing owed → no-op. An intake-driven pod
227
+ is woken on the batch it owes (`policy.intakePending`), never on the tick, so
228
+ a standing router with an empty inbox costs nothing
229
+ ([ADR 0026 amendment](docs/adr/0026-affinity-root-message-and-delivery-address.md));
226
230
  - idle + parked (consecutive-failure stop) → no-op until a human `wake`
227
231
  clears it;
228
232
  - live but progressless (no document change for 60 min) → killed via the
@@ -252,7 +256,9 @@ section — below the agent-owned `## Waiting`, and appended only while no
252
256
  wake-up is live, then one batch, one wake on the ordinary wake path; the
253
257
  agent serves entries and flips their checkboxes. A block with a Delivery
254
258
  Address carries ` — to: <Delivery Address>` on its checkbox line, never in
255
- the copied body (ADRs 0014, 0026).
259
+ the copied body (ADRs 0014, 0026). Between batches the pod is not merely idle
260
+ but **asleep**: the tick alone does not wake a bound pod, which is what makes
261
+ "serve this inbox" a shape that can stand for days (ADR 0026 amendment).
256
262
 
257
263
  Overlapping passes: one TTL'd single-flight lock (`reconcile.lock`, stale
258
264
  after 5 minutes by wall clock — never PID liveness) skips fresh-held ticks
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-pod",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Supervision for long-running coding agents: detached pods, heartbeat liveness, cron-ticked reconcile, and a Pi extension for spawning and handoff",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "type": "module",
package/src/cli.ts CHANGED
@@ -442,6 +442,14 @@ async function reconcilePass(
442
442
  pod.policy.failureStreak = 0;
443
443
  delete pod.policy.nextWakeAt;
444
444
  }
445
+ // A clean exit served the batch it was woken for, so the debt is paid
446
+ // (ADR 0026 amendment). `failed` and `lost` leave it standing: the next
447
+ // admitted wake-up is the retry, and it is bounded by backoff and park
448
+ // exactly like any other failure. `cancelled` leaves it too — a human
449
+ // stopped a window, not the work.
450
+ if (wake.outcome === "more" || wake.outcome === "done") {
451
+ delete pod.policy.intakePending;
452
+ }
445
453
  pod.policy.appliedOutcomes += 1;
446
454
  changed = true;
447
455
  }
@@ -502,13 +510,26 @@ async function reconcilePass(
502
510
  * woken. It runs after intake so an item that just landed in a pod's `## Inbox`
503
511
  * is served by the same pass's wake (ADR 0014), and the read is the registry's
504
512
  * current state — the same compare-and-swap ADR 0018 relies on, one write.
513
+ *
514
+ * One gate sits in front of it (ADR 0026 amendment): a pod **bound by a
515
+ * listener** is intake-driven, so it is admitted only while it owes a batch
516
+ * (`policy.intakePending`). Without the gate a standing router with an empty
517
+ * inbox burned one whole agent run per tick — the shape that got the first
518
+ * router pod cancelled. An unbound pod has no inbox to check, so ADR 0011's
519
+ * optimistic rule stays its whole policy.
505
520
  */
506
521
  async function wakeDuePods(now: number): Promise<void> {
507
522
  const pods = await readPods();
508
523
  const projects = await readProjects();
524
+ const intakeBound = new Set(
525
+ (await readListeners()).flatMap((listener) =>
526
+ listener.mode === "pod" && listener.podId !== undefined ? [listener.podId] : [],
527
+ ),
528
+ );
509
529
  let changed = false;
510
530
  for (const pod of pods) {
511
531
  if (pod.state !== "idle" || pod.policy?.parked) continue;
532
+ if (intakeBound.has(pod.id) && !pod.policy?.intakePending) continue;
512
533
  if (pod.policy?.nextWakeAt !== undefined && pod.policy.nextWakeAt > now) continue;
513
534
  if (pod.after !== undefined) {
514
535
  const upstream = pods.find((candidate) => candidate.id === pod.after);
@@ -773,6 +794,14 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
773
794
  const claim = waiting.find((candidate) => candidate.item === item.id);
774
795
  if (claim !== undefined) claim.appended = true;
775
796
  }
797
+ // The batch is in the document, so the pod owes a wake-up it did not owe
798
+ // before (ADR 0026 amendment). Written here — inside the same registry lock
799
+ // as the append — because the wake phase reads this field as the pod's only
800
+ // admission: idle + owed batch, never the tick. A capacity-blocked launch
801
+ // leaves the count standing, so the next pass is the retry.
802
+ pod.policy ??= { failureStreak: 0, appliedOutcomes: 0 };
803
+ pod.policy.intakePending = (pod.policy.intakePending ?? 0) + batch.length;
804
+ await writePods(pods);
776
805
  appended = true;
777
806
  }
778
807
  // Append first, then mark: the marker is on disk before the wake phase can
@@ -99,13 +99,15 @@ export function registerManagementTools(pi: ExtensionAPI): void {
99
99
  name: "spawn_pod",
100
100
  label: "Spawn Pod",
101
101
  description:
102
- "Spawn a pod: a standing unit that outlives this session and wakes fresh agents inside it, again and again, until its goal is done. Returns the pod id.",
103
- promptSnippet: "Spawn a standing pod for long-running work",
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.",
103
+ promptSnippet: "Spawn a standing pod for work that outlives this session",
104
104
  promptGuidelines: [
105
- "Use spawn_pod when the user wants work to keep going across sessions — a multi-day refactor, a standing watch, an inbox to serve — not for a task this session can finish.",
106
- "spawn_pod returns a pod id; report that id back to the user so they can run /pod status on it.",
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.",
107
+ "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.",
107
108
  "A pod's agents share documents, not this conversation: put everything the successor must know into the goal text.",
108
- "Do not spawn a second pod for the same goal while an existing pod covers it; check /pod list first.",
109
+ "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.",
110
+ "Two pods for one goal is waste, but the user's ask wins: spawn first, and when you report the id, say so if a live pod already covers the goal (/pod list shows it). A cancelled or done pod never blocks a spawn.",
109
111
  ],
110
112
  parameters: Type.Object({
111
113
  goal: Type.String({
package/src/registry.ts CHANGED
@@ -51,6 +51,14 @@ export interface WakePolicy {
51
51
  parked?: boolean;
52
52
  /** How many wake-up outcomes the policy has already taken into account. */
53
53
  appliedOutcomes: number;
54
+ /**
55
+ * Unserved intake a bound pod still owes a wake-up (ADR 0026 amendment).
56
+ * Written by the intake phase when a batch lands in the pod's `## Inbox`,
57
+ * cleared by the pass that applies a clean outcome for the wake-up it
58
+ * admitted. Non-zero is the only thing that admits an intake-driven pod's
59
+ * wake — the tick does not (ADR 0011 stays the rule for unbound pods).
60
+ */
61
+ intakePending?: number;
54
62
  /** The agent every wake-up resolves to unless one wake names another (ADR 0012). */
55
63
  agent?: string;
56
64
  /** The pod's model pin: it beats the definition's model (ADR 0012). */