agents-pod 0.3.5 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +114 -0
- package/README.md +87 -19
- package/package.json +1 -1
- package/src/admission.ts +102 -0
- package/src/cadence.ts +67 -0
- package/src/cli.ts +378 -50
- package/src/doctor.ts +132 -3
- package/src/extension/handoff.ts +17 -6
- package/src/extension/management.ts +19 -3
- package/src/extension/self.ts +8 -5
- package/src/registry.ts +57 -1
- 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[],
|
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));
|
|
@@ -93,17 +93,26 @@ export function registerManagementCommands(pi: ExtensionAPI): void {
|
|
|
93
93
|
* pod, and the guardrails that make fan-out safe — the per-project cap and the
|
|
94
94
|
* `--after` dependency — are the same ones the CLI enforces, because it is the
|
|
95
95
|
* CLI doing the enforcing.
|
|
96
|
+
*
|
|
97
|
+
* The model-facing text is also where a hybrid ask is decomposed (ADR 0033):
|
|
98
|
+
* trigger × work give three shapes, the middle one is a schedule that hands over
|
|
99
|
+
* to a pod, and the rewrite is stated in one line — naming both units and the
|
|
100
|
+
* admission owner — before the first tool call, because the tie-break it
|
|
101
|
+
* replaces is what put a clock inside a pod's goal (issue #31).
|
|
96
102
|
*/
|
|
97
103
|
export function registerManagementTools(pi: ExtensionAPI): void {
|
|
98
104
|
pi.registerTool({
|
|
99
105
|
name: "spawn_pod",
|
|
100
106
|
label: "Spawn Pod",
|
|
101
107
|
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:
|
|
108
|
+
"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
109
|
promptSnippet: "Spawn a standing pod for work that outlives this session",
|
|
104
110
|
promptGuidelines: [
|
|
105
|
-
"
|
|
106
|
-
"
|
|
111
|
+
"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`. Its spawn wake-up runs once immediately; every firing after that belongs to the handover. Never a pod alone, and never `self`. (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
|
+
"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
|
+
"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
|
+
"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
116
|
"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
117
|
"A pod's agents share documents, not this conversation: put everything the successor must know into the goal text.",
|
|
109
118
|
"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 +131,12 @@ export function registerManagementTools(pi: ExtensionAPI): void {
|
|
|
122
131
|
cwd: Type.Optional(
|
|
123
132
|
Type.String({ description: "Working directory for wake-ups, relative to this project." }),
|
|
124
133
|
),
|
|
134
|
+
admission: Type.Optional(
|
|
135
|
+
Type.Union([Type.Literal("intake"), Type.Literal("external"), Type.Literal("self")], {
|
|
136
|
+
description:
|
|
137
|
+
"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
|
+
),
|
|
125
140
|
}),
|
|
126
141
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx: ExtensionContext) {
|
|
127
142
|
const argv = ["spawn", params.goal, "--project", ctx.cwd];
|
|
@@ -129,6 +144,7 @@ export function registerManagementTools(pi: ExtensionAPI): void {
|
|
|
129
144
|
if (params.model !== undefined) argv.push("--model", params.model);
|
|
130
145
|
if (params.after !== undefined) argv.push("--after", params.after);
|
|
131
146
|
if (params.cwd !== undefined) argv.push("--cwd", params.cwd);
|
|
147
|
+
if (params.admission !== undefined) argv.push("--admission", params.admission);
|
|
132
148
|
|
|
133
149
|
const outcome = await runCli(argv, ctx.cwd);
|
|
134
150
|
const podId = outcome.lines.join("\n").trim();
|
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/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/wrapper.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
withRegistryLock,
|
|
29
29
|
writePods,
|
|
30
30
|
type PodRecord,
|
|
31
|
+
type WakeUpEntry,
|
|
31
32
|
type WakeUpOutcome,
|
|
32
33
|
} from "./registry.ts";
|
|
33
34
|
import { snapshotDocs } from "./workspace.ts";
|
|
@@ -70,7 +71,7 @@ export function composePrompt(goal: string, docsDir: string, roleInstructions =
|
|
|
70
71
|
`1. ${join(docsDir, "HANDOFF.md")} — your predecessor's handoff; start where it ended.`,
|
|
71
72
|
`2. ${join(docsDir, "TASKS.md")} — the plan and its checkboxes.`,
|
|
72
73
|
`3. ${join(docsDir, "DECISIONS.md")} — the why behind the plan.`,
|
|
73
|
-
"When this window's work is done, end with pod_handoff
|
|
74
|
+
"When this window's work is done, end with pod_handoff so your successor starts clean: status rotating (the goal is open and the next wake-up continues), quiet (this window is satisfied, nothing is scheduled — wake me when work arrives), or done (the goal is reached and the pod ends). Write-then-exit is the protocol: an exit without a fresh handoff counts as a failure.",
|
|
74
75
|
].join("\n");
|
|
75
76
|
const goalBlock = `Goal: ${goal}`;
|
|
76
77
|
const sections = [preamble, roleInstructions.trim(), goalBlock].filter(
|
|
@@ -170,14 +171,19 @@ export interface Adjudication {
|
|
|
170
171
|
async function freshDeclaration(
|
|
171
172
|
docsDir: string,
|
|
172
173
|
wakeStart: number,
|
|
173
|
-
): Promise<"more" | "done" | undefined> {
|
|
174
|
+
): Promise<"more" | "quiet" | "done" | undefined> {
|
|
174
175
|
const handoff = join(docsDir, "HANDOFF.md");
|
|
175
176
|
const info = await stat(handoff).catch(() => undefined);
|
|
176
177
|
if (!info || info.mtimeMs <= wakeStart) return undefined;
|
|
177
178
|
const firstLine = (await readFile(handoff, "utf8")).split("\n", 1)[0]!.trim();
|
|
178
|
-
|
|
179
|
+
// Three words, and no fourth (ADR 0009 amendment, #36): `rotating` — another
|
|
180
|
+
// wake-up is the work; `quiet` — this window is satisfied and nothing is
|
|
181
|
+
// scheduled; `done` — terminal. Anything else is not a declaration.
|
|
182
|
+
const declared = /^status:\s*(rotating|quiet|done)\s*$/.exec(firstLine);
|
|
179
183
|
if (!declared) return undefined;
|
|
180
|
-
|
|
184
|
+
const word = declared[1];
|
|
185
|
+
if (word === "done") return "done";
|
|
186
|
+
return word === "quiet" ? "quiet" : "more";
|
|
181
187
|
}
|
|
182
188
|
|
|
183
189
|
/**
|
|
@@ -265,7 +271,13 @@ async function recordUnresolved(
|
|
|
265
271
|
what: string,
|
|
266
272
|
): Promise<boolean> {
|
|
267
273
|
return updatePodRecord(podId, podRoot, n, (record) => {
|
|
268
|
-
record.wakeUps.push({
|
|
274
|
+
record.wakeUps.push({
|
|
275
|
+
n,
|
|
276
|
+
outcome: "failed",
|
|
277
|
+
detail: what,
|
|
278
|
+
at: Date.now(),
|
|
279
|
+
...(record.admitted === undefined ? {} : { trigger: record.admitted }),
|
|
280
|
+
});
|
|
269
281
|
record.state = "idle";
|
|
270
282
|
});
|
|
271
283
|
}
|
|
@@ -493,11 +505,13 @@ export async function runWrapper(argv: string[]): Promise<number> {
|
|
|
493
505
|
// a wedged kill. A human cancel has no intent and records exactly as
|
|
494
506
|
// before.
|
|
495
507
|
if (record.killIntent?.n !== n) {
|
|
496
|
-
record.wakeUps.push(
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
508
|
+
record.wakeUps.push({
|
|
509
|
+
n,
|
|
510
|
+
outcome: "cancelled",
|
|
511
|
+
...(killedAfterGrace ? { detail: "killed after grace" } : {}),
|
|
512
|
+
at: Date.now(),
|
|
513
|
+
...(record.admitted === undefined ? {} : { trigger: record.admitted }),
|
|
514
|
+
});
|
|
501
515
|
record.state = "cancelled";
|
|
502
516
|
}
|
|
503
517
|
});
|
|
@@ -518,7 +532,17 @@ export async function runWrapper(argv: string[]): Promise<number> {
|
|
|
518
532
|
// (ADR 0025). Failures are logged inside the flush and never change outcome.
|
|
519
533
|
await flushOutbox(podId, pod.project, n);
|
|
520
534
|
const recorded = await updatePodRecord(podId, podRoot, n, (record) => {
|
|
521
|
-
|
|
535
|
+
// Provenance (ADR 0032): when, and what admitted the window. `admitted` is
|
|
536
|
+
// written by the launcher under the registry lock, so a wake-up the tick
|
|
537
|
+
// started and one a human started stop looking identical after the fact.
|
|
538
|
+
const entry: WakeUpEntry = {
|
|
539
|
+
n,
|
|
540
|
+
outcome,
|
|
541
|
+
...(detail === undefined ? {} : { detail }),
|
|
542
|
+
at: Date.now(),
|
|
543
|
+
...(record.admitted === undefined ? {} : { trigger: record.admitted }),
|
|
544
|
+
};
|
|
545
|
+
record.wakeUps.push(entry);
|
|
522
546
|
record.state = outcome === "done" ? "done" : "idle";
|
|
523
547
|
});
|
|
524
548
|
if (!recorded) return 1;
|