agents-pod 0.3.4 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +127 -1
- package/README.md +87 -19
- package/package.json +1 -1
- package/src/admission.ts +102 -0
- package/src/cadence.ts +67 -0
- package/src/cli.ts +378 -50
- package/src/doctor.ts +132 -3
- package/src/extension/handoff.ts +17 -6
- package/src/extension/index.ts +15 -1
- package/src/extension/management.ts +19 -3
- package/src/extension/self.ts +8 -5
- package/src/registry.ts +57 -1
- package/src/wrapper.ts +35 -11
package/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,133 @@ 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
|
-
## [
|
|
7
|
+
## [0.3.6] — 2026-11-05
|
|
8
|
+
|
|
9
|
+
One pod woke 41 times in five hours, and the store could not say why. Every
|
|
10
|
+
layer held one fact and named none of them: nothing recorded *who may wake this
|
|
11
|
+
pod* (`more` re-armed it, the tick obeyed, and a hand-written `crontab` line
|
|
12
|
+
raced both), nothing recorded *when* a wake-up happened or *what* woke it, and a
|
|
13
|
+
standing goal whose last word could not be `done` had no honest way to say
|
|
14
|
+
"this window is satisfied" — so it lied `more`, which means "now". Six issues,
|
|
15
|
+
four ADRs: the owner is a field (#33, ADR 0030), the derivation is told the
|
|
16
|
+
owner (#34, ADR 0031), every wake-up says when and who (#35, ADR 0032), a
|
|
17
|
+
satisfied window has a word (#36, ADR 0011 amendment), the incident is a fixture
|
|
18
|
+
(#37), and a hybrid ask decomposes on two axes (#32, ADR 0033).
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **A pod has one admission owner, decided at spawn and recorded** (issue #33,
|
|
23
|
+
ADR 0030). `admission: intake | external | self` — `intake` is the batch a
|
|
24
|
+
Listener owes it, `external` is an outside clock or a human calling
|
|
25
|
+
`agents-pod wake`, `self` is the tick, legal only for a goal that reaches
|
|
26
|
+
`done`. `spawn --admission <owner>` states it and `--external` is the
|
|
27
|
+
shorthand; the default is `self`, which is what the tick used to assume for
|
|
28
|
+
every pod. A Listener binding *is* an intake declaration, so a bound pod reads
|
|
29
|
+
as `intake` without a second flag, and a pod recorded `external` refuses to be
|
|
30
|
+
bound — two owners is zero owners when both fire. `status` prints the owner;
|
|
31
|
+
`doctor` names the two shapes that cost money (a tick-owned pod whose goal
|
|
32
|
+
names a clock, a tick-owned pod whose goal is open-ended) and their mirror (an
|
|
33
|
+
`intake` pod no Listener is bound to, which can never be given work). Records
|
|
34
|
+
written before this release keep their old behaviour and are named, never
|
|
35
|
+
rewritten.
|
|
36
|
+
- **A goal that names a clock is refused at the surface** (issue #33, ADR 0030).
|
|
37
|
+
`spawn` — and so `spawn_pod` — exits 2 when the goal carries a time rule
|
|
38
|
+
(`09:00`, `每天`, `每 30 分钟`, `every 30 minutes`, `daily`) and no outside
|
|
39
|
+
owner is declared; the refusal names `schedule_task` and the three owners. With
|
|
40
|
+
`--admission external` or `intake` the clock is context and the goal is
|
|
41
|
+
accepted; with `self` it is refused, because that is precisely the incident.
|
|
42
|
+
- **Every wake-up says when, and who admitted it** (issue #35, ADR 0032).
|
|
43
|
+
`WakeUpEntry` gains `at` and `trigger: "spawn" | "tick" | "wake"`, and the
|
|
44
|
+
launcher writes `admitted` and `active` in one registry-locked write *before*
|
|
45
|
+
it launches, so a crash leaves an adjudicable `lost` instead of an invisible
|
|
46
|
+
launch. `status` prints the rhythm — `cadence: 3 in 24h, median interval 2m,
|
|
47
|
+
last trigger tick` — and a record written before provenance says `not
|
|
48
|
+
recorded` instead of inventing a timestamp. `doctor` prints a rate line for a
|
|
49
|
+
pod above 6 wake-ups an hour and names the shape it found; `cron.log` records
|
|
50
|
+
each launch as `wake <id> tick (admission self)`. This is the sentence the
|
|
51
|
+
incident needed in round 1 and reconstructed from 41 rounds of its own logs.
|
|
52
|
+
- **A daily ceiling on wake-ups, with a stop that a launcher cannot re-open**
|
|
53
|
+
(issue #35, ADR 0032). `policy.wakeBudgetPerDay` (default 24, `spawn
|
|
54
|
+
--wake-budget <n>`) counts the stamped wake-ups in the last 24 h; above it,
|
|
55
|
+
reconcile parks the pod with `parkedBy: "budget"` and — same park, one
|
|
56
|
+
notification (ADR 0021) — `status` says *over the daily budget* rather than
|
|
57
|
+
*consecutive failures: 0*. An ordinary `wake` refuses (exit 2, naming the
|
|
58
|
+
count, the budget and both ways forward); one more window costs a deliberate
|
|
59
|
+
`wake --over-budget`, because a ceiling any launcher can re-open is not a
|
|
60
|
+
ceiling.
|
|
61
|
+
- **A satisfied window has a word: `quiet`** (issue #36, ADR 0009 and ADR 0011
|
|
62
|
+
amendments). The handoff's first line takes a third declaration — `status:
|
|
63
|
+
quiet` → outcome `quiet` — for a standing goal whose goal forbids `done`: this
|
|
64
|
+
window is satisfied and nothing is scheduled. It is a clean exit (the streak
|
|
65
|
+
resets, the batch it was admitted for is paid) that sets
|
|
66
|
+
`policy.awaiting = true` and writes no clock; the wake phase skips an awaiting
|
|
67
|
+
pod before it even asks who owns it, and `status` prints `state: idle
|
|
68
|
+
(awaiting)`. A new intake batch, a human `wake`, or the removal of the last
|
|
69
|
+
Listener lifts it — a satisfied pod nobody can feed is reported by `doctor`,
|
|
70
|
+
never fixed.
|
|
71
|
+
- **A hybrid ask decomposes on two axes, and the clock's unit hands over**
|
|
72
|
+
(issue #32, ADR 0033). 「每天早上 9:00 汇总 CI,直到我说停」 carries both signals;
|
|
73
|
+
a tie-break applied to a two-signal sentence is a coin flip, and this one
|
|
74
|
+
landed on 41 wake-ups. The prompt side now reads `trigger` (`time` ·
|
|
75
|
+
`message` · `role alive`) and `work` (`stateless per firing` · `needs memory
|
|
76
|
+
across firings`) and produces exactly three shapes — `schedule_task` alone, a
|
|
77
|
+
schedule that **hands over** to a pod admitted `intake` or `external`, or a
|
|
78
|
+
`spawn_pod` with no clock anywhere in the goal — stated as one line before the
|
|
79
|
+
first tool call. The handover's durable form is the Inbox Item and its minimal
|
|
80
|
+
form is `agents-pod wake`; neither surface may reimplement the other, so
|
|
81
|
+
`spawn_pod`'s guidelines and the scheduler skill each name the other. The
|
|
82
|
+
incident's own ask is now a fixture: a `hybrid` class in
|
|
83
|
+
`scripts/first-turn-check.sh` (with `--replay`, so the judgement is
|
|
84
|
+
model-independent) fails a clock inside a `spawn_pod` goal, a missing
|
|
85
|
+
`schedule_task`, and a schedule with no handover named
|
|
86
|
+
(`tests/fixtures/first-turn/`, six cases, `tests/first-turn-check.test.ts`).
|
|
87
|
+
|
|
88
|
+
### Changed
|
|
89
|
+
|
|
90
|
+
- **An outcome never schedules a wake-up its owner would not have made** (issue
|
|
91
|
+
#34, ADR 0031). One function, `applyOutcome(policy, owner, wake, now)`, is now
|
|
92
|
+
the only writer of `nextWakeAt`, and it is told the owner resolved once per pod
|
|
93
|
+
per pass. `more` re-arms a `self` pod on the next pass, as before; for an
|
|
94
|
+
`intake` or `external` pod it writes nothing, because the next wake-up is its
|
|
95
|
+
owner's decision and a `nextWakeAt` there was a second, invisible owner. The
|
|
96
|
+
backoff follows the same rule (three failures still park any owner — a stop is
|
|
97
|
+
about money, not scheduling); `cancelled`, `quiet` and any outcome a later
|
|
98
|
+
version adds are recorded and ignored rather than counting as failures. The
|
|
99
|
+
two phases still read the Listener bindings at their own moment: sharing one
|
|
100
|
+
read let a `listener add` that landed mid-pass wake the just-bound pod at the
|
|
101
|
+
end of that same pass.
|
|
102
|
+
- **`spawn_pod` takes the owner as a parameter** (ADR 0030, ADR 0033), so a
|
|
103
|
+
model decomposing a standing ask names who wakes the pod while it spawns,
|
|
104
|
+
instead of leaving the tick to guess.
|
|
105
|
+
|
|
106
|
+
### Fixed
|
|
107
|
+
|
|
108
|
+
- **A flag that arrives with no value is a usage error, not a word in the goal**
|
|
109
|
+
(issue #35). `spawn fix it --wake-budget` used to compose a goal containing the
|
|
110
|
+
literal flag and run on the default budget — a guardrail whose typo silently
|
|
111
|
+
disables it is not a guardrail. `parseFlags` now reports every value-taking
|
|
112
|
+
flag that arrived dangling.
|
|
113
|
+
- **The package no longer ships compiled Python.** `examples/feishu/__pycache__/*.pyc`
|
|
114
|
+
was in the published tarball and is now ignored.
|
|
115
|
+
|
|
116
|
+
Known gap, owned by #43: `spawn` still launches one wake-up immediately, so a
|
|
117
|
+
hybrid pod spawned at 15:00 does its first round at 15:00 and its next at 09:00.
|
|
118
|
+
One run is not a storm and the ceiling bounds what follows, so the guideline says
|
|
119
|
+
it out loud rather than claiming the clock owned the first one.
|
|
120
|
+
|
|
121
|
+
## [0.3.5] — 2026-11-04
|
|
122
|
+
|
|
123
|
+
### Fixed
|
|
124
|
+
|
|
125
|
+
- **A reload no longer leaves the extension inert** (issue #28). `/reload`,
|
|
126
|
+
`/new`, `/resume` and `/fork` replace the whole extension runtime: Pi
|
|
127
|
+
re-imports the module in the same process and rebuilds every command and tool
|
|
128
|
+
registry from scratch, while `globalThis` — and with it the `Symbol.for` guard
|
|
129
|
+
that keeps a second installed copy inert — survives. The reloaded copy saw a
|
|
130
|
+
claimed process, returned early, and `/pod`, `spawn_pod` and `pod_handoff`
|
|
131
|
+
vanished until Pi restarted. The guard is now released on `session_shutdown`,
|
|
132
|
+
so it claims a runtime instead of a process; a second copy in one runtime is
|
|
133
|
+
still kept out (ADR 0010).
|
|
8
134
|
|
|
9
135
|
## [0.3.4] — 2026-11-04
|
|
10
136
|
|
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,12 +144,15 @@ 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)
|
|
143
149
|
agents-pod project cap [n|unlimited] [--project <dir>] # per-project live wake-up cap
|
|
144
150
|
agents-pod project move <old> <new> [--apply] [--move-artifacts] # the tree was renamed: relocate records (ADR 0028)
|
|
145
151
|
agents-pod list [--json]
|
|
146
152
|
agents-pod status <podId>
|
|
147
153
|
agents-pod logs <podId> [--follow]
|
|
148
|
-
agents-pod wake <podId> [--agent <id>] [--warm]
|
|
154
|
+
agents-pod wake <podId> [--agent <id>] [--warm] [--over-budget]
|
|
155
|
+
# fresh by default; --warm resumes a lost wake-up's session
|
|
149
156
|
agents-pod cancel <podId>
|
|
150
157
|
agents-pod reconcile # the cron entry point; idempotent
|
|
151
158
|
agents-pod agent add <id> --type <pi|claude|codex> [--model <id>] [--instructions <file>]
|
|
@@ -165,8 +172,12 @@ agents-pod deliver rm <name>
|
|
|
165
172
|
agents-pod adapter sync <name> [--dest <dir>] [--apply] [--force] # copy the package's adapter, record its provenance (ADR 0029)
|
|
166
173
|
```
|
|
167
174
|
|
|
168
|
-
`spawn` claims a pod id, writes the pod record
|
|
169
|
-
|
|
175
|
+
`spawn` claims a pod id, writes the pod record (including who admits its
|
|
176
|
+
wake-ups — `--admission`, ADR 0030), wakes the first agent, and returns
|
|
177
|
+
immediately with the id. A goal that names a clock (`09:00`, `每天`, `每 30
|
|
178
|
+
分钟`) is refused with exit 2 unless an outside owner is declared: starting work
|
|
179
|
+
at a time is a schedule's job, and a pod carrying a clock is the incident
|
|
180
|
+
([`tests/reconcile.test.ts`](tests/reconcile.test.ts) replays it). With `--after`, the first wake waits for the
|
|
170
181
|
upstream pod's `done`; a cancelled upstream never releases it. When the
|
|
171
182
|
project's cap is full, the new pod likewise waits idle with its document store
|
|
172
183
|
ready. Manual `wake` overrides park/backoff, never dependencies or capacity.
|
|
@@ -198,22 +209,34 @@ tool is a guarantee on top of that contract, never a replacement for it.
|
|
|
198
209
|
| --- | --- | --- |
|
|
199
210
|
| `spawning` | record created, first wake-up not yet confirmed | `spawn` |
|
|
200
211
|
| `active` | a wake-up is live: wrapper alive, heartbeat fresh | wake / reconcile |
|
|
201
|
-
| `idle` | goal open, nothing live; documents current | wrapper / reconcile |
|
|
212
|
+
| `idle` | goal open, nothing live; documents current — `idle (awaiting)` when the last window declared itself satisfied | wrapper / reconcile |
|
|
202
213
|
| `done` | goal reached; terminal | wrapper |
|
|
203
214
|
| `cancelled` | user cancel completed; terminal | wrapper |
|
|
204
215
|
|
|
205
|
-
A pod never exits — its wake-ups do. Wake-up outcomes
|
|
206
|
-
|
|
207
|
-
|
|
216
|
+
A pod never exits — its wake-ups do. Wake-up outcomes — declared in the
|
|
217
|
+
handoff's first line, in this order of preference: `more` (`status: rotating`,
|
|
218
|
+
the goal needs another wake-up now), `quiet` (`status: quiet`, this window is
|
|
219
|
+
satisfied and nothing is scheduled: wake me when work arrives), `done`
|
|
220
|
+
(`status: done`, goal reached) — plus `failed`, `lost` (crash, reboot, orphan)
|
|
221
|
+
and `cancelled`. `quiet` is what a standing goal says instead of lying `more` to
|
|
222
|
+
stay alive (ADR 0011 amendment, #36). Outcomes are adjudicated in three layers —
|
|
208
223
|
the agent's HANDOFF.md declaration over the wrapper's event observation
|
|
209
224
|
over OS exit facts — and a clean exit with no fresh handoff is `failed`
|
|
210
225
|
([ADR 0009](docs/adr/0009-wake-up-outcome-classification.md)). The wake
|
|
211
226
|
policy turns outcomes into next actions
|
|
212
|
-
([ADR 0011](docs/adr/0011-minimal-wake-policy.md)
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
227
|
+
([ADR 0011](docs/adr/0011-minimal-wake-policy.md), and [ADR 0031](docs/adr/0031-outcomes-do-not-schedule.md)
|
|
228
|
+
for the part that depends on the owner): a clean `more` re-wakes a pod **the
|
|
229
|
+
tick owns** immediately and schedules nothing for a pod somebody else wakes;
|
|
230
|
+
`failed` and `lost` back off exponentially (1m ×2, cap 15m, one streak, reset on
|
|
231
|
+
`more`) — again only for the tick's own pod, since a backoff would admit a pod
|
|
232
|
+
without its owner; three consecutive failures park the pod — still `idle`,
|
|
233
|
+
auto-wake suspended — until a human wakes it, whatever its owner. A **daily
|
|
234
|
+
ceiling** (`policy.wakeBudgetPerDay`, default 24, `spawn --wake-budget`) parks it
|
|
235
|
+
the same way — the same single notification — when the last 24 h hold more
|
|
236
|
+
wake-ups than that, and that stop is not lifted by an ordinary `wake` (only by a
|
|
237
|
+
deliberate `wake --over-budget`), because a ceiling any launcher can re-open is
|
|
238
|
+
not a ceiling ([ADR 0032](docs/adr/0032-provenance-and-a-daily-ceiling.md)).
|
|
239
|
+
Reconcile policy, per record:
|
|
217
240
|
|
|
218
241
|
- active + wrapper alive + heartbeat fresh → no-op;
|
|
219
242
|
- active + nothing alive (crash, reboot — boot-time mismatch means lost,
|
|
@@ -221,13 +244,20 @@ policy, per record:
|
|
|
221
244
|
(death notifications in P1); the wake policy's backoff applies;
|
|
222
245
|
- agent alive but wrapper dead (orphan) → kill the recorded process group,
|
|
223
246
|
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
|
|
247
|
+
- `admission: self` + idle + goal open + not parked + wake eligible (past
|
|
248
|
+
backoff — the only owner for which an outcome writes one) → wake the pod's default agent — which agent, which model, after how
|
|
249
|
+
much backoff, is pod-record policy, written by reconcile from outcomes
|
|
227
250
|
([ADR 0011](docs/adr/0011-minimal-wake-policy.md));
|
|
228
|
-
-
|
|
229
|
-
|
|
230
|
-
|
|
251
|
+
- **awaiting** (the last word was `quiet`) → no-op until work arrives: a new
|
|
252
|
+
intake batch, a human `wake`, or the removal of the Listener the claim was made
|
|
253
|
+
to ([ADR 0011 amendment](docs/adr/0011-minimal-wake-policy.md)). `status`
|
|
254
|
+
prints `idle (awaiting)`, and `doctor` notes an awaiting pod with no Listener,
|
|
255
|
+
because nothing can ever deliver work to it;
|
|
256
|
+
- `admission: external` → no-op, always: an outside clock or a human calls
|
|
257
|
+
`wake`, and the tick never launches it ([ADR 0030](docs/adr/0030-wake-admission-owners.md));
|
|
258
|
+
- `admission: intake` + nothing owed → no-op. An intake-driven pod is woken on
|
|
259
|
+
the batch it owes (`policy.intakePending`), never on the tick, so a standing
|
|
260
|
+
router with an empty inbox costs nothing
|
|
231
261
|
([ADR 0026 amendment](docs/adr/0026-affinity-root-message-and-delivery-address.md));
|
|
232
262
|
- idle + parked (consecutive-failure stop) → no-op until a human `wake`
|
|
233
263
|
clears it;
|
|
@@ -262,6 +292,42 @@ the copied body (ADRs 0014, 0026). Between batches the pod is not merely idle
|
|
|
262
292
|
but **asleep**: the tick alone does not wake a bound pod, which is what makes
|
|
263
293
|
"serve this inbox" a shape that can stand for days (ADR 0026 amendment).
|
|
264
294
|
|
|
295
|
+
### Who admits a wake-up
|
|
296
|
+
|
|
297
|
+
A pod record carries one owner ([ADR 0030](docs/adr/0030-wake-admission-owners.md)),
|
|
298
|
+
decided at spawn, printed by `status`, and read by the wake phase before it
|
|
299
|
+
launches anything. There are three shapes, and picking one is the operator's
|
|
300
|
+
decision — not a policy the tick guesses:
|
|
301
|
+
|
|
302
|
+
| `admission` | who wakes it | the shape |
|
|
303
|
+
| --- | --- | --- |
|
|
304
|
+
| `self` | the tick, after every `more` | a goal that reaches `done`: a refactor, an audit, a migration. Default when nobody says. |
|
|
305
|
+
| `intake` | the batch it owes — `listener add --pod` | a standing inbox server or router: idle between batches, and an empty inbox costs nothing |
|
|
306
|
+
| `external` | `agents-pod wake` — an outside clock or a human | a `crontab` line, a webhook, a CI trigger: the clock lives outside core |
|
|
307
|
+
|
|
308
|
+
`--external` is shorthand for `--admission external`. A Listener binding is
|
|
309
|
+
itself an intake declaration, so `spawn` → `listener add --inbox <dir> --pod
|
|
310
|
+
<id>` needs no flag; a pod recorded `external` refuses to be bound, because two
|
|
311
|
+
owners is zero owners when both fire. `doctor` names the two shapes that cost
|
|
312
|
+
money: a pod the tick owns whose goal names a clock, and a pod the tick owns
|
|
313
|
+
whose goal is open-ended (`常驻`, `监听`, `我说停才停`) — the record is legal,
|
|
314
|
+
the intent is not, and only the operator knows which owner it should have had.
|
|
315
|
+
|
|
316
|
+
A goal that names a clock is a misroute, not a pod: the schedule owns the time
|
|
317
|
+
and hands the work over. The canonical example is replayed in
|
|
318
|
+
[`tests/reconcile.test.ts`](tests/reconcile.test.ts) and
|
|
319
|
+
[`tests/fixtures/first-turn/`](tests/fixtures/first-turn/), where the incident's
|
|
320
|
+
own ask is the fixture.
|
|
321
|
+
|
|
322
|
+
### Reading a rate
|
|
323
|
+
|
|
324
|
+
Every wake-up records `at` and `trigger` (`spawn` / `tick` / `wake`, ADR 0032), so
|
|
325
|
+
the question the incident could not answer — *why did this pod wake 41 times?* —
|
|
326
|
+
is one command per layer: `status <id>` prints `cadence: 41 in 24h, median
|
|
327
|
+
interval 2m, last trigger tick`; `doctor` prints a rate line for any pod above
|
|
328
|
+
6 wake-ups an hour; `cron.log` records each launch as `wake <id> tick (admission
|
|
329
|
+
self)`. The daily ceiling parks the pod before a rhythm becomes a storm.
|
|
330
|
+
|
|
265
331
|
Overlapping passes: one TTL'd single-flight lock (`reconcile.lock`, stale
|
|
266
332
|
after 5 minutes by wall clock — never PID liveness) skips fresh-held ticks
|
|
267
333
|
with exit 0 and steals stale ones with an audit line; correctness lives in
|
|
@@ -287,6 +353,8 @@ a Source command, `source add`, `listener add`, `deliver add`, the cron line,
|
|
|
287
353
|
```text
|
|
288
354
|
store ✓ 1 pods, 0 active
|
|
289
355
|
pass ✓ last pass 41s ago
|
|
356
|
+
wake ? pod-c1329c: 41 wake-ups in the last hour, median interval 2m — above the 6/h ceiling
|
|
357
|
+
its goal names a clock ("9:00") and the tick admits it — that is the #31 shape
|
|
290
358
|
intake ✗ source feishu: the last drain (2s ago) failed — exit 1
|
|
291
359
|
run: agents-pod doctor --probe
|
|
292
360
|
listeners ✓ listener-4f1a: source feishu → template triage.md → /work/app
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agents-pod",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
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
|
+
}
|
package/src/cadence.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { WakeUpEntry } from "./registry.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cadence: what the wake-up record says about *when* a Pod woke, read the same
|
|
5
|
+
* way by `status` (one Pod), `doctor` (the fleet's rate) and the daily ceiling
|
|
6
|
+
* (ADR 0032). One module because the three must agree on what counts, and
|
|
7
|
+
* because "41 wake-ups in 5 h" was the whole incident and nothing in the store
|
|
8
|
+
* could say it in one command (issue #35).
|
|
9
|
+
*
|
|
10
|
+
* A record written before ADR 0032 carries no `at`: those wake-ups are counted
|
|
11
|
+
* (they are history) but never timed, and every reader says so rather than
|
|
12
|
+
* inventing a timestamp.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const HOUR_MS = 3_600_000;
|
|
16
|
+
export const DAY_MS = 24 * HOUR_MS;
|
|
17
|
+
|
|
18
|
+
/** A wake-up entry that knows when it happened — the only kind a rate counts. */
|
|
19
|
+
export interface StampedWakeUp extends WakeUpEntry {
|
|
20
|
+
at: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function stamped(wakeUps: readonly WakeUpEntry[]): StampedWakeUp[] {
|
|
24
|
+
return wakeUps.filter(
|
|
25
|
+
(wake): wake is StampedWakeUp => typeof wake.at === "number" && Number.isFinite(wake.at),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The stamped wake-ups inside a window ending now (half-open: `(now - ms, now]`).
|
|
31
|
+
* Every outcome counts, `lost` included: a wake-up whose wrapper died still
|
|
32
|
+
* spent an attempt, and a flapping machine that is called busy is exactly the
|
|
33
|
+
* report this is meant to prevent.
|
|
34
|
+
*/
|
|
35
|
+
export function inWindow(
|
|
36
|
+
wakeUps: readonly WakeUpEntry[],
|
|
37
|
+
now: number,
|
|
38
|
+
windowMs: number,
|
|
39
|
+
): StampedWakeUp[] {
|
|
40
|
+
const from = now - windowMs;
|
|
41
|
+
return stamped(wakeUps).filter((wake) => wake.at > from && wake.at <= now);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The median interval between consecutive stamped wake-ups, in ms — the one
|
|
46
|
+
* number that separates "it works when asked" from "it is re-arming itself".
|
|
47
|
+
* `undefined` when fewer than two wake-ups are timed.
|
|
48
|
+
*/
|
|
49
|
+
export function medianIntervalMs(wakeUps: readonly WakeUpEntry[]): number | undefined {
|
|
50
|
+
const times = stamped(wakeUps)
|
|
51
|
+
.map((wake) => wake.at)
|
|
52
|
+
.sort((left, right) => left - right);
|
|
53
|
+
if (times.length < 2) return undefined;
|
|
54
|
+
const gaps: number[] = [];
|
|
55
|
+
for (let index = 1; index < times.length; index += 1) gaps.push(times[index]! - times[index - 1]!);
|
|
56
|
+
gaps.sort((left, right) => left - right);
|
|
57
|
+
const middle = Math.floor(gaps.length / 2);
|
|
58
|
+
return gaps.length % 2 === 1 ? gaps[middle]! : Math.round((gaps[middle - 1]! + gaps[middle]!) / 2);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Ages and intervals are read at a glance, never as an epoch (ADR 0027's habit). */
|
|
62
|
+
export function duration(ms: number): string {
|
|
63
|
+
if (ms < 1_000) return `${Math.max(0, Math.round(ms))}ms`;
|
|
64
|
+
if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
|
|
65
|
+
if (ms < HOUR_MS) return `${Math.round(ms / 60_000)}m`;
|
|
66
|
+
return `${Math.round((ms / HOUR_MS) * 10) / 10}h`;
|
|
67
|
+
}
|