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/src/doctor.ts
CHANGED
|
@@ -14,7 +14,9 @@ import {
|
|
|
14
14
|
reconcileLockPath,
|
|
15
15
|
storeRoot,
|
|
16
16
|
} from "./paths.ts";
|
|
17
|
-
import {
|
|
17
|
+
import { admissionOf, clockPhrase, openEnded } from "./admission.ts";
|
|
18
|
+
import { HOUR_MS, duration, inWindow, medianIntervalMs } from "./cadence.ts";
|
|
19
|
+
import { readPods, type Admission, type PodRecord } from "./registry.ts";
|
|
18
20
|
import { readSources, type SourceRecord } from "./sources.ts";
|
|
19
21
|
|
|
20
22
|
/**
|
|
@@ -63,6 +65,8 @@ const PROBE_OUTPUT_CAP = 8 * 1024;
|
|
|
63
65
|
const PROBE_LINES = 6;
|
|
64
66
|
/** A pass lock older than this is what the next pass steals (ADR 0018). */
|
|
65
67
|
const LOCK_STALE_MS = 5 * 60_000;
|
|
68
|
+
/** A Pod above this many wake-ups in an hour is worth a line (ADR 0032). */
|
|
69
|
+
const WAKE_RATE_CEILING_PER_HOUR = 6;
|
|
66
70
|
|
|
67
71
|
export async function doctor(options: DoctorOptions = {}): Promise<DoctorReport> {
|
|
68
72
|
const now = options.now ?? Date.now();
|
|
@@ -77,13 +81,24 @@ export async function doctor(options: DoctorOptions = {}): Promise<DoctorReport>
|
|
|
77
81
|
const notify = await loadNotifyConfig(undefined, (message) => warnings.push(message));
|
|
78
82
|
const lastPass = await readLastPass();
|
|
79
83
|
|
|
84
|
+
// The first run is where a defaulted owner is most likely (nothing else is
|
|
85
|
+
// configured yet), so admission is reported before the recipe can swallow it.
|
|
86
|
+
const sections: DoctorSection[] = [];
|
|
87
|
+
if (registry.error === undefined && registry.pods.length > 0) {
|
|
88
|
+
sections.push({ title: "wake", lines: wakeLines(registry.pods, listeners, now) });
|
|
89
|
+
}
|
|
90
|
+
|
|
80
91
|
const firstRun =
|
|
81
92
|
sources.length === 0 && deliverer === undefined && notify.channels.length === 0;
|
|
82
93
|
if (firstRun) {
|
|
83
|
-
return {
|
|
94
|
+
return {
|
|
95
|
+
store: root,
|
|
96
|
+
sections,
|
|
97
|
+
recipe: recipeFor(sources, listeners, deliverer, notify, lastPass),
|
|
98
|
+
healthy: false,
|
|
99
|
+
};
|
|
84
100
|
}
|
|
85
101
|
|
|
86
|
-
const sections: DoctorSection[] = [];
|
|
87
102
|
const storeLines: DoctorLine[] = [];
|
|
88
103
|
if (registry.error !== undefined) {
|
|
89
104
|
storeLines.push({ mark: "bad", text: `pods.json is unreadable: ${registry.error}` });
|
|
@@ -204,6 +219,120 @@ async function passLines(
|
|
|
204
219
|
return [{ mark: "ok", text: `last pass ${age(now - lastPass)} ago` }];
|
|
205
220
|
}
|
|
206
221
|
|
|
222
|
+
/**
|
|
223
|
+
* Admission, and the two shapes that cost money (ADR 0030, issue #31): a Pod on
|
|
224
|
+
* the tick whose goal names a clock is re-armed after every `more`, and a Pod on
|
|
225
|
+
* the tick whose goal is open-ended is woken with nothing new to work on. Both
|
|
226
|
+
* are legal records and broken intent, so they are named here rather than
|
|
227
|
+
* rewritten: the fix is an owner, and only the operator knows which one.
|
|
228
|
+
*/
|
|
229
|
+
function wakeLines(pods: PodRecord[], listeners: ListenerRecord[], now: number): DoctorLine[] {
|
|
230
|
+
const bound = new Set(
|
|
231
|
+
listeners.flatMap((listener) =>
|
|
232
|
+
listener.mode === "pod" && listener.podId !== undefined ? [listener.podId] : [],
|
|
233
|
+
),
|
|
234
|
+
);
|
|
235
|
+
const counts: Record<Admission, number> = { intake: 0, external: 0, self: 0 };
|
|
236
|
+
const findings: DoctorLine[] = [];
|
|
237
|
+
for (const pod of pods) {
|
|
238
|
+
if (pod.state === "done" || pod.state === "cancelled") continue;
|
|
239
|
+
const owner = admissionOf(pod, bound);
|
|
240
|
+
counts[owner] += 1;
|
|
241
|
+
// The mirror of `external` (ADR 0030): a Pod owned by intake with no
|
|
242
|
+
// Listener bound to it can never be given work — nothing writes the debt.
|
|
243
|
+
if (owner === "intake" && !bound.has(pod.id)) {
|
|
244
|
+
findings.push({
|
|
245
|
+
mark: "bad",
|
|
246
|
+
text: `pod ${pod.id}: admission intake but no listener is bound to it — nothing will ever give it work`,
|
|
247
|
+
detail: [`next: agents-pod listener add --inbox <dir> --pod ${pod.id}`],
|
|
248
|
+
});
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (owner !== "self") continue;
|
|
252
|
+
// A Pod that declared itself satisfied and scheduled nothing is not a Pod
|
|
253
|
+
// the tick loops on (ADR 0011 amendment, #36): `awaiting` is the honest
|
|
254
|
+
// shape of a standing goal, and naming it as a problem would teach exactly
|
|
255
|
+
// the wrong lesson. But a satisfied Pod with nothing that can deliver work
|
|
256
|
+
// has stopped for good — "wake me when work arrives" with no arrival path
|
|
257
|
+
// is the one silence this report exists to break.
|
|
258
|
+
if (pod.policy?.awaiting) {
|
|
259
|
+
if (!bound.has(pod.id)) {
|
|
260
|
+
findings.push({
|
|
261
|
+
mark: "note",
|
|
262
|
+
text: `pod ${pod.id}: awaiting — its last window was satisfied and nothing can deliver work to it`,
|
|
263
|
+
detail: [
|
|
264
|
+
` bind an inbox: agents-pod listener add --inbox <dir> --pod ${pod.id}`,
|
|
265
|
+
` or wake it by hand: agents-pod wake ${pod.id}`,
|
|
266
|
+
],
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const clock = clockPhrase(pod.goal);
|
|
272
|
+
if (clock !== undefined) {
|
|
273
|
+
findings.push({
|
|
274
|
+
mark: "bad",
|
|
275
|
+
text: `pod ${pod.id}: its goal names a clock ("${clock}") and the tick admits it — every handoff re-arms it at the speed of the passes`,
|
|
276
|
+
detail: [
|
|
277
|
+
`next: spawn the work again with --admission external (an outside clock calls \`agents-pod wake\`)`,
|
|
278
|
+
" or move the clock to schedule_task and let the schedule hand the work over",
|
|
279
|
+
],
|
|
280
|
+
});
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (openEnded(pod.goal)) {
|
|
284
|
+
findings.push({
|
|
285
|
+
mark: "bad",
|
|
286
|
+
text: `pod ${pod.id}: admission self with an open-ended goal — the tick admits it after every handoff, with nothing new to work on`,
|
|
287
|
+
detail: [
|
|
288
|
+
` if a listener feeds it: agents-pod listener add --inbox <dir> --pod ${pod.id}`,
|
|
289
|
+
" if an outside clock wakes it: a Pod spawned with --admission external (ADR 0030)",
|
|
290
|
+
],
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// The rate line (ADR 0032): a Pod the tick re-arms shows up as a rhythm, not
|
|
295
|
+
// as a single failure, and the storm was 41 wake-ups in five hours with no
|
|
296
|
+
// line anywhere that said so.
|
|
297
|
+
for (const pod of pods) {
|
|
298
|
+
if (pod.state === "done" || pod.state === "cancelled") continue;
|
|
299
|
+
const hour = inWindow(pod.wakeUps, now, HOUR_MS).length;
|
|
300
|
+
if (hour <= WAKE_RATE_CEILING_PER_HOUR) continue;
|
|
301
|
+
const median = medianIntervalMs(pod.wakeUps);
|
|
302
|
+
const rhythm = median === undefined ? "" : `, median interval ${duration(median)}`;
|
|
303
|
+
const clock = clockPhrase(pod.goal);
|
|
304
|
+
const owner = admissionOf(pod, bound);
|
|
305
|
+
findings.push({
|
|
306
|
+
mark: "note",
|
|
307
|
+
text: `pod ${pod.id}: ${hour} wake-ups in the last hour${rhythm} — above the ${WAKE_RATE_CEILING_PER_HOUR}/h ceiling`,
|
|
308
|
+
detail:
|
|
309
|
+
clock === undefined
|
|
310
|
+
? [
|
|
311
|
+
` the goal names no clock: check who admits it (agents-pod status ${pod.id})`,
|
|
312
|
+
" and its daily budget (agents-pod spawn --wake-budget, ADR 0032)",
|
|
313
|
+
]
|
|
314
|
+
: owner === "self"
|
|
315
|
+
? [
|
|
316
|
+
` its goal names a clock ("${clock}") and the tick admits it — that is the #31 shape`,
|
|
317
|
+
" next: agents-pod spawn … --admission external, or move the clock to schedule_task",
|
|
318
|
+
]
|
|
319
|
+
: [
|
|
320
|
+
` its goal names a clock ("${clock}") and ${owner} admits it — the rhythm is that clock's`,
|
|
321
|
+
` next: check the schedule that calls agents-pod wake ${pod.id}`,
|
|
322
|
+
],
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const census = `${counts.intake} intake, ${counts.external} external, ${counts.self} self`;
|
|
327
|
+
return [
|
|
328
|
+
{
|
|
329
|
+
mark: findings.length > 0 ? "note" : "ok",
|
|
330
|
+
text: `admission by owner: ${census}`,
|
|
331
|
+
},
|
|
332
|
+
...findings,
|
|
333
|
+
];
|
|
334
|
+
}
|
|
335
|
+
|
|
207
336
|
async function intakeLines(
|
|
208
337
|
sources: SourceRecord[],
|
|
209
338
|
listeners: ListenerRecord[],
|
|
@@ -268,6 +397,19 @@ async function intakeLines(
|
|
|
268
397
|
}
|
|
269
398
|
lines.push({ mark: "ok", text: `source ${source.name}: ${source.cmd}` });
|
|
270
399
|
lines.push({ mark: "ok", text: ` ${drained}, ${cursor}` });
|
|
400
|
+
// Routing leaves items behind on purpose (ADR 0034), so the count is a
|
|
401
|
+
// question and not a failure: a group nobody listens to is legitimate, a
|
|
402
|
+
// group nobody wanted is what the operator has to see.
|
|
403
|
+
if ((source.lastDrainUnrouted ?? 0) > 0) {
|
|
404
|
+
lines.push({
|
|
405
|
+
mark: "note",
|
|
406
|
+
text: `source ${source.name}: ${source.lastDrainUnrouted} item(s) of the last drain matched no listener — unrouted`,
|
|
407
|
+
detail: [
|
|
408
|
+
` the cursor passed them; a route added now applies to what arrives next`,
|
|
409
|
+
`next: agents-pod listener add --source ${source.name} --chat <chat_id> --template <file> [--project <dir>]`,
|
|
410
|
+
],
|
|
411
|
+
});
|
|
412
|
+
}
|
|
271
413
|
}
|
|
272
414
|
if (sources.length === 0) {
|
|
273
415
|
lines.push({
|
package/src/extension/handoff.ts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The Handoff document, rendered once and written once (ADR 0008, 0010).
|
|
3
3
|
*
|
|
4
|
-
* The only machine surface is the first line — `status: rotating`
|
|
5
|
-
* `status: done` — because outcome classification greps it and the
|
|
6
|
-
* `freshDeclaration()` refuses anything else. Everything after it is free
|
|
4
|
+
* The only machine surface is the first line — `status: rotating`, `status:
|
|
5
|
+
* quiet`, or `status: done` — because outcome classification greps it and the
|
|
6
|
+
* wrapper's `freshDeclaration()` refuses anything else. Everything after it is free
|
|
7
7
|
* markdown for the successor, so the sections are the ones CONTEXT.md names:
|
|
8
8
|
* goal restated, decisions and reasons, done, next, pointers.
|
|
9
9
|
*/
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* `rotating` — the goal is open and the next wake-up is the work; `quiet` — the
|
|
12
|
+
* window is satisfied, nothing is scheduled, wake me when work arrives (#36);
|
|
13
|
+
* `done` — the goal is reached and the Pod ends.
|
|
14
|
+
*/
|
|
15
|
+
export type HandoffStatus = "rotating" | "quiet" | "done";
|
|
11
16
|
|
|
12
17
|
export interface HandoffInput {
|
|
13
18
|
status: HandoffStatus;
|
|
@@ -44,12 +49,18 @@ export function renderHandoff(input: HandoffInput, now: Date = new Date()): stri
|
|
|
44
49
|
lines.push(...section("## Decisions and reasons", input.decisions));
|
|
45
50
|
lines.push(...section("## Done", input.done));
|
|
46
51
|
// Next is the one section a successor cannot live without, so it is always
|
|
47
|
-
// present — an empty one says so instead of disappearing
|
|
52
|
+
// present — an empty one says so instead of disappearing, and the two legal
|
|
53
|
+
// "nothing to do" answers say why they are empty, because the difference
|
|
54
|
+
// between them is the difference between an ended goal and a waiting one.
|
|
48
55
|
lines.push(
|
|
49
56
|
...section(
|
|
50
57
|
"## Next",
|
|
51
58
|
input.next,
|
|
52
|
-
input.status === "done"
|
|
59
|
+
input.status === "done"
|
|
60
|
+
? "(nothing — the goal is reached)"
|
|
61
|
+
: input.status === "quiet"
|
|
62
|
+
? "(nothing scheduled — the next window starts when work arrives)"
|
|
63
|
+
: "(unstated)",
|
|
53
64
|
),
|
|
54
65
|
);
|
|
55
66
|
lines.push(...section("## Pointers", input.pointers));
|
|
@@ -49,6 +49,7 @@ const POD_HELP = [
|
|
|
49
49
|
"/pod spawn <goal…> spawn a pod in this project",
|
|
50
50
|
"/pod wake <podId> wake an idle pod now",
|
|
51
51
|
"/pod cancel <podId> cancel a live wake-up",
|
|
52
|
+
"/pod rm <podId> discard a pod that no wake-up owns, with its workspace",
|
|
52
53
|
"/pod agents named agents",
|
|
53
54
|
"/pod listeners listeners and their claims",
|
|
54
55
|
].join("\n");
|
|
@@ -67,7 +68,7 @@ export function registerManagementCommands(pi: ExtensionAPI): void {
|
|
|
67
68
|
ctx.ui.notify(POD_HELP, "info");
|
|
68
69
|
return;
|
|
69
70
|
}
|
|
70
|
-
const needsPodId = ["status", "logs", "wake", "cancel", "spawn"].includes(action);
|
|
71
|
+
const needsPodId = ["status", "logs", "wake", "cancel", "rm", "spawn"].includes(action);
|
|
71
72
|
if (needsPodId && rest.length === 0) {
|
|
72
73
|
ctx.ui.notify(POD_HELP, "info");
|
|
73
74
|
return;
|
|
@@ -93,17 +94,26 @@ export function registerManagementCommands(pi: ExtensionAPI): void {
|
|
|
93
94
|
* pod, and the guardrails that make fan-out safe — the per-project cap and the
|
|
94
95
|
* `--after` dependency — are the same ones the CLI enforces, because it is the
|
|
95
96
|
* CLI doing the enforcing.
|
|
97
|
+
*
|
|
98
|
+
* The model-facing text is also where a hybrid ask is decomposed (ADR 0033):
|
|
99
|
+
* trigger × work give three shapes, the middle one is a schedule that hands over
|
|
100
|
+
* to a pod, and the rewrite is stated in one line — naming both units and the
|
|
101
|
+
* admission owner — before the first tool call, because the tie-break it
|
|
102
|
+
* replaces is what put a clock inside a pod's goal (issue #31).
|
|
96
103
|
*/
|
|
97
104
|
export function registerManagementTools(pi: ExtensionAPI): void {
|
|
98
105
|
pi.registerTool({
|
|
99
106
|
name: "spawn_pod",
|
|
100
107
|
label: "Spawn Pod",
|
|
101
108
|
description:
|
|
102
|
-
"Spawn a pod: a standing unit that outlives this session and wakes fresh agents inside it, one context window per wake-up, for as long as its goal stays open. A pod does not finish on its own — cancel it, or retire the goal, to end it: a router, a watcher, an inbox server, a multi-day refactor are all one pod. A pod is fed from a chat or an inbox (a source, e.g. feishu, plus a listener bound to it), sleeps between batches, and routes work to other pods by writing inbox items — a pod never spawns pods. A pod has NO CLOCK:
|
|
109
|
+
"Spawn a pod: a standing unit that outlives this session and wakes fresh agents inside it, one context window per wake-up, for as long as its goal stays open. A pod does not finish on its own — cancel it, or retire the goal, to end it: a router, a watcher, an inbox server, a multi-day refactor are all one pod. A pod is fed from a chat or an inbox (a source, e.g. feishu, plus a listener bound to it), sleeps between batches, and routes work to other pods by writing inbox items — a pod never spawns pods. A pod has NO CLOCK: a time rule belongs to schedule_task (agents-cron), so an ask that has both a clock and memory across firings is a schedule that hands the work over to a pod, never a pod that reads the clock. Returns the pod id.",
|
|
103
110
|
promptSnippet: "Spawn a standing pod for work that outlives this session",
|
|
104
111
|
promptGuidelines: [
|
|
105
|
-
"
|
|
106
|
-
"
|
|
112
|
+
"Read two axes before you call anything, because one ask can carry both signals, and a trigger alone does not decide — `time` without memory is a schedule (agents-cron / `schedule_task`), `message` or `role alive` is a pod (`spawn_pod`), and only the work axis separates the hybrid. trigger: `time` — 每天, 每小时, 每 30 分钟, 到点, 明天提醒我, 09:00; `message` — 收消息, 服务某个 inbox, 有活就干; `role alive` — 常驻, 监听, 路由, 直到我说停, or one job that spans days. work: `stateless` — one firing produces one answer and keeps nothing; `memory` — it needs what an earlier firing learned (跨天对比, 累计, 记得上次, 一份持续更新的报告).",
|
|
113
|
+
"Three shapes fall out of those two axes, and there is no fourth. (1) time + stateless → `schedule_task` alone: agents-cron owns the clock and the work, and a pod here is the incident. (2) time + memory → `schedule_task` owns the clock and hands the work over, `spawn_pod` owns it: bind the pod to the inbox the task appends to (`agents-pod listener add --inbox <file> --pod <podId>`) and give it `admission: intake`, or — when no batch has to be queued — let the task call `agents-pod wake <podId>` with `admission: external`. Spawn it with `dormant: true`: the record exists and nothing runs, so the clock owns every firing including the first, and the handover's first batch or `wake` is wake-up 1. Never a pod alone, never `self`, and never a spawn wake-up the clock did not order. (3) message / role alive → one pod via `spawn_pod`, `intake` when a listener or an inbox feeds it and `self` only when the goal reaches done, with no clock anywhere in its goal — call it as your FIRST tool call, without surveying the repo or the plugin first.",
|
|
114
|
+
"Say the rewrite in one line before the first tool call, naming every unit the ask actually has — and, whenever a pod is involved, its admission owner: 「这句我拆成:09:00 由 schedule_task 写 ci-daily.jsonl → pod X 被 intake 唤醒;pod X 负责汇总与跨天对比」. A schedule-only ask names no pod. If your line comes out as a pod plus a hand-written `crontab wake`, as a clock inside the pod's goal, or as a pod on `self`, that is the illegal combination (#31): say so and fix the shape instead of proceeding.",
|
|
115
|
+
"Never put a clock in a pod's goal: the phrases the guard recognises — 09:00, 每天, 每 30 分钟, 9 点, every 30 minutes, daily, 到点 — are refused at spawn when the pod's owner is the tick, and a pod re-armed on every pass is the storm. The clock belongs to the schedule; a pod is woken by its admission owner — a listener's batch, an outside wake, or the tick — never by a time rule written inside the goal.",
|
|
116
|
+
"Say who admits the pod's wake-ups (`admission`), because the tick is only one of three owners and it is the wrong one for a standing pod: `intake` when a listener or inbox feeds it (the router/watcher/inbox-server shape), `external` when an outside clock or a human wakes it with `agents-pod wake`, `self` only for a bounded goal the tick may re-wake until done. Left unstated it defaults to `self`, which wakes a standing pod every tick — the incident #31 exists to prevent.",
|
|
107
117
|
"A pod is the right shape for a role, not only for a big job: a router that judges incoming messages, a watcher, an inbox server.",
|
|
108
118
|
"A pod's agents share documents, not this conversation: put everything the successor must know into the goal text.",
|
|
109
119
|
"spawn_pod returns a pod id; report that id back to the user so they can run /pod status on it. The pod owns the work from there — do not start doing its work in this session.",
|
|
@@ -122,6 +132,18 @@ export function registerManagementTools(pi: ExtensionAPI): void {
|
|
|
122
132
|
cwd: Type.Optional(
|
|
123
133
|
Type.String({ description: "Working directory for wake-ups, relative to this project." }),
|
|
124
134
|
),
|
|
135
|
+
admission: Type.Optional(
|
|
136
|
+
Type.Union([Type.Literal("intake"), Type.Literal("external"), Type.Literal("self")], {
|
|
137
|
+
description:
|
|
138
|
+
"Who admits this pod's wake-ups (ADR 0030): `intake` — a listener feeds it (the standing/inbox shape); `external` — an outside clock or a human calls `agents-pod wake`; `self` — the tick, legal only for a bounded goal that reaches done. Default is `self`; a standing pod left there is woken on every tick.",
|
|
139
|
+
}),
|
|
140
|
+
),
|
|
141
|
+
dormant: Type.Optional(
|
|
142
|
+
Type.Boolean({
|
|
143
|
+
description:
|
|
144
|
+
"Write the pod and launch nothing, so its first wake-up belongs to its owner (ADR 0033 amendment, #43). Required for the hybrid shape: a schedule owns every firing including the first one. Needs `admission: intake` or `external` — with `self` the tick would wake it on its next pass, so the flag is refused there.",
|
|
145
|
+
}),
|
|
146
|
+
),
|
|
125
147
|
}),
|
|
126
148
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx: ExtensionContext) {
|
|
127
149
|
const argv = ["spawn", params.goal, "--project", ctx.cwd];
|
|
@@ -129,6 +151,8 @@ export function registerManagementTools(pi: ExtensionAPI): void {
|
|
|
129
151
|
if (params.model !== undefined) argv.push("--model", params.model);
|
|
130
152
|
if (params.after !== undefined) argv.push("--after", params.after);
|
|
131
153
|
if (params.cwd !== undefined) argv.push("--cwd", params.cwd);
|
|
154
|
+
if (params.admission !== undefined) argv.push("--admission", params.admission);
|
|
155
|
+
if (params.dormant === true) argv.push("--dormant");
|
|
132
156
|
|
|
133
157
|
const outcome = await runCli(argv, ctx.cwd);
|
|
134
158
|
const podId = outcome.lines.join("\n").trim();
|
|
@@ -141,10 +165,13 @@ export function registerManagementTools(pi: ExtensionAPI): void {
|
|
|
141
165
|
content: [
|
|
142
166
|
{
|
|
143
167
|
type: "text" as const,
|
|
144
|
-
text:
|
|
168
|
+
text:
|
|
169
|
+
params.dormant === true
|
|
170
|
+
? `Spawned pod ${podId} dormant: the record exists and no wake-up has run, so its first window belongs to its owner. The first handover starts it — an inbox item for \`intake\`, or \`agents-pod wake ${podId}\` for \`external\`. /pod status ${podId} shows it as "dormant: no wake-up yet".`
|
|
171
|
+
: `Spawned pod ${podId}. Wake-ups run detached; /pod status ${podId} shows progress.`,
|
|
145
172
|
},
|
|
146
173
|
],
|
|
147
|
-
details: { podId },
|
|
174
|
+
details: { podId, ...(params.dormant === true ? { dormant: true } : {}) },
|
|
148
175
|
};
|
|
149
176
|
},
|
|
150
177
|
});
|
package/src/extension/self.ts
CHANGED
|
@@ -10,9 +10,9 @@ import { renderHandoff, type HandoffStatus } from "./handoff.ts";
|
|
|
10
10
|
export const DOCS_DIR_ENV = "AGENTS_POD_DOCS_DIR";
|
|
11
11
|
|
|
12
12
|
const handoffSchema = Type.Object({
|
|
13
|
-
status: StringEnum(["rotating", "done"] as const, {
|
|
13
|
+
status: StringEnum(["rotating", "quiet", "done"] as const, {
|
|
14
14
|
description:
|
|
15
|
-
"rotating: the goal is open, a
|
|
15
|
+
"rotating: the goal is open and the next wake-up is the work. quiet: this window is satisfied, nothing is scheduled — wake me when work arrives (the honest ending for a standing goal). done: the goal is reached and the pod ends.",
|
|
16
16
|
}),
|
|
17
17
|
goal: Type.Optional(Type.String({ description: "The goal as this wake-up understood it." })),
|
|
18
18
|
decisions: Type.Optional(
|
|
@@ -47,7 +47,7 @@ export function registerSelf(pi: ExtensionAPI): void {
|
|
|
47
47
|
promptSnippet: "Write the pod handoff and end this wake-up",
|
|
48
48
|
promptGuidelines: [
|
|
49
49
|
"Call pod_handoff as the last action of a wake-up, after the work is recorded in the documents.",
|
|
50
|
-
"Use pod_handoff with status done only when the pod's goal is reached; rotating means the goal is open and the next wake-up continues from the handoff.",
|
|
50
|
+
"Use pod_handoff with status done only when the pod's goal is reached; rotating means the goal is open and the next wake-up continues from the handoff; quiet means this window is satisfied and nothing is scheduled — the honest ending for a standing goal, which must not say rotating to stay alive.",
|
|
51
51
|
"An exit without a fresh pod_handoff is classified as failed — pod_handoff is the clean exit, not a summary you may skip.",
|
|
52
52
|
],
|
|
53
53
|
parameters: handoffSchema,
|
|
@@ -59,9 +59,12 @@ export function registerSelf(pi: ExtensionAPI): void {
|
|
|
59
59
|
throw new Error(`pod_handoff requires ${DOCS_DIR_ENV}: this session is not a pod wake-up`);
|
|
60
60
|
}
|
|
61
61
|
const file = join(docsDir, "HANDOFF.md");
|
|
62
|
-
// The schema admits exactly these
|
|
62
|
+
// The schema admits exactly these three words; narrowing here keeps the
|
|
63
63
|
// hard line legal even if a provider reorders the enum.
|
|
64
|
-
const status: HandoffStatus =
|
|
64
|
+
const status: HandoffStatus =
|
|
65
|
+
params.status === "done" || params.status === "quiet"
|
|
66
|
+
? (params.status as HandoffStatus)
|
|
67
|
+
: "rotating";
|
|
65
68
|
const body = renderHandoff({ ...params, status });
|
|
66
69
|
// The agent may be editing HANDOFF.md in the same turn: one queue per file.
|
|
67
70
|
await withFileMutationQueue(file, () => writeFile(file, body));
|
package/src/listeners.ts
CHANGED
|
@@ -17,13 +17,19 @@ export type ListenerMode = "template" | "pod";
|
|
|
17
17
|
* `blocked` is a reason materialization will not happen
|
|
18
18
|
* (a configuration error, or an item that vanished). `appended` is ADR 0014's
|
|
19
19
|
* one boolean for a bound standing pod: the block is in the pod's `## Inbox`.
|
|
20
|
-
*
|
|
20
|
+
* The item copy lets a Source claim outlive the drain that produced it (ADR
|
|
21
|
+
* 0034). Beyond that the document is the truth — there is no per-item lifecycle.
|
|
21
22
|
*/
|
|
22
23
|
export interface ClaimRecord {
|
|
23
24
|
inbox: string;
|
|
24
25
|
item: string;
|
|
25
|
-
/**
|
|
26
|
+
/** The successful drain that made the claim; re-emission, not age, is its horizon (ADR 0034). */
|
|
26
27
|
claimedAt?: number;
|
|
28
|
+
title?: string;
|
|
29
|
+
body?: string;
|
|
30
|
+
chatId?: string;
|
|
31
|
+
rootId?: string;
|
|
32
|
+
threadId?: string;
|
|
27
33
|
spawnedPodId?: string;
|
|
28
34
|
blocked?: string;
|
|
29
35
|
appended?: boolean;
|
|
@@ -43,6 +49,12 @@ export interface ListenerRecord {
|
|
|
43
49
|
source?: string;
|
|
44
50
|
/** P0's rule: a plain substring; absent means every item matches. */
|
|
45
51
|
match?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Routing filter (ADR 0034): the groups this Listener claims, by the item's
|
|
54
|
+
* `chat_id`. Absent means every group. A filter is ANDed with `match`, and the
|
|
55
|
+
* first Listener in registry order that holds claims the item.
|
|
56
|
+
*/
|
|
57
|
+
chats?: string[];
|
|
46
58
|
mode: ListenerMode;
|
|
47
59
|
/** Template mode: the goal template file. */
|
|
48
60
|
template?: string;
|
package/src/registry.ts
CHANGED
|
@@ -10,12 +10,39 @@ import { podsFile, registryLockPath } from "./paths.ts";
|
|
|
10
10
|
*/
|
|
11
11
|
export type PodState = "spawning" | "active" | "idle" | "done" | "cancelled";
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
/**
|
|
14
|
+
* `more` — the goal needs another wake-up now; `quiet` — this window is
|
|
15
|
+
* satisfied and nothing is scheduled (ADR 0011 amendment, #36); `done` — the
|
|
16
|
+
* goal is reached; the rest are failures of the window, not of the Pod.
|
|
17
|
+
*/
|
|
18
|
+
export type WakeUpOutcome = "more" | "quiet" | "done" | "failed" | "lost" | "cancelled";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Who may wake this Pod (ADR 0030): `intake` — the batch a Listener owes;
|
|
22
|
+
* `external` — an outside clock or a human (`wake`); `self` — the tick. Absent
|
|
23
|
+
* on a record written before ADR 0030, where the listener binding was the only
|
|
24
|
+
* thing that could say it (src/admission.ts derives that answer, never writes it).
|
|
25
|
+
*/
|
|
26
|
+
export type Admission = "intake" | "external" | "self";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* What admitted a wake-up (ADR 0032). Three launchers existed and were
|
|
30
|
+
* indistinguishable after the fact; a storm of 41 wake-ups could not be
|
|
31
|
+
* attributed to any of them from the record.
|
|
32
|
+
*/
|
|
33
|
+
export type WakeTrigger = "spawn" | "tick" | "wake";
|
|
34
|
+
|
|
35
|
+
/** The default daily ceiling (ADR 0032): the storm ran at 41 in five hours. */
|
|
36
|
+
export const DEFAULT_WAKE_BUDGET_PER_DAY = 24;
|
|
14
37
|
|
|
15
38
|
export interface WakeUpEntry {
|
|
16
39
|
n: number;
|
|
17
40
|
outcome: WakeUpOutcome;
|
|
18
41
|
detail?: string;
|
|
42
|
+
/** When the outcome was recorded, epoch ms. Absent on records written before ADR 0032. */
|
|
43
|
+
at?: number;
|
|
44
|
+
/** What admitted this wake-up; absent on records written before ADR 0032. */
|
|
45
|
+
trigger?: WakeTrigger;
|
|
19
46
|
}
|
|
20
47
|
|
|
21
48
|
export interface PodRecord {
|
|
@@ -26,6 +53,15 @@ export interface PodRecord {
|
|
|
26
53
|
project: string;
|
|
27
54
|
/** The upstream Pod whose done state admits this Pod's first wake-up (ADR 0022). */
|
|
28
55
|
after?: string;
|
|
56
|
+
/** Decided at spawn, one of three (ADR 0030); the tick reads it before it launches. */
|
|
57
|
+
admission?: Admission;
|
|
58
|
+
/**
|
|
59
|
+
* What admitted the wake-up that is live now (ADR 0032). Written by whichever
|
|
60
|
+
* launcher ran under the registry lock, read when the outcome is recorded, so
|
|
61
|
+
* every entry can say who woke it — including the ones reconcile records on a
|
|
62
|
+
* wrapper's behalf (`lost`, wedged, progressless).
|
|
63
|
+
*/
|
|
64
|
+
admitted?: WakeTrigger;
|
|
29
65
|
wakeUps: WakeUpEntry[];
|
|
30
66
|
policy?: WakePolicy;
|
|
31
67
|
/** When a pass first observed a wedged wrapper (ADR 0017's two-pass rule). */
|
|
@@ -49,6 +85,13 @@ export interface WakePolicy {
|
|
|
49
85
|
failureStreak: number;
|
|
50
86
|
nextWakeAt?: number;
|
|
51
87
|
parked?: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Which stop parked it (ADR 0032): three different facts that read
|
|
90
|
+
* identically as `parked`. Only `failures` is lifted by an ordinary `wake`;
|
|
91
|
+
* a `budget` stop needs `wake --over-budget`, and an `agent` stop needs the
|
|
92
|
+
* definition back. Absent on records written before ADR 0032.
|
|
93
|
+
*/
|
|
94
|
+
parkedBy?: "failures" | "budget" | "agent";
|
|
52
95
|
/** How many wake-up outcomes the policy has already taken into account. */
|
|
53
96
|
appliedOutcomes: number;
|
|
54
97
|
/**
|
|
@@ -59,10 +102,23 @@ export interface WakePolicy {
|
|
|
59
102
|
* wake — the tick does not (ADR 0011 stays the rule for unbound pods).
|
|
60
103
|
*/
|
|
61
104
|
intakePending?: number;
|
|
105
|
+
/**
|
|
106
|
+
* The window declared itself satisfied with nothing scheduled (`quiet`). The
|
|
107
|
+
* tick stops admitting it; a new intake batch or a human `wake` lifts it
|
|
108
|
+
* (ADR 0011 amendment, #36). Redundant for a Pod nobody else wakes, and
|
|
109
|
+
* visible in `status` where a standing goal used to be forced into `more`.
|
|
110
|
+
*/
|
|
111
|
+
awaiting?: boolean;
|
|
62
112
|
/** The agent every wake-up resolves to unless one wake names another (ADR 0012). */
|
|
63
113
|
agent?: string;
|
|
64
114
|
/** The pod's model pin: it beats the definition's model (ADR 0012). */
|
|
65
115
|
model?: string;
|
|
116
|
+
/**
|
|
117
|
+
* The daily ceiling on wake-ups (ADR 0032), from `spawn --wake-budget`;
|
|
118
|
+
* `DEFAULT_WAKE_BUDGET_PER_DAY` when unset. Exceeding it parks the Pod, which
|
|
119
|
+
* is the one notification path that already exists (ADR 0021).
|
|
120
|
+
*/
|
|
121
|
+
wakeBudgetPerDay?: number;
|
|
66
122
|
}
|
|
67
123
|
|
|
68
124
|
export async function readPods(): Promise<PodRecord[]> {
|
package/src/sources.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { randomBytes } from "node:crypto";
|
|
|
3
3
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
4
4
|
import { dirname } from "node:path";
|
|
5
5
|
import type { BadInboxLine, InboxItem } from "./inbox.ts";
|
|
6
|
-
import type { ListenerRecord } from "./listeners.ts";
|
|
6
|
+
import type { ClaimRecord, ListenerRecord } from "./listeners.ts";
|
|
7
7
|
import { sourcesFile } from "./paths.ts";
|
|
8
8
|
|
|
9
9
|
/** A Source outlives its Listeners; its opaque cursor lives beside them (ADR 0024). */
|
|
@@ -12,13 +12,19 @@ export interface SourceRecord {
|
|
|
12
12
|
cmd: string;
|
|
13
13
|
timeoutMs: number;
|
|
14
14
|
cursor?: string;
|
|
15
|
-
/** The
|
|
15
|
+
/** The last successful drain's timestamp; the cursor, not its age, bounds claims (ADR 0034). */
|
|
16
16
|
lastDrainAt?: number;
|
|
17
17
|
/**
|
|
18
18
|
* That drain's yield (ADR 0027): zero is a fact, not a gap — a pull that runs
|
|
19
19
|
* and delivers nothing is the shape of a conversation that never reaches it.
|
|
20
20
|
*/
|
|
21
21
|
lastDrainItems?: number;
|
|
22
|
+
/**
|
|
23
|
+
* How many of that drain's items no Listener claimed (ADR 0034). A group nobody
|
|
24
|
+
* listens to is legitimate; a group being drained into nothing is the number an
|
|
25
|
+
* operator needs, and `doctor` prints it as a question rather than a failure.
|
|
26
|
+
*/
|
|
27
|
+
lastDrainUnrouted?: number;
|
|
22
28
|
/** A failed drain's fact (ADR 0027): a report cannot say `ok` while every pass fails. */
|
|
23
29
|
lastFailureAt?: number;
|
|
24
30
|
lastError?: string;
|
|
@@ -29,9 +35,10 @@ export interface SourceDrain {
|
|
|
29
35
|
items: InboxItem[];
|
|
30
36
|
bad: BadInboxLine[];
|
|
31
37
|
cursor?: string;
|
|
32
|
-
/**
|
|
38
|
+
/** A successful drain's timestamp, retained for its Source and claim records. */
|
|
33
39
|
drainedAt?: number;
|
|
34
|
-
|
|
40
|
+
/** Items this drain delivered that no Listener claimed (ADR 0034). */
|
|
41
|
+
unrouted?: number;
|
|
35
42
|
/** A failed drain contributes nothing and leaves the cursor untouched. */
|
|
36
43
|
error?: string;
|
|
37
44
|
}
|
|
@@ -202,13 +209,12 @@ async function drainOne(source: SourceRecord, project: string): Promise<SourceDr
|
|
|
202
209
|
});
|
|
203
210
|
});
|
|
204
211
|
if (result.error !== undefined) return { ...empty, error: result.error };
|
|
205
|
-
// Distinct
|
|
206
|
-
//
|
|
212
|
+
// Distinct drains need distinct timestamps even when two local drains finish
|
|
213
|
+
// within one wall-clock millisecond.
|
|
207
214
|
const drainedAt = Math.max(Date.now(), (source.lastDrainAt ?? 0) + 1);
|
|
208
215
|
return {
|
|
209
216
|
...parseDrainOutput(source.name, result.stdout ?? ""),
|
|
210
217
|
drainedAt,
|
|
211
|
-
...(source.lastDrainAt === undefined ? {} : { previousDrainAt: source.lastDrainAt }),
|
|
212
218
|
};
|
|
213
219
|
}
|
|
214
220
|
|
|
@@ -232,30 +238,39 @@ export async function drainSources(
|
|
|
232
238
|
}
|
|
233
239
|
|
|
234
240
|
/**
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
241
|
+
* A successful drain is the claim's horizon (ADRs 0024, 0034): an id it did not
|
|
242
|
+
* re-emit is behind the cursor, while an id it keeps re-emitting stays claimed
|
|
243
|
+
* for any number of passes. Failed drains and other inbox kinds have no say.
|
|
244
|
+
* Legacy claims without `claimedAt` stay because their provenance is unproven.
|
|
245
|
+
*
|
|
246
|
+
* Claims that began this pass unserved are sheltered until the next one. Their
|
|
247
|
+
* own item copy must get a chance to materialize, and a legacy miss must remain
|
|
248
|
+
* `blocked` long enough to be observed; pruning either here would recreate the
|
|
249
|
+
* silent loss this rule is meant to end. The caller writes the transformed store
|
|
250
|
+
* inside the registry lock before advancing the Source records.
|
|
240
251
|
*/
|
|
241
252
|
export function pruneSourceClaims(
|
|
242
253
|
listeners: ListenerRecord[],
|
|
243
254
|
drained: SourceDrain[],
|
|
255
|
+
deferredClaims: ReadonlySet<ClaimRecord> = new Set(),
|
|
244
256
|
): { listeners: ListenerRecord[]; changed: boolean } {
|
|
245
|
-
const
|
|
257
|
+
const reemitted = new Map<string, Set<string>>();
|
|
246
258
|
for (const drain of drained) {
|
|
247
|
-
if (drain.error === undefined
|
|
248
|
-
|
|
259
|
+
if (drain.error === undefined) {
|
|
260
|
+
reemitted.set(`source:${drain.name}`, new Set(drain.items.map((item) => item.id)));
|
|
249
261
|
}
|
|
250
262
|
}
|
|
251
|
-
if (
|
|
263
|
+
if (reemitted.size === 0) return { listeners, changed: false };
|
|
252
264
|
|
|
253
265
|
let changed = false;
|
|
254
266
|
const pruned = listeners.map((listener) => {
|
|
255
267
|
const claimed = listener.claimed.filter((claim) => {
|
|
256
|
-
const
|
|
268
|
+
const emitted = reemitted.get(claim.inbox);
|
|
257
269
|
return (
|
|
258
|
-
|
|
270
|
+
emitted === undefined ||
|
|
271
|
+
claim.claimedAt === undefined ||
|
|
272
|
+
deferredClaims.has(claim) ||
|
|
273
|
+
emitted.has(claim.item)
|
|
259
274
|
);
|
|
260
275
|
});
|
|
261
276
|
if (claimed.length === listener.claimed.length) return listener;
|
|
@@ -265,7 +280,7 @@ export function pruneSourceClaims(
|
|
|
265
280
|
return { listeners: pruned, changed };
|
|
266
281
|
}
|
|
267
282
|
|
|
268
|
-
/** Claims and pruning land before this cursor
|
|
283
|
+
/** Claims and pruning land before this cursor write (ADRs 0024, 0034). */
|
|
269
284
|
export async function commitSourceCursors(drained: SourceDrain[]): Promise<void> {
|
|
270
285
|
const sources = await readSources();
|
|
271
286
|
let changed = false;
|
|
@@ -287,6 +302,12 @@ export async function commitSourceCursors(drained: SourceDrain[]): Promise<void>
|
|
|
287
302
|
source.lastDrainItems = drain.items.length;
|
|
288
303
|
changed = true;
|
|
289
304
|
}
|
|
305
|
+
// What the filters left behind is a fact of this drain (ADR 0034), recorded
|
|
306
|
+
// whether it is zero or not for the same reason the yield is.
|
|
307
|
+
if (drain.unrouted !== undefined && source.lastDrainUnrouted !== drain.unrouted) {
|
|
308
|
+
source.lastDrainUnrouted = drain.unrouted;
|
|
309
|
+
changed = true;
|
|
310
|
+
}
|
|
290
311
|
// A success clears the failure it disproved (ADR 0027).
|
|
291
312
|
if (source.lastError !== undefined) {
|
|
292
313
|
delete source.lastError;
|