agents-pod 0.2.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.
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env python3
2
+ """Feishu reply deliverer — the single agents-pod delivery command (ADR 0025).
3
+
4
+ The wrapper invokes this once per outbox entry, at wake-up exit. The entry
5
+ arrives verbatim on stdin:
6
+
7
+ {"to": "chat=<chat_id>;affinity=<root_id>[;thread=<thread_id>]", "text": "…"}
8
+
9
+ `to` is the Delivery Address rendered by `{{reply_to}}` (ADR 0026). A reply goes
10
+ to the affinity message — inside its thread when `thread` is present — so the
11
+ human's answer inherits `root_id` and the next turn routes back to the same
12
+ Conversation. `AGENTS_POD_OUTBOX_UUID` is Feishu's one-hour reply idempotency
13
+ key: the same value is sent on every retry of the same entry.
14
+
15
+ Exit 0 acknowledges delivery (core deletes the entry); anything else leaves the
16
+ file for the next pass. `--check` (ADR 0027) prints a self-check instead, and
17
+ exits 0 healthy or 1 not.
18
+ """
19
+
20
+ import json
21
+ import os
22
+ import sys
23
+
24
+ import feishu_client as fc
25
+
26
+ # Feishu text messages cap at 150 KB; a chat reply has no business being long.
27
+ TEXT_CAP = 30000
28
+
29
+
30
+ def parse_address(raw):
31
+ """`chat=…;affinity=…[;thread=…]` → dict, tolerating spaces and order."""
32
+ fields = {}
33
+ for part in (raw or "").split(";"):
34
+ key, _, value = part.partition("=")
35
+ key = key.strip().lower()
36
+ if key:
37
+ fields[key] = value.strip()
38
+ return fields
39
+
40
+
41
+ def reply(token, fields, text, uuid):
42
+ chat_id = fields.get("chat", "")
43
+ affinity = fields.get("affinity", "")
44
+ thread_id = fields.get("thread", "")
45
+ content = json.dumps({"text": text}, ensure_ascii=False)
46
+ body = {"msg_type": "text", "content": content}
47
+ if uuid:
48
+ body["uuid"] = uuid
49
+
50
+ if affinity:
51
+ try:
52
+ payload = dict(body)
53
+ if thread_id:
54
+ payload["reply_in_thread"] = True
55
+ fc.api("POST", "/im/v1/messages/%s/reply" % affinity, token=token, body=payload)
56
+ return "reply:%s%s" % (affinity, ":thread" if thread_id else "")
57
+ except fc.FeishuError as err:
58
+ sys.stderr.write("[feishu-deliver] reply to %s failed (%s); trying the chat\n"
59
+ % (affinity, err))
60
+ if not chat_id:
61
+ raise
62
+ if not chat_id:
63
+ raise fc.FeishuError("/im/v1/messages", "no-address", "neither affinity nor chat")
64
+ fc.api("POST", "/im/v1/messages", token=token,
65
+ params={"receive_id_type": "chat_id"},
66
+ body={"receive_id": chat_id, **body})
67
+ return "chat:%s" % chat_id
68
+
69
+
70
+ def self_check():
71
+ """`--check`: the operator command's own report (ADR 0027). Exit 0 healthy, 1 not."""
72
+ fc.load_env()
73
+ try:
74
+ fc.tenant_token()
75
+ except fc.FeishuError as err:
76
+ print("no tenant token: %s" % err)
77
+ print(" credentials live in .env beside this file: cp .env.example .env, then fill")
78
+ return 1
79
+ print("token ok (tenant_access_token)")
80
+
81
+ address = "chat=oc_x;affinity=om_x;thread=t_x"
82
+ parsed = parse_address(address)
83
+ if parsed != {"chat": "oc_x", "affinity": "om_x", "thread": "t_x"}:
84
+ print("Delivery Address parser broken: %r -> %r" % (address, parsed))
85
+ return 1
86
+ print("Delivery Address parser ok: chat=…;affinity=…[;thread=…]")
87
+
88
+ # No scope-check endpoint exists: `im:message:send_as_bot` is proven by a
89
+ # real send, and a probe must not post into someone's chat.
90
+ print("send scope is only proven by a real send; the first reply verifies it")
91
+ return 0
92
+
93
+
94
+ def main():
95
+ if "--check" in sys.argv[1:]:
96
+ return self_check()
97
+ fc.load_env()
98
+ raw = sys.stdin.read()
99
+ try:
100
+ entry = json.loads(raw)
101
+ except ValueError:
102
+ sys.stderr.write("[feishu-deliver] entry is not JSON\n")
103
+ return 1
104
+ if not isinstance(entry, dict):
105
+ sys.stderr.write("[feishu-deliver] entry is not a JSON object\n")
106
+ return 1
107
+
108
+ text = entry.get("text")
109
+ address = entry.get("to")
110
+ if not isinstance(text, str) or not text.strip():
111
+ sys.stderr.write("[feishu-deliver] entry has no text\n")
112
+ return 1
113
+ if not isinstance(address, str) or not address.strip():
114
+ sys.stderr.write("[feishu-deliver] entry has no Delivery Address\n")
115
+ return 1
116
+ if len(text) > TEXT_CAP:
117
+ text = text[:TEXT_CAP] + "\n…(truncated)"
118
+
119
+ fields = parse_address(address)
120
+ if not fields.get("chat") and not fields.get("affinity"):
121
+ sys.stderr.write("[feishu-deliver] unusable Delivery Address %r\n" % address)
122
+ return 1
123
+
124
+ uuid = (os.environ.get("AGENTS_POD_OUTBOX_UUID") or "").strip()
125
+ try:
126
+ token = fc.tenant_token()
127
+ where = reply(token, fields, text, uuid)
128
+ except fc.FeishuError as err:
129
+ sys.stderr.write("[feishu-deliver] %s\n" % err)
130
+ return 1
131
+ sys.stdout.write("[feishu-deliver] delivered to %s\n" % where)
132
+ return 0
133
+
134
+
135
+ if __name__ == "__main__":
136
+ sys.exit(main())
@@ -0,0 +1,305 @@
1
+ #!/usr/bin/env python3
2
+ """Feishu pull drain — an agents-pod Source command (ADR 0024).
3
+
4
+ One invocation = one reconcile pass. Reads every group chat the bot is in
5
+ (plus the thread containers those chats reveal) from the committed cursor,
6
+ prints one JSONL item per human message, then the new cursor.
7
+
8
+ Environment (all optional, read from `~/.agents/pods/adapters/.env` too):
9
+ FEISHU_APP_ID / FEISHU_APP_SECRET the bot app credentials
10
+ FEISHU_CHATS comma-separated chat_id allowlist (empty: all)
11
+ FEISHU_REQUIRE_MENTION 1 = only messages that @-mention the bot
12
+ FEISHU_LOOKBACK_SECONDS read this far back when there is no cursor (3600)
13
+ FEISHU_OVERLAP_SECONDS re-read overlap behind the cursor (60)
14
+ FEISHU_MAX_ITEMS at most this many items per drain (40)
15
+ --check print a self-check instead of draining (ADR 0027):
16
+ exit 0 healthy, 1 not; `agents-pod doctor --probe`
17
+ runs it and quotes these lines
18
+
19
+ The cursor never advances past an item this drain did not print, so a burst
20
+ bigger than FEISHU_MAX_ITEMS simply arrives over several passes. p2p chats are
21
+ not enumerable (ADR 0024), so only group conversations are read.
22
+ """
23
+
24
+ import json
25
+ import os
26
+ import re
27
+ import sys
28
+ import time
29
+
30
+ import feishu_client as fc
31
+
32
+ PAGE_SIZE = 50
33
+ THREAD_PAGE_CEILING = 6
34
+ THREAD_KEEP_SECONDS = 7 * 24 * 3600
35
+
36
+
37
+ def env_int(name, default):
38
+ try:
39
+ return int(str(os.environ.get(name, "")).strip() or default)
40
+ except ValueError:
41
+ return default
42
+
43
+
44
+ def env_flag(name):
45
+ return str(os.environ.get(name, "")).strip().lower() in ("1", "true", "yes", "on")
46
+
47
+
48
+ def warn(message):
49
+ sys.stderr.write("[feishu-drain] %s\n" % message)
50
+
51
+
52
+ def list_chats(token):
53
+ """Every chat the bot can see; the chat list itself excludes p2p (ADR 0024)."""
54
+ chats, page_token = [], ""
55
+ while True:
56
+ data = fc.api("GET", "/im/v1/chats", token=token,
57
+ params={"page_size": 100, "page_token": page_token})
58
+ chats.extend(data.get("items") or [])
59
+ page_token = data.get("page_token") or ""
60
+ if not data.get("has_more") or not page_token:
61
+ return chats
62
+
63
+
64
+ def iter_messages(token, container_type, container_id, since=None):
65
+ """Pages one container in ascending create-time order."""
66
+ page_token, pages = "", 0
67
+ while True:
68
+ params = {
69
+ "container_id_type": container_type,
70
+ "container_id": container_id,
71
+ "sort_type": "ByCreateTimeAsc",
72
+ "page_size": PAGE_SIZE,
73
+ "page_token": page_token,
74
+ "start_time": since if since else None,
75
+ }
76
+ data = fc.api("GET", "/im/v1/messages", token=token, params=params)
77
+ for msg in data.get("items") or []:
78
+ yield msg
79
+ page_token = data.get("page_token") or ""
80
+ pages += 1
81
+ if not data.get("has_more") or not page_token:
82
+ return
83
+ if container_type == "thread" and pages >= THREAD_PAGE_CEILING:
84
+ warn("thread %s: stopped after %d pages (ceiling)" % (container_id, pages))
85
+ return
86
+
87
+
88
+ def created_at(msg):
89
+ try:
90
+ return int(msg.get("create_time") or 0) // 1000
91
+ except (TypeError, ValueError):
92
+ return 0
93
+
94
+
95
+ def is_bot(msg):
96
+ return ((msg.get("sender") or {}).get("sender_type") or "") == "app"
97
+
98
+
99
+ def mentions_bot(msg, open_id):
100
+ if not open_id:
101
+ return True
102
+ for mention in msg.get("mentions") or []:
103
+ if (mention.get("id") or {}).get("open_id") == open_id:
104
+ return True
105
+ return False
106
+
107
+
108
+ def main():
109
+ if "--check" in sys.argv[1:]:
110
+ return self_check()
111
+ fc.load_env()
112
+ try:
113
+ token = fc.tenant_token()
114
+ except fc.FeishuError as err:
115
+ warn("no tenant token: %s" % err)
116
+ return 1
117
+
118
+ cursor_raw = (os.environ.get("AGENTS_POD_CURSOR") or "").strip()
119
+ now = int(time.time())
120
+ overlap = env_int("FEISHU_OVERLAP_SECONDS", 60)
121
+ lookback = env_int("FEISHU_LOOKBACK_SECONDS", 3600)
122
+ max_items = max(1, env_int("FEISHU_MAX_ITEMS", 40))
123
+ allowlist = {c.strip() for c in (os.environ.get("FEISHU_CHATS") or "").split(",") if c.strip()}
124
+ require_mention = env_flag("FEISHU_REQUIRE_MENTION")
125
+
126
+ cursor = int(cursor_raw) if cursor_raw.isdigit() else None
127
+ floor = (cursor if cursor is not None else now - lookback) - overlap
128
+ since = max(0, floor)
129
+
130
+ state = fc.load_state()
131
+ known_chats = set(state["known_chats"])
132
+ threads = state["threads"]
133
+
134
+ try:
135
+ open_id = fc.bot_open_id(token) if require_mention else None
136
+ chats = list_chats(token)
137
+ except fc.FeishuError as err:
138
+ warn("chat enumeration failed: %s" % err)
139
+ return 1
140
+
141
+ chats = [c for c in chats if not allowlist or c.get("chat_id") in allowlist]
142
+ chat_names = {c.get("chat_id"): (c.get("name") or c.get("chat_id")) for c in chats}
143
+
144
+ collected, seen = [], set()
145
+
146
+ def absorb(chat_id, msg):
147
+ msg_id = msg.get("message_id")
148
+ if not msg_id or msg_id in seen or is_bot(msg):
149
+ return
150
+ if require_mention and not mentions_bot(msg, open_id):
151
+ return
152
+ body = fc.flatten_message(msg)
153
+ if not body:
154
+ return
155
+ seen.add(msg_id)
156
+ thread_id = msg.get("thread_id") or ""
157
+ collected.append({
158
+ "id": msg_id,
159
+ "title": "%s: %s" % (chat_names.get(chat_id, chat_id or "chat"),
160
+ body.splitlines()[0][:60]),
161
+ "body": body,
162
+ "chat_id": chat_id,
163
+ "root_id": msg.get("root_id") or msg.get("parent_id") or msg_id,
164
+ "thread_id": thread_id,
165
+ "at": created_at(msg),
166
+ })
167
+ # A thread container is the only place its replies appear (ADR 0024).
168
+ if thread_id and thread_id not in threads:
169
+ threads[thread_id] = {"chat_id": chat_id, "seen_at": created_at(msg) or now}
170
+
171
+ for chat in chats:
172
+ chat_id = chat.get("chat_id")
173
+ if not chat_id:
174
+ continue
175
+ # A chat the bot just joined has no cursor of its own; read a lookback.
176
+ chat_since = since if chat_id in known_chats else max(0, now - lookback)
177
+ try:
178
+ for msg in iter_messages(token, "chat", chat_id, chat_since):
179
+ absorb(chat_id, msg)
180
+ except fc.FeishuError as err:
181
+ warn("chat %s: %s" % (chat_id, err))
182
+ return 1
183
+ known_chats.add(chat_id)
184
+ chat_names.setdefault(chat_id, chat.get("name") or chat_id)
185
+
186
+ for thread_id in list(threads.keys()):
187
+ info = threads[thread_id] or {}
188
+ chat_id = info.get("chat_id") or ""
189
+ if allowlist and chat_id not in allowlist:
190
+ continue
191
+ try:
192
+ for msg in iter_messages(token, "thread", thread_id):
193
+ if created_at(msg) < since:
194
+ continue
195
+ absorb(chat_id or msg.get("chat_id") or "", msg)
196
+ except fc.FeishuError as err:
197
+ warn("thread %s: %s" % (thread_id, err))
198
+ continue
199
+ if now - (info.get("seen_at") or now) > THREAD_KEEP_SECONDS:
200
+ del threads[thread_id]
201
+
202
+ state["known_chats"] = sorted(known_chats)
203
+ state["threads"] = threads
204
+ try:
205
+ fc.save_state(state)
206
+ except OSError as err:
207
+ warn("state write failed: %s" % err)
208
+
209
+ collected.sort(key=lambda item: item["at"])
210
+ emitted = collected[:max_items]
211
+ # The cursor stops at the last item printed: the rest must not be skipped.
212
+ next_cursor = max(cursor or 0, emitted[-1]["at"] if emitted else 0)
213
+
214
+ for item in emitted:
215
+ item.pop("at", None)
216
+ sys.stdout.write(json.dumps(item, ensure_ascii=False) + "\n")
217
+
218
+ if len(collected) > len(emitted):
219
+ warn("%d more messages wait for the next pass" % (len(collected) - len(emitted)))
220
+ if emitted and next_cursor:
221
+ sys.stdout.write(json.dumps({"cursor": str(next_cursor)}) + "\n")
222
+ sys.stdout.flush()
223
+ return 0
224
+
225
+
226
+ def scope_hint(err):
227
+ """A missing scope names its console link inside the error - or we build one."""
228
+ hint = ["grant it in the app console, then publish a new version (权限管理 → 版本管理与发布)"]
229
+ text = " ".join([err.msg or "", getattr(err, "ext", "") or ""])
230
+ url = None
231
+ for word in re.split(r"[:\s]+", text):
232
+ if word.startswith("https://open.feishu.cn/"):
233
+ url = word
234
+ break
235
+ scopes = re.findall(r"need scope: ([a-z:._]+)", text)
236
+ app_id = (os.environ.get("FEISHU_APP_ID") or "").strip()
237
+ if url is None and scopes and app_id:
238
+ url = ("https://open.feishu.cn/app/%s/auth?q=%s&op_from=openapi&token_type=tenant"
239
+ % (app_id, ",".join(scopes)))
240
+ if url:
241
+ hint.insert(0, url)
242
+ return hint
243
+
244
+
245
+ def self_check():
246
+ """`--check`: the operator command's own report (ADR 0027). Exit 0 healthy, 1 not."""
247
+ fc.load_env()
248
+ failures = 0
249
+ try:
250
+ token = fc.tenant_token()
251
+ except fc.FeishuError as err:
252
+ print("no tenant token: %s" % err)
253
+ print(" credentials live in .env beside this file: cp .env.example .env, then fill")
254
+ print(" FEISHU_APP_ID / FEISHU_APP_SECRET from https://open.feishu.cn/app")
255
+ return 1
256
+ print("token ok (tenant_access_token, cached in .token.json for its 2 h)")
257
+
258
+ try:
259
+ bot = fc.api("GET", "/bot/v3/info", token=token).get("bot") or {}
260
+ print("bot %s (%s)" % (bot.get("app_name") or "?", bot.get("open_id") or "?"))
261
+ except fc.FeishuError as err:
262
+ print("bot info failed: %s" % err)
263
+ failures += 1
264
+
265
+ allowlist = {c.strip() for c in (os.environ.get("FEISHU_CHATS") or "").split(",") if c.strip()}
266
+ try:
267
+ visible = list_chats(token)
268
+ except fc.FeishuError as err:
269
+ print("chat list failed: %s" % err)
270
+ for line in scope_hint(err):
271
+ print(" " + line)
272
+ return 1
273
+ chats = [c for c in visible if not allowlist or c.get("chat_id") in allowlist]
274
+ if not chats:
275
+ print("no readable chat: this app is in no group chat%s"
276
+ % (" (or FEISHU_CHATS excludes every one)" if allowlist else ""))
277
+ print(" add it: 群设置 → 群机器人 → 添加机器人 %s" % (bot.get("app_name") or ""))
278
+ print(" a p2p chat cannot be enumerated by a pull drain (ADR 0024)")
279
+ return 1
280
+ print("%d chat(s) visible%s" % (len(chats), ", %d allowed by FEISHU_CHATS" % len(allowlist)
281
+ if allowlist else ""))
282
+
283
+ chat = chats[0]
284
+ name = chat.get("name") or chat.get("chat_id")
285
+ try:
286
+ fc.api("GET", "/im/v1/messages", token=token, params={
287
+ "container_id_type": "chat", "container_id": chat.get("chat_id"),
288
+ "sort_type": "ByCreateTimeAsc", "page_size": 1})
289
+ print("read ok in %s" % name)
290
+ except fc.FeishuError as err:
291
+ print("read failed in %s: %s" % (name, err))
292
+ for line in scope_hint(err):
293
+ print(" " + line)
294
+ failures += 1
295
+ state = fc.load_state()
296
+ print("state: %d known chat(s), %d known thread(s)"
297
+ % (len(state["known_chats"]), len(state["threads"])))
298
+ if env_flag("FEISHU_REQUIRE_MENTION") and not fc.bot_open_id(token):
299
+ print("FEISHU_REQUIRE_MENTION is on but the bot's open_id is unknown")
300
+ failures += 1
301
+ return 0 if failures == 0 else 1
302
+
303
+
304
+ if __name__ == "__main__":
305
+ sys.exit(main())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-pod",
3
- "version": "0.2.0",
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",
@@ -13,6 +13,7 @@
13
13
  ]
