agents-pod 0.0.0-stage → 0.2.0
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 +83 -0
- package/LICENSE +661 -0
- package/README.md +530 -2
- package/bin/agents-pod.mjs +15 -0
- package/bin/ts-loader.mjs +66 -0
- package/bin/wrapper.mjs +12 -0
- package/index.mjs +11 -0
- package/package.json +39 -4
- package/src/agents-store.ts +88 -0
- package/src/cli.ts +1864 -0
- package/src/extension/handoff.ts +57 -0
- package/src/extension/index.ts +34 -0
- package/src/extension/management.ts +149 -0
- package/src/extension/self.ts +80 -0
- package/src/inbox.ts +221 -0
- package/src/listeners.ts +84 -0
- package/src/liveness.ts +191 -0
- package/src/locks.ts +74 -0
- package/src/notify/config.ts +125 -0
- package/src/notify/dispatch.ts +63 -0
- package/src/notify/email.ts +72 -0
- package/src/notify/format.ts +42 -0
- package/src/notify/system.ts +79 -0
- package/src/notify/types.ts +96 -0
- package/src/notify/webhook.ts +39 -0
- package/src/outbox.ts +294 -0
- package/src/paths.ts +67 -0
- package/src/projects.ts +49 -0
- package/src/registry.ts +103 -0
- package/src/sources.ts +278 -0
- package/src/workspace.ts +131 -0
- package/src/wrapper.ts +533 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pod notifications: the second adapter family, copied from agents-cron
|
|
3
|
+
* (ADR 0021). Notifiers own "how a pod moment is announced"; reconcile owns
|
|
4
|
+
* "when". The inventory is two events only — park and done.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Identifies one configured channel — the `id` of a `channels[]` entry. */
|
|
8
|
+
export type ChannelId = string;
|
|
9
|
+
|
|
10
|
+
/** Posts the moment to an HTTP endpoint (Slack, Lark, Discord, n8n, …). */
|
|
11
|
+
export type WebhookChannel = {
|
|
12
|
+
id: string;
|
|
13
|
+
type: "webhook";
|
|
14
|
+
url: string;
|
|
15
|
+
/** Extra request headers, e.g. `{"Authorization": "Bearer …"}`. */
|
|
16
|
+
headers?: Record<string, string>;
|
|
17
|
+
/** json (default) posts the raw notification; text posts plain text; feishu posts the Feishu bot body. */
|
|
18
|
+
format?: "json" | "text" | "feishu";
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Sends the moment as an email over SMTP. */
|
|
22
|
+
export type EmailChannel = {
|
|
23
|
+
id: string;
|
|
24
|
+
type: "email";
|
|
25
|
+
host: string;
|
|
26
|
+
/** Defaults to 465. */
|
|
27
|
+
port?: number;
|
|
28
|
+
/** Defaults to true when the port is 465, false otherwise. */
|
|
29
|
+
secure?: boolean;
|
|
30
|
+
/** SMTP user; omit for an unauthenticated relay. */
|
|
31
|
+
user?: string;
|
|
32
|
+
/** Env var holding the SMTP password; defaults to AGENTS_POD_SMTP_PASSWORD. */
|
|
33
|
+
passwordEnv?: string;
|
|
34
|
+
from: string;
|
|
35
|
+
/** Comma-separated recipient list. */
|
|
36
|
+
to: string;
|
|
37
|
+
subjectPrefix?: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Shows the moment as a desktop notification (macOS Notification Center, Linux notify-send). */
|
|
41
|
+
export type SystemChannel = {
|
|
42
|
+
id: string;
|
|
43
|
+
type: "system";
|
|
44
|
+
/** macOS sound name (Glass, Ping, …); ignored on Linux. */
|
|
45
|
+
sound?: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type NotificationChannel = WebhookChannel | EmailChannel | SystemChannel;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Which pod moments produce a notification (ADR 0021): `error` means park
|
|
52
|
+
* only, `always` means park + done, `never` means nothing. Default `always` —
|
|
53
|
+
* for work measured in days, completion is the product.
|
|
54
|
+
*/
|
|
55
|
+
export type NotifyLevel = "always" | "error" | "never";
|
|
56
|
+
|
|
57
|
+
/** The full inventory: two events, no more. */
|
|
58
|
+
export type NotificationEvent = "park" | "done";
|
|
59
|
+
|
|
60
|
+
export type NotificationsConfig = {
|
|
61
|
+
on: NotifyLevel;
|
|
62
|
+
channels: NotificationChannel[];
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** One failure in the consecutive run that provoked a park (ADR 0009 detail). */
|
|
66
|
+
export type FailureLine = {
|
|
67
|
+
n: number;
|
|
68
|
+
outcome: string;
|
|
69
|
+
detail?: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A pod moment shaped for delivery. Park carries the verdict (pod id, goal
|
|
74
|
+
* first line, the consecutive failures with their details); done carries the
|
|
75
|
+
* handoff's status line (ADR 0021).
|
|
76
|
+
*/
|
|
77
|
+
export type PodNotification = {
|
|
78
|
+
event: NotificationEvent;
|
|
79
|
+
podId: string;
|
|
80
|
+
/** The goal's first line — the one-line handle (ADR 0011). */
|
|
81
|
+
goal: string;
|
|
82
|
+
/** park: consecutive failures at the verdict. */
|
|
83
|
+
consecutiveFailures?: number;
|
|
84
|
+
/** park: each failure with its ADR 0009 detail. */
|
|
85
|
+
failures?: FailureLine[];
|
|
86
|
+
/** park: a non-streak cause, e.g. agent resolution failure (ADR 0012). */
|
|
87
|
+
reason?: string;
|
|
88
|
+
/** done: the handoff's status line. */
|
|
89
|
+
handoffStatus?: string;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** One delivery channel, resolved and ready to send. */
|
|
93
|
+
export type Notifier = {
|
|
94
|
+
readonly id: string;
|
|
95
|
+
send(notification: PodNotification): Promise<void>;
|
|
96
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { bodyOf, subjectOf } from "./format.ts";
|
|
2
|
+
import type { Notifier, PodNotification, WebhookChannel } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/** Delivery budget per channel; a hung endpoint must not stall a pass (ADR 0021). */
|
|
5
|
+
const TIMEOUT_MS = 10_000;
|
|
6
|
+
|
|
7
|
+
export function webhookNotifier(channel: WebhookChannel): Notifier {
|
|
8
|
+
return {
|
|
9
|
+
id: channel.id,
|
|
10
|
+
async send(n: PodNotification): Promise<void> {
|
|
11
|
+
const body =
|
|
12
|
+
channel.format === "feishu"
|
|
13
|
+
? JSON.stringify({ msg_type: "text", content: { text: `${subjectOf(n)}\n\n${bodyOf(n)}` } })
|
|
14
|
+
: channel.format === "text"
|
|
15
|
+
? `${subjectOf(n)}\n\n${bodyOf(n)}`
|
|
16
|
+
: JSON.stringify({ source: "agents-pod", ...n }, null, 2);
|
|
17
|
+
const res = await fetch(channel.url, {
|
|
18
|
+
method: "POST",
|
|
19
|
+
headers: {
|
|
20
|
+
"content-type": channel.format === "text" ? "text/plain; charset=utf-8" : "application/json",
|
|
21
|
+
...(channel.headers ?? {}),
|
|
22
|
+
},
|
|
23
|
+
body,
|
|
24
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
25
|
+
});
|
|
26
|
+
if (!res.ok) throw new Error(`webhook answered HTTP ${res.status}`);
|
|
27
|
+
if (channel.format === "feishu") {
|
|
28
|
+
// Feishu answers HTTP 200 even for business errors (bad keyword,
|
|
29
|
+
// wrong format); a non-zero code means the bot never delivered.
|
|
30
|
+
const answer = (await res.json().catch(() => undefined)) as { code?: number; msg?: string } | undefined;
|
|
31
|
+
// Only an explicit non-zero code is a rejection; endpoints that answer
|
|
32
|
+
// nothing (or Feishu-compatible servers without a body) stay OK.
|
|
33
|
+
if (typeof answer?.code === "number" && answer.code !== 0) {
|
|
34
|
+
throw new Error(`feishu webhook rejected the message: ${answer.code} ${answer.msg ?? ""}`.trim());
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
package/src/outbox.ts
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import {
|
|
4
|
+
appendFile,
|
|
5
|
+
mkdir,
|
|
6
|
+
readFile,
|
|
7
|
+
readdir,
|
|
8
|
+
rename,
|
|
9
|
+
rm,
|
|
10
|
+
stat,
|
|
11
|
+
writeFile,
|
|
12
|
+
} from "node:fs/promises";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { cronLogPath, delivererFile, podDir } from "./paths.ts";
|
|
15
|
+
|
|
16
|
+
/** The single operator delivery command (ADR 0025). */
|
|
17
|
+
export interface DelivererRecord {
|
|
18
|
+
name: string;
|
|
19
|
+
cmd: string;
|
|
20
|
+
timeoutMs: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const OUTBOX_FLUSH_MAX_ENTRIES = 10;
|
|
24
|
+
export const OUTBOX_FLUSH_BUDGET_MS = 10_000;
|
|
25
|
+
export const OUTBOX_DEAD_AFTER_MS = 3_600_000;
|
|
26
|
+
|
|
27
|
+
export async function readDeliverer(): Promise<DelivererRecord | undefined> {
|
|
28
|
+
try {
|
|
29
|
+
const parsed: unknown = JSON.parse(await readFile(delivererFile(), "utf8"));
|
|
30
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
|
31
|
+
? (parsed as DelivererRecord)
|
|
32
|
+
: undefined;
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The single record has its own atomic write, beside sources.json (ADR 0025). */
|
|
40
|
+
export async function writeDeliverer(deliverer: DelivererRecord): Promise<void> {
|
|
41
|
+
const file = delivererFile();
|
|
42
|
+
await mkdir(dirname(file), { recursive: true });
|
|
43
|
+
const tmp = `${file}.${randomBytes(4).toString("hex")}.tmp`;
|
|
44
|
+
await writeFile(tmp, `${JSON.stringify(deliverer, null, 2)}\n`);
|
|
45
|
+
await rename(tmp, file);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The filename is the uuid's third input, so a later pass can re-derive it
|
|
50
|
+
* without registry state (ADR 0025, amended by #14). Fifty hex chars fit the
|
|
51
|
+
* delivery platform's uuid limit.
|
|
52
|
+
*/
|
|
53
|
+
export function outboxUuid(podId: string, wakeup: number, filename: string): string {
|
|
54
|
+
return createHash("sha256")
|
|
55
|
+
.update(podId)
|
|
56
|
+
.update("\0")
|
|
57
|
+
.update(String(wakeup))
|
|
58
|
+
.update("\0")
|
|
59
|
+
.update(filename)
|
|
60
|
+
.digest("hex")
|
|
61
|
+
.slice(0, 50);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function logOutbox(line: string): Promise<void> {
|
|
65
|
+
const file = cronLogPath();
|
|
66
|
+
await mkdir(dirname(file), { recursive: true });
|
|
67
|
+
await appendFile(file, `${line}\n`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function validEntry(raw: string): boolean {
|
|
71
|
+
try {
|
|
72
|
+
const parsed: unknown = JSON.parse(raw);
|
|
73
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return false;
|
|
74
|
+
const entry = parsed as { to?: unknown; text?: unknown };
|
|
75
|
+
// The shape is fixed by ADR 0025: two strings, the address and the words.
|
|
76
|
+
return typeof entry.to === "string" && entry.to !== "" && typeof entry.text === "string";
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Exit 0 is the only acknowledgement; every other outcome leaves the file. */
|
|
83
|
+
async function deliverOne(
|
|
84
|
+
deliverer: DelivererRecord,
|
|
85
|
+
podId: string,
|
|
86
|
+
project: string,
|
|
87
|
+
wakeup: number,
|
|
88
|
+
filename: string,
|
|
89
|
+
raw: string,
|
|
90
|
+
timeoutMs: number,
|
|
91
|
+
): Promise<string | undefined> {
|
|
92
|
+
return new Promise((resolve) => {
|
|
93
|
+
const child = spawn(deliverer.cmd, [], {
|
|
94
|
+
cwd: project,
|
|
95
|
+
env: {
|
|
96
|
+
...process.env,
|
|
97
|
+
AGENTS_POD_POD_ID: podId,
|
|
98
|
+
AGENTS_POD_WAKEUP_N: String(wakeup),
|
|
99
|
+
AGENTS_POD_OUTBOX_ENTRY: filename,
|
|
100
|
+
AGENTS_POD_OUTBOX_UUID: outboxUuid(podId, wakeup, filename),
|
|
101
|
+
},
|
|
102
|
+
// A delivery command can leave a child behind; its own process group
|
|
103
|
+
// makes the invocation timeout a hard bound (ADR 0025).
|
|
104
|
+
detached: true,
|
|
105
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
106
|
+
});
|
|
107
|
+
const kill = (): void => {
|
|
108
|
+
if (child.pid === undefined) {
|
|
109
|
+
child.kill("SIGKILL");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
process.kill(-child.pid, "SIGKILL");
|
|
114
|
+
} catch {
|
|
115
|
+
child.kill("SIGKILL");
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
let timedOut = false;
|
|
119
|
+
let spawnError: string | undefined;
|
|
120
|
+
const timer = setTimeout(() => {
|
|
121
|
+
timedOut = true;
|
|
122
|
+
kill();
|
|
123
|
+
}, timeoutMs);
|
|
124
|
+
child.on("error", (error) => {
|
|
125
|
+
spawnError = error.message;
|
|
126
|
+
});
|
|
127
|
+
child.on("close", (code, signal) => {
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
if (timedOut) return resolve(`timed out after ${timeoutMs / 1000}s`);
|
|
130
|
+
if (spawnError !== undefined) return resolve(spawnError);
|
|
131
|
+
if (code !== 0) {
|
|
132
|
+
return resolve(
|
|
133
|
+
signal === null ? `exit ${code ?? "unknown"}` : `terminated by ${signal}`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
resolve(undefined);
|
|
137
|
+
});
|
|
138
|
+
// A command may exit without reading stdin; that is its exit status, not a
|
|
139
|
+
// wrapper-level EPIPE failure.
|
|
140
|
+
child.stdin.on("error", () => undefined);
|
|
141
|
+
child.stdin.end(raw);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The leading filename field is the entry's immutable wake-up attribution. */
|
|
146
|
+
function entryWakeup(filename: string): number | undefined {
|
|
147
|
+
const match = /^([1-9]\d*)-/.exec(filename);
|
|
148
|
+
if (match === null) return undefined;
|
|
149
|
+
const wakeup = Number(match[1]);
|
|
150
|
+
return Number.isSafeInteger(wakeup) ? wakeup : undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Dead-lettering changes the name, never the contents, and keeps the evidence. */
|
|
154
|
+
async function deadLetter(podId: string, file: string, name: string, reason: string): Promise<void> {
|
|
155
|
+
try {
|
|
156
|
+
await rename(file, `${file}.dead`);
|
|
157
|
+
await logOutbox(`outbox ${podId}: ${name} dead-lettered (${reason}); kept as ${name}.dead`);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
await logOutbox(
|
|
160
|
+
`outbox ${podId}: ${name} dead-letter rename failed (${(error as Error).message}); file kept`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The wrapper and reconcile share one bounded flush (ADR 0025). A wrapper
|
|
167
|
+
* touches only its own wake-up's entries: older ones belong to pass retries.
|
|
168
|
+
* A pass derives every uuid from the filename, so retries keep their original
|
|
169
|
+
* wake-up number. Failures are audit lines, never outcome, policy, or
|
|
170
|
+
* notification changes.
|
|
171
|
+
*/
|
|
172
|
+
async function flushEntries(
|
|
173
|
+
podId: string,
|
|
174
|
+
project: string,
|
|
175
|
+
currentWakeup?: number,
|
|
176
|
+
): Promise<void> {
|
|
177
|
+
const dir = join(podDir(project, podId), "outbox");
|
|
178
|
+
let names: string[];
|
|
179
|
+
try {
|
|
180
|
+
names = (await readdir(dir)).filter((name) => name.endsWith(".json")).sort();
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
183
|
+
await logOutbox(`outbox ${podId}: unreadable (${(error as Error).message})`);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (names.length === 0) return;
|
|
187
|
+
|
|
188
|
+
let deliverer: DelivererRecord | undefined;
|
|
189
|
+
try {
|
|
190
|
+
deliverer = await readDeliverer();
|
|
191
|
+
} catch (error) {
|
|
192
|
+
await logOutbox(`outbox ${podId}: deliverer unreadable (${(error as Error).message})`);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
// The unconfigured wrapper flush stays a no-op; the pass owns waiting and
|
|
196
|
+
// aging so each pass can make the missing configuration audible once.
|
|
197
|
+
if (deliverer === undefined && currentWakeup !== undefined) return;
|
|
198
|
+
|
|
199
|
+
const startedAt = Date.now();
|
|
200
|
+
let invocations = 0;
|
|
201
|
+
let waiting = 0;
|
|
202
|
+
for (const name of names) {
|
|
203
|
+
const elapsed = Date.now() - startedAt;
|
|
204
|
+
if (
|
|
205
|
+
deliverer !== undefined &&
|
|
206
|
+
(invocations >= OUTBOX_FLUSH_MAX_ENTRIES || elapsed >= OUTBOX_FLUSH_BUDGET_MS)
|
|
207
|
+
) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const file = join(dir, name);
|
|
211
|
+
const wakeup = entryWakeup(name);
|
|
212
|
+
if (wakeup === undefined) {
|
|
213
|
+
await deadLetter(podId, file, name, "unparseable wake-up");
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (currentWakeup !== undefined) {
|
|
217
|
+
// An earlier wake-up's failed entry must survive later wrapper flushes;
|
|
218
|
+
// the pass retries it with the same uuid until the one-hour boundary.
|
|
219
|
+
if (wakeup < currentWakeup) continue;
|
|
220
|
+
if (wakeup > currentWakeup) {
|
|
221
|
+
await deadLetter(podId, file, name, `wake-up ${wakeup} is after ${currentWakeup}`);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const info = await stat(file).catch(() => undefined);
|
|
227
|
+
if (info === undefined) {
|
|
228
|
+
await logOutbox(`outbox ${podId}: ${name} unreadable; skipped`);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (Date.now() - info.mtimeMs > OUTBOX_DEAD_AFTER_MS) {
|
|
232
|
+
await deadLetter(podId, file, name, "older than one hour");
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let raw: string;
|
|
237
|
+
try {
|
|
238
|
+
raw = await readFile(file, "utf8");
|
|
239
|
+
} catch (error) {
|
|
240
|
+
await logOutbox(`outbox ${podId}: ${name} unreadable (${(error as Error).message}); skipped`);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (!validEntry(raw)) {
|
|
244
|
+
await deadLetter(podId, file, name, "invalid entry (expected JSON with to and text)");
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (deliverer === undefined) {
|
|
248
|
+
waiting += 1;
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const remainingMs = OUTBOX_FLUSH_BUDGET_MS - (Date.now() - startedAt);
|
|
253
|
+
if (remainingMs <= 0) return;
|
|
254
|
+
invocations += 1;
|
|
255
|
+
const error = await deliverOne(
|
|
256
|
+
deliverer,
|
|
257
|
+
podId,
|
|
258
|
+
project,
|
|
259
|
+
wakeup,
|
|
260
|
+
name,
|
|
261
|
+
raw,
|
|
262
|
+
Math.min(deliverer.timeoutMs, remainingMs),
|
|
263
|
+
);
|
|
264
|
+
if (error === undefined) {
|
|
265
|
+
try {
|
|
266
|
+
// Deletion is the acknowledgement; no completion marker exists.
|
|
267
|
+
await rm(file, { force: true });
|
|
268
|
+
} catch (deleteError) {
|
|
269
|
+
await logOutbox(
|
|
270
|
+
`outbox ${podId}: ${name} delivered but delete failed (${(deleteError as Error).message})`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
} else {
|
|
274
|
+
await logOutbox(`outbox ${podId}: ${name} delivery failed (${error}); file kept`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (deliverer === undefined && waiting > 0) {
|
|
278
|
+
await logOutbox(`outbox ${podId}: no deliverer configured; ${waiting} entries waiting`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Flush this wake-up's outbox after adjudication and before idle (ADR 0025). */
|
|
283
|
+
export async function flushOutbox(
|
|
284
|
+
podId: string,
|
|
285
|
+
project: string,
|
|
286
|
+
wakeup: number,
|
|
287
|
+
): Promise<void> {
|
|
288
|
+
await flushEntries(podId, project, wakeup);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Retry one non-active Pod's outbox during reconcile (ADR 0025). */
|
|
292
|
+
export async function retryOutbox(podId: string, project: string): Promise<void> {
|
|
293
|
+
await flushEntries(podId, project);
|
|
294
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The machine-global store root: `$AGENTS_POD_HOME ?? $HOME` + `.agents/pods/`.
|
|
6
|
+
* The env override exists for the same reason agents-cron's does: tests and
|
|
7
|
+
* secondary installs need a throwaway root without touching the real store
|
|
8
|
+
* (ADR 0004: own store, never shared with agents-cron).
|
|
9
|
+
*/
|
|
10
|
+
export function storeRoot(): string {
|
|
11
|
+
const home = process.env.AGENTS_POD_HOME ?? homedir();
|
|
12
|
+
return join(home, ".agents", "pods");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function podsFile(): string {
|
|
16
|
+
return join(storeRoot(), "pods.json");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Per-project wake-up capacity (ADR 0022), machine-global like the Pod registry. */
|
|
20
|
+
export function projectsFile(): string {
|
|
21
|
+
return join(storeRoot(), "projects.json");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The one definition source in P0 (ADR 0012): machine-global, no project layer. */
|
|
25
|
+
export function agentsFile(): string {
|
|
26
|
+
return join(storeRoot(), "agents.json");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Listeners and their claim records: intake is data, never a process (ADR 0007). */
|
|
30
|
+
export function listenersFile(): string {
|
|
31
|
+
return join(storeRoot(), "listeners.json");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Sources and their committed cursors, beside rather than inside the registry (ADR 0024). */
|
|
35
|
+
export function sourcesFile(): string {
|
|
36
|
+
return join(storeRoot(), "sources.json");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The single outbound delivery command, beside the Source records (ADR 0025). */
|
|
40
|
+
export function delivererFile(): string {
|
|
41
|
+
return join(storeRoot(), "deliverer.json");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The pass's single-flight advisory lock, one for the whole registry (ADR 0018). */
|
|
45
|
+
export function reconcileLockPath(): string {
|
|
46
|
+
return join(storeRoot(), "reconcile.lock");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Where a stolen lock is audited: one line per steal, read by cron (ADR 0018). */
|
|
50
|
+
export function cronLogPath(): string {
|
|
51
|
+
return join(storeRoot(), "cron.log");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The one notification config, in the store root (ADR 0021): no per-pod routing. */
|
|
55
|
+
export function notifyConfigFile(): string {
|
|
56
|
+
return join(storeRoot(), "config.json");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The short-lived registry mutex that makes state transitions CAS (ADR 0017, 0018). */
|
|
60
|
+
export function registryLockPath(): string {
|
|
61
|
+
return join(storeRoot(), "registry.lock");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The pod's working tree lives under the project (ADR 0004): docs, log, wake-ups. */
|
|
65
|
+
export function podDir(project: string, podId: string): string {
|
|
66
|
+
return join(project, ".agents", "pods", podId);
|
|
67
|
+
}
|
package/src/projects.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { projectsFile } from "./paths.ts";
|
|
5
|
+
import type { PodRecord } from "./registry.ts";
|
|
6
|
+
|
|
7
|
+
/** One project's wake-up capacity; absent records mean unlimited (ADR 0022). */
|
|
8
|
+
export interface ProjectRecord {
|
|
9
|
+
project: string;
|
|
10
|
+
maxActive: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function readProjects(): Promise<ProjectRecord[]> {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(await readFile(projectsFile(), "utf8")) as ProjectRecord[];
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function writeProjects(projects: ProjectRecord[]): Promise<void> {
|
|
23
|
+
const file = projectsFile();
|
|
24
|
+
await mkdir(dirname(file), { recursive: true });
|
|
25
|
+
const tmp = `${file}.${randomBytes(4).toString("hex")}.tmp`;
|
|
26
|
+
await writeFile(tmp, `${JSON.stringify(projects, null, 2)}\n`);
|
|
27
|
+
await rename(tmp, file);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function projectMaxActive(project: string, projects: ProjectRecord[]): number | undefined {
|
|
31
|
+
const root = resolve(project);
|
|
32
|
+
return projects.find((candidate) => resolve(candidate.project) === root)?.maxActive;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Spawning and active are the two states with a live Wake-up being admitted. */
|
|
36
|
+
export function projectHasCapacity(
|
|
37
|
+
project: string,
|
|
38
|
+
pods: PodRecord[],
|
|
39
|
+
projects: ProjectRecord[],
|
|
40
|
+
): boolean {
|
|
41
|
+
const maxActive = projectMaxActive(project, projects);
|
|
42
|
+
if (maxActive === undefined) return true;
|
|
43
|
+
const root = resolve(project);
|
|
44
|
+
const active = pods.filter(
|
|
45
|
+
(pod) =>
|
|
46
|
+
resolve(pod.project) === root && (pod.state === "spawning" || pod.state === "active"),
|
|
47
|
+
).length;
|
|
48
|
+
return active < maxActive;
|
|
49
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { acquireWithRetry, releaseLock } from "./locks.ts";
|
|
5
|
+
import { podsFile, registryLockPath } from "./paths.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Pod states (ADR 0006): spawning / active / idle / done / cancelled. `lost`
|
|
9
|
+
* and `cancelled` are wake-up outcomes, never pod states.
|
|
10
|
+
*/
|
|
11
|
+
export type PodState = "spawning" | "active" | "idle" | "done" | "cancelled";
|
|
12
|
+
|
|
13
|
+
export type WakeUpOutcome = "more" | "done" | "failed" | "lost" | "cancelled";
|
|
14
|
+
|
|
15
|
+
export interface WakeUpEntry {
|
|
16
|
+
n: number;
|
|
17
|
+
outcome: WakeUpOutcome;
|
|
18
|
+
detail?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface PodRecord {
|
|
22
|
+
id: string;
|
|
23
|
+
goal: string;
|
|
24
|
+
state: PodState;
|
|
25
|
+
/** The project root the pod's workspace lives under. */
|
|
26
|
+
project: string;
|
|
27
|
+
/** The upstream Pod whose done state admits this Pod's first wake-up (ADR 0022). */
|
|
28
|
+
after?: string;
|
|
29
|
+
wakeUps: WakeUpEntry[];
|
|
30
|
+
policy?: WakePolicy;
|
|
31
|
+
/** When a pass first observed a wedged wrapper (ADR 0017's two-pass rule). */
|
|
32
|
+
wedgedAt?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Reconcile's progress-kill intent (ADR 0019): written under the registry
|
|
35
|
+
* lock before the signal, so the verdict is on disk before it is acted on.
|
|
36
|
+
*/
|
|
37
|
+
/** A reconcile kill's intent, written before the signal (ADRs 0017, 0019): the
|
|
38
|
+
* wrapper defers to the verdict and records no second outcome. */
|
|
39
|
+
killIntent?: { n: number; at: number; cause: "progressless" | "wedged" };
|
|
40
|
+
/**
|
|
41
|
+
* When this episode's notification was sent (ADR 0021). A human wake that
|
|
42
|
+
* clears a park also clears it, opening the next episode.
|
|
43
|
+
*/
|
|
44
|
+
notifiedAt?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The policy block reconcile derives from outcome facts (ADR 0011). */
|
|
48
|
+
export interface WakePolicy {
|
|
49
|
+
failureStreak: number;
|
|
50
|
+
nextWakeAt?: number;
|
|
51
|
+
parked?: boolean;
|
|
52
|
+
/** How many wake-up outcomes the policy has already taken into account. */
|
|
53
|
+
appliedOutcomes: number;
|
|
54
|
+
/** The agent every wake-up resolves to unless one wake names another (ADR 0012). */
|
|
55
|
+
agent?: string;
|
|
56
|
+
/** The pod's model pin: it beats the definition's model (ADR 0012). */
|
|
57
|
+
model?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function readPods(): Promise<PodRecord[]> {
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(await readFile(podsFile(), "utf8")) as PodRecord[];
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Atomic write: a crash mid-write never leaves a half-written registry. The
|
|
71
|
+
* temp name is unique per writer — the CLI and a detached wrapper both write
|
|
72
|
+
* this file, and a shared temp name let one rename steal the other's file.
|
|
73
|
+
*/
|
|
74
|
+
export async function writePods(pods: PodRecord[]): Promise<void> {
|
|
75
|
+
const file = podsFile();
|
|
76
|
+
await mkdir(dirname(file), { recursive: true });
|
|
77
|
+
const tmp = `${file}.${randomBytes(4).toString("hex")}.tmp`;
|
|
78
|
+
await writeFile(tmp, `${JSON.stringify(pods, null, 2)}\n`);
|
|
79
|
+
await rename(tmp, file);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The registry mutex (ADR 0017's lock budget, ADR 0018's division of labour):
|
|
84
|
+
* a state transition re-reads pods.json inside it, verifies its condition
|
|
85
|
+
* still holds, then writes — the pass lock only keeps one pass around, this is
|
|
86
|
+
* what makes two racers (two passes, or a human and a pass) unable to both
|
|
87
|
+
* win. `undefined` means the lock could not be had: callers skip, never write
|
|
88
|
+
* blind.
|
|
89
|
+
*/
|
|
90
|
+
export async function withRegistryLock<T>(
|
|
91
|
+
work: () => Promise<T>,
|
|
92
|
+
): Promise<T | undefined> {
|
|
93
|
+
const held = await acquireWithRetry(registryLockPath(), {
|
|
94
|
+
pid: process.pid,
|
|
95
|
+
startedAt: Date.now(),
|
|
96
|
+
});
|
|
97
|
+
if (!held) return undefined;
|
|
98
|
+
try {
|
|
99
|
+
return await work();
|
|
100
|
+
} finally {
|
|
101
|
+
await releaseLock(registryLockPath());
|
|
102
|
+
}
|
|
103
|
+
}
|