agents-pod 0.3.6 → 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 +40 -0
- package/README.md +55 -5
- package/examples/feishu/README.md +65 -0
- package/package.json +1 -1
- package/src/cli.ts +521 -58
- package/src/doctor.ts +13 -0
- package/src/extension/management.ts +15 -4
- package/src/listeners.ts +14 -2
- package/src/sources.ts +40 -19
package/src/doctor.ts
CHANGED
|
@@ -397,6 +397,19 @@ async function intakeLines(
|
|
|
397
397
|
}
|
|
398
398
|
lines.push({ mark: "ok", text: `source ${source.name}: ${source.cmd}` });
|
|
399
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
|
+
}
|
|
400
413
|
}
|
|
401
414
|
if (sources.length === 0) {
|
|
402
415
|
lines.push({
|
|
@@ -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;
|
|
@@ -109,7 +110,7 @@ export function registerManagementTools(pi: ExtensionAPI): void {
|
|
|
109
110
|
promptSnippet: "Spawn a standing pod for work that outlives this session",
|
|
110
111
|
promptGuidelines: [
|
|
111
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 (跨天对比, 累计, 记得上次, 一份持续更新的报告).",
|
|
112
|
-
"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`.
|
|
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.",
|
|
113
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.",
|
|
114
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.",
|
|
115
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.",
|
|
@@ -137,6 +138,12 @@ export function registerManagementTools(pi: ExtensionAPI): void {
|
|
|
137
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.",
|
|
138
139
|
}),
|
|
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
|
+
),
|
|
140
147
|
}),
|
|
141
148
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx: ExtensionContext) {
|
|
142
149
|
const argv = ["spawn", params.goal, "--project", ctx.cwd];
|
|
@@ -145,6 +152,7 @@ export function registerManagementTools(pi: ExtensionAPI): void {
|
|
|
145
152
|
if (params.after !== undefined) argv.push("--after", params.after);
|
|
146
153
|
if (params.cwd !== undefined) argv.push("--cwd", params.cwd);
|
|
147
154
|
if (params.admission !== undefined) argv.push("--admission", params.admission);
|
|
155
|
+
if (params.dormant === true) argv.push("--dormant");
|
|
148
156
|
|
|
149
157
|
const outcome = await runCli(argv, ctx.cwd);
|
|
150
158
|
const podId = outcome.lines.join("\n").trim();
|
|
@@ -157,10 +165,13 @@ export function registerManagementTools(pi: ExtensionAPI): void {
|
|
|
157
165
|
content: [
|
|
158
166
|
{
|
|
159
167
|
type: "text" as const,
|
|
160
|
-
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.`,
|
|
161
172
|
},
|
|
162
173
|
],
|
|
163
|
-
details: { podId },
|
|
174
|
+
details: { podId, ...(params.dormant === true ? { dormant: true } : {}) },
|
|
164
175
|
};
|
|
165
176
|
},
|
|
166
177
|
});
|
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/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;
|