agent-lyceum 0.22.0 → 0.24.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/dist/adapters/claude.d.ts +6 -1
- package/dist/adapters/claude.js +13 -9
- package/dist/adapters/codex.js +6 -4
- package/dist/adapters/types.d.ts +6 -0
- package/dist/call.js +0 -1
- package/dist/cli.js +7 -3
- package/dist/doctor.js +12 -0
- package/dist/fs-util.d.ts +23 -0
- package/dist/fs-util.js +78 -0
- package/dist/guard.d.ts +8 -0
- package/dist/guard.js +123 -1
- package/dist/mailbox.js +6 -0
- package/dist/memory-tidy.d.ts +14 -1
- package/dist/memory-tidy.js +184 -48
- package/dist/message-store.js +22 -1
- package/dist/paths.d.ts +0 -1
- package/dist/paths.js +0 -1
- package/dist/policy.d.ts +5 -5
- package/dist/policy.js +12 -12
- package/dist/prompt.d.ts +28 -4
- package/dist/prompt.js +71 -43
- package/dist/records.d.ts +30 -0
- package/dist/records.js +130 -0
- package/dist/redo.d.ts +57 -0
- package/dist/redo.js +281 -0
- package/dist/run-session.d.ts +5 -0
- package/dist/run-session.js +50 -8
- package/dist/scaffold.js +1 -1
- package/package.json +1 -1
package/dist/redo.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { cutUtf8, readRegularFileCappedNoFollow, trimPartialUtf8 } from "./fs-util.js";
|
|
5
|
+
const METADATA_LIMIT = 64 * 1024;
|
|
6
|
+
const DRAFT_BUDGET = 4096;
|
|
7
|
+
const DRAFT_FILES = 5;
|
|
8
|
+
const OUTBOX_SCAN = 64;
|
|
9
|
+
const UNREADABLE = "unknown (attempt record unreadable)";
|
|
10
|
+
const hasControl = (s) => [...s].some((ch) => ch.charCodeAt(0) < 0x20);
|
|
11
|
+
/** Can `s` be one path segment (a claim or attempt id taken from a file)? */
|
|
12
|
+
function isSegment(s) {
|
|
13
|
+
return typeof s === "string" && s.length >= 1 && s.length <= 200 && !/[/\\]/.test(s) && !s.includes("\0") && s !== "." && s !== ".." && !s.startsWith(".");
|
|
14
|
+
}
|
|
15
|
+
/** Read a claim or attempt JSON without following a symlink or blocking on a FIFO, at most 64 KiB. Never throws. */
|
|
16
|
+
function readMetadataJson(file) {
|
|
17
|
+
const r = readRegularFileCappedNoFollow(file, METADATA_LIMIT);
|
|
18
|
+
if (!r.ok)
|
|
19
|
+
return { skip: r.reason };
|
|
20
|
+
if (r.truncated)
|
|
21
|
+
return { skip: "metadata_too_large" };
|
|
22
|
+
try {
|
|
23
|
+
return { value: JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(r.buf)) };
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return { skip: "metadata_unreadable" };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
30
|
+
const str = (v) => (typeof v === "string" ? v : undefined);
|
|
31
|
+
/** The attempt record, or the reason it is not usable. The id that builds paths must equal the file name. */
|
|
32
|
+
function validAttempt(v, fileBase) {
|
|
33
|
+
if (!isObject(v))
|
|
34
|
+
return "bad_record";
|
|
35
|
+
const { id, claim_id: claimId, agent, status, n } = v;
|
|
36
|
+
if (typeof id !== "string" || typeof agent !== "string" || typeof status !== "string")
|
|
37
|
+
return "bad_record";
|
|
38
|
+
if (!isSegment(claimId) || !Number.isSafeInteger(n) || n < 1)
|
|
39
|
+
return "bad_record";
|
|
40
|
+
if (id !== fileBase || id !== `${claimId}-a${n}`)
|
|
41
|
+
return "bad_record";
|
|
42
|
+
return { ...v, id, claim_id: claimId, agent, status, n, error: str(v.error), started_at: str(v.started_at) ?? "", finished_at: str(v.finished_at) };
|
|
43
|
+
}
|
|
44
|
+
function validClaim(v, claimId) {
|
|
45
|
+
if (!isObject(v) || v.id !== claimId || typeof v.agent !== "string" || typeof v.status !== "string")
|
|
46
|
+
return "bad_record";
|
|
47
|
+
if (!Array.isArray(v.message_ids) || !v.message_ids.every((x) => typeof x === "string"))
|
|
48
|
+
return "bad_record";
|
|
49
|
+
return v;
|
|
50
|
+
}
|
|
51
|
+
const rel = (...p) => path.join(...p);
|
|
52
|
+
/** lstat each level: a real folder, or the reason it is not one ("missing" when it does not exist). */
|
|
53
|
+
function realDir(file) {
|
|
54
|
+
try {
|
|
55
|
+
const st = fs.lstatSync(file);
|
|
56
|
+
if (st.isSymbolicLink())
|
|
57
|
+
return "ancestor_symlink";
|
|
58
|
+
return st.isDirectory() ? "ok" : "not_dir";
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return "missing";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** The attempt folder's mtime in ms, 0 when it cannot be read. */
|
|
65
|
+
function dirMtime(dir) {
|
|
66
|
+
try {
|
|
67
|
+
return fs.lstatSync(dir).mtimeMs;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Attempts of `agent` that failed before this invoke, on a claim for one of `messageIds`. Never throws. */
|
|
74
|
+
export function priorFailedAttempts(runDir, agent, messageIds, exclude) {
|
|
75
|
+
const skipped = [];
|
|
76
|
+
const empty = () => ({ prior: [], skipped });
|
|
77
|
+
const levels = [".", "mail", rel("mail", "attempts"), rel("mail", "claims")];
|
|
78
|
+
for (const l of levels) {
|
|
79
|
+
const st = realDir(path.join(runDir, l));
|
|
80
|
+
if (st === "ancestor_symlink" || st === "not_dir") {
|
|
81
|
+
skipped.push({ path: l, reason: st });
|
|
82
|
+
return empty();
|
|
83
|
+
}
|
|
84
|
+
if (st === "missing") {
|
|
85
|
+
if (l === rel("mail", "claims") && realDir(path.join(runDir, "mail", "attempts")) === "ok")
|
|
86
|
+
skipped.push({ path: l, reason: "metadata_missing" });
|
|
87
|
+
return empty();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const dir = path.join(runDir, "mail", "attempts");
|
|
91
|
+
let names;
|
|
92
|
+
try {
|
|
93
|
+
names = fs.readdirSync(dir);
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
skipped.push({ path: rel("mail", "attempts"), reason: `open_error:${e.code ?? "unknown"}` });
|
|
97
|
+
return empty();
|
|
98
|
+
}
|
|
99
|
+
// Candidates by claim id; an attempt without a usable record is kept as "unreadable".
|
|
100
|
+
const found = [];
|
|
101
|
+
const recorded = new Set();
|
|
102
|
+
for (const name of names.filter((x) => x.endsWith(".json")).sort()) {
|
|
103
|
+
const base = name.slice(0, -5);
|
|
104
|
+
const file = rel("mail", "attempts", name);
|
|
105
|
+
const r = readMetadataJson(path.join(dir, name));
|
|
106
|
+
if ("skip" in r) {
|
|
107
|
+
skipped.push({ path: file, reason: r.skip });
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const rec = validAttempt(r.value, base);
|
|
111
|
+
if (typeof rec === "string") {
|
|
112
|
+
skipped.push({ path: file, reason: rec });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
recorded.add(base);
|
|
116
|
+
if (rec.status !== "failed" || rec.agent !== agent || rec.id === exclude)
|
|
117
|
+
continue;
|
|
118
|
+
const t = [rec.finished_at, rec.started_at].map((s) => (s ? Date.parse(s) : NaN)).find((x) => Number.isFinite(x));
|
|
119
|
+
found.push({ id: rec.id, claimId: rec.claim_id, n: rec.n, error: rec.error ?? "unknown error", at: t ?? dirMtime(path.join(dir, rec.id)), parked: Array.isArray(rec.parked) ? rec.parked.filter((x) => typeof x === "string") : undefined });
|
|
120
|
+
}
|
|
121
|
+
for (const name of names.filter((x) => !x.endsWith(".json") && !recorded.has(x))) {
|
|
122
|
+
const m = /^(.+)-a([1-9]\d*)$/.exec(name);
|
|
123
|
+
if (!m || !isSegment(m[1]) || name === exclude || realDir(path.join(dir, name)) !== "ok")
|
|
124
|
+
continue;
|
|
125
|
+
found.push({ id: name, claimId: m[1], n: Number(m[2]), error: UNREADABLE, at: dirMtime(path.join(dir, name)) });
|
|
126
|
+
}
|
|
127
|
+
const accepted = new Map();
|
|
128
|
+
const usable = (claimId) => {
|
|
129
|
+
const known = accepted.get(claimId);
|
|
130
|
+
if (known !== undefined)
|
|
131
|
+
return known;
|
|
132
|
+
const file = rel("mail", "claims", `${claimId}.json`);
|
|
133
|
+
let ok = false;
|
|
134
|
+
const r = readMetadataJson(path.join(runDir, file));
|
|
135
|
+
if ("skip" in r)
|
|
136
|
+
skipped.push({ path: file, reason: r.skip });
|
|
137
|
+
else {
|
|
138
|
+
const claim = validClaim(r.value, claimId);
|
|
139
|
+
if (typeof claim === "string")
|
|
140
|
+
skipped.push({ path: file, reason: claim });
|
|
141
|
+
else
|
|
142
|
+
ok = claim.agent === agent && (claim.status === "claimed" || claim.status === "abandoned") && claim.message_ids.some((id) => messageIds.includes(id));
|
|
143
|
+
}
|
|
144
|
+
accepted.set(claimId, ok);
|
|
145
|
+
return ok;
|
|
146
|
+
};
|
|
147
|
+
const prior = found.filter((a) => usable(a.claimId)).sort((a, b) => b.at - a.at || b.n - a.n);
|
|
148
|
+
return { prior, skipped };
|
|
149
|
+
}
|
|
150
|
+
/** The drafts block for the attempts in `prior` (newest first), read from their folders, at most `budget` bytes and `maxFiles` files. */
|
|
151
|
+
export function collectDrafts(runDir, prior, budget = DRAFT_BUDGET, maxFiles = DRAFT_FILES) {
|
|
152
|
+
const result = { text: "", injected: [], skipped: [], reconciled: [] };
|
|
153
|
+
const candidates = [];
|
|
154
|
+
for (const a of prior) {
|
|
155
|
+
const outboxRel = rel("mail", "attempts", a.id, "outbox");
|
|
156
|
+
const parts = [rel("mail", "attempts", a.id), outboxRel];
|
|
157
|
+
const bad = parts.map((p) => ({ p, st: realDir(path.join(runDir, p)) })).find((x) => x.st !== "ok");
|
|
158
|
+
if (bad) {
|
|
159
|
+
// A missing outbox just means nothing was set aside.
|
|
160
|
+
if (bad.st !== "missing")
|
|
161
|
+
result.skipped.push({ path: bad.p, reason: bad.st });
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
let entries;
|
|
165
|
+
try {
|
|
166
|
+
entries = fs.readdirSync(path.join(runDir, outboxRel), { withFileTypes: true }).sort((x, y) => (x.name < y.name ? -1 : 1));
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
result.skipped.push({ path: outboxRel, reason: `open_error:${e.code ?? "unknown"}` });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (entries.length > OUTBOX_SCAN)
|
|
173
|
+
result.skipped.push({ path: outboxRel, reason: "over_entry_cap" });
|
|
174
|
+
const onDisk = [];
|
|
175
|
+
for (const e of entries.slice(0, OUTBOX_SCAN)) {
|
|
176
|
+
const file = rel(outboxRel, e.name);
|
|
177
|
+
if (e.name.startsWith("."))
|
|
178
|
+
continue;
|
|
179
|
+
if (!e.isFile()) {
|
|
180
|
+
result.skipped.push({ path: file, reason: e.isSymbolicLink() ? "symlink" : "not_regular" });
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (hasControl(e.name) || e.name.length > 200) {
|
|
184
|
+
result.skipped.push({ path: file, reason: "bad_name" });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
onDisk.push(e.name);
|
|
188
|
+
candidates.push({ attempt: a, rel: file, name: e.name });
|
|
189
|
+
}
|
|
190
|
+
if (a.parked) {
|
|
191
|
+
const onlyOnDisk = onDisk.filter((x) => !a.parked?.includes(x));
|
|
192
|
+
const onlyInRecord = a.parked.filter((x) => !onDisk.includes(x));
|
|
193
|
+
if (onlyOnDisk.length || onlyInRecord.length)
|
|
194
|
+
result.reconciled.push({ attempt: a.id, onlyOnDisk, onlyInRecord });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (candidates.length === 0)
|
|
198
|
+
return result;
|
|
199
|
+
const header = "# Drafts from your failed attempt (unverified, not delivered)\nThese files were in your outbox when an earlier attempt at this message failed. They were set aside and never delivered; they may be incomplete or wrong. Check them against the repository before reusing any of them.\n";
|
|
200
|
+
const more = (n, c) => `(${n} more draft file(s) not shown: ${rel("mail", "attempts", c.attempt.id, "outbox")}/)`;
|
|
201
|
+
const render = (c, content, truncated, size) => {
|
|
202
|
+
const longest = Math.max(0, ...(content.match(/`+/g) ?? []).map((x) => x.length));
|
|
203
|
+
const fence = "`".repeat(Math.max(3, longest + 1));
|
|
204
|
+
return `\n## ${c.rel} (attempt ${c.attempt.n}, ${c.attempt.error})\n${fence}\n${content}\n${fence}\n${truncated ? `[truncated: ${c.rel}, ${size} B]\n` : ""}`;
|
|
205
|
+
};
|
|
206
|
+
const bytes = (s) => Buffer.byteLength(s);
|
|
207
|
+
const reserve = Math.max(...candidates.map((c) => bytes(`\n${more(999, c)}\n`)));
|
|
208
|
+
let used = bytes(header);
|
|
209
|
+
let text = header;
|
|
210
|
+
const seen = new Set();
|
|
211
|
+
let shown = 0;
|
|
212
|
+
let left = candidates.length;
|
|
213
|
+
for (const c of candidates) {
|
|
214
|
+
if (shown >= maxFiles)
|
|
215
|
+
break;
|
|
216
|
+
const room = budget - used - reserve;
|
|
217
|
+
const overhead = bytes(render(c, "", true, Number.MAX_SAFE_INTEGER));
|
|
218
|
+
if (room - overhead < 1)
|
|
219
|
+
break;
|
|
220
|
+
left--;
|
|
221
|
+
const r = readRegularFileCappedNoFollow(path.join(runDir, c.rel), room - overhead);
|
|
222
|
+
if (!r.ok) {
|
|
223
|
+
result.skipped.push({ path: c.rel, reason: r.reason });
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
// The capped read can end inside a character: drop the incomplete tail before decoding.
|
|
227
|
+
if (r.truncated)
|
|
228
|
+
r.buf = trimPartialUtf8(r.buf);
|
|
229
|
+
if (r.buf.includes(0)) {
|
|
230
|
+
result.skipped.push({ path: c.rel, reason: "binary" });
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (!r.truncated) {
|
|
234
|
+
const h = crypto.createHash("sha256").update(r.buf).digest("hex");
|
|
235
|
+
if (seen.has(h))
|
|
236
|
+
continue;
|
|
237
|
+
seen.add(h);
|
|
238
|
+
}
|
|
239
|
+
// Backtick-heavy content lengthens the fence; trim until the block fits.
|
|
240
|
+
let lim = r.buf.length;
|
|
241
|
+
let block = render(c, "", r.truncated, r.size);
|
|
242
|
+
for (;;) {
|
|
243
|
+
const cut = cutUtf8(r.buf, lim);
|
|
244
|
+
block = render(c, cut.toString("utf8"), r.truncated || cut.length < r.buf.length, r.size);
|
|
245
|
+
if (bytes(block) <= room || lim <= 0)
|
|
246
|
+
break;
|
|
247
|
+
lim = cut.length - (bytes(block) - room);
|
|
248
|
+
}
|
|
249
|
+
if (bytes(block) > room) {
|
|
250
|
+
left++;
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
text += block;
|
|
254
|
+
used += bytes(block);
|
|
255
|
+
shown++;
|
|
256
|
+
result.injected.push({ path: c.rel, bytes: r.size, truncated: block.includes(`[truncated: ${c.rel},`) });
|
|
257
|
+
}
|
|
258
|
+
if (shown === 0)
|
|
259
|
+
return result;
|
|
260
|
+
const rest = candidates.filter((c) => !result.injected.some((i) => i.path === c.rel) && !result.skipped.some((s) => s.path === c.rel));
|
|
261
|
+
if (left > 0 && rest[0])
|
|
262
|
+
text += `${more(rest.length, rest[0])}\n`;
|
|
263
|
+
result.text = text.trimEnd();
|
|
264
|
+
return result;
|
|
265
|
+
}
|
|
266
|
+
const TASK_MEMORY = (dir) => (dir ? ` Read your task memory (\`${dir}\`) first: progress you recorded there survives a failed attempt.` : "");
|
|
267
|
+
/** Prompt for a repeat attempt in the same wake-up: the earlier one failed and may have changed things already. */
|
|
268
|
+
export function retryPrompt(prompt, error, o) {
|
|
269
|
+
const mail = o.drafts ? "Its mail was set aside, not delivered: see the drafts below." : "Its mail was discarded.";
|
|
270
|
+
return `${prompt}\n\n# Retry notice\nYour previous attempt at this wake-up failed${error ? ` (${error})` : ""} after possibly changing files already. ${mail} Check the current state of the repository before redoing the work, and send the mail again.${TASK_MEMORY(o.taskMemory)}`;
|
|
271
|
+
}
|
|
272
|
+
/** The user prompt for this invoke: `base`, a Retry or Redo notice when there is something to redo, then the drafts. No IO. */
|
|
273
|
+
export function redoUserPrompt(base, o) {
|
|
274
|
+
const tail = o.drafts ? `\n\n${o.drafts}` : "";
|
|
275
|
+
if (o.attempt > 0)
|
|
276
|
+
return `${retryPrompt(base, o.error, { drafts: !!o.drafts, taskMemory: o.taskMemory })}${tail}`;
|
|
277
|
+
const last = o.prior[0];
|
|
278
|
+
if (!last)
|
|
279
|
+
return base;
|
|
280
|
+
return `${base}\n\n# Redo notice\nAn earlier attempt at this message did not finish (${last.error}; attempt ${last.id}). It may have changed files already, and its mail was set aside, not delivered${o.drafts ? ": see the drafts below" : ""}. Check the current state of the repository before redoing the work, and send the mail again.${TASK_MEMORY(o.taskMemory)}${tail}`;
|
|
281
|
+
}
|
package/dist/run-session.d.ts
CHANGED
|
@@ -113,6 +113,11 @@ export declare class RunSession {
|
|
|
113
113
|
private pendingAgents;
|
|
114
114
|
private pickBatch;
|
|
115
115
|
private wake;
|
|
116
|
+
/**
|
|
117
|
+
* The earlier failed attempts at these messages and the drafts they left, for the invoke that `attemptId` is about to make.
|
|
118
|
+
* Every skip and the outcome go to the log; a read problem never fails the wake-up.
|
|
119
|
+
*/
|
|
120
|
+
private redoContext;
|
|
116
121
|
/** Copy the shared cooldown table into the state (only once something has cooled down); true when it was. */
|
|
117
122
|
private syncCooldowns;
|
|
118
123
|
/**
|
package/dist/run-session.js
CHANGED
|
@@ -6,11 +6,12 @@ import { briefOf, digestOf, doneContract, mergeSteps, parseSteps } from "./forma
|
|
|
6
6
|
import { ProtectedGuard } from "./guard.js";
|
|
7
7
|
import { addBatch, askersWithAnswers, askReplyPath, checkAnswers, describeCheck, isComplete, markDelivered, parseAskBody, readAskReply, renderAnswers, resolveOptionLetters, writeAskReply } from "./ask-reply.js";
|
|
8
8
|
import { deliver, deliverOnce, ensureProjectDirs, finishDone, listUnread, rejectDone, routeOutboxes, unreadFiles } from "./mailbox.js";
|
|
9
|
-
import { abandonClaim, attemptLogDir, sourceIdOf, beginAttempt, claimMessages, commitClaim, finishAttempt, markOutputReady, parkOutbox, recoverRunMail, RouteJournal } from "./message-store.js";
|
|
9
|
+
import { abandonClaim, attemptLogDir, sourceIdOf, beginAttempt, claimMessages, commitClaim, fault, finishAttempt, markOutputReady, parkOutbox, recoverRunMail, RouteJournal } from "./message-store.js";
|
|
10
10
|
import { createRunLog } from "./run-log.js";
|
|
11
11
|
import { endOutcome, resolveDoneOutcome } from "./run-outcome.js";
|
|
12
12
|
import { outboxDir, sharedIndex } from "./policy.js";
|
|
13
13
|
import { buildSystemPrompt, buildUserPrompt, pickMessages } from "./prompt.js";
|
|
14
|
+
import { collectDrafts, priorFailedAttempts, redoUserPrompt } from "./redo.js";
|
|
14
15
|
import { atomicWrite, readFileCapped } from "./fs-util.js";
|
|
15
16
|
import { assertWorktreeRunnable, collectAgentChanges, dirtyPaths, existingAgentWorkspace, finishLeadBranch, integrateAgentChanges, prepareAgentWorkspace, recutLeadWorkspace, refusedIntegration, snapshotBase, snapshotHead, toplevel } from "./worktree.js";
|
|
16
17
|
import { bindRunProject, newRunId, newRunState, roundLimit, roundsLabel, saveRunState } from "./run-store.js";
|
|
@@ -32,10 +33,8 @@ function readIndex(file) {
|
|
|
32
33
|
return undefined;
|
|
33
34
|
return readFileCapped(file, INDEX_COMPARE_LIMIT)?.text ?? "";
|
|
34
35
|
}
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
return `${prompt}\n\n# Retry notice\nYour previous attempt at this wake-up failed${error ? ` (${error})` : ""} after possibly changing files already. Its mail was discarded. Check the current state of the repository before redoing the work, and send the mail again.`;
|
|
38
|
-
}
|
|
36
|
+
/** The `state.sessions` key of the session an agent runs in on candidate `c`: a backup has a session of its own. */
|
|
37
|
+
const sessionKeyOf = (agent, c) => (c.primary ? agent.name : `${agent.name}@${c.key}`);
|
|
39
38
|
/** `runtime/model`, or the runtime alone for its default model. */
|
|
40
39
|
const modelLabel = (c) => (c.model ? `${c.runtime}/${c.model}` : c.runtime);
|
|
41
40
|
/** Each model given up on once, in order, with its last failure (retries on the same model are one entry). */
|
|
@@ -343,7 +342,8 @@ export class RunSession {
|
|
|
343
342
|
if (opts.signal?.aborted)
|
|
344
343
|
return;
|
|
345
344
|
say(` ${agent.name} falling back to ${modelLabel(to)} (${kind ?? "unknown error"})`);
|
|
346
|
-
|
|
345
|
+
const resumes = agent.resume && !!state.sessions[sessionKeyOf(agent, to)];
|
|
346
|
+
this.note(`${agent.name} fell back from ${modelLabel(from)} to ${modelLabel(to)} (${kind ?? "unknown error"}); the backup ${resumes ? "resumes its own earlier session" : "starts a fresh session"} and redoes this wake-up. ` +
|
|
347
347
|
`Anything the failed attempts already did outside the mailbox (file edits, commands) was not undone: side effects may have happened.`);
|
|
348
348
|
},
|
|
349
349
|
invoke: async (c, ctx) => {
|
|
@@ -352,10 +352,13 @@ export class RunSession {
|
|
|
352
352
|
const attempt = ctx.attempt;
|
|
353
353
|
// A backup runs as the same agent on another runtime/model, in a session of its own.
|
|
354
354
|
const runAs = agentForCandidate(agent, c);
|
|
355
|
-
const sessionKey =
|
|
355
|
+
const sessionKey = sessionKeyOf(agent, c);
|
|
356
356
|
const round = ++state.rounds;
|
|
357
357
|
const started = Date.now();
|
|
358
358
|
const att = beginAttempt(runDir, claim.id);
|
|
359
|
+
// Looked up again for every call: a retry sees what the attempt before it just set aside.
|
|
360
|
+
const redo = this.redoContext(agent.name, unread.map((m) => m.meta.id), att.id, attempt);
|
|
361
|
+
const userPrompt = redoUserPrompt(base.userPrompt, { attempt, error: ctx.previous?.error, prior: redo.prior, drafts: redo.drafts, taskMemory: agent.memory.task });
|
|
359
362
|
const retryN = chain ? ctx.candidateAttempt : attempt;
|
|
360
363
|
say(`[round ${roundsLabel(state.rounds, state.max_rounds)}] waking ${agent.name} (${chain ? modelLabel(c) : agent.runtime})${retryN ? ` — retry ${retryN}` : ""}`);
|
|
361
364
|
const sessionId = agent.resume ? state.sessions[sessionKey] : undefined;
|
|
@@ -369,7 +372,7 @@ export class RunSession {
|
|
|
369
372
|
agent: runAs,
|
|
370
373
|
signal: opts.signal,
|
|
371
374
|
logDir: attemptLogDir(runDir, att.id),
|
|
372
|
-
userPrompt
|
|
375
|
+
userPrompt,
|
|
373
376
|
sessionId,
|
|
374
377
|
}).catch((e) => ({
|
|
375
378
|
ok: false,
|
|
@@ -411,6 +414,7 @@ export class RunSession {
|
|
|
411
414
|
duration_ms: Date.now() - started,
|
|
412
415
|
messages: unread.map((m) => m.meta.id),
|
|
413
416
|
error: result.error,
|
|
417
|
+
...(redo.prior.length ? { redo_of: redo.prior.map((a) => a.id) } : {}),
|
|
414
418
|
...(chain ? { model: c.model, fallback_from: which.fallback_from, kind: result.kind } : {}),
|
|
415
419
|
});
|
|
416
420
|
this.saveState();
|
|
@@ -425,11 +429,13 @@ export class RunSession {
|
|
|
425
429
|
if (result.cancelled) {
|
|
426
430
|
// Stopped on purpose: not a failure to report. Its output is set aside (its input stays unread for the resume, below).
|
|
427
431
|
const parked = parkOutbox(runDir, outboxDir(project, agent.name), att.id);
|
|
432
|
+
fault("park:before-finish");
|
|
428
433
|
finishAttempt(runDir, att.id, "failed", { error: "cancelled", parked });
|
|
429
434
|
return result;
|
|
430
435
|
}
|
|
431
436
|
// A failed attempt's output is never trusted: park it under the attempt, away from the next attempt's result.
|
|
432
437
|
const parked = parkOutbox(runDir, outboxDir(project, agent.name), att.id);
|
|
438
|
+
fault("park:before-finish");
|
|
433
439
|
finishAttempt(runDir, att.id, "failed", { error: result.error, parked });
|
|
434
440
|
say(` ${agent.name} failed: ${result.error ?? "unknown error"}`);
|
|
435
441
|
this.note(`${agent.name} attempt ${att.n} failed (${result.error ?? "unknown error"})${parked.length ? `; ${parked.length} output file(s) set aside in ${path.join("mail", "attempts", att.id, "outbox")}` : ""}. ` +
|
|
@@ -456,6 +462,42 @@ export class RunSession {
|
|
|
456
462
|
// The agent failed every time it was called: the run stops here (see settle) and its message stays unread for the resume.
|
|
457
463
|
return { ok: false, error: result?.error, attempts: calls, ...(chain ? { tried: lastPerModel(outcome.tried) } : {}) };
|
|
458
464
|
}
|
|
465
|
+
/**
|
|
466
|
+
* The earlier failed attempts at these messages and the drafts they left, for the invoke that `attemptId` is about to make.
|
|
467
|
+
* Every skip and the outcome go to the log; a read problem never fails the wake-up.
|
|
468
|
+
*/
|
|
469
|
+
redoContext(agent, messageIds, attemptId, attempt) {
|
|
470
|
+
const { log, runDir } = this;
|
|
471
|
+
let prior = [];
|
|
472
|
+
let drafts;
|
|
473
|
+
const skipped = [];
|
|
474
|
+
try {
|
|
475
|
+
const found = priorFailedAttempts(runDir, agent, messageIds, attemptId);
|
|
476
|
+
prior = found.prior;
|
|
477
|
+
skipped.push(...found.skipped);
|
|
478
|
+
if (prior.length) {
|
|
479
|
+
drafts = collectDrafts(runDir, prior);
|
|
480
|
+
skipped.push(...drafts.skipped);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
catch (e) {
|
|
484
|
+
prior = [];
|
|
485
|
+
drafts = undefined;
|
|
486
|
+
skipped.push({ path: "mail/attempts", reason: `error:${e.message}` });
|
|
487
|
+
}
|
|
488
|
+
for (const s of skipped)
|
|
489
|
+
log("draft_skip", { agent, attempt: attemptId, path: s.path, reason: s.reason });
|
|
490
|
+
if (!prior.length)
|
|
491
|
+
return { prior, drafts: "" };
|
|
492
|
+
log("redo", { agent, attempt: attemptId, prior: prior.map((a) => a.id), notice: attempt > 0 ? "retry" : "redo" });
|
|
493
|
+
for (const r of drafts?.reconciled ?? [])
|
|
494
|
+
log("draft_reconcile", { agent, attempt: r.attempt, only_on_disk: r.onlyOnDisk, only_in_record: r.onlyInRecord });
|
|
495
|
+
if (drafts?.injected.length)
|
|
496
|
+
log("draft_inject", { agent, attempt: attemptId, files: drafts.injected });
|
|
497
|
+
else
|
|
498
|
+
log("draft_none", { agent, attempt: attemptId, prior: prior.map((a) => a.id) });
|
|
499
|
+
return { prior, drafts: drafts?.text ?? "" };
|
|
500
|
+
}
|
|
459
501
|
/** Copy the shared cooldown table into the state (only once something has cooled down); true when it was. */
|
|
460
502
|
syncCooldowns() {
|
|
461
503
|
const table = this.cooldowns.toJSON();
|
package/dist/scaffold.js
CHANGED
|
@@ -7,7 +7,7 @@ const PERSONAS = {
|
|
|
7
7
|
You coordinate the team. You receive the user's task, break it into concrete pieces, and delegate each piece to the right teammate by mail.
|
|
8
8
|
|
|
9
9
|
- Keep \`COMMON.md\` (shared team context: goal, conventions) accurate and short.
|
|
10
|
-
- Before you send \`done\`, record what was decided and why
|
|
10
|
+
- Before you send \`done\`, record what was decided and why as decision entries, and what the project learned as lesson entries, in the records folders named in your rules (one file per entry, with frontmatter); mark outdated entries \`superseded\`. Later runs start from the effective-active ones (about 6 KB per kind).
|
|
11
11
|
- Write every task with the standard task headings, and cite the mail it derives from under Upstream so teammates can trace it.
|
|
12
12
|
- When a teammate reports back, decide the next step: assign follow-up work, ask another teammate to verify, or accept the result.
|
|
13
13
|
- Teammates cannot message each other by default; route everything through yourself.
|