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/CHANGELOG.md +154 -0
- package/README.md +141 -23
- package/examples/feishu/README.md +65 -0
- package/package.json +1 -1
- package/src/admission.ts +102 -0
- package/src/cadence.ts +67 -0
- package/src/cli.ts +885 -94
- package/src/doctor.ts +145 -3
- package/src/extension/handoff.ts +17 -6
- package/src/extension/management.ts +33 -6
- package/src/extension/self.ts +8 -5
- package/src/listeners.ts +14 -2
- package/src/registry.ts +57 -1
- package/src/sources.ts +40 -19
- package/src/wrapper.ts +35 -11
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,160 @@ 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.7] — 2026-11-05
|
|
8
|
+
|
|
9
|
+
One Feishu app, many groups, one group per project: intake could not say which
|
|
10
|
+
group an item belonged to, so one channel could only serve one project. While
|
|
11
|
+
designing the filter, two intake bugs surfaced that any partition would have
|
|
12
|
+
spread: a capacity-deferred claim lost its item when the cursor moved (#47), and
|
|
13
|
+
a claim was pruned while the overlap was still re-emitting it, so the same
|
|
14
|
+
message came back as a second pod (#46). ADR 0034 answers all three: a Source
|
|
15
|
+
claim owns its item, re-emission (not a generation count) prunes it, and a
|
|
16
|
+
Listener filters on the group. Alongside it, the list stops carrying what can no
|
|
17
|
+
longer act: terminal pods are hidden, and `rm` discards the ones nothing owns.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- `listener add|edit --chat <chat_id>` — a routing filter over the item's
|
|
22
|
+
`chat_id`, ANDed with `--match`; one Source feeds N Listeners, so one channel
|
|
23
|
+
routes each group to its own project (#45, ADR 0034). A directory inbox is
|
|
24
|
+
refused with exit `2`: its items carry no group.
|
|
25
|
+
- `listener edit <id>` changing `--match`, `--chat`, `--project` and
|
|
26
|
+
`--max-concurrent` in place, claims included; `rm` + `add` stays a fresh claim
|
|
27
|
+
namespace and replays the overlap window.
|
|
28
|
+
- `source rewind <name> --to <cursor|"">` — re-read past the cursor after an
|
|
29
|
+
unrouted batch or a route fixed too late; claims dedupe what returns.
|
|
30
|
+
- `lastDrainUnrouted` recorded per Source, printed by `source list` and `doctor`,
|
|
31
|
+
and one `cron.log` line per pass with a non-zero count — a group nobody
|
|
32
|
+
listens to is legitimate, a group nothing wants is not silent.
|
|
33
|
+
- `list` hides `done` and `cancelled` pods by default (`--all` brings them back,
|
|
34
|
+
the footer counts them), and `rm <podId>` discards a pod no wake-up owns — its
|
|
35
|
+
record and its workspace (ADR 0035). `rm` refuses a live pod, a bound Listener,
|
|
36
|
+
a dependent's `after`, and an undelivered Outbox entry.
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- A capacity-deferred Source claim carries its item, so it materializes when
|
|
41
|
+
capacity frees instead of vanishing with the drain that produced it (#47).
|
|
42
|
+
- A claim is pruned by re-emission, not by a two-generation count, so a quiet
|
|
43
|
+
chat's re-delivered last message no longer returns as a second pod (#46).
|
|
44
|
+
- `parseFlags` keeps every value of a repeatable flag; before, `--chat a --chat b`
|
|
45
|
+
silently dropped `a`.
|
|
46
|
+
|
|
47
|
+
## [0.3.6] — 2026-11-05
|
|
48
|
+
|
|
49
|
+
One pod woke 41 times in five hours, and the store could not say why. Every
|
|
50
|
+
layer held one fact and named none of them: nothing recorded *who may wake this
|
|
51
|
+
pod* (`more` re-armed it, the tick obeyed, and a hand-written `crontab` line
|
|
52
|
+
raced both), nothing recorded *when* a wake-up happened or *what* woke it, and a
|
|
53
|
+
standing goal whose last word could not be `done` had no honest way to say
|
|
54
|
+
"this window is satisfied" — so it lied `more`, which means "now". Six issues,
|
|
55
|
+
four ADRs: the owner is a field (#33, ADR 0030), the derivation is told the
|
|
56
|
+
owner (#34, ADR 0031), every wake-up says when and who (#35, ADR 0032), a
|
|
57
|
+
satisfied window has a word (#36, ADR 0011 amendment), the incident is a fixture
|
|
58
|
+
(#37), and a hybrid ask decomposes on two axes (#32, ADR 0033).
|
|
59
|
+
|
|
60
|
+
### Added
|
|
61
|
+
|
|
62
|
+
- **A pod has one admission owner, decided at spawn and recorded** (issue #33,
|
|
63
|
+
ADR 0030). `admission: intake | external | self` — `intake` is the batch a
|
|
64
|
+
Listener owes it, `external` is an outside clock or a human calling
|
|
65
|
+
`agents-pod wake`, `self` is the tick, legal only for a goal that reaches
|
|
66
|
+
`done`. `spawn --admission <owner>` states it and `--external` is the
|
|
67
|
+
shorthand; the default is `self`, which is what the tick used to assume for
|
|
68
|
+
every pod. A Listener binding *is* an intake declaration, so a bound pod reads
|
|
69
|
+
as `intake` without a second flag, and a pod recorded `external` refuses to be
|
|
70
|
+
bound — two owners is zero owners when both fire. `status` prints the owner;
|
|
71
|
+
`doctor` names the two shapes that cost money (a tick-owned pod whose goal
|
|
72
|
+
names a clock, a tick-owned pod whose goal is open-ended) and their mirror (an
|
|
73
|
+
`intake` pod no Listener is bound to, which can never be given work). Records
|
|
74
|
+
written before this release keep their old behaviour and are named, never
|
|
75
|
+
rewritten.
|
|
76
|
+
- **A goal that names a clock is refused at the surface** (issue #33, ADR 0030).
|
|
77
|
+
`spawn` — and so `spawn_pod` — exits 2 when the goal carries a time rule
|
|
78
|
+
(`09:00`, `每天`, `每 30 分钟`, `every 30 minutes`, `daily`) and no outside
|
|
79
|
+
owner is declared; the refusal names `schedule_task` and the three owners. With
|
|
80
|
+
`--admission external` or `intake` the clock is context and the goal is
|
|
81
|
+
accepted; with `self` it is refused, because that is precisely the incident.
|
|
82
|
+
- **Every wake-up says when, and who admitted it** (issue #35, ADR 0032).
|
|
83
|
+
`WakeUpEntry` gains `at` and `trigger: "spawn" | "tick" | "wake"`, and the
|
|
84
|
+
launcher writes `admitted` and `active` in one registry-locked write *before*
|
|
85
|
+
it launches, so a crash leaves an adjudicable `lost` instead of an invisible
|
|
86
|
+
launch. `status` prints the rhythm — `cadence: 3 in 24h, median interval 2m,
|
|
87
|
+
last trigger tick` — and a record written before provenance says `not
|
|
88
|
+
recorded` instead of inventing a timestamp. `doctor` prints a rate line for a
|
|
89
|
+
pod above 6 wake-ups an hour and names the shape it found; `cron.log` records
|
|
90
|
+
each launch as `wake <id> tick (admission self)`. This is the sentence the
|
|
91
|
+
incident needed in round 1 and reconstructed from 41 rounds of its own logs.
|
|
92
|
+
- **A daily ceiling on wake-ups, with a stop that a launcher cannot re-open**
|
|
93
|
+
(issue #35, ADR 0032). `policy.wakeBudgetPerDay` (default 24, `spawn
|
|
94
|
+
--wake-budget <n>`) counts the stamped wake-ups in the last 24 h; above it,
|
|
95
|
+
reconcile parks the pod with `parkedBy: "budget"` and — same park, one
|
|
96
|
+
notification (ADR 0021) — `status` says *over the daily budget* rather than
|
|
97
|
+
*consecutive failures: 0*. An ordinary `wake` refuses (exit 2, naming the
|
|
98
|
+
count, the budget and both ways forward); one more window costs a deliberate
|
|
99
|
+
`wake --over-budget`, because a ceiling any launcher can re-open is not a
|
|
100
|
+
ceiling.
|
|
101
|
+
- **A satisfied window has a word: `quiet`** (issue #36, ADR 0009 and ADR 0011
|
|
102
|
+
amendments). The handoff's first line takes a third declaration — `status:
|
|
103
|
+
quiet` → outcome `quiet` — for a standing goal whose goal forbids `done`: this
|
|
104
|
+
window is satisfied and nothing is scheduled. It is a clean exit (the streak
|
|
105
|
+
resets, the batch it was admitted for is paid) that sets
|
|
106
|
+
`policy.awaiting = true` and writes no clock; the wake phase skips an awaiting
|
|
107
|
+
pod before it even asks who owns it, and `status` prints `state: idle
|
|
108
|
+
(awaiting)`. A new intake batch, a human `wake`, or the removal of the last
|
|
109
|
+
Listener lifts it — a satisfied pod nobody can feed is reported by `doctor`,
|
|
110
|
+
never fixed.
|
|
111
|
+
- **A hybrid ask decomposes on two axes, and the clock's unit hands over**
|
|
112
|
+
(issue #32, ADR 0033). 「每天早上 9:00 汇总 CI,直到我说停」 carries both signals;
|
|
113
|
+
a tie-break applied to a two-signal sentence is a coin flip, and this one
|
|
114
|
+
landed on 41 wake-ups. The prompt side now reads `trigger` (`time` ·
|
|
115
|
+
`message` · `role alive`) and `work` (`stateless per firing` · `needs memory
|
|
116
|
+
across firings`) and produces exactly three shapes — `schedule_task` alone, a
|
|
117
|
+
schedule that **hands over** to a pod admitted `intake` or `external`, or a
|
|
118
|
+
`spawn_pod` with no clock anywhere in the goal — stated as one line before the
|
|
119
|
+
first tool call. The handover's durable form is the Inbox Item and its minimal
|
|
120
|
+
form is `agents-pod wake`; neither surface may reimplement the other, so
|
|
121
|
+
`spawn_pod`'s guidelines and the scheduler skill each name the other. The
|
|
122
|
+
incident's own ask is now a fixture: a `hybrid` class in
|
|
123
|
+
`scripts/first-turn-check.sh` (with `--replay`, so the judgement is
|
|
124
|
+
model-independent) fails a clock inside a `spawn_pod` goal, a missing
|
|
125
|
+
`schedule_task`, and a schedule with no handover named
|
|
126
|
+
(`tests/fixtures/first-turn/`, six cases, `tests/first-turn-check.test.ts`).
|
|
127
|
+
|
|
128
|
+
### Changed
|
|
129
|
+
|
|
130
|
+
- **An outcome never schedules a wake-up its owner would not have made** (issue
|
|
131
|
+
#34, ADR 0031). One function, `applyOutcome(policy, owner, wake, now)`, is now
|
|
132
|
+
the only writer of `nextWakeAt`, and it is told the owner resolved once per pod
|
|
133
|
+
per pass. `more` re-arms a `self` pod on the next pass, as before; for an
|
|
134
|
+
`intake` or `external` pod it writes nothing, because the next wake-up is its
|
|
135
|
+
owner's decision and a `nextWakeAt` there was a second, invisible owner. The
|
|
136
|
+
backoff follows the same rule (three failures still park any owner — a stop is
|
|
137
|
+
about money, not scheduling); `cancelled`, `quiet` and any outcome a later
|
|
138
|
+
version adds are recorded and ignored rather than counting as failures. The
|
|
139
|
+
two phases still read the Listener bindings at their own moment: sharing one
|
|
140
|
+
read let a `listener add` that landed mid-pass wake the just-bound pod at the
|
|
141
|
+
end of that same pass.
|
|
142
|
+
- **`spawn_pod` takes the owner as a parameter** (ADR 0030, ADR 0033), so a
|
|
143
|
+
model decomposing a standing ask names who wakes the pod while it spawns,
|
|
144
|
+
instead of leaving the tick to guess.
|
|
145
|
+
|
|
146
|
+
### Fixed
|
|
147
|
+
|
|
148
|
+
- **A flag that arrives with no value is a usage error, not a word in the goal**
|
|
149
|
+
(issue #35). `spawn fix it --wake-budget` used to compose a goal containing the
|
|
150
|
+
literal flag and run on the default budget — a guardrail whose typo silently
|
|
151
|
+
disables it is not a guardrail. `parseFlags` now reports every value-taking
|
|
152
|
+
flag that arrived dangling.
|
|
153
|
+
- **The package no longer ships compiled Python.** `examples/feishu/__pycache__/*.pyc`
|
|
154
|
+
was in the published tarball and is now ignored.
|
|
155
|
+
|
|
156
|
+
Known gap, owned by #43: `spawn` still launches one wake-up immediately, so a
|
|
157
|
+
hybrid pod spawned at 15:00 does its first round at 15:00 and its next at 09:00.
|
|
158
|
+
One run is not a storm and the ceiling bounds what follows, so the guideline says
|
|
159
|
+
it out loud rather than claiming the clock owned the first one.
|
|
160
|
+
|
|
7
161
|
## [0.3.5] — 2026-11-04
|
|
8
162
|
|
|
9
163
|
### Fixed
|
package/README.md
CHANGED
|
@@ -37,7 +37,11 @@ Sibling of [agents-cron](https://pi.dev/packages/agents-cron): **cron owns when
|
|
|
37
37
|
work starts; pod owns keeping work alive.** Use agents-cron for recurring short
|
|
38
38
|
tasks ("check CI every 30 minutes"). Use agents-pod for standing work
|
|
39
39
|
("refactor the auth layer — this may take days", "keep watching this incident",
|
|
40
|
-
"serve this inbox — wake an agent per dropped task").
|
|
40
|
+
"serve this inbox — wake an agent per dropped task"). An ask that carries both
|
|
41
|
+
(「每天早上 9:00 汇总 CI,直到我说停」) is neither alone: cron owns the clock and
|
|
42
|
+
**hands the work over** — one Inbox Item when a per-firing input has to be
|
|
43
|
+
queued, or `agents-pod wake` when nothing does — and the pod's goal carries no
|
|
44
|
+
time rule ([ADR 0033](docs/adr/0033-a-hybrid-ask-decomposes-on-two-axes.md)).
|
|
41
45
|
|
|
42
46
|
## Why a separate plugin
|
|
43
47
|
|
|
@@ -140,36 +144,67 @@ package until both plugins demonstrably need one.
|
|
|
140
144
|
agents-pod doctor [--probe] [--json] # what is missing, and the command that fixes it
|
|
141
145
|
agents-pod spawn <goal prompt> [--project <dir>] [--agent <id>] [--model <id>] [--cwd <dir>]
|
|
142
146
|
[--after <podId>] # wait for the upstream pod to reach done
|
|
147
|
+
[--admission intake|external|self | --external] # who may wake it (ADR 0030)
|
|
148
|
+
[--wake-budget <n>] # daily ceiling, default 24 (ADR 0032)
|
|
149
|
+
[--dormant] # write the record and launch nothing (ADR 0033)
|
|
143
150
|
agents-pod project cap [n|unlimited] [--project <dir>] # per-project live wake-up cap
|
|
144
151
|
agents-pod project move <old> <new> [--apply] [--move-artifacts] # the tree was renamed: relocate records (ADR 0028)
|
|
145
|
-
agents-pod list [--json]
|
|
152
|
+
agents-pod list [--all] [--json] # live pods; --all includes terminal ones
|
|
146
153
|
agents-pod status <podId>
|
|
147
154
|
agents-pod logs <podId> [--follow]
|
|
148
|
-
agents-pod wake <podId> [--agent <id>] [--warm]
|
|
149
|
-
|
|
155
|
+
agents-pod wake <podId> [--agent <id>] [--warm] [--over-budget]
|
|
156
|
+
# fresh by default; --warm resumes a lost wake-up's session
|
|
157
|
+
agents-pod cancel <podId> # signals a live wake-up
|
|
158
|
+
agents-pod rm <podId> # discard a pod no wake-up owns, with its workspace (ADR 0035)
|
|
150
159
|
agents-pod reconcile # the cron entry point; idempotent
|
|
151
160
|
agents-pod agent add <id> --type <pi|claude|codex> [--model <id>] [--instructions <file>]
|
|
152
161
|
agents-pod agent edit <id> # $EDITOR on the stored definition
|
|
153
162
|
agents-pod agent rm <id> # pods left referencing it park at reconcile (ADR 0012)
|
|
154
163
|
agents-pod agent list [--json] # named agents: type, model, role
|
|
155
164
|
agents-pod listener add (--inbox <dir|file> | --source <name>) (--pod <podId> | --template <file>)
|
|
156
|
-
[--match <substr>] [--max-concurrent <n>] [--project <dir>]
|
|
165
|
+
[--match <substr>] [--chat <chat_id> ...] [--max-concurrent <n>] [--project <dir>]
|
|
166
|
+
agents-pod listener edit <listenerId> [--match <substr>] [--chat <chat_id> ... | --no-chat]
|
|
167
|
+
[--max-concurrent <n>] [--project <dir>]
|
|
157
168
|
agents-pod listener list [--json]
|
|
158
169
|
agents-pod listener rm <listenerId>
|
|
159
170
|
agents-pod source add <name> --cmd <path> [--timeout <s>]
|
|
160
171
|
agents-pod source list [--json]
|
|
161
172
|
agents-pod source rm <name>
|
|
173
|
+
agents-pod source rewind <name> --to <cursor|""> # re-read past the cursor (ADR 0034)
|
|
162
174
|
agents-pod deliver add <name> --cmd <path> [--timeout <s>]
|
|
163
175
|
agents-pod deliver list [--json]
|
|
164
176
|
agents-pod deliver rm <name>
|
|
165
177
|
agents-pod adapter sync <name> [--dest <dir>] [--apply] [--force] # copy the package's adapter, record its provenance (ADR 0029)
|
|
166
178
|
```
|
|
167
179
|
|
|
168
|
-
`spawn` claims a pod id, writes the pod record
|
|
169
|
-
|
|
180
|
+
`spawn` claims a pod id, writes the pod record (including who admits its
|
|
181
|
+
wake-ups — `--admission`, ADR 0030), wakes the first agent, and returns
|
|
182
|
+
immediately with the id. A goal that names a clock (`09:00`, `每天`, `每 30
|
|
183
|
+
分钟`) is refused with exit 2 unless an outside owner is declared: starting work
|
|
184
|
+
at a time is a schedule's job, and a pod carrying a clock is the incident
|
|
185
|
+
([`tests/reconcile.test.ts`](tests/reconcile.test.ts) replays it). With `--after`, the first wake waits for the
|
|
170
186
|
upstream pod's `done`; a cancelled upstream never releases it. When the
|
|
171
187
|
project's cap is full, the new pod likewise waits idle with its document store
|
|
172
188
|
ready. Manual `wake` overrides park/backoff, never dependencies or capacity.
|
|
189
|
+
With `--dormant` the record, the workspace and the owner are written and
|
|
190
|
+
**nothing is launched**: the first wake-up belongs to the owner, which is what a
|
|
191
|
+
hybrid pod needs so the schedule owns every firing, including the first one
|
|
192
|
+
([ADR 0033 amendment](docs/adr/0033-a-hybrid-ask-decomposes-on-two-axes.md)).
|
|
193
|
+
`--dormant` is refused with `admission: self` — the tick admits an idle pod on
|
|
194
|
+
its next pass, so the stop would last a minute and the flag would lie. A manual
|
|
195
|
+
`wake` starts a dormant pod of any owner: the human is the override, not a
|
|
196
|
+
fourth admission owner, and the wake-up records `trigger: wake` rather than
|
|
197
|
+
claiming a batch arrived.
|
|
198
|
+
|
|
199
|
+
`list` shows the pods that are still live work: `done` and `cancelled` records
|
|
200
|
+
are hidden unless `--all` is passed, and the footer says how many are hidden.
|
|
201
|
+
`rm` is the record's end, not a stop ([ADR
|
|
202
|
+
0035](docs/adr/0035-removal-is-garbage-collection.md)): it refuses `active` and
|
|
203
|
+
`spawning` pods (`cancel` first), and deletes the record plus
|
|
204
|
+
`<project>/.agents/pods/<podId>/` — documents, log and wake-up sessions
|
|
205
|
+
included. It refuses while a Listener is bound to the pod, while another pod's
|
|
206
|
+
`after` names it, or while its Outbox still holds an undelivered entry; clear
|
|
207
|
+
those first, because each would be left dangling.
|
|
173
208
|
Exit codes follow the house rule: `0` success
|
|
174
209
|
or nothing to do, `1` at least one pod failed (or `doctor` found something
|
|
175
210
|
that blocks a channel), `2` configuration or storage error.
|
|
@@ -179,7 +214,7 @@ first, record before exiting.
|
|
|
179
214
|
|
|
180
215
|
The Pi extension has two planes, gated by the env the wrapper injects
|
|
181
216
|
([ADR 0010](docs/adr/0010-two-extension-planes.md)). The **management
|
|
182
|
-
plane** (`/pod list|status|logs|spawn|wake|cancel|agents|listeners` and the
|
|
217
|
+
plane** (`/pod list|status|logs|spawn|wake|cancel|rm|agents|listeners` and the
|
|
183
218
|
`spawn_pod` tool) registers only in an interactive session: inside a pod
|
|
184
219
|
those tools and commands are not registered at all, so fan-out is
|
|
185
220
|
structurally impossible rather than forbidden. The **self plane** registers
|
|
@@ -198,22 +233,40 @@ tool is a guarantee on top of that contract, never a replacement for it.
|
|
|
198
233
|
| --- | --- | --- |
|
|
199
234
|
| `spawning` | record created, first wake-up not yet confirmed | `spawn` |
|
|
200
235
|
| `active` | a wake-up is live: wrapper alive, heartbeat fresh | wake / reconcile |
|
|
201
|
-
| `idle` | goal open, nothing live; documents current | wrapper / reconcile |
|
|
236
|
+
| `idle` | goal open, nothing live; documents current — `idle (awaiting)` when the last window declared itself satisfied, `idle (dormant: no wake-up yet)` when none has run | wrapper / reconcile |
|
|
202
237
|
| `done` | goal reached; terminal | wrapper |
|
|
203
238
|
| `cancelled` | user cancel completed; terminal | wrapper |
|
|
204
239
|
|
|
205
|
-
A pod
|
|
206
|
-
|
|
207
|
-
|
|
240
|
+
A terminal pod is not deleted by reaching its state: it stays visible to
|
|
241
|
+
`status`, `list --all` and its documents until `rm` discards it, and it is never
|
|
242
|
+
re-woken (ADR 0035). `rm` accepts `done`, `cancelled` and `idle` — a pod that no
|
|
243
|
+
wake-up owns — and refuses `active`/`spawning`, a bound Listener, a dependent's
|
|
244
|
+
`after`, and an undelivered Outbox entry.
|
|
245
|
+
|
|
246
|
+
A pod never exits — its wake-ups do. Wake-up outcomes — declared in the
|
|
247
|
+
handoff's first line, in this order of preference: `more` (`status: rotating`,
|
|
248
|
+
the goal needs another wake-up now), `quiet` (`status: quiet`, this window is
|
|
249
|
+
satisfied and nothing is scheduled: wake me when work arrives), `done`
|
|
250
|
+
(`status: done`, goal reached) — plus `failed`, `lost` (crash, reboot, orphan)
|
|
251
|
+
and `cancelled`. `quiet` is what a standing goal says instead of lying `more` to
|
|
252
|
+
stay alive (ADR 0011 amendment, #36). Outcomes are adjudicated in three layers —
|
|
208
253
|
the agent's HANDOFF.md declaration over the wrapper's event observation
|
|
209
254
|
over OS exit facts — and a clean exit with no fresh handoff is `failed`
|
|
210
255
|
([ADR 0009](docs/adr/0009-wake-up-outcome-classification.md)). The wake
|
|
211
256
|
policy turns outcomes into next actions
|
|
212
|
-
([ADR 0011](docs/adr/0011-minimal-wake-policy.md)
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
257
|
+
([ADR 0011](docs/adr/0011-minimal-wake-policy.md), and [ADR 0031](docs/adr/0031-outcomes-do-not-schedule.md)
|
|
258
|
+
for the part that depends on the owner): a clean `more` re-wakes a pod **the
|
|
259
|
+
tick owns** immediately and schedules nothing for a pod somebody else wakes;
|
|
260
|
+
`failed` and `lost` back off exponentially (1m ×2, cap 15m, one streak, reset on
|
|
261
|
+
`more`) — again only for the tick's own pod, since a backoff would admit a pod
|
|
262
|
+
without its owner; three consecutive failures park the pod — still `idle`,
|
|
263
|
+
auto-wake suspended — until a human wakes it, whatever its owner. A **daily
|
|
264
|
+
ceiling** (`policy.wakeBudgetPerDay`, default 24, `spawn --wake-budget`) parks it
|
|
265
|
+
the same way — the same single notification — when the last 24 h hold more
|
|
266
|
+
wake-ups than that, and that stop is not lifted by an ordinary `wake` (only by a
|
|
267
|
+
deliberate `wake --over-budget`), because a ceiling any launcher can re-open is
|
|
268
|
+
not a ceiling ([ADR 0032](docs/adr/0032-provenance-and-a-daily-ceiling.md)).
|
|
269
|
+
Reconcile policy, per record:
|
|
217
270
|
|
|
218
271
|
- active + wrapper alive + heartbeat fresh → no-op;
|
|
219
272
|
- active + nothing alive (crash, reboot — boot-time mismatch means lost,
|
|
@@ -221,13 +274,20 @@ policy, per record:
|
|
|
221
274
|
(death notifications in P1); the wake policy's backoff applies;
|
|
222
275
|
- agent alive but wrapper dead (orphan) → kill the recorded process group,
|
|
223
276
|
wake-up `lost`, pod → idle;
|
|
224
|
-
- idle + goal open + not parked + wake eligible (past
|
|
225
|
-
pod's default agent — which agent, which model, after how
|
|
226
|
-
is pod-record policy, written by reconcile from outcomes
|
|
277
|
+
- `admission: self` + idle + goal open + not parked + wake eligible (past
|
|
278
|
+
backoff — the only owner for which an outcome writes one) → wake the pod's default agent — which agent, which model, after how
|
|
279
|
+
much backoff, is pod-record policy, written by reconcile from outcomes
|
|
227
280
|
([ADR 0011](docs/adr/0011-minimal-wake-policy.md));
|
|
228
|
-
-
|
|
229
|
-
|
|
230
|
-
|
|
281
|
+
- **awaiting** (the last word was `quiet`) → no-op until work arrives: a new
|
|
282
|
+
intake batch, a human `wake`, or the removal of the Listener the claim was made
|
|
283
|
+
to ([ADR 0011 amendment](docs/adr/0011-minimal-wake-policy.md)). `status`
|
|
284
|
+
prints `idle (awaiting)`, and `doctor` notes an awaiting pod with no Listener,
|
|
285
|
+
because nothing can ever deliver work to it;
|
|
286
|
+
- `admission: external` → no-op, always: an outside clock or a human calls
|
|
287
|
+
`wake`, and the tick never launches it ([ADR 0030](docs/adr/0030-wake-admission-owners.md));
|
|
288
|
+
- `admission: intake` + nothing owed → no-op. An intake-driven pod is woken on
|
|
289
|
+
the batch it owes (`policy.intakePending`), never on the tick, so a standing
|
|
290
|
+
router with an empty inbox costs nothing
|
|
231
291
|
([ADR 0026 amendment](docs/adr/0026-affinity-root-message-and-delivery-address.md));
|
|
232
292
|
- idle + parked (consecutive-failure stop) → no-op until a human `wake`
|
|
233
293
|
clears it;
|
|
@@ -253,6 +313,16 @@ placeholders: `{{id}}` `{{title}}` `{{body}}` `{{path}}` and `{{reply_to}}`.
|
|
|
253
313
|
The last placeholder carries the Delivery Address consumed by the outbound
|
|
254
314
|
contract below (ADRs 0013, 0026).
|
|
255
315
|
|
|
316
|
+
One Source may feed several Listeners, so one channel can serve many
|
|
317
|
+
groups: `--chat <chat_id>` (repeatable) is a routing filter over the item's
|
|
318
|
+
`chat_id`, ANDed with `--match`, and the first Listener in `listeners.json`
|
|
319
|
+
order that holds claims the item (ADR 0034). A Source drained once, its items
|
|
320
|
+
split by their group to as many projects as there are Listeners. What no
|
|
321
|
+
Listener claims is counted, not silently dropped: `source list`/`doctor`
|
|
322
|
+
report `lastDrainUnrouted` and the pass writes one `cron.log` line. A
|
|
323
|
+
`--chat` filter needs an item that carries a group, so `listener add --chat`
|
|
324
|
+
against a directory inbox exits `2`; JSONL and Source items are filterable.
|
|
325
|
+
|
|
256
326
|
For a standing pod, claimed items land in its `TASKS.md` `## Inbox`
|
|
257
327
|
section — below the agent-owned `## Waiting`, and appended only while no
|
|
258
328
|
wake-up is live, then one batch, one wake on the ordinary wake path; the
|
|
@@ -262,6 +332,49 @@ the copied body (ADRs 0014, 0026). Between batches the pod is not merely idle
|
|
|
262
332
|
but **asleep**: the tick alone does not wake a bound pod, which is what makes
|
|
263
333
|
"serve this inbox" a shape that can stand for days (ADR 0026 amendment).
|
|
264
334
|
|
|
335
|
+
### Who admits a wake-up
|
|
336
|
+
|
|
337
|
+
A pod record carries one owner ([ADR 0030](docs/adr/0030-wake-admission-owners.md)),
|
|
338
|
+
decided at spawn, printed by `status`, and read by the wake phase before it
|
|
339
|
+
launches anything. There are three shapes, and picking one is the operator's
|
|
340
|
+
decision — not a policy the tick guesses:
|
|
341
|
+
|
|
342
|
+
| `admission` | who wakes it | the shape |
|
|
343
|
+
| --- | --- | --- |
|
|
344
|
+
| `self` | the tick, after every `more` | a goal that reaches `done`: a refactor, an audit, a migration. Default when nobody says. |
|
|
345
|
+
| `intake` | the batch it owes — `listener add --pod` | a standing inbox server or router: idle between batches, and an empty inbox costs nothing |
|
|
346
|
+
| `external` | `agents-pod wake` — an outside clock or a human | a `crontab` line, a webhook, a CI trigger: the clock lives outside core |
|
|
347
|
+
|
|
348
|
+
`--external` is shorthand for `--admission external`. A Listener binding is
|
|
349
|
+
itself an intake declaration, so `spawn` → `listener add --inbox <dir> --pod
|
|
350
|
+
<id>` needs no flag; a pod recorded `external` refuses to be bound, because two
|
|
351
|
+
owners is zero owners when both fire. `doctor` names the two shapes that cost
|
|
352
|
+
money: a pod the tick owns whose goal names a clock, and a pod the tick owns
|
|
353
|
+
whose goal is open-ended (`常驻`, `监听`, `我说停才停`) — the record is legal,
|
|
354
|
+
the intent is not, and only the operator knows which owner it should have had.
|
|
355
|
+
|
|
356
|
+
A goal that names a clock is a misroute, not a pod: the schedule owns the time
|
|
357
|
+
and hands the work over. The canonical example is replayed in
|
|
358
|
+
[`tests/reconcile.test.ts`](tests/reconcile.test.ts) and
|
|
359
|
+
[`tests/fixtures/first-turn/`](tests/fixtures/first-turn/), where the incident's
|
|
360
|
+
own ask is the fixture.
|
|
361
|
+
|
|
362
|
+
The handover's first firing needs one more promise: `spawn --dormant` (or
|
|
363
|
+
`spawn_pod`'s `dormant: true`). A hybrid pod is written without its spawn
|
|
364
|
+
wake-up, so the schedule owns every firing and the first Inbox Item — or the
|
|
365
|
+
first `agents-pod wake` — is wake-up 1. `status` shows such a pod as
|
|
366
|
+
`idle (dormant: no wake-up yet)`, derived from an empty wake-up list rather than
|
|
367
|
+
stored: nothing has run, so nothing has to be cleared when it does.
|
|
368
|
+
|
|
369
|
+
### Reading a rate
|
|
370
|
+
|
|
371
|
+
Every wake-up records `at` and `trigger` (`spawn` / `tick` / `wake`, ADR 0032), so
|
|
372
|
+
the question the incident could not answer — *why did this pod wake 41 times?* —
|
|
373
|
+
is one command per layer: `status <id>` prints `cadence: 41 in 24h, median
|
|
374
|
+
interval 2m, last trigger tick`; `doctor` prints a rate line for any pod above
|
|
375
|
+
6 wake-ups an hour; `cron.log` records each launch as `wake <id> tick (admission
|
|
376
|
+
self)`. The daily ceiling parks the pod before a rhythm becomes a storm.
|
|
377
|
+
|
|
265
378
|
Overlapping passes: one TTL'd single-flight lock (`reconcile.lock`, stale
|
|
266
379
|
after 5 minutes by wall clock — never PID liveness) skips fresh-held ticks
|
|
267
380
|
with exit 0 and steals stale ones with an audit line; correctness lives in
|
|
@@ -287,6 +400,8 @@ a Source command, `source add`, `listener add`, `deliver add`, the cron line,
|
|
|
287
400
|
```text
|
|
288
401
|
store ✓ 1 pods, 0 active
|
|
289
402
|
pass ✓ last pass 41s ago
|
|
403
|
+
wake ? pod-c1329c: 41 wake-ups in the last hour, median interval 2m — above the 6/h ceiling
|
|
404
|
+
its goal names a clock ("9:00") and the tick admits it — that is the #31 shape
|
|
290
405
|
intake ✗ source feishu: the last drain (2s ago) failed — exit 1
|
|
291
406
|
run: agents-pod doctor --probe
|
|
292
407
|
listeners ✓ listener-4f1a: source feishu → template triage.md → /work/app
|
|
@@ -335,6 +450,9 @@ name, removing an in-use Source, or adding a second deliverer returns `2`.
|
|
|
335
450
|
`--pod <podId>`. With `--template`, `--project <dir>` selects the new Pods'
|
|
336
451
|
project and defaults to the reconcile invocation's cwd; with `--pod` it is a
|
|
337
452
|
configuration error (exit `2`), because the bound Pod keeps its own project.
|
|
453
|
+
`--chat` may repeat and replaces the whole filter; `listener edit` changes a
|
|
454
|
+
rule in place, claims included — `rm` + `add` is a fresh claim namespace and
|
|
455
|
+
the replay window hands the old ids back (ADR 0034).
|
|
338
456
|
|
|
339
457
|
### Writing a Source drain
|
|
340
458
|
|
|
@@ -31,6 +31,29 @@ behaviour alone cannot report it. A file you edited yourself is reported as a
|
|
|
31
31
|
local edit and kept; `--force` is the only way to replace it. Plain `cp` still
|
|
32
32
|
works; it just leaves nothing for `doctor` to compare against.
|
|
33
33
|
|
|
34
|
+
## One app, one drain, many groups
|
|
35
|
+
|
|
36
|
+
A tenant usually runs several groups, one per project. That needs **one**
|
|
37
|
+
drain and one Listener per group — the drain already walks every chat the bot
|
|
38
|
+
sits in, and every item carries its `chat_id` (ADR 0034):
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
agents-pod source add feishu --cmd ~/.agents/pods/adapters/feishu_drain.py --timeout 60
|
|
42
|
+
agents-pod listener add --source feishu --chat oc_aaaa --template ~/work/app-a/TASK.md --project ~/work/app-a
|
|
43
|
+
agents-pod listener add --source feishu --chat oc_bbbb --template ~/work/app-b/TASK.md --project ~/work/app-b
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
One cursor, one poll per pass, one Pod per group's message; a reply goes back to
|
|
47
|
+
the group it came from because the Delivery Address travels with the item. A
|
|
48
|
+
group no Listener names is **counted as `unrouted`** and printed by `doctor` —
|
|
49
|
+
the bot is reading it and nothing wants it, which is worth being told. Add
|
|
50
|
+
`--chat` to an existing Listener with `listener edit`, never `rm` + `add`: the
|
|
51
|
+
claims live in the record, and re-adding replays the overlap window as new work.
|
|
52
|
+
|
|
53
|
+
`FEISHU_CHATS` is a different axis: the adapter-level allowlist limits what the
|
|
54
|
+
**drain** reads at all (and gives each group its own cursor if you run one copy
|
|
55
|
+
per group). Use it for hard isolation, `--chat` for routing.
|
|
56
|
+
|
|
34
57
|
## The one thing this cannot do for you: the app
|
|
35
58
|
|
|
36
59
|
Feishu's console is a human surface. In order:
|
|
@@ -160,3 +183,45 @@ message is still subject to `FEISHU_REQUIRE_MENTION`.
|
|
|
160
183
|
The pair shares a client file and nothing else. A different platform is a
|
|
161
184
|
second pair of commands and needs nothing from core; a second Feishu app needs
|
|
162
185
|
nothing from this directory but another `.env`.
|
|
186
|
+
|
|
187
|
+
## Limits the code meets, and the road not taken (push)
|
|
188
|
+
|
|
189
|
+
Facts from Feishu's own docs, recorded because they bound the *next* change
|
|
190
|
+
rather than this one. Every claim names the page it came from; none of them is
|
|
191
|
+
in the code yet.
|
|
192
|
+
|
|
193
|
+
**Frequency.** Error `230020` is the rate limit: **5 QPS to one group**, shared
|
|
194
|
+
by every bot in it, and 5 QPS to one user. A chat walk is one call per chat, so
|
|
195
|
+
the per-group cap — not the per-tenant one — is what a wide `FEISHU_CHATS`
|
|
196
|
+
meets first. ([message list](https://open.feishu.cn/document/server-docs/im-v1/message/list))
|
|
197
|
+
|
|
198
|
+
**Reply payloads** (what `feishu_deliver.py` may send): `content` is a
|
|
199
|
+
*serialized JSON string* whose shape follows `msg_type`; text is capped at
|
|
200
|
+
150 KB, cards and rich text at 30 KB; `uuid` is ≤50 characters with one hour of
|
|
201
|
+
idempotency. `reply_in_thread` refuses with `230071` (the group does not support
|
|
202
|
+
topic replies) and `230072` (aggregated messages). ([reply message](https://open.feishu.cn/document/server-docs/im-v1/message/reply))
|
|
203
|
+
|
|
204
|
+
**Message-list parameters** the drain does not pass yet: `sort_type`
|
|
205
|
+
(`ByCreateTimeAsc` default, fixed once paging starts), `page_size` 1–50
|
|
206
|
+
(default 20), `card_msg_content_type`, `only_thread_root_messages`,
|
|
207
|
+
`with_sender_name`. ([message list](https://open.feishu.cn/document/server-docs/im-v1/message/list))
|
|
208
|
+
|
|
209
|
+
**Push, if anyone ever wants it** (ADR 0024 leaves the door open; core stays
|
|
210
|
+
pull-only). The two transports share the at-least-once contract and the retry
|
|
211
|
+
schedule — 15 s, 5 min, 1 h, 6 h, at most four retries, ≈7 h — and differ in
|
|
212
|
+
everything else:
|
|
213
|
+
|
|
214
|
+
- *Webhook* needs a **public IPv4** URL, must echo `url_verification` within
|
|
215
|
+
**1 s** before the URL can even be saved, and must return HTTP 200 within
|
|
216
|
+
**3 s** (TCP connect 2 s + callback 3 s). A laptop that sleeps or changes
|
|
217
|
+
network fails that ack, and the retry window is the only recovery.
|
|
218
|
+
- *Long connection* (outbound WebSocket) needs no public address — only
|
|
219
|
+
outbound internet — and is **enterprise self-built apps only**. Success is the
|
|
220
|
+
handler returning within **3 s** without throwing. The hard limits: **at most
|
|
221
|
+
50 connections per app**, each initialized client being one connection, and
|
|
222
|
+
delivery is **cluster mode with no broadcast** — with several clients of one
|
|
223
|
+
app, only one randomly chosen client receives each message. A shim is
|
|
224
|
+
therefore one connection, never a pool.
|
|
225
|
+
|
|
226
|
+
That last fact is why "push" here can only be a single resident process with a
|
|
227
|
+
file inbox behind it: any fan-out is the platform's, not ours.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agents-pod",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
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/admission.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { Admission, PodRecord } from "./registry.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Who admits a Pod's wake-up (ADR 0030). ADR 0011's single wake rule —
|
|
5
|
+
* idle + goal open + not parked + due — is right for a bounded goal and an
|
|
6
|
+
* unbounded money pit for a standing one: the tick cannot tell "nobody feeds me"
|
|
7
|
+
* from "a crontab line feeds me at 09:00", so it assumed the second and woke a
|
|
8
|
+
* router 41 times in five hours. The missing fact was ownership, not a policy
|
|
9
|
+
* knob, so it is recorded as a fact on the Pod Record and read from there.
|
|
10
|
+
*
|
|
11
|
+
* Three owners, exactly one per Pod:
|
|
12
|
+
* intake — a Listener-bound Pod; the batch it owes (`policy.intakePending`)
|
|
13
|
+
* is its only admission. An empty inbox costs nothing.
|
|
14
|
+
* external — an outside clock or a human calls `agents-pod wake`; the tick
|
|
15
|
+
* never launches it.
|
|
16
|
+
* self — the tick admits it. Legal for a goal that reaches `done`, never
|
|
17
|
+
* for a standing one (`more` re-arms it on every pass).
|
|
18
|
+
*/
|
|
19
|
+
export const ADMISSIONS = ["intake", "external", "self"] as const;
|
|
20
|
+
|
|
21
|
+
export function isAdmission(value: string): value is Admission {
|
|
22
|
+
return (ADMISSIONS as readonly string[]).includes(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Who admits this record. A Listener binding is itself an intake declaration —
|
|
27
|
+
* binding a Pod says "this is fed from an inbox", and it is the act the operator
|
|
28
|
+
* performs, not a state we ask them to restate. An unbound Pod is owned by the
|
|
29
|
+
* owner recorded at spawn, or by the tick when nobody recorded one (a record
|
|
30
|
+
* written before ADR 0030). The binding therefore never rewrites a record: it
|
|
31
|
+
* outranks it, and `listener add --pod` refuses a Pod recorded as `external`
|
|
32
|
+
* rather than quietly contradicting it.
|
|
33
|
+
*/
|
|
34
|
+
export function admissionOf(
|
|
35
|
+
pod: Pick<PodRecord, "id" | "admission">,
|
|
36
|
+
intakeBound: ReadonlySet<string>,
|
|
37
|
+
): Admission {
|
|
38
|
+
if (intakeBound.has(pod.id)) return "intake";
|
|
39
|
+
return pod.admission ?? "self";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The phrase that makes a goal a time rule, or `undefined` (issue #37: "a goal
|
|
44
|
+
* that names a clock is a misroute, not a pod"). Deliberately narrow — every
|
|
45
|
+
* match refuses a spawn on the creation side, so a false positive costs an
|
|
46
|
+
* operator a rejected command. `每天` / `每小时` / `每 N 分钟` / `HH:MM` are the
|
|
47
|
+
* shapes the incident was written in; `crontab` is NOT here, because "fix the
|
|
48
|
+
* crontab line" is a legitimate goal about a clock, not a goal with one.
|
|
49
|
+
*/
|
|
50
|
+
const CLOCKS: ReadonlyArray<RegExp> = [
|
|
51
|
+
/\d{1,2}[::]\d{2}/, // 09:00, 9:30
|
|
52
|
+
/(?:每天|每日|每晚|每早|每周|每月|每年|每小时)/,
|
|
53
|
+
/每\s*\d+\s*(?:分钟|分|小时|钟头|天|日|周|星期|月|秒)/, // 每 30 分钟
|
|
54
|
+
/(?:早上|上午|中午|下午|晚上|凌晨|深夜)\s*\d{1,2}\s*点/,
|
|
55
|
+
/\d{1,2}\s*点(?:半|整|钟|\d{2})?/, // 9点, 9点30
|
|
56
|
+
/到点/,
|
|
57
|
+
/\bevery\s+(?:\d+\s*)?(?:minute|min|hour|hr|day|week|month|morning|night)/i,
|
|
58
|
+
/\b(?:daily|hourly|weekly|monthly|nightly)\b/i,
|
|
59
|
+
/\bat\s+\d{1,2}:\d{2}\b/i, // at 9:30 — a bare "at 3" is not a time ("look at 3 tests")
|
|
60
|
+
/\d{1,2}\s*(?:am|pm)\b/i,
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
export function clockPhrase(goal: string): string | undefined {
|
|
64
|
+
for (const pattern of CLOCKS) {
|
|
65
|
+
const found = pattern.exec(goal);
|
|
66
|
+
if (found !== null) return found[0].trim();
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Standing language: a goal that describes a role rather than a finish line
|
|
73
|
+
* (`常驻`, `监听`, `我说停才停`). Only `doctor` and the spawn warning read it, and
|
|
74
|
+
* only to name the shape — a Pod on `self` with an open-ended goal is woken
|
|
75
|
+
* after every `more`, with nothing new to work on.
|
|
76
|
+
*/
|
|
77
|
+
const OPEN_ENDED: ReadonlyArray<RegExp> = [
|
|
78
|
+
/(?:常驻|长期|永久|无限期|永远|一直|监听|盯着|盯住|轮询|说停|直到.{0,6}停)/,
|
|
79
|
+
/\b(?:forever|indefinitely|standing)\b/i,
|
|
80
|
+
/\buntil\s+\w+\s+(?:says?|asks?)\s+(?:stop|enough)/i,
|
|
81
|
+
/\bkeep\s+(?:it\s+)?(?:running|watching|going)\b/i,
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
export function openEnded(goal: string): boolean {
|
|
85
|
+
return OPEN_ENDED.some((pattern) => pattern.test(goal));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The refusal `spawn` prints when a goal carries a clock and nobody has said who
|
|
90
|
+
* admits the wake-ups: the schedule already owns the time, and the incident's
|
|
91
|
+
* pod was born exactly here (ADR 0030, issue #37).
|
|
92
|
+
*/
|
|
93
|
+
export function clockRefusal(phrase: string): string[] {
|
|
94
|
+
return [
|
|
95
|
+
`a goal that names a clock is a misroute, not a pod: "${phrase}"`,
|
|
96
|
+
"starting work at a time is a schedule's job: call schedule_task (agents-cron) for the clock, and let the schedule hand the work to a pod",
|
|
97
|
+
"if this pod really is admitted from elsewhere, say so (exactly one owner, ADR 0030):",
|
|
98
|
+
" --admission external an outside clock or a human calls `agents-pod wake`",
|
|
99
|
+
" --admission intake a listener feeds it: listener add --inbox <dir> --pod <id>",
|
|
100
|
+
" --admission self the tick admits it — for a bounded goal that reaches done, never for a standing one",
|
|
101
|
+
];
|
|
102
|
+
}
|