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.
- package/CHANGELOG.md +75 -0
- package/README.md +86 -9
- package/examples/feishu/.env.example +23 -0
- package/examples/feishu/README.md +92 -0
- package/examples/feishu/feishu_client.py +220 -0
- package/examples/feishu/feishu_deliver.py +136 -0
- package/examples/feishu/feishu_drain.py +305 -0
- package/package.json +2 -1
- package/src/cli.ts +104 -2
- package/src/doctor.ts +551 -0
- package/src/extension/management.ts +6 -5
- package/src/paths.ts +5 -0
- package/src/registry.ts +8 -0
- package/src/sources.ts +34 -0
package/src/doctor.ts
ADDED
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { access, readdir, readFile, stat } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { readListeners, type ListenerRecord } from "./listeners.ts";
|
|
6
|
+
import { readLock } from "./locks.ts";
|
|
7
|
+
import { loadNotifyConfig } from "./notify/config.ts";
|
|
8
|
+
import type { NotificationsConfig } from "./notify/types.ts";
|
|
9
|
+
import { readDeliverer, type DelivererRecord } from "./outbox.ts";
|
|
10
|
+
import {
|
|
11
|
+
lastPassFile,
|
|
12
|
+
notifyConfigFile,
|
|
13
|
+
reconcileLockPath,
|
|
14
|
+
storeRoot,
|
|
15
|
+
} from "./paths.ts";
|
|
16
|
+
import { readPods, type PodRecord } from "./registry.ts";
|
|
17
|
+
import { readSources, type SourceRecord } from "./sources.ts";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Doctor is a read-only Health Report over the store (ADR 0027): what is
|
|
21
|
+
* missing for a conversation to reach a Pod and a reply to leave one, and the
|
|
22
|
+
* one command that fixes each gap. It reads records only — never a platform,
|
|
23
|
+
* never a credential — because ADRs 0024/0025 keep both inside the operator's
|
|
24
|
+
* commands. It prints; it never writes.
|
|
25
|
+
*/
|
|
26
|
+
export type DoctorMark = "ok" | "bad" | "note";
|
|
27
|
+
|
|
28
|
+
export interface DoctorLine {
|
|
29
|
+
mark: DoctorMark;
|
|
30
|
+
text: string;
|
|
31
|
+
/** The one command that fixes a `bad` line (ADR 0027). */
|
|
32
|
+
detail?: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface DoctorSection {
|
|
36
|
+
title: string;
|
|
37
|
+
lines: DoctorLine[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface DoctorReport {
|
|
41
|
+
store: string;
|
|
42
|
+
sections: DoctorSection[];
|
|
43
|
+
/** Present when nothing is configured: the ordered first run (ADR 0027). */
|
|
44
|
+
recipe: string[];
|
|
45
|
+
healthy: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface DoctorOptions {
|
|
49
|
+
/** `--probe`: run each operator command's `--check` (ADR 0027). Never default. */
|
|
50
|
+
probe?: boolean;
|
|
51
|
+
cwd?: string;
|
|
52
|
+
/** The environment password probes read; defaults to the process's own. */
|
|
53
|
+
env?: Record<string, string | undefined>;
|
|
54
|
+
now?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A probe's own budget: the delivery and drain budget, unchanged (ADR 0021). */
|
|
58
|
+
const PROBE_TIMEOUT_MS = 10_000;
|
|
59
|
+
/** A command that does not implement `--check` says so with this code (ADR 0027). */
|
|
60
|
+
const NO_SELF_CHECK = 3;
|
|
61
|
+
const PROBE_OUTPUT_CAP = 8 * 1024;
|
|
62
|
+
const PROBE_LINES = 6;
|
|
63
|
+
/** A pass lock older than this is what the next pass steals (ADR 0018). */
|
|
64
|
+
const LOCK_STALE_MS = 5 * 60_000;
|
|
65
|
+
|
|
66
|
+
export async function doctor(options: DoctorOptions = {}): Promise<DoctorReport> {
|
|
67
|
+
const now = options.now ?? Date.now();
|
|
68
|
+
const env = options.env ?? process.env;
|
|
69
|
+
const root = storeRoot();
|
|
70
|
+
|
|
71
|
+
const registry = await readRegistry();
|
|
72
|
+
const sources = await readSources().catch(() => [] as SourceRecord[]);
|
|
73
|
+
const listeners = await readListeners();
|
|
74
|
+
const deliverer = await readDeliverer();
|
|
75
|
+
const warnings: string[] = [];
|
|
76
|
+
const notify = await loadNotifyConfig(undefined, (message) => warnings.push(message));
|
|
77
|
+
const lastPass = await readLastPass();
|
|
78
|
+
|
|
79
|
+
const firstRun =
|
|
80
|
+
sources.length === 0 && deliverer === undefined && notify.channels.length === 0;
|
|
81
|
+
if (firstRun) {
|
|
82
|
+
return { store: root, sections: [], recipe: recipeFor(sources, listeners, deliverer, notify, lastPass), healthy: false };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const sections: DoctorSection[] = [];
|
|
86
|
+
const storeLines: DoctorLine[] = [];
|
|
87
|
+
if (registry.error !== undefined) {
|
|
88
|
+
storeLines.push({ mark: "bad", text: `pods.json is unreadable: ${registry.error}` });
|
|
89
|
+
} else {
|
|
90
|
+
const active = registry.pods.filter((pod) => pod.state === "active").length;
|
|
91
|
+
storeLines.push({ mark: "ok", text: `${registry.pods.length} pods, ${active} active` });
|
|
92
|
+
}
|
|
93
|
+
const lock = await readLock(reconcileLockPath());
|
|
94
|
+
if (lock !== undefined) {
|
|
95
|
+
storeLines.push(
|
|
96
|
+
now - lock.startedAt > LOCK_STALE_MS
|
|
97
|
+
? { mark: "note", text: `a pass lock has been held for ${age(now - lock.startedAt)} (the next pass steals it)` }
|
|
98
|
+
: { mark: "note", text: `a pass is running (pid ${lock.pid})` },
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
sections.push({ title: "store", lines: storeLines });
|
|
102
|
+
|
|
103
|
+
sections.push({ title: "pass", lines: await passLines(lastPass, now, sources, listeners, registry.pods) });
|
|
104
|
+
sections.push({ title: "intake", lines: await intakeLines(sources, listeners, now) });
|
|
105
|
+
sections.push({ title: "listeners", lines: await listenerLines(listeners, sources, registry.pods) });
|
|
106
|
+
sections.push({ title: "outbound", lines: await outboundLines(deliverer, registry.pods) });
|
|
107
|
+
sections.push({ title: "notify", lines: notifyLines(notify, warnings, env) });
|
|
108
|
+
|
|
109
|
+
if (options.probe) {
|
|
110
|
+
sections.push({
|
|
111
|
+
title: "checks",
|
|
112
|
+
lines: [
|
|
113
|
+
...(await probeAll(sources, listeners, deliverer, options.cwd ?? process.cwd(), env)),
|
|
114
|
+
],
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const recipe = firstRun
|
|
119
|
+
? []
|
|
120
|
+
: recipeFor(sources, listeners, deliverer, notify, lastPass);
|
|
121
|
+
const healthy = sections.every((section) => section.lines.every((line) => line.mark !== "bad"));
|
|
122
|
+
return { store: root, sections, recipe, healthy };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function readRegistry(): Promise<{ pods: PodRecord[]; error?: string }> {
|
|
126
|
+
try {
|
|
127
|
+
return { pods: await readPods() };
|
|
128
|
+
} catch (error) {
|
|
129
|
+
return { pods: [], error: error instanceof Error ? error.message : String(error) };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The pass marker (ADR 0027) exists because "cron is not installed" and "cron
|
|
135
|
+
* runs and finds nothing" are the same silence without it.
|
|
136
|
+
*/
|
|
137
|
+
async function readLastPass(): Promise<number | undefined> {
|
|
138
|
+
try {
|
|
139
|
+
const parsed: unknown = JSON.parse(await readFile(lastPassFile(), "utf8"));
|
|
140
|
+
const at = (parsed as { at?: unknown }).at;
|
|
141
|
+
return typeof at === "number" ? at : undefined;
|
|
142
|
+
} catch {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function passLines(
|
|
148
|
+
lastPass: number | undefined,
|
|
149
|
+
now: number,
|
|
150
|
+
sources: SourceRecord[],
|
|
151
|
+
listeners: ListenerRecord[],
|
|
152
|
+
pods: PodRecord[],
|
|
153
|
+
): Promise<DoctorLine[]> {
|
|
154
|
+
const expected = sources.length > 0 || listeners.length > 0 || pods.length > 0;
|
|
155
|
+
if (lastPass === undefined) {
|
|
156
|
+
return [
|
|
157
|
+
expected
|
|
158
|
+
? {
|
|
159
|
+
mark: "bad",
|
|
160
|
+
text: "no pass has ever run — nothing is woken, no Source is drained",
|
|
161
|
+
detail: ["next: crontab -e", " * * * * * cd <project> && agents-pod reconcile >> ~/.agents/pods/cron.log 2>&1"],
|
|
162
|
+
}
|
|
163
|
+
: { mark: "note", text: "no pass has run yet" },
|
|
164
|
+
];
|
|
165
|
+
}
|
|
166
|
+
return [{ mark: "ok", text: `last pass ${age(now - lastPass)} ago` }];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function intakeLines(
|
|
170
|
+
sources: SourceRecord[],
|
|
171
|
+
listeners: ListenerRecord[],
|
|
172
|
+
now: number,
|
|
173
|
+
): Promise<DoctorLine[]> {
|
|
174
|
+
const lines: DoctorLine[] = [];
|
|
175
|
+
const consumed = new Set(listeners.flatMap((listener) => listener.source ?? []));
|
|
176
|
+
for (const source of sources) {
|
|
177
|
+
const consumer = consumed.has(source.name);
|
|
178
|
+
const executable = await isExecutable(source.cmd);
|
|
179
|
+
if (!executable) {
|
|
180
|
+
lines.push({
|
|
181
|
+
mark: "bad",
|
|
182
|
+
text: `source ${source.name}: ${source.cmd} is not an executable file`,
|
|
183
|
+
detail: [
|
|
184
|
+
`next: agents-pod source rm ${source.name}`,
|
|
185
|
+
` agents-pod source add ${source.name} --cmd <path> --timeout ${source.timeoutMs / 1000}`,
|
|
186
|
+
],
|
|
187
|
+
});
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const drained =
|
|
191
|
+
source.lastDrainAt === undefined
|
|
192
|
+
? "never drained successfully"
|
|
193
|
+
: `last success ${age(now - source.lastDrainAt)} ago, ${
|
|
194
|
+
source.lastDrainItems === undefined
|
|
195
|
+
? "yield not recorded"
|
|
196
|
+
: `${source.lastDrainItems} items`
|
|
197
|
+
}`;
|
|
198
|
+
const cursor = source.cursor === undefined ? "no cursor" : `cursor ${source.cursor}`;
|
|
199
|
+
// A Source that fails every pass is the loudest thing a report can say; the
|
|
200
|
+
// cursor never moving is otherwise indistinguishable from a quiet chat.
|
|
201
|
+
if (source.lastError !== undefined) {
|
|
202
|
+
const failed = source.lastFailureAt === undefined ? "the last drain" : `the last drain (${age(now - source.lastFailureAt)} ago)`;
|
|
203
|
+
lines.push({
|
|
204
|
+
mark: "bad",
|
|
205
|
+
text: `source ${source.name}: ${failed} failed — ${source.lastError}`,
|
|
206
|
+
detail: [
|
|
207
|
+
` ${drained}; ${cursor} (neither moves until a drain succeeds)`,
|
|
208
|
+
" run: agents-pod doctor --probe",
|
|
209
|
+
],
|
|
210
|
+
});
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (!consumer) {
|
|
214
|
+
lines.push({
|
|
215
|
+
mark: "note",
|
|
216
|
+
text: `source ${source.name}: ${drained}, ${cursor} — no listener consumes it, so no pass runs it`,
|
|
217
|
+
detail: ["next: agents-pod listener add --source " + source.name + " --template <file> [--project <dir>]"],
|
|
218
|
+
});
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
// Zero items is a quiet chat, not a failure — but it is the exact shape of
|
|
222
|
+
// "the pull is pointed at nothing", so it never hides (ADR 0027).
|
|
223
|
+
if (source.lastDrainItems === 0) {
|
|
224
|
+
lines.push({
|
|
225
|
+
mark: "note",
|
|
226
|
+
text: `source ${source.name}: ${drained} — nothing arrives; the command runs, the conversation does not reach it`,
|
|
227
|
+
detail: [` ${cursor}; run: agents-pod doctor --probe`],
|
|
228
|
+
});
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
lines.push({ mark: "ok", text: `source ${source.name}: ${source.cmd}` });
|
|
232
|
+
lines.push({ mark: "ok", text: ` ${drained}, ${cursor}` });
|
|
233
|
+
}
|
|
234
|
+
if (sources.length === 0) {
|
|
235
|
+
lines.push({
|
|
236
|
+
mark: "note",
|
|
237
|
+
text: "no Source: nothing from outside this machine becomes an Inbox Item",
|
|
238
|
+
detail: ['next: agents-pod source add <name> --cmd <path> (README "Writing a Source drain")'],
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
return lines;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function listenerLines(
|
|
245
|
+
listeners: ListenerRecord[],
|
|
246
|
+
sources: SourceRecord[],
|
|
247
|
+
pods: PodRecord[],
|
|
248
|
+
): Promise<DoctorLine[]> {
|
|
249
|
+
const lines: DoctorLine[] = [];
|
|
250
|
+
for (const listener of listeners) {
|
|
251
|
+
const problems: string[] = [];
|
|
252
|
+
const origin =
|
|
253
|
+
listener.source !== undefined
|
|
254
|
+
? `source ${listener.source}`
|
|
255
|
+
: `inbox ${listener.inbox ?? "(none)"}`;
|
|
256
|
+
if (listener.source !== undefined && !sources.some((source) => source.name === listener.source)) {
|
|
257
|
+
problems.push(`its Source ${listener.source} is not registered`);
|
|
258
|
+
}
|
|
259
|
+
if (listener.source === undefined && !(await exists(listener.inbox ?? ""))) {
|
|
260
|
+
problems.push(`its inbox ${listener.inbox ?? "(none)"} does not exist`);
|
|
261
|
+
}
|
|
262
|
+
if (listener.mode === "template") {
|
|
263
|
+
if (!(await exists(listener.template ?? ""))) {
|
|
264
|
+
problems.push(`its template ${listener.template ?? "(none)"} does not exist`);
|
|
265
|
+
}
|
|
266
|
+
if (listener.project !== undefined && !(await exists(listener.project))) {
|
|
267
|
+
problems.push(`its project ${listener.project} does not exist`);
|
|
268
|
+
}
|
|
269
|
+
} else if (!pods.some((pod) => pod.id === listener.podId)) {
|
|
270
|
+
problems.push(`its Pod ${listener.podId ?? "(none)"} is not in the registry`);
|
|
271
|
+
}
|
|
272
|
+
if (problems.length > 0) {
|
|
273
|
+
lines.push({
|
|
274
|
+
mark: "bad",
|
|
275
|
+
text: `${listener.id}: ${problems.join("; ")}`,
|
|
276
|
+
detail: [`next: agents-pod listener rm ${listener.id}`, " then add it again with a path that exists"],
|
|
277
|
+
});
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const target =
|
|
281
|
+
listener.mode === "template" ? `template ${listener.template ?? "(none)"}` : `pod ${listener.podId}`;
|
|
282
|
+
const project = listener.project !== undefined ? ` → ${listener.project}` : "";
|
|
283
|
+
lines.push({ mark: "ok", text: `${listener.id}: ${origin} → ${target}${project}` });
|
|
284
|
+
}
|
|
285
|
+
if (listeners.length === 0) {
|
|
286
|
+
lines.push({
|
|
287
|
+
mark: "note",
|
|
288
|
+
text: "no Listener: a Source is drained only for a Listener that consumes it",
|
|
289
|
+
detail: ["next: agents-pod listener add --source <name> --template <file> [--project <dir>]"],
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
return lines;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function outboundLines(deliverer: DelivererRecord | undefined, pods: PodRecord[]): Promise<DoctorLine[]> {
|
|
296
|
+
const lines: DoctorLine[] = [];
|
|
297
|
+
const { pending, dead } = await outboxFacts(pods);
|
|
298
|
+
if (deliverer === undefined) {
|
|
299
|
+
lines.push({
|
|
300
|
+
mark: "bad",
|
|
301
|
+
text:
|
|
302
|
+
pending > 0
|
|
303
|
+
? `no deliverer: ${pending} entries in Pod outboxes are never sent`
|
|
304
|
+
: "no deliverer: a Pod's outbox is never sent",
|
|
305
|
+
detail: [
|
|
306
|
+
"next: agents-pod deliver add <name> --cmd <path> --timeout <s>",
|
|
307
|
+
" the command owns the platform credentials (ADR 0025)",
|
|
308
|
+
],
|
|
309
|
+
});
|
|
310
|
+
} else if (!(await isExecutable(deliverer.cmd))) {
|
|
311
|
+
lines.push({
|
|
312
|
+
mark: "bad",
|
|
313
|
+
text: `deliverer ${deliverer.name}: ${deliverer.cmd} is not an executable file`,
|
|
314
|
+
detail: [`next: agents-pod deliver rm ${deliverer.name}, then deliver add with a real path`],
|
|
315
|
+
});
|
|
316
|
+
} else {
|
|
317
|
+
lines.push({ mark: "ok", text: `deliverer ${deliverer.name}: ${deliverer.cmd}` });
|
|
318
|
+
}
|
|
319
|
+
if (pending > 0) lines.push({ mark: "note", text: `${pending} entries waiting in Pod outboxes` });
|
|
320
|
+
if (dead > 0) {
|
|
321
|
+
lines.push({
|
|
322
|
+
mark: "note",
|
|
323
|
+
text: `${dead} dead entries retained (older than an hour or malformed; kept, never retried)`,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
return lines;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Outbox entries are facts of the Pod's workspace, not of the registry (ADR 0025). */
|
|
330
|
+
async function outboxFacts(pods: PodRecord[]): Promise<{ pending: number; dead: number }> {
|
|
331
|
+
let pending = 0;
|
|
332
|
+
let dead = 0;
|
|
333
|
+
for (const pod of pods) {
|
|
334
|
+
const dir = join(pod.project, ".agents", "pods", pod.id, "outbox");
|
|
335
|
+
let names: string[];
|
|
336
|
+
try {
|
|
337
|
+
names = await readdir(dir);
|
|
338
|
+
} catch {
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
for (const name of names) {
|
|
342
|
+
if (name.endsWith(".dead")) dead += 1;
|
|
343
|
+
else if (name.endsWith(".json")) pending += 1;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return { pending, dead };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function notifyLines(
|
|
350
|
+
notify: NotificationsConfig,
|
|
351
|
+
warnings: string[],
|
|
352
|
+
env: Record<string, string | undefined>,
|
|
353
|
+
): DoctorLine[] {
|
|
354
|
+
const lines: DoctorLine[] = warnings.map((warning) => ({ mark: "bad" as const, text: warning }));
|
|
355
|
+
if (notify.channels.length === 0) {
|
|
356
|
+
lines.push({
|
|
357
|
+
mark: "note",
|
|
358
|
+
text: "no notification channels: parks and dones stay silent",
|
|
359
|
+
detail: [`next: add channels to ${notifyConfigFile()} (README "Notifications")`],
|
|
360
|
+
});
|
|
361
|
+
return lines;
|
|
362
|
+
}
|
|
363
|
+
lines.push({ mark: "ok", text: `${notify.channels.length} channels (on: ${notify.on})` });
|
|
364
|
+
if (notify.on === "never") {
|
|
365
|
+
lines.push({ mark: "note", text: "notifications.on is never: every channel is ignored" });
|
|
366
|
+
}
|
|
367
|
+
for (const channel of notify.channels) {
|
|
368
|
+
if (channel.type !== "email" || channel.user === undefined) continue;
|
|
369
|
+
const passwordEnv = channel.passwordEnv ?? "AGENTS_POD_SMTP_PASSWORD";
|
|
370
|
+
if (!env[passwordEnv]) {
|
|
371
|
+
lines.push({
|
|
372
|
+
mark: "bad",
|
|
373
|
+
text: `channel ${channel.id}: ${passwordEnv} is not set in this environment`,
|
|
374
|
+
detail: [" an authenticated SMTP send will fail; core reads no credentials (ADR 0021)"],
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return lines;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* `--check` is the only way core can learn about a credential it must not hold
|
|
383
|
+
* (ADR 0027): exit 0 healthy, exit 3 no self-check, anything else unhealthy
|
|
384
|
+
* with the command's own words quoted.
|
|
385
|
+
*/
|
|
386
|
+
async function probeAll(
|
|
387
|
+
sources: SourceRecord[],
|
|
388
|
+
listeners: ListenerRecord[],
|
|
389
|
+
deliverer: DelivererRecord | undefined,
|
|
390
|
+
cwd: string,
|
|
391
|
+
env: Record<string, string | undefined>,
|
|
392
|
+
): Promise<DoctorLine[]> {
|
|
393
|
+
const lines: DoctorLine[] = [];
|
|
394
|
+
const consumed = new Set(listeners.flatMap((listener) => listener.source ?? []));
|
|
395
|
+
for (const source of sources) {
|
|
396
|
+
lines.push(
|
|
397
|
+
...(await trace(`source ${source.name}`, source.cmd, {
|
|
398
|
+
...env,
|
|
399
|
+
AGENTS_POD_SOURCE_ID: source.name,
|
|
400
|
+
AGENTS_POD_CURSOR: source.cursor ?? "",
|
|
401
|
+
}, cwd)),
|
|
402
|
+
);
|
|
403
|
+
if (!consumed.has(source.name)) {
|
|
404
|
+
lines.push({ mark: "note", text: `source ${source.name}: no listener consumes it, so no pass runs it` });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (deliverer !== undefined) {
|
|
408
|
+
lines.push(...(await trace(`deliverer ${deliverer.name}`, deliverer.cmd, env, cwd)));
|
|
409
|
+
}
|
|
410
|
+
if (lines.length === 0) {
|
|
411
|
+
lines.push({ mark: "note", text: "nothing to probe: no Source and no deliverer configured" });
|
|
412
|
+
}
|
|
413
|
+
return lines;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function trace(
|
|
417
|
+
label: string,
|
|
418
|
+
cmd: string,
|
|
419
|
+
env: Record<string, string | undefined>,
|
|
420
|
+
cwd: string,
|
|
421
|
+
): Promise<DoctorLine[]> {
|
|
422
|
+
if (!(await isExecutable(cmd))) {
|
|
423
|
+
return [{ mark: "bad", text: `${label}: ${cmd} is not an executable file` }];
|
|
424
|
+
}
|
|
425
|
+
const { code, signal, text } = await runCheck(cmd, env, cwd);
|
|
426
|
+
const quoted = text
|
|
427
|
+
.split("\n")
|
|
428
|
+
.map((line) => line.trim())
|
|
429
|
+
.filter((line) => line !== "")
|
|
430
|
+
.slice(0, PROBE_LINES)
|
|
431
|
+
.map((line) => ` ${line}`);
|
|
432
|
+
if (code === 0) {
|
|
433
|
+
return [{ mark: "ok", text: `${label}: self-check passed`, ...(quoted.length ? { detail: quoted } : {}) }];
|
|
434
|
+
}
|
|
435
|
+
if (code === NO_SELF_CHECK) {
|
|
436
|
+
return [
|
|
437
|
+
{
|
|
438
|
+
mark: "note",
|
|
439
|
+
text: `${label}: no self-check (exit ${NO_SELF_CHECK}) — core cannot verify anything it owns`,
|
|
440
|
+
detail: [" add `--check` to the command (exit 0 healthy, 3 unsupported) to have it verified here"],
|
|
441
|
+
},
|
|
442
|
+
];
|
|
443
|
+
}
|
|
444
|
+
const why = signal !== null ? `terminated by ${signal}` : `exit ${code ?? "unknown"}`;
|
|
445
|
+
return [
|
|
446
|
+
{ mark: "bad", text: `${label}: self-check failed (${why})`, detail: quoted.length ? quoted : undefined },
|
|
447
|
+
];
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function runCheck(
|
|
451
|
+
cmd: string,
|
|
452
|
+
env: Record<string, string | undefined>,
|
|
453
|
+
cwd: string,
|
|
454
|
+
): Promise<{ code: number | null; signal: NodeJS.Signals | null; text: string }> {
|
|
455
|
+
return new Promise((resolve) => {
|
|
456
|
+
const child = spawn(cmd, ["--check"], { cwd, env: env as NodeJS.ProcessEnv, detached: true });
|
|
457
|
+
const chunks: Buffer[] = [];
|
|
458
|
+
let bytes = 0;
|
|
459
|
+
const collect = (chunk: Buffer): void => {
|
|
460
|
+
if (bytes >= PROBE_OUTPUT_CAP) return;
|
|
461
|
+
bytes += chunk.length;
|
|
462
|
+
chunks.push(chunk);
|
|
463
|
+
};
|
|
464
|
+
const timer = setTimeout(() => {
|
|
465
|
+
try {
|
|
466
|
+
process.kill(-child.pid!, "SIGKILL");
|
|
467
|
+
} catch {
|
|
468
|
+
child.kill("SIGKILL");
|
|
469
|
+
}
|
|
470
|
+
}, PROBE_TIMEOUT_MS);
|
|
471
|
+
child.stdout.on("data", collect);
|
|
472
|
+
child.stderr.on("data", collect);
|
|
473
|
+
child.on("error", (error) => {
|
|
474
|
+
clearTimeout(timer);
|
|
475
|
+
resolve({ code: null, signal: null, text: error.message });
|
|
476
|
+
});
|
|
477
|
+
child.on("close", (code, signal) => {
|
|
478
|
+
clearTimeout(timer);
|
|
479
|
+
resolve({ code, signal, text: Buffer.concat(chunks).toString("utf8") });
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* The first run, in order (ADR 0027): each line is one command, and the last
|
|
486
|
+
* one is the report that says every line is `✓`.
|
|
487
|
+
*/
|
|
488
|
+
function recipeFor(
|
|
489
|
+
sources: SourceRecord[],
|
|
490
|
+
listeners: ListenerRecord[],
|
|
491
|
+
deliverer: DelivererRecord | undefined,
|
|
492
|
+
notify: NotificationsConfig,
|
|
493
|
+
lastPass: number | undefined,
|
|
494
|
+
): string[] {
|
|
495
|
+
const steps: string[][] = [];
|
|
496
|
+
if (sources.length === 0) {
|
|
497
|
+
steps.push([
|
|
498
|
+
'write a Source command: one JSONL item per message, then {"cursor":"…"}',
|
|
499
|
+
' README "Writing a Source drain"; a worked Feishu pair: examples/feishu/',
|
|
500
|
+
" agents-pod source add <name> --cmd <path> --timeout <s>",
|
|
501
|
+
]);
|
|
502
|
+
}
|
|
503
|
+
if (listeners.length === 0) {
|
|
504
|
+
steps.push(["agents-pod listener add --source <name> --template <file> [--project <dir>]"]);
|
|
505
|
+
}
|
|
506
|
+
if (deliverer === undefined) {
|
|
507
|
+
steps.push(["agents-pod deliver add <name> --cmd <path> --timeout <s>"]);
|
|
508
|
+
}
|
|
509
|
+
if (notify.channels.length === 0) {
|
|
510
|
+
steps.push([`add notification channels to ${notifyConfigFile()} (README "Notifications")`]);
|
|
511
|
+
}
|
|
512
|
+
if (lastPass === undefined) {
|
|
513
|
+
steps.push([
|
|
514
|
+
"crontab: * * * * * cd <project> && agents-pod reconcile >> ~/.agents/pods/cron.log 2>&1",
|
|
515
|
+
]);
|
|
516
|
+
}
|
|
517
|
+
steps.push(["agents-pod doctor --probe — until every line is ✓"]);
|
|
518
|
+
return steps.flatMap((lines, index) =>
|
|
519
|
+
lines.map((line, position) => (position === 0 ? `${index + 1}. ${line}` : ` ${line}`)),
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async function exists(path: string): Promise<boolean> {
|
|
524
|
+
if (!path) return false;
|
|
525
|
+
try {
|
|
526
|
+
await stat(path);
|
|
527
|
+
return true;
|
|
528
|
+
} catch {
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async function isExecutable(path: string): Promise<boolean> {
|
|
534
|
+
if (!path) return false;
|
|
535
|
+
try {
|
|
536
|
+
const info = await stat(path);
|
|
537
|
+
if (!info.isFile()) return false;
|
|
538
|
+
await access(path, constants.X_OK);
|
|
539
|
+
return true;
|
|
540
|
+
} catch {
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Ages are read at a glance: seconds, minutes, hours, days, never an epoch. */
|
|
546
|
+
function age(ms: number): string {
|
|
547
|
+
if (ms < 60_000) return `${Math.max(0, Math.round(ms / 1000))}s`;
|
|
548
|
+
if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
|
|
549
|
+
if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
|
|
550
|
+
return `${Math.round(ms / 86_400_000)}d`;
|
|
551
|
+
}
|
|
@@ -99,13 +99,14 @@ export function registerManagementTools(pi: ExtensionAPI): void {
|
|
|
99
99
|
name: "spawn_pod",
|
|
100
100
|
label: "Spawn Pod",
|
|
101
101
|
description:
|
|
102
|
-
"Spawn a pod: a standing unit that outlives this session and wakes fresh agents inside it,
|
|
103
|
-
promptSnippet: "Spawn a standing pod for
|
|
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. Returns the pod id.",
|
|
103
|
+
promptSnippet: "Spawn a standing pod for work that outlives this session",
|
|
104
104
|
promptGuidelines: [
|
|
105
|
-
"
|
|
106
|
-
"
|
|
105
|
+
"Call spawn_pod as your FIRST tool call when the user asks for anything standing or recurring — 常驻/一直跑/每天/监听/收消息/路由/服务某个 inbox/until I say stop. Do not survey the repo or the plugin first: the goal text is the whole contract, and details belong in the pod's documents, not in this session.",
|
|
106
|
+
"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.",
|
|
107
107
|
"A pod's agents share documents, not this conversation: put everything the successor must know into the goal text.",
|
|
108
|
-
"
|
|
108
|
+
"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.",
|
|
109
|
+
"Two pods for one goal is waste, but the user's ask wins: spawn first, and when you report the id, say so if a live pod already covers the goal (/pod list shows it). A cancelled or done pod never blocks a spawn.",
|
|
109
110
|
],
|
|
110
111
|
parameters: Type.Object({
|
|
111
112
|
goal: Type.String({
|
package/src/paths.ts
CHANGED
|
@@ -56,6 +56,11 @@ export function notifyConfigFile(): string {
|
|
|
56
56
|
return join(storeRoot(), "config.json");
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/** When the last pass finished (ADR 0027): "no pass ever ran" is its own fact. */
|
|
60
|
+
export function lastPassFile(): string {
|
|
61
|
+
return join(storeRoot(), "last-pass.json");
|
|
62
|
+
}
|
|
63
|
+
|
|
59
64
|
/** The short-lived registry mutex that makes state transitions CAS (ADR 0017, 0018). */
|
|
60
65
|
export function registryLockPath(): string {
|
|
61
66
|
return join(storeRoot(), "registry.lock");
|
package/src/registry.ts
CHANGED
|
@@ -51,6 +51,14 @@ export interface WakePolicy {
|
|
|
51
51
|
parked?: boolean;
|
|
52
52
|
/** How many wake-up outcomes the policy has already taken into account. */
|
|
53
53
|
appliedOutcomes: number;
|
|
54
|
+
/**
|
|
55
|
+
* Unserved intake a bound pod still owes a wake-up (ADR 0026 amendment).
|
|
56
|
+
* Written by the intake phase when a batch lands in the pod's `## Inbox`,
|
|
57
|
+
* cleared by the pass that applies a clean outcome for the wake-up it
|
|
58
|
+
* admitted. Non-zero is the only thing that admits an intake-driven pod's
|
|
59
|
+
* wake — the tick does not (ADR 0011 stays the rule for unbound pods).
|
|
60
|
+
*/
|
|
61
|
+
intakePending?: number;
|
|
54
62
|
/** The agent every wake-up resolves to unless one wake names another (ADR 0012). */
|
|
55
63
|
agent?: string;
|
|
56
64
|
/** The pod's model pin: it beats the definition's model (ADR 0012). */
|
package/src/sources.ts
CHANGED
|
@@ -14,6 +14,14 @@ export interface SourceRecord {
|
|
|
14
14
|
cursor?: string;
|
|
15
15
|
/** The previous successful drain is the claim-retention horizon (ADR 0024). */
|
|
16
16
|
lastDrainAt?: number;
|
|
17
|
+
/**
|
|
18
|
+
* That drain's yield (ADR 0027): zero is a fact, not a gap — a pull that runs
|
|
19
|
+
* and delivers nothing is the shape of a conversation that never reaches it.
|
|
20
|
+
*/
|
|
21
|
+
lastDrainItems?: number;
|
|
22
|
+
/** A failed drain's fact (ADR 0027): a report cannot say `ok` while every pass fails. */
|
|
23
|
+
lastFailureAt?: number;
|
|
24
|
+
lastError?: string;
|
|
17
25
|
}
|
|
18
26
|
|
|
19
27
|
export interface SourceDrain {
|
|
@@ -273,6 +281,32 @@ export async function commitSourceCursors(drained: SourceDrain[]): Promise<void>
|
|
|
273
281
|
source.lastDrainAt = drain.drainedAt;
|
|
274
282
|
changed = true;
|
|
275
283
|
}
|
|
284
|
+
// The yield is recorded even when it is zero (ADR 0027): doctor reports a
|
|
285
|
+
// silent pull only because the count exists.
|
|
286
|
+
if (source.lastDrainItems !== drain.items.length) {
|
|
287
|
+
source.lastDrainItems = drain.items.length;
|
|
288
|
+
changed = true;
|
|
289
|
+
}
|
|
290
|
+
// A success clears the failure it disproved (ADR 0027).
|
|
291
|
+
if (source.lastError !== undefined) {
|
|
292
|
+
delete source.lastError;
|
|
293
|
+
delete source.lastFailureAt;
|
|
294
|
+
changed = true;
|
|
295
|
+
}
|
|
276
296
|
}
|
|
277
297
|
if (changed) await writeSources(sources);
|
|
278
298
|
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* A failed drain leaves the cursor and the yield alone, so its only trace is a
|
|
302
|
+
* `cron.log` line — invisible to anyone not tailing the log. The record keeps
|
|
303
|
+
* the fact for the report (ADR 0027), and a later success clears it.
|
|
304
|
+
*/
|
|
305
|
+
export async function recordSourceFailure(name: string, error: string): Promise<void> {
|
|
306
|
+
const sources = await readSources();
|
|
307
|
+
const source = sources.find((candidate) => candidate.name === name);
|
|
308
|
+
if (source === undefined) return;
|
|
309
|
+
source.lastFailureAt = Date.now();
|
|
310
|
+
source.lastError = error;
|
|
311
|
+
await writeSources(sources);
|
|
312
|
+
}
|