agents-pod 0.3.0 → 0.3.1
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 +35 -0
- package/README.md +7 -1
- package/package.json +1 -1
- package/src/cli.ts +29 -0
- package/src/extension/management.ts +6 -5
- package/src/registry.ts +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,41 @@ 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.1] — 2026-11-04
|
|
8
|
+
|
|
9
|
+
Two measurements decided this patch: a standing pod with an empty inbox is no
|
|
10
|
+
longer woken, and `spawn_pod` finally says what it is for. The first is the gate
|
|
11
|
+
ADR 0026 deferred until "the ticking hurts" — it hurt, one whole agent run per
|
|
12
|
+
minute on a router with nothing to do (issue #20). The second is a discovery
|
|
13
|
+
failure measured headlessly: neither a router request nor "每天早上 9 点汇总 CI"
|
|
14
|
+
ever reached the tool, because its description promised a pod that ends and a
|
|
15
|
+
guideline made turn 1 a survey (issue #21).
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- **`spawn_pod`'s model-facing text now names standing work** (issue #21).
|
|
20
|
+
Measured headless runs never called the tool — 18 turns and 31 tool calls for
|
|
21
|
+
a router request, 7 turns and 10 for "每天 9 点汇总 CI", zero spawns — because
|
|
22
|
+
the description promised a pod that ends "until its goal is done", said
|
|
23
|
+
nothing about intake, and a guideline made turn 1 a `/pod list` survey. The
|
|
24
|
+
description now says a pod stands while its goal is open, is fed from a
|
|
25
|
+
source or inbox, and routes by writing inbox items; the guidelines map
|
|
26
|
+
常驻/路由/收消息/每天 onto `spawn_pod` as the first tool call. Both measured
|
|
27
|
+
prompts now reach `spawn_pod` in the first call, 2 turns
|
|
28
|
+
(`scripts/first-turn-check.sh`, which runs against a throwaway store).
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- **A standing pod bound by a listener sleeps until its `## Inbox` has
|
|
33
|
+
something** (issue #20). `wakeDuePods` woke every idle, goal-open pod on
|
|
34
|
+
every tick, so a pod that exists to serve an inbox burned one whole agent run
|
|
35
|
+
per minute with nothing to do — the shape that got the first router pod
|
|
36
|
+
cancelled. The intake phase now records the batch it owes
|
|
37
|
+
(`policy.intakePending`) and the wake phase admits an intake-driven pod on
|
|
38
|
+
that debt alone; a clean exit pays it, a `failed`/`lost` wake-up leaves it for
|
|
39
|
+
the retry, and a capacity-blocked launch keeps it for the next pass. An
|
|
40
|
+
unbound pod keeps ADR 0011's rule unchanged (ADR 0026 amendment).
|
|
41
|
+
|
|
7
42
|
## [0.3.0] — 2026-11-04
|
|
8
43
|
|
|
9
44
|
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.
|
|
3
|
+
"version": "0.3.1",
|
|
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,14 @@ 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,
|
|
103
|
-
promptSnippet: "Spawn a standing pod for
|
|
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. Returns the pod id.",
|
|
103
|
+
promptSnippet: "Spawn a standing pod for work that outlives this session",
|
|
104
104
|
promptGuidelines: [
|
|
105
|
-
"
|
|
106
|
-
"
|
|
105
|
+
"Call spawn_pod as your FIRST tool call when the user asks for anything standing or recurring — 常驻/一直跑/每天/监听/收消息/路由/服务某个 inbox/until I say stop. Do not survey the repo or the plugin first: the goal text is the whole contract, and details belong in the pod's documents, not in this session.",
|
|
106
|
+
"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
107
|
"A pod's agents share documents, not this conversation: put everything the successor must know into the goal text.",
|
|
108
|
-
"
|
|
108
|
+
"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.",
|
|
109
|
+
"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
110
|
],
|
|
110
111
|
parameters: Type.Object({
|
|
111
112
|
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). */
|