14
14
  },
15
15
  "files": [
16
+ "examples",
16
17
  "bin",
17
18
  "src",
18
19
  "index.mjs",
package/src/cli.ts CHANGED
@@ -22,6 +22,11 @@ import {
22
22
  writeAgents,
23
23
  } from "./agents-store.ts";
24
24
  import { renderTemplate, scanInbox, type InboxItem } from "./inbox.ts";
25
+ import {
26
+ doctor,
27
+ type DoctorMark,
28
+ type DoctorReport,
29
+ } from "./doctor.ts";
25
30
  import {
26
31
  claimKey,
27
32
  newListenerId,
@@ -33,7 +38,7 @@ import { acquireLock, readLock, releaseLock, sleep } from "./locks.ts";
33
38
  import { loadNotifyConfig } from "./notify/config.ts";
34
39
  import { dispatchNotification, shouldNotify } from "./notify/dispatch.ts";
35
40
  import type { NotificationsConfig, PodNotification } from "./notify/types.ts";
36
- import { cronLogPath, delivererFile, podDir, reconcileLockPath } from "./paths.ts";
41
+ import { cronLogPath, delivererFile, lastPassFile, podDir, reconcileLockPath } from "./paths.ts";
37
42
  import { readDeliverer, retryOutbox, writeDeliverer } from "./outbox.ts";
38
43
  import {
39
44
  projectHasCapacity,
@@ -48,6 +53,7 @@ import {
48
53
  drainSources,
49
54
  pruneSourceClaims,
50
55
  readSources,
56
+ recordSourceFailure,
51
57
  writeSources,
52
58
  type SourceDrain,
53
59
  } from "./sources.ts";
@@ -257,6 +263,9 @@ async function reconcileCmd(deps: CliDeps = {}): Promise<number> {
257
263
  await appendCronLog(
258
264
  `source ${result.name}: drain failed (${result.error}); no items, cursor unchanged`,
259
265
  );
266
+ // The failure is also a record fact, so `doctor` can report it without
267
+ // tailing cron.log (ADR 0027).
268
+ await recordSourceFailure(result.name, result.error);
260
269
  }
261
270
  for (const bad of result.bad) {
262
271
  await appendCronLog(
@@ -278,6 +287,9 @@ async function reconcileCmd(deps: CliDeps = {}): Promise<number> {
278
287
  (await withRegistryLock(() => reconcilePass(notify, project, drained))) ?? [];
279
288
  } finally {
280
289
  await releaseLock(reconcileLockPath());
290
+ // The pass marker (ADR 0027): without it, "cron is not installed" and
291
+ // "cron runs and finds nothing" are the same silence in every report.
292
+ await writeFile(lastPassFile(), `${JSON.stringify({ at: Date.now() })}\n`).catch(() => undefined);
281
293
  }
282
294
  // A bad config degrades, it never kills the pass (ADR 0021): the warnings
283
295
  // and any delivery failure are cron.log lines, and nothing is retried.
@@ -430,6 +442,14 @@ async function reconcilePass(
430
442
  pod.policy.failureStreak = 0;
431
443
  delete pod.policy.nextWakeAt;
432
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
+ }
433
453
  pod.policy.appliedOutcomes += 1;
434
454
  changed = true;
435
455
  }
@@ -490,13 +510,26 @@ async function reconcilePass(
490
510
  * woken. It runs after intake so an item that just landed in a pod's `## Inbox`
491
511
  * is served by the same pass's wake (ADR 0014), and the read is the registry's
492
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.
493
520
  */
494
521
  async function wakeDuePods(now: number): Promise<void> {
495
522
  const pods = await readPods();
496
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
+ );
497
529
  let changed = false;
498
530
  for (const pod of pods) {
499
531
  if (pod.state !== "idle" || pod.policy?.parked) continue;
532
+ if (intakeBound.has(pod.id) && !pod.policy?.intakePending) continue;
500
533
  if (pod.policy?.nextWakeAt !== undefined && pod.policy.nextWakeAt > now) continue;
501
534
  if (pod.after !== undefined) {
502
535
  const upstream = pods.find((candidate) => candidate.id === pod.after);
@@ -761,6 +794,14 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
761
794
  const claim = waiting.find((candidate) => candidate.item === item.id);
762
795
  if (claim !== undefined) claim.appended = true;
763
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);
764
805
  appended = true;
765
806
  }
766
807
  // Append first, then mark: the marker is on disk before the wake phase can
@@ -1827,11 +1868,70 @@ async function listenerCmd(
1827
1868
  }
1828
1869
  }
1829
1870
 
1871
+ const MARKS: Record<DoctorMark, string> = { ok: "✓", bad: "✗", note: "?" };
1872
+
1873
+ /** One line per finding, then the first run only when it is what is missing (ADR 0027). */
1874
+ function printDoctor(report: DoctorReport, out: (line: string) => void): void {
1875
+ out(`agents-pod doctor ${report.store}`);
1876
+ if (report.sections.length > 0) out("");
1877
+ for (const section of report.sections) {
1878
+ section.lines.forEach((line, index) => {
1879
+ const head = index === 0 ? section.title.padEnd(10) : " ".repeat(10);
1880
+ out(`${head}${MARKS[line.mark]} ${line.text}`);
1881
+ for (const detail of line.detail ?? []) out(`${" ".repeat(12)}${detail}`);
1882
+ });
1883
+ }
1884
+ if (report.recipe.length > 0) {
1885
+ out("");
1886
+ out(
1887
+ report.sections.length === 0
1888
+ ? "nothing is configured yet — the first run, in order:"
1889
+ : "still missing:",
1890
+ );
1891
+ for (const step of report.recipe) out(` ${step}`);
1892
+ }
1893
+ }
1894
+
1895
+ /**
1896
+ * `doctor` is read-only, and `--probe` is its only mode that runs operator code
1897
+ * (ADR 0027). Exit 0 healthy, 1 something blocks a channel, 2 bad usage.
1898
+ */
1899
+ async function doctorCmd(
1900
+ words: string[],
1901
+ deps: CliDeps,
1902
+ out: (line: string) => void,
1903
+ err: (line: string) => void,
1904
+ ): Promise<number> {
1905
+ const unknown = words.filter((word) => word !== "--probe" && word !== "--json");
1906
+ if (unknown.length > 0) {
1907
+ err(`Unknown doctor argument: ${unknown[0]}. Usage: agents-pod doctor [--probe] [--json]`);
1908
+ return 2;
1909
+ }
1910
+ const report = await doctor({
1911
+ probe: words.includes("--probe"),
1912
+ ...(deps.cwd === undefined ? {} : { cwd: deps.cwd }),
1913
+ });
1914
+ if (words.includes("--json")) out(JSON.stringify(report));
1915
+ else printDoctor(report, out);
1916
+ return report.healthy ? 0 : 1;
1917
+ }
1918
+
1830
1919
  export async function main(argv: string[], deps: CliDeps = {}): Promise<number> {
1831
1920
  const out = deps.stdout ?? ((line: string) => console.log(line));
1832
1921
  const err = deps.stderr ?? ((line: string) => console.error(line));
1833
1922
  const [cmd, ...rest] = argv;
1834
1923
 
1924
+ // The bare invocation is where a first run happens, so it points at the
1925
+ // report that says what is missing (ADR 0027).
1926
+ if (cmd === undefined) {
1927
+ out("agents-pod — standing Pods across days, one cron-ticked pass");
1928
+ out("");
1929
+ out("start here agents-pod doctor what is missing on this machine, and the command that fixes it");
1930
+ out("pods spawn | list | status <id> | logs <id> | wake <id> | cancel <id>");
1931
+ out("records agent | listener | source | deliver | project | reconcile");
1932
+ return 0;
1933
+ }
1934
+
1835
1935
  switch (cmd) {
1836
1936
  case "spawn":
1837
1937
  return spawnCmd(rest, deps, out, err);
@@ -1857,8 +1957,10 @@ export async function main(argv: string[], deps: CliDeps = {}): Promise<number>
1857
1957
  return deliverCmd(rest, deps, out, err);
1858
1958
  case "project":
1859
1959
  return projectCmd(rest, deps, out, err);
1960
+ case "doctor":
1961
+ return doctorCmd(rest, deps, out, err);
1860
1962
  default:
1861
- err(`Unknown command: ${cmd ?? "(none)"}. Try: spawn | list | status | logs | wake | cancel | reconcile | agent | listener | source | deliver | project`);
1963
+ err(`Unknown command: ${cmd ?? "(none)"}. Try: doctor | spawn | list | status | logs | wake | cancel | reconcile | agent | listener | source | deliver | project`);
1862
1964
  return 2;
1863
1965
  }
1864
1966
  }