agents-pod 0.1.0 → 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 +28 -0
- package/README.md +209 -22
- package/package.json +1 -1
- package/src/cli.ts +399 -47
- package/src/inbox.ts +75 -9
- package/src/listeners.ts +11 -5
- package/src/outbox.ts +294 -0
- package/src/paths.ts +10 -0
- package/src/sources.ts +278 -0
- package/src/workspace.ts +11 -4
- package/src/wrapper.ts +71 -22
package/src/sources.ts
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
import type { BadInboxLine, InboxItem } from "./inbox.ts";
|
|
6
|
+
import type { ListenerRecord } from "./listeners.ts";
|
|
7
|
+
import { sourcesFile } from "./paths.ts";
|
|
8
|
+
|
|
9
|
+
/** A Source outlives its Listeners; its opaque cursor lives beside them (ADR 0024). */
|
|
10
|
+
export interface SourceRecord {
|
|
11
|
+
name: string;
|
|
12
|
+
cmd: string;
|
|
13
|
+
timeoutMs: number;
|
|
14
|
+
cursor?: string;
|
|
15
|
+
/** The previous successful drain is the claim-retention horizon (ADR 0024). */
|
|
16
|
+
lastDrainAt?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SourceDrain {
|
|
20
|
+
name: string;
|
|
21
|
+
items: InboxItem[];
|
|
22
|
+
bad: BadInboxLine[];
|
|
23
|
+
cursor?: string;
|
|
24
|
+
/** Successful drains carry their generation and the preceding one's boundary. */
|
|
25
|
+
drainedAt?: number;
|
|
26
|
+
previousDrainAt?: number;
|
|
27
|
+
/** A failed drain contributes nothing and leaves the cursor untouched. */
|
|
28
|
+
error?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One MiB is the whole successful stdout budget (ADR 0024). */
|
|
32
|
+
export const DRAIN_STDOUT_CAP = 1024 * 1024;
|
|
33
|
+
|
|
34
|
+
export async function readSources(): Promise<SourceRecord[]> {
|
|
35
|
+
try {
|
|
36
|
+
const parsed: unknown = JSON.parse(await readFile(sourcesFile(), "utf8"));
|
|
37
|
+
return Array.isArray(parsed) ? (parsed as SourceRecord[]) : [];
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The cursor's own atomic write, separate from listeners.json (ADR 0024). */
|
|
45
|
+
export async function writeSources(sources: SourceRecord[]): Promise<void> {
|
|
46
|
+
const file = sourcesFile();
|
|
47
|
+
await mkdir(dirname(file), { recursive: true });
|
|
48
|
+
const tmp = `${file}.${randomBytes(4).toString("hex")}.tmp`;
|
|
49
|
+
await writeFile(tmp, `${JSON.stringify(sources, null, 2)}\n`);
|
|
50
|
+
await rename(tmp, file);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function routingField(value: unknown): string | undefined {
|
|
54
|
+
if (typeof value !== "string") return undefined;
|
|
55
|
+
const trimmed = value.trim();
|
|
56
|
+
return trimmed === "" ? undefined : trimmed;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A stream item is complete when its line parses; no two-pass guard applies. */
|
|
60
|
+
export function parseDrainOutput(name: string, text: string): SourceDrain {
|
|
61
|
+
const items: InboxItem[] = [];
|
|
62
|
+
const bad: BadInboxLine[] = [];
|
|
63
|
+
let cursor: string | undefined;
|
|
64
|
+
const lines = text.split("\n")
|
|
65
|
+
.map((raw, index) => ({ raw, line: index + 1 }))
|
|
66
|
+
.filter(({ raw }) => raw.trim() !== "");
|
|
67
|
+
|
|
68
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
69
|
+
const { raw, line } = lines[index]!;
|
|
70
|
+
let parsed: unknown;
|
|
71
|
+
try {
|
|
72
|
+
parsed = JSON.parse(raw);
|
|
73
|
+
} catch {
|
|
74
|
+
bad.push({ line, reason: "invalid JSON" });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
78
|
+
bad.push({ line, reason: "invalid JSON (not an object)" });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const record = parsed as {
|
|
82
|
+
id?: unknown;
|
|
83
|
+
body?: unknown;
|
|
84
|
+
title?: unknown;
|
|
85
|
+
cursor?: unknown;
|
|
86
|
+
chat_id?: unknown;
|
|
87
|
+
root_id?: unknown;
|
|
88
|
+
thread_id?: unknown;
|
|
89
|
+
};
|
|
90
|
+
if (
|
|
91
|
+
index === lines.length - 1 &&
|
|
92
|
+
"cursor" in record &&
|
|
93
|
+
record.id === undefined &&
|
|
94
|
+
record.body === undefined
|
|
95
|
+
) {
|
|
96
|
+
if (typeof record.cursor !== "string") {
|
|
97
|
+
bad.push({ line, reason: "invalid cursor" });
|
|
98
|
+
} else {
|
|
99
|
+
cursor = record.cursor;
|
|
100
|
+
}
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (typeof record.id !== "string" || record.id.trim() === "") {
|
|
104
|
+
bad.push({ line, reason: "no id" });
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (typeof record.body !== "string") {
|
|
108
|
+
bad.push({ line, reason: "no body" });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const title =
|
|
112
|
+
typeof record.title === "string" && record.title.trim() !== ""
|
|
113
|
+
? record.title
|
|
114
|
+
: record.body.split("\n", 1)[0]!.trim();
|
|
115
|
+
const chatId = routingField(record.chat_id);
|
|
116
|
+
const rootId = routingField(record.root_id);
|
|
117
|
+
const threadId = routingField(record.thread_id);
|
|
118
|
+
items.push({
|
|
119
|
+
id: record.id,
|
|
120
|
+
title,
|
|
121
|
+
body: record.body,
|
|
122
|
+
path: "",
|
|
123
|
+
mtimeMs: 0,
|
|
124
|
+
size: 0,
|
|
125
|
+
...(chatId === undefined ? {} : { chatId }),
|
|
126
|
+
...(rootId === undefined ? {} : { rootId }),
|
|
127
|
+
...(threadId === undefined ? {} : { threadId }),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return { name, items, bad, ...(cursor === undefined ? {} : { cursor }) };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function drainOne(source: SourceRecord, project: string): Promise<SourceDrain> {
|
|
134
|
+
const empty = { name: source.name, items: [], bad: [] };
|
|
135
|
+
const result = await new Promise<{ stdout?: string; error?: string }>((resolve) => {
|
|
136
|
+
const child = spawn(source.cmd, [], {
|
|
137
|
+
cwd: project,
|
|
138
|
+
env: {
|
|
139
|
+
...process.env,
|
|
140
|
+
AGENTS_POD_SOURCE_ID: source.name,
|
|
141
|
+
AGENTS_POD_CURSOR: source.cursor ?? "",
|
|
142
|
+
},
|
|
143
|
+
// A shell drain can leave a child holding stdout after the shell dies.
|
|
144
|
+
// Its own process group makes the timeout and output cap real bounds.
|
|
145
|
+
detached: true,
|
|
146
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
147
|
+
});
|
|
148
|
+
const kill = (): void => {
|
|
149
|
+
if (child.pid === undefined) {
|
|
150
|
+
child.kill("SIGKILL");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
process.kill(-child.pid, "SIGKILL");
|
|
155
|
+
} catch {
|
|
156
|
+
child.kill("SIGKILL");
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
const chunks: Buffer[] = [];
|
|
160
|
+
let bytes = 0;
|
|
161
|
+
let oversized = false;
|
|
162
|
+
let timedOut = false;
|
|
163
|
+
let spawnError: string | undefined;
|
|
164
|
+
const timer = setTimeout(() => {
|
|
165
|
+
timedOut = true;
|
|
166
|
+
kill();
|
|
167
|
+
}, source.timeoutMs);
|
|
168
|
+
|
|
169
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
170
|
+
bytes += chunk.length;
|
|
171
|
+
if (bytes > DRAIN_STDOUT_CAP) {
|
|
172
|
+
if (!oversized) {
|
|
173
|
+
oversized = true;
|
|
174
|
+
kill();
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
chunks.push(chunk);
|
|
179
|
+
});
|
|
180
|
+
child.on("error", (error) => {
|
|
181
|
+
spawnError = error.message;
|
|
182
|
+
});
|
|
183
|
+
child.on("close", (code, signal) => {
|
|
184
|
+
clearTimeout(timer);
|
|
185
|
+
if (oversized) return resolve({ error: "stdout exceeded 1 MiB" });
|
|
186
|
+
if (timedOut) return resolve({ error: `timed out after ${source.timeoutMs / 1000}s` });
|
|
187
|
+
if (spawnError !== undefined) return resolve({ error: spawnError });
|
|
188
|
+
if (code !== 0) {
|
|
189
|
+
return resolve({
|
|
190
|
+
error: signal === null ? `exit ${code ?? "unknown"}` : `terminated by ${signal}`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
resolve({ stdout: Buffer.concat(chunks).toString("utf8") });
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
if (result.error !== undefined) return { ...empty, error: result.error };
|
|
197
|
+
// Distinct generations need distinct boundaries even when two local drains
|
|
198
|
+
// finish within one wall-clock millisecond.
|
|
199
|
+
const drainedAt = Math.max(Date.now(), (source.lastDrainAt ?? 0) + 1);
|
|
200
|
+
return {
|
|
201
|
+
...parseDrainOutput(source.name, result.stdout ?? ""),
|
|
202
|
+
drainedAt,
|
|
203
|
+
...(source.lastDrainAt === undefined ? {} : { previousDrainAt: source.lastDrainAt }),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Drain each distinct Source once, before the registry lock or any Pod work (ADR 0024). */
|
|
208
|
+
export async function drainSources(
|
|
209
|
+
project: string,
|
|
210
|
+
listeners: ListenerRecord[],
|
|
211
|
+
): Promise<SourceDrain[]> {
|
|
212
|
+
const sources = await readSources();
|
|
213
|
+
const names = [...new Set(listeners.flatMap((listener) => listener.source ?? []))];
|
|
214
|
+
const drained: SourceDrain[] = [];
|
|
215
|
+
for (const name of names) {
|
|
216
|
+
const source = sources.find((candidate) => candidate.name === name);
|
|
217
|
+
drained.push(
|
|
218
|
+
source === undefined
|
|
219
|
+
? { name, items: [], bad: [], error: "source record is missing" }
|
|
220
|
+
: await drainOne(source, project),
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return drained;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Two generations are enough: after this drain, anything older than the prior
|
|
228
|
+
* successful one is behind the committed cursor and cannot arrive again. The
|
|
229
|
+
* store is transformed without I/O; the caller writes it inside the registry
|
|
230
|
+
* lock before advancing the Source records (ADR 0024). Legacy claims without a
|
|
231
|
+
* timestamp are retained because their generation cannot be proven.
|
|
232
|
+
*/
|
|
233
|
+
export function pruneSourceClaims(
|
|
234
|
+
listeners: ListenerRecord[],
|
|
235
|
+
drained: SourceDrain[],
|
|
236
|
+
): { listeners: ListenerRecord[]; changed: boolean } {
|
|
237
|
+
const horizons = new Map<string, number>();
|
|
238
|
+
for (const drain of drained) {
|
|
239
|
+
if (drain.error === undefined && drain.previousDrainAt !== undefined) {
|
|
240
|
+
horizons.set(`source:${drain.name}`, drain.previousDrainAt);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (horizons.size === 0) return { listeners, changed: false };
|
|
244
|
+
|
|
245
|
+
let changed = false;
|
|
246
|
+
const pruned = listeners.map((listener) => {
|
|
247
|
+
const claimed = listener.claimed.filter((claim) => {
|
|
248
|
+
const horizon = horizons.get(claim.inbox);
|
|
249
|
+
return (
|
|
250
|
+
horizon === undefined || claim.claimedAt === undefined || claim.claimedAt >= horizon
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
if (claimed.length === listener.claimed.length) return listener;
|
|
254
|
+
changed = true;
|
|
255
|
+
return { ...listener, claimed };
|
|
256
|
+
});
|
|
257
|
+
return { listeners: pruned, changed };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Claims and pruning land before this cursor/retention-horizon write (ADR 0024). */
|
|
261
|
+
export async function commitSourceCursors(drained: SourceDrain[]): Promise<void> {
|
|
262
|
+
const sources = await readSources();
|
|
263
|
+
let changed = false;
|
|
264
|
+
for (const drain of drained) {
|
|
265
|
+
if (drain.error !== undefined || drain.drainedAt === undefined) continue;
|
|
266
|
+
const source = sources.find((candidate) => candidate.name === drain.name);
|
|
267
|
+
if (source === undefined) continue;
|
|
268
|
+
if (drain.cursor !== undefined && source.cursor !== drain.cursor) {
|
|
269
|
+
source.cursor = drain.cursor;
|
|
270
|
+
changed = true;
|
|
271
|
+
}
|
|
272
|
+
if (source.lastDrainAt !== drain.drainedAt) {
|
|
273
|
+
source.lastDrainAt = drain.drainedAt;
|
|
274
|
+
changed = true;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (changed) await writeSources(sources);
|
|
278
|
+
}
|
package/src/workspace.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import type
|
|
4
|
+
import { deliveryAddress, type InboxItem } from "./inbox.ts";
|
|
5
5
|
import { podDir } from "./paths.ts";
|
|
6
6
|
import type { PodRecord } from "./registry.ts";
|
|
7
7
|
|
|
@@ -48,7 +48,7 @@ const TEMPLATES: Record<string, string> = {
|
|
|
48
48
|
"One line per decision, with its reason. Append-only.",
|
|
49
49
|
"",
|
|
50
50
|
].join("\n"),
|
|
51
|
-
"TASKS.md": ["# Tasks", "", "## Plan", "", "## Inbox", ""].join("\n"),
|
|
51
|
+
"TASKS.md": ["# Tasks", "", "## Plan", "", "## Waiting", "", "## Inbox", ""].join("\n"),
|
|
52
52
|
};
|
|
53
53
|
|
|
54
54
|
/** The one section reconcile may write (ADR 0008, 0014). */
|
|
@@ -58,6 +58,9 @@ export async function createPodWorkspace(pod: PodRecord): Promise<string> {
|
|
|
58
58
|
const dir = podDir(pod.project, pod.id);
|
|
59
59
|
const docs = join(dir, "docs");
|
|
60
60
|
await mkdir(docs, { recursive: true });
|
|
61
|
+
// The outbox is beside the three-slot Document Store, never a fourth slot
|
|
62
|
+
// (ADRs 0008, 0025).
|
|
63
|
+
await mkdir(join(dir, "outbox"), { recursive: true });
|
|
61
64
|
for (const [name, body] of Object.entries(TEMPLATES)) {
|
|
62
65
|
await writeFile(join(docs, name), body);
|
|
63
66
|
}
|
|
@@ -70,9 +73,13 @@ export async function createPodWorkspace(pod: PodRecord): Promise<string> {
|
|
|
70
73
|
* audit have an anchor; the body is copied because the source file belongs to
|
|
71
74
|
* its writer and may be deleted once the item is claimed.
|
|
72
75
|
*/
|
|
73
|
-
export function inboxBlock(
|
|
76
|
+
export function inboxBlock(
|
|
77
|
+
item: Pick<InboxItem, "id" | "title" | "body" | "chatId" | "rootId" | "threadId">,
|
|
78
|
+
): string {
|
|
74
79
|
const title = item.title.split("\n", 1)[0]!.trim();
|
|
75
|
-
const
|
|
80
|
+
const address = deliveryAddress(item);
|
|
81
|
+
const suffix = address === "" ? "" : ` — to: ${address}`;
|
|
82
|
+
const lines = [`- [ ] ${item.id} — ${title}${suffix}`];
|
|
76
83
|
const body = item.body.replace(/\r\n/g, "\n").replace(/\n+$/, "");
|
|
77
84
|
if (body.length > 0) {
|
|
78
85
|
for (const line of body.split("\n")) {
|
package/src/wrapper.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import {
|
|
4
|
+
appendFile,
|
|
4
5
|
copyFile,
|
|
5
6
|
mkdir,
|
|
6
7
|
open,
|
|
@@ -20,7 +21,15 @@ import {
|
|
|
20
21
|
type WrapperRecord,
|
|
21
22
|
} from "./liveness.ts";
|
|
22
23
|
import { podDir } from "./paths.ts";
|
|
23
|
-
import {
|
|
24
|
+
import { flushOutbox } from "./outbox.ts";
|
|
25
|
+
import { sleep } from "./locks.ts";
|
|
26
|
+
import {
|
|
27
|
+
readPods,
|
|
28
|
+
withRegistryLock,
|
|
29
|
+
writePods,
|
|
30
|
+
type PodRecord,
|
|
31
|
+
type WakeUpOutcome,
|
|
32
|
+
} from "./registry.ts";
|
|
24
33
|
import { snapshotDocs } from "./workspace.ts";
|
|
25
34
|
|
|
26
35
|
/** The heartbeat interval: the mtime is the liveness signal (ADR 0017). */
|
|
@@ -213,17 +222,52 @@ function instructionsHash(instructions: string): string {
|
|
|
213
222
|
return createHash("sha256").update(instructions).digest("hex").slice(0, 12);
|
|
214
223
|
}
|
|
215
224
|
|
|
225
|
+
/**
|
|
226
|
+
* One pod's read-modify-write belongs wholly inside the registry lock (ADR
|
|
227
|
+
* 0018). A wrapper that cannot record its result must fail loudly and keep its
|
|
228
|
+
* identity: reconcile may call that wake-up lost, but its outcome is never
|
|
229
|
+
* silently discarded.
|
|
230
|
+
*/
|
|
231
|
+
async function updatePodRecord(
|
|
232
|
+
podId: string,
|
|
233
|
+
podRoot: string,
|
|
234
|
+
n: number,
|
|
235
|
+
mutate: (record: PodRecord) => void,
|
|
236
|
+
): Promise<boolean> {
|
|
237
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
238
|
+
const recorded = await withRegistryLock(async () => {
|
|
239
|
+
const pods = await readPods();
|
|
240
|
+
const record = pods.find((candidate) => candidate.id === podId);
|
|
241
|
+
if (record) {
|
|
242
|
+
mutate(record);
|
|
243
|
+
await writePods(pods);
|
|
244
|
+
}
|
|
245
|
+
return true;
|
|
246
|
+
});
|
|
247
|
+
if (recorded !== undefined) return true;
|
|
248
|
+
if (attempt < 2) await sleep(100 * 2 ** attempt);
|
|
249
|
+
}
|
|
250
|
+
await appendFile(
|
|
251
|
+
join(podRoot, "output.log"),
|
|
252
|
+
`wrapper: registry lock unavailable; wake-up ${n} outcome not recorded\n`,
|
|
253
|
+
);
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
|
|
216
257
|
/**
|
|
217
258
|
* Records a wake-up that never reached an agent. The outcome is a fact; the
|
|
218
259
|
* policy that follows from it — backoff, streak, park — stays reconcile's.
|
|
219
260
|
*/
|
|
220
|
-
async function recordUnresolved(
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
261
|
+
async function recordUnresolved(
|
|
262
|
+
podId: string,
|
|
263
|
+
podRoot: string,
|
|
264
|
+
n: number,
|
|
265
|
+
what: string,
|
|
266
|
+
): Promise<boolean> {
|
|
267
|
+
return updatePodRecord(podId, podRoot, n, (record) => {
|
|
268
|
+
record.wakeUps.push({ n, outcome: "failed", detail: what });
|
|
269
|
+
record.state = "idle";
|
|
270
|
+
});
|
|
227
271
|
}
|
|
228
272
|
|
|
229
273
|
export async function runWrapper(argv: string[]): Promise<number> {
|
|
@@ -255,8 +299,7 @@ export async function runWrapper(argv: string[]): Promise<number> {
|
|
|
255
299
|
const agentId = agentWord ?? pod.policy?.agent ?? "default";
|
|
256
300
|
const agent = resolveAgentDefinition(await readAgents(), agentId);
|
|
257
301
|
if (!agent) {
|
|
258
|
-
await recordUnresolved(podId, n, "agent resolution failed");
|
|
259
|
-
return 0;
|
|
302
|
+
return (await recordUnresolved(podId, podRoot, n, "agent resolution failed")) ? 0 : 1;
|
|
260
303
|
}
|
|
261
304
|
|
|
262
305
|
// The second seat of the Pi-only guard (ADR 0023, agents-store.ts): the CLI refuses an
|
|
@@ -266,8 +309,14 @@ export async function runWrapper(argv: string[]): Promise<number> {
|
|
|
266
309
|
// from there; it never silently runs pi in claude's name.
|
|
267
310
|
const refusal = agentTypeRefusal(agent.type);
|
|
268
311
|
if (refusal !== undefined) {
|
|
269
|
-
await recordUnresolved(
|
|
270
|
-
|
|
312
|
+
return (await recordUnresolved(
|
|
313
|
+
podId,
|
|
314
|
+
podRoot,
|
|
315
|
+
n,
|
|
316
|
+
`agent ${agent.id} has type ${agent.type}: ${refusal}`,
|
|
317
|
+
))
|
|
318
|
+
? 0
|
|
319
|
+
: 1;
|
|
271
320
|
}
|
|
272
321
|
|
|
273
322
|
const wakeupDir = join(podRoot, "wakeups", String(n));
|
|
@@ -437,9 +486,7 @@ export async function runWrapper(argv: string[]): Promise<number> {
|
|
|
437
486
|
// owner, and its detail is the wind-down, not the agent's last words. The
|
|
438
487
|
// pod is terminal, so reconcile never walks it back to a wake (ADR 0011).
|
|
439
488
|
if (cancelRequested) {
|
|
440
|
-
const
|
|
441
|
-
const record = pods.find((p) => p.id === podId);
|
|
442
|
-
if (record) {
|
|
489
|
+
const recorded = await updatePodRecord(podId, podRoot, n, (record) => {
|
|
443
490
|
// A reconcile kill already adjudicated this wake-up (ADRs 0017, 0019):
|
|
444
491
|
// its intent was written before the signal, so the wrapper adds no
|
|
445
492
|
// second, contradictory outcome — whether the cause was progressless or
|
|
@@ -452,9 +499,9 @@ export async function runWrapper(argv: string[]): Promise<number> {
|
|
|
452
499
|
: { n, outcome: "cancelled" },
|
|
453
500
|
);
|
|
454
501
|
record.state = "cancelled";
|
|
455
|
-
await writePods(pods);
|
|
456
502
|
}
|
|
457
|
-
}
|
|
503
|
+
});
|
|
504
|
+
if (!recorded) return 1;
|
|
458
505
|
// The identity outlives its purpose only until the outcome is recorded.
|
|
459
506
|
await rm(wrapperRecordFile, { force: true });
|
|
460
507
|
return 0;
|
|
@@ -466,13 +513,15 @@ export async function runWrapper(argv: string[]): Promise<number> {
|
|
|
466
513
|
() => undefined,
|
|
467
514
|
);
|
|
468
515
|
const { outcome, detail } = await adjudicate(docsDir, wakeStart, eventsFile, code);
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
516
|
+
// Delivery holds the active state until its bounded flush is over; a
|
|
517
|
+
// concurrent pass must not wake a second wrapper over the same outbox
|
|
518
|
+
// (ADR 0025). Failures are logged inside the flush and never change outcome.
|
|
519
|
+
await flushOutbox(podId, pod.project, n);
|
|
520
|
+
const recorded = await updatePodRecord(podId, podRoot, n, (record) => {
|
|
472
521
|
record.wakeUps.push(detail === undefined ? { n, outcome } : { n, outcome, detail });
|
|
473
522
|
record.state = outcome === "done" ? "done" : "idle";
|
|
474
|
-
|
|
475
|
-
|
|
523
|
+
});
|
|
524
|
+
if (!recorded) return 1;
|
|
476
525
|
// The identity outlives its purpose only until the outcome is recorded.
|
|
477
526
|
await rm(wrapperRecordFile, { force: true });
|
|
478
527
|
return 0;
|