agent-lyceum 0.17.3 → 0.19.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/README.md +4 -3
- package/README.zh-TW.md +4 -3
- package/dist/adapters/claude.d.ts +12 -0
- package/dist/adapters/claude.js +39 -8
- package/dist/adapters/codex.d.ts +7 -0
- package/dist/adapters/codex.js +42 -1
- package/dist/adapters/types.d.ts +5 -0
- package/dist/call.d.ts +5 -0
- package/dist/call.js +35 -13
- package/dist/cli.js +17 -9
- package/dist/config.d.ts +36 -7
- package/dist/config.js +75 -8
- package/dist/demo.js +0 -1
- package/dist/dispatcher.d.ts +2 -1
- package/dist/dispatcher.js +3 -1
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +25 -17
- package/dist/fallback.d.ts +111 -0
- package/dist/fallback.js +157 -0
- package/dist/paths.d.ts +0 -2
- package/dist/paths.js +0 -11
- package/dist/policy.d.ts +2 -6
- package/dist/policy.js +6 -10
- package/dist/process-runner.js +4 -2
- package/dist/project-lock.d.ts +10 -2
- package/dist/project-lock.js +23 -12
- package/dist/prompt.js +0 -3
- package/dist/run-outcome.d.ts +3 -11
- package/dist/run-outcome.js +5 -0
- package/dist/run-session.d.ts +11 -7
- package/dist/run-session.js +184 -113
- package/dist/run-store.d.ts +32 -7
- package/dist/run-store.js +23 -1
- package/dist/scaffold.js +0 -5
- package/dist/schema.d.ts +117 -22
- package/dist/schema.js +22 -7
- package/dist/solo.js +1 -2
- package/dist/status.d.ts +16 -7
- package/dist/status.js +49 -1
- package/dist/validate.js +41 -38
- package/dist/worktree.d.ts +19 -12
- package/dist/worktree.js +35 -74
- package/package.json +1 -1
package/dist/run-session.js
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { realInvoker } from "./adapters/index.js";
|
|
4
|
+
import { agentForCandidate, candidatesFor, Cooldowns, planAttempts, runWithFallback } from "./fallback.js";
|
|
4
5
|
import { briefOf, digestOf, doneContract, mergeSteps, parseSteps } from "./format.js";
|
|
5
6
|
import { ProtectedGuard } from "./guard.js";
|
|
6
7
|
import { addBatch, askersWithAnswers, askReplyPath, checkAnswers, describeCheck, isComplete, markDelivered, parseAskBody, readAskReply, renderAnswers, resolveOptionLetters, writeAskReply } from "./ask-reply.js";
|
|
7
8
|
import { deliver, deliverOnce, ensureProjectDirs, finishDone, listUnread, rejectDone, routeOutboxes, unreadFiles } from "./mailbox.js";
|
|
8
9
|
import { abandonClaim, attemptLogDir, sourceIdOf, beginAttempt, claimMessages, commitClaim, finishAttempt, markOutputReady, parkOutbox, recoverRunMail, RouteJournal } from "./message-store.js";
|
|
9
|
-
import { isInside } from "./paths.js";
|
|
10
10
|
import { createRunLog } from "./run-log.js";
|
|
11
11
|
import { endOutcome, resolveDoneOutcome } from "./run-outcome.js";
|
|
12
|
-
import { outboxDir,
|
|
12
|
+
import { outboxDir, sharedIndex } from "./policy.js";
|
|
13
13
|
import { buildSystemPrompt, buildUserPrompt, pickMessages } from "./prompt.js";
|
|
14
14
|
import { atomicWrite, readFileCapped } from "./fs-util.js";
|
|
15
|
-
import { assertWorktreeRunnable, collectAgentChanges, dirtyPaths, existingAgentWorkspace, finishLeadBranch, integrateAgentChanges, prepareAgentWorkspace, recutLeadWorkspace, refusedIntegration,
|
|
15
|
+
import { assertWorktreeRunnable, collectAgentChanges, dirtyPaths, existingAgentWorkspace, finishLeadBranch, integrateAgentChanges, prepareAgentWorkspace, recutLeadWorkspace, refusedIntegration, snapshotBase, snapshotHead, toplevel } from "./worktree.js";
|
|
16
16
|
import { bindRunProject, newRunId, newRunState, roundLimit, roundsLabel, saveRunState } from "./run-store.js";
|
|
17
17
|
import { soloProject } from "./solo.js";
|
|
18
18
|
import { taskMessageBody } from "./task.js";
|
|
@@ -36,6 +36,21 @@ function readIndex(file) {
|
|
|
36
36
|
function retryPrompt(prompt, error) {
|
|
37
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
38
|
}
|
|
39
|
+
/** `runtime/model`, or the runtime alone for its default model. */
|
|
40
|
+
const modelLabel = (c) => (c.model ? `${c.runtime}/${c.model}` : c.runtime);
|
|
41
|
+
/** Each model given up on once, in order, with its last failure (retries on the same model are one entry). */
|
|
42
|
+
function lastPerModel(tried) {
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const t of tried) {
|
|
45
|
+
const last = out.at(-1);
|
|
46
|
+
const entry = { runtime: t.runtime, ...(t.model ? { model: t.model } : {}), ...(t.kind ? { kind: t.kind } : {}), ...(t.error ? { error: t.error } : {}) };
|
|
47
|
+
if (last && last.runtime === t.runtime && last.model === t.model)
|
|
48
|
+
out[out.length - 1] = entry;
|
|
49
|
+
else
|
|
50
|
+
out.push(entry);
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
39
54
|
export class RunSession {
|
|
40
55
|
opts;
|
|
41
56
|
resume;
|
|
@@ -59,6 +74,8 @@ export class RunSession {
|
|
|
59
74
|
doneOutcome;
|
|
60
75
|
doneNote;
|
|
61
76
|
failure;
|
|
77
|
+
/** Models cooling down after a rate limit or quota error; one table for the run, shared by parallel wake-ups. */
|
|
78
|
+
cooldowns;
|
|
62
79
|
doneVerification;
|
|
63
80
|
/** The accepted `done` carried `open_pr: true` (only the lead can send `done`). */
|
|
64
81
|
openPrRequested = false;
|
|
@@ -91,12 +108,13 @@ export class RunSession {
|
|
|
91
108
|
const layout = resume ? resume.mail_layout : "run";
|
|
92
109
|
// Each run gets its own memory directory per agent and (new runs) its own mailboxes.
|
|
93
110
|
const project = bindRunProject(base, runDir, layout);
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
const
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
111
|
+
// Where agents work was decided before the lock was taken and is kept for the run's resumes. A resumed run that uses worktrees
|
|
112
|
+
// is refused early (before anything is written) when its repo can no longer give them.
|
|
113
|
+
const { plan } = opts;
|
|
114
|
+
const concurrent = plan.leadWorktree;
|
|
115
|
+
const wsMode = plan.mode;
|
|
116
|
+
if (resume && wsMode === "worktree")
|
|
117
|
+
assertWorktreeRunnable(project);
|
|
100
118
|
// Concurrent runs: the lead works in its own worktree cut from HEAD, so no other run (and not you) shares its files.
|
|
101
119
|
let leadSpace;
|
|
102
120
|
let recut = false;
|
|
@@ -123,6 +141,9 @@ export class RunSession {
|
|
|
123
141
|
say(` starting from HEAD ${leadSpace.base.slice(0, 8)}${uncommitted ? ` (your ${uncommitted} uncommitted file(s) are not part of this run)` : ""}; ${project.lead} works on branch ${leadSpace.branch}`);
|
|
124
142
|
log("lead-workspace", { base: leadSpace.base, branch: leadSpace.branch, dir: leadSpace.dir, uncommitted });
|
|
125
143
|
}
|
|
144
|
+
if (!resume && plan.mode === "shared" && plan.reason) {
|
|
145
|
+
say(` agents take turns in ${project.dir} (no separate git worktrees: ${plan.reason}); commit to a git repo to run them in parallel`);
|
|
146
|
+
}
|
|
126
147
|
let state;
|
|
127
148
|
if (resume) {
|
|
128
149
|
state = { ...resume, max_rounds: roundLimit(resume, cfg.max_rounds), pid: process.pid, ended_at: undefined, end_reason: undefined, failure: undefined, workspace_mode: wsMode, outcome: undefined, outcome_note: undefined, verification: undefined, active: {}, wakes: [...resume.wakes] };
|
|
@@ -222,6 +243,7 @@ export class RunSession {
|
|
|
222
243
|
this.log = log;
|
|
223
244
|
this.guard = guard;
|
|
224
245
|
this.state = state;
|
|
246
|
+
this.cooldowns = Cooldowns.from(state.cooldowns);
|
|
225
247
|
// Only a segment that starts here has a baseline: a fresh run or a follow-up reopen. A plain resume continues one begun by an earlier process.
|
|
226
248
|
this.sharedAtStart = !resume || opts.followup ? readIndex(sharedIndex(project)) : undefined;
|
|
227
249
|
}
|
|
@@ -261,29 +283,16 @@ export class RunSession {
|
|
|
261
283
|
return rows.map((r) => r.a);
|
|
262
284
|
}
|
|
263
285
|
pickBatch(pending) {
|
|
264
|
-
const {
|
|
286
|
+
const { project } = this;
|
|
265
287
|
const lead = pending.find((a) => a.name === project.lead);
|
|
266
|
-
if (lead
|
|
267
|
-
return [
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
if (batch.length >= cfg.max_parallel)
|
|
272
|
-
break;
|
|
273
|
-
const mine = ownsDirs(project, a);
|
|
274
|
-
const clash = mine.some((x) => taken.some((y) => isInside(x, y) || isInside(y, x)));
|
|
275
|
-
if (clash || mine.length === 0) {
|
|
276
|
-
if (batch.length === 0)
|
|
277
|
-
batch.push(a);
|
|
278
|
-
continue;
|
|
279
|
-
}
|
|
280
|
-
batch.push(a);
|
|
281
|
-
taken.push(...mine);
|
|
282
|
-
}
|
|
283
|
-
return batch;
|
|
288
|
+
if (lead)
|
|
289
|
+
return [lead];
|
|
290
|
+
if (this.opts.plan.mode !== "worktree")
|
|
291
|
+
return [must(pending[0], "an agent to wake")];
|
|
292
|
+
return pending;
|
|
284
293
|
}
|
|
285
294
|
async wake(agent) {
|
|
286
|
-
const { invoke, say, cfg, runDir, project, log, state, claims, attempts, noSessionNoted, opts } = this;
|
|
295
|
+
const { invoke, say, cfg, runDir, project, log, state, claims, attempts, noSessionNoted, opts, cooldowns } = this;
|
|
287
296
|
const queue = listUnread(project, agent.name);
|
|
288
297
|
if (queue.length === 0)
|
|
289
298
|
return { ok: true };
|
|
@@ -303,92 +312,153 @@ export class RunSession {
|
|
|
303
312
|
userPrompt: buildUserPrompt(project, agent, queue, unread.length, (state.reopened?.length ?? 0) > 0),
|
|
304
313
|
timeoutSec: cfg.wake_timeout_sec,
|
|
305
314
|
};
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
315
|
+
// Without a fallback chain (one candidate) the loop, its output and its records are exactly those of plain retries (spec 20.2 decision 11).
|
|
316
|
+
const all = candidatesFor(agent);
|
|
317
|
+
const chain = all.length > 1;
|
|
318
|
+
const candidates = chain ? planAttempts(agent, cooldowns, Date.now()) : all;
|
|
319
|
+
// The model a backup's calls stand in for: the one switched away from in this wake, or the cooling-down primary it started past.
|
|
320
|
+
let replaced = candidates[0]?.primary ? undefined : all[0];
|
|
321
|
+
let calls = 0;
|
|
322
|
+
let stoppedByCancel = false;
|
|
323
|
+
const outcome = await runWithFallback({
|
|
324
|
+
candidates,
|
|
325
|
+
legacy: !chain,
|
|
326
|
+
retry: cfg.retry,
|
|
327
|
+
cooldowns,
|
|
328
|
+
cooldownSec: cfg.fallback_cooldown_sec,
|
|
329
|
+
signal: opts.signal,
|
|
330
|
+
sleep: opts.sleep,
|
|
331
|
+
beforeAttempt: (_c, ctx) => {
|
|
332
|
+
// A cancel that came during the previous call: no new model is tried (without a chain the call still goes ahead, as before).
|
|
333
|
+
if (chain && ctx.attempt > 0 && opts.signal?.aborted) {
|
|
334
|
+
stoppedByCancel = true;
|
|
335
|
+
return "stop";
|
|
336
|
+
}
|
|
309
337
|
// The round limit (when one is set) ends the run; this message stays unread for the resume.
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
state.
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
round,
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
338
|
+
return state.max_rounds > 0 && state.rounds >= state.max_rounds ? "stop" : undefined;
|
|
339
|
+
},
|
|
340
|
+
onSwitch: (from, to, kind) => {
|
|
341
|
+
replaced = from;
|
|
342
|
+
this.syncCooldowns();
|
|
343
|
+
if (opts.signal?.aborted)
|
|
344
|
+
return;
|
|
345
|
+
say(` ${agent.name} falling back to ${modelLabel(to)} (${kind ?? "unknown error"})`);
|
|
346
|
+
this.note(`${agent.name} fell back from ${modelLabel(from)} to ${modelLabel(to)} (${kind ?? "unknown error"}); the backup starts a fresh session and redoes this wake-up. ` +
|
|
347
|
+
`Anything the failed attempts already did outside the mailbox (file edits, commands) was not undone: side effects may have happened.`);
|
|
348
|
+
},
|
|
349
|
+
invoke: async (c, ctx) => {
|
|
350
|
+
calls++;
|
|
351
|
+
this.syncCooldowns();
|
|
352
|
+
const attempt = ctx.attempt;
|
|
353
|
+
// A backup runs as the same agent on another runtime/model, in a session of its own.
|
|
354
|
+
const runAs = agentForCandidate(agent, c);
|
|
355
|
+
const sessionKey = c.primary ? agent.name : `${agent.name}@${c.key}`;
|
|
356
|
+
const round = ++state.rounds;
|
|
357
|
+
const started = Date.now();
|
|
358
|
+
const att = beginAttempt(runDir, claim.id);
|
|
359
|
+
const retryN = chain ? ctx.candidateAttempt : attempt;
|
|
360
|
+
say(`[round ${roundsLabel(state.rounds, state.max_rounds)}] waking ${agent.name} (${chain ? modelLabel(c) : agent.runtime})${retryN ? ` — retry ${retryN}` : ""}`);
|
|
361
|
+
const sessionId = agent.resume ? state.sessions[sessionKey] : undefined;
|
|
362
|
+
const handling = unread.map((m) => ({ from: m.meta.from, type: m.meta.type, subject: m.meta.subject, brief: briefOf(m.body), digest: digestOf(m.meta.type, m.body) }));
|
|
363
|
+
// What the panel shows for the message being worked on is uncut; the wake record below keeps the short digest.
|
|
364
|
+
const activeHandling = unread.map((m, i) => ({ ...handling[i], digest: digestOf(m.meta.type, m.body, true) }));
|
|
365
|
+
state.active[agent.name] = { round, since: new Date(started).toISOString(), handling: activeHandling };
|
|
366
|
+
this.saveState();
|
|
367
|
+
const result = await invoke({
|
|
368
|
+
...base,
|
|
369
|
+
agent: runAs,
|
|
370
|
+
signal: opts.signal,
|
|
371
|
+
logDir: attemptLogDir(runDir, att.id),
|
|
372
|
+
userPrompt: attempt ? retryPrompt(base.userPrompt, ctx.previous?.error) : base.userPrompt,
|
|
373
|
+
sessionId,
|
|
374
|
+
}).catch((e) => ({
|
|
375
|
+
ok: false,
|
|
376
|
+
text: "",
|
|
377
|
+
exitCode: null,
|
|
378
|
+
timedOut: false,
|
|
379
|
+
error: e.message,
|
|
380
|
+
}));
|
|
381
|
+
if (result.sessionId && agent.resume)
|
|
382
|
+
state.sessions[sessionKey] = result.sessionId;
|
|
383
|
+
if (result.outputTokens)
|
|
384
|
+
state.output_tokens += result.outputTokens;
|
|
385
|
+
delete state.active[agent.name];
|
|
386
|
+
// With a chain only: which model ran, what it replaced, why it failed.
|
|
387
|
+
const which = chain ? { runtime: c.runtime, model: c.model, fallback_from: c.primary || !replaced ? undefined : modelLabel(replaced), kind: result.kind } : {};
|
|
388
|
+
state.wakes.push({
|
|
389
|
+
round,
|
|
390
|
+
agent: agent.name,
|
|
391
|
+
at: new Date().toISOString(),
|
|
392
|
+
duration_ms: Date.now() - started,
|
|
393
|
+
ok: result.ok,
|
|
394
|
+
output_tokens: result.outputTokens,
|
|
395
|
+
handling,
|
|
396
|
+
error: result.error,
|
|
397
|
+
...which,
|
|
398
|
+
});
|
|
399
|
+
state.last_wake[agent.name] = { at: new Date().toISOString(), ok: result.ok, error: result.error, ...(chain ? { runtime: c.runtime, model: c.model } : {}) };
|
|
400
|
+
log("wake", {
|
|
401
|
+
agent: agent.name,
|
|
402
|
+
runtime: c.runtime,
|
|
403
|
+
attempt,
|
|
404
|
+
ok: result.ok,
|
|
405
|
+
exit_code: result.exitCode,
|
|
406
|
+
timed_out: result.timedOut,
|
|
407
|
+
duration_ms: Date.now() - started,
|
|
408
|
+
messages: unread.map((m) => m.meta.id),
|
|
409
|
+
error: result.error,
|
|
410
|
+
...(chain ? { model: c.model, fallback_from: which.fallback_from, kind: result.kind } : {}),
|
|
411
|
+
});
|
|
412
|
+
this.saveState();
|
|
413
|
+
if (result.ok) {
|
|
414
|
+
attempts.set(agent.name, finishAttempt(runDir, att.id, "output_ready"));
|
|
415
|
+
if (agent.resume && !result.sessionId && !state.sessions[sessionKey] && !noSessionNoted.has(agent.name)) {
|
|
416
|
+
noSessionNoted.add(agent.name);
|
|
417
|
+
this.note(`${agent.name} has resume: true but its ${c.runtime} CLI reported no session id, so every wake-up starts a fresh session.`);
|
|
418
|
+
}
|
|
419
|
+
return result;
|
|
364
420
|
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
421
|
+
if (result.cancelled) {
|
|
422
|
+
// Stopped on purpose: not a failure to report. Its output is set aside (its input stays unread for the resume, below).
|
|
423
|
+
const parked = parkOutbox(runDir, outboxDir(project, agent.name), att.id);
|
|
424
|
+
finishAttempt(runDir, att.id, "failed", { error: "cancelled", parked });
|
|
425
|
+
return result;
|
|
426
|
+
}
|
|
427
|
+
// A failed attempt's output is never trusted: park it under the attempt, away from the next attempt's result.
|
|
369
428
|
const parked = parkOutbox(runDir, outboxDir(project, agent.name), att.id);
|
|
370
|
-
finishAttempt(runDir, att.id, "failed", { error:
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
return
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
`Anything it already did outside its mailbox (file edits, commands) was not undone: side effects may have happened.`);
|
|
382
|
-
}
|
|
383
|
-
if (result?.ok)
|
|
429
|
+
finishAttempt(runDir, att.id, "failed", { error: result.error, parked });
|
|
430
|
+
say(` ${agent.name} failed: ${result.error ?? "unknown error"}`);
|
|
431
|
+
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")}` : ""}. ` +
|
|
432
|
+
`Anything it already did outside its mailbox (file edits, commands) was not undone: side effects may have happened.`);
|
|
433
|
+
return result;
|
|
434
|
+
},
|
|
435
|
+
});
|
|
436
|
+
if (this.syncCooldowns())
|
|
437
|
+
this.saveState();
|
|
438
|
+
const { result } = outcome;
|
|
439
|
+
if (result?.ok) {
|
|
384
440
|
markOutputReady(runDir, claim.id);
|
|
385
|
-
|
|
386
|
-
// The agent failed every time it was called: the run stops here (see settle) and its message stays unread for the resume.
|
|
387
|
-
abandonClaim(runDir, claim.id);
|
|
388
|
-
claims.delete(agent.name);
|
|
389
|
-
return { ok: false, error: result?.error, attempts: cfg.retry + 1 };
|
|
441
|
+
return { ok: true };
|
|
390
442
|
}
|
|
391
|
-
|
|
443
|
+
// The input stays unread for the resume: the round limit, or a cancel (during a call or a backoff).
|
|
444
|
+
abandonClaim(runDir, claim.id);
|
|
445
|
+
claims.delete(agent.name);
|
|
446
|
+
if (outcome.stopped === "beforeAttempt" && !stoppedByCancel)
|
|
447
|
+
return { ok: false, cancelled: true };
|
|
448
|
+
if (result?.cancelled || outcome.stopped) {
|
|
449
|
+
say(` ${agent.name} stopped (cancelled)`);
|
|
450
|
+
return { ok: false, cancelled: true };
|
|
451
|
+
}
|
|
452
|
+
// The agent failed every time it was called: the run stops here (see settle) and its message stays unread for the resume.
|
|
453
|
+
return { ok: false, error: result?.error, attempts: calls, ...(chain ? { tried: lastPerModel(outcome.tried) } : {}) };
|
|
454
|
+
}
|
|
455
|
+
/** Copy the shared cooldown table into the state (only once something has cooled down); true when it was. */
|
|
456
|
+
syncCooldowns() {
|
|
457
|
+
const table = this.cooldowns.toJSON();
|
|
458
|
+
if (!Object.keys(table).length)
|
|
459
|
+
return false;
|
|
460
|
+
this.state.cooldowns = table;
|
|
461
|
+
return true;
|
|
392
462
|
}
|
|
393
463
|
/**
|
|
394
464
|
* A finished member's files go from its worktree into the repo before anyone reads its mail, so the lead
|
|
@@ -399,7 +469,7 @@ export class RunSession {
|
|
|
399
469
|
let changes;
|
|
400
470
|
let r;
|
|
401
471
|
try {
|
|
402
|
-
changes = collectAgentChanges(ws
|
|
472
|
+
changes = collectAgentChanges(ws);
|
|
403
473
|
r = integrateAgentChanges(project, changes, this.leadSpace);
|
|
404
474
|
}
|
|
405
475
|
catch (e) {
|
|
@@ -553,7 +623,8 @@ export class RunSession {
|
|
|
553
623
|
const failedIdx = results.findIndex((r) => !r.ok && !r.cancelled);
|
|
554
624
|
if (failedIdx >= 0) {
|
|
555
625
|
const failed = must(batch[failedIdx], "failed agent");
|
|
556
|
-
|
|
626
|
+
const r = results[failedIdx];
|
|
627
|
+
this.failure = { agent: failed.name, error: r?.error, attempts: r?.attempts ?? this.cfg.retry + 1, ...(r?.tried ? { tried: r.tried } : {}) };
|
|
557
628
|
state.failure = this.failure;
|
|
558
629
|
this.endReason = failed.name === project.lead ? "lead_failed" : "agent_failed";
|
|
559
630
|
return;
|
|
@@ -669,7 +740,7 @@ export class RunSession {
|
|
|
669
740
|
if (endReason !== "done" || outcome !== "completed")
|
|
670
741
|
return skip(`the run did not end completed (${endReason}, outcome ${outcome}).`);
|
|
671
742
|
if (!this.leadSpace)
|
|
672
|
-
return skip(
|
|
743
|
+
return skip(`this run works directly in ${this.project.dir} (not a git repo with a commit), so there is no branch to push.`);
|
|
673
744
|
if (!rb)
|
|
674
745
|
return skip(`the work could not be committed on ${this.leadSpace.branch}.`);
|
|
675
746
|
if (!rb.files)
|
package/dist/run-store.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ResolvedProject } from "./config.js";
|
|
2
2
|
import type { DigestSection, Step } from "./format.js";
|
|
3
|
-
import type
|
|
3
|
+
import { type ErrorKind, type Runtime, type RunOutcome } from "./schema.js";
|
|
4
4
|
import type { ResultBranch } from "./worktree.js";
|
|
5
5
|
export declare const STATE_FILE = "state.json";
|
|
6
6
|
export declare const STATE_SCHEMA_VERSION = 2;
|
|
@@ -36,6 +36,27 @@ export interface WakeRecord {
|
|
|
36
36
|
/** Mail this wake handed to others; `to` is "(done)" for the lead's final message. */
|
|
37
37
|
sent?: SentTopic[];
|
|
38
38
|
error?: string;
|
|
39
|
+
/** With a fallback chain only: the runtime and model this call ran on. */
|
|
40
|
+
runtime?: Runtime;
|
|
41
|
+
model?: string;
|
|
42
|
+
/** With a fallback chain only: the `runtime/model` this call replaced (set on a backup's calls). */
|
|
43
|
+
fallback_from?: string;
|
|
44
|
+
/** With a fallback chain only: why the call failed. */
|
|
45
|
+
kind?: ErrorKind;
|
|
46
|
+
}
|
|
47
|
+
/** A model given up on during a wake with a fallback chain, for `failure.tried`. */
|
|
48
|
+
export interface TriedModel {
|
|
49
|
+
runtime: Runtime;
|
|
50
|
+
model?: string;
|
|
51
|
+
kind?: ErrorKind;
|
|
52
|
+
error?: string;
|
|
53
|
+
}
|
|
54
|
+
/** The run stopped because an agent kept failing: who, the last error, how many times it was called; `tried` only with a fallback chain. */
|
|
55
|
+
export interface RunFailure {
|
|
56
|
+
agent: string;
|
|
57
|
+
error?: string;
|
|
58
|
+
attempts: number;
|
|
59
|
+
tried?: TriedModel[];
|
|
39
60
|
}
|
|
40
61
|
export interface RunState {
|
|
41
62
|
/** 1 for runs written before versioning; 2 for runs with their own mailboxes. */
|
|
@@ -78,16 +99,19 @@ export interface RunState {
|
|
|
78
99
|
snapshots?: number;
|
|
79
100
|
/** Concurrent runs: where the lead's work ended up (`git merge <branch>`). Set when the run ends. */
|
|
80
101
|
result_branch?: ResultBranch;
|
|
81
|
-
/** Set when the run started with the lead in its own worktree (
|
|
102
|
+
/** Set when the run started with the lead in its own worktree (isolation mode); its resumes keep that, whatever the config says later. */
|
|
82
103
|
lead_workspace?: true;
|
|
83
104
|
/** The pull request the dispatcher opened for this run (when the lead asked for one and it worked). */
|
|
84
105
|
pr_url?: string;
|
|
85
106
|
sessions: Record<string, string>;
|
|
86
107
|
output_tokens: number;
|
|
108
|
+
/** `runtime`/`model` only for agents with a fallback chain. */
|
|
87
109
|
last_wake: Record<string, {
|
|
88
110
|
at: string;
|
|
89
111
|
ok: boolean;
|
|
90
112
|
error?: string;
|
|
113
|
+
runtime?: Runtime;
|
|
114
|
+
model?: string;
|
|
91
115
|
}>;
|
|
92
116
|
/** Process running this dispatcher, so a monitor can tell "running" from "interrupted". */
|
|
93
117
|
pid: number;
|
|
@@ -98,11 +122,12 @@ export interface RunState {
|
|
|
98
122
|
/** Finished wakes, oldest first. */
|
|
99
123
|
wakes: WakeRecord[];
|
|
100
124
|
/** Set when the run stopped because an agent kept failing: who, the last error, how many times it was called. */
|
|
101
|
-
failure?:
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
125
|
+
failure?: RunFailure;
|
|
126
|
+
/** Models cooling down after `rate_limit`/`quota` (`<runtime>/<model>` → until when); absent until one is. */
|
|
127
|
+
cooldowns?: Record<string, {
|
|
128
|
+
until: string;
|
|
129
|
+
kind: ErrorKind;
|
|
130
|
+
}>;
|
|
106
131
|
/** Checklist progress: seeded from the task, then replaced by the lead's latest `## Steps` (after a reopen, added to `step_base`). */
|
|
107
132
|
steps?: Step[];
|
|
108
133
|
/** The checklist as it stood at the last reopen: the follow-up's steps are shown after it, and its open steps do not hold up the follow-up's done. */
|
package/dist/run-store.js
CHANGED
|
@@ -3,6 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { atomicWrite } from "./fs-util.js";
|
|
6
|
+
import { ERROR_KINDS, RUNTIMES } from "./schema.js";
|
|
6
7
|
export const STATE_FILE = "state.json";
|
|
7
8
|
export const STATE_SCHEMA_VERSION = 2;
|
|
8
9
|
/** `20261006T010203456Z-<128 random bits>`: sorts by start time and cannot collide, even for two starts in one millisecond. */
|
|
@@ -11,6 +12,19 @@ export function newRunId(d = new Date()) {
|
|
|
11
12
|
return `${t}-${crypto.randomBytes(16).toString("hex")}`;
|
|
12
13
|
}
|
|
13
14
|
const nonNeg = z.number().finite();
|
|
15
|
+
const CooldownEntry = z.object({ until: z.string(), kind: z.enum(ERROR_KINDS) });
|
|
16
|
+
/** `state.cooldowns` is advisory: keep the entries that parse and drop the rest, so one bad entry never makes the run unreadable. */
|
|
17
|
+
function lenientCooldowns(raw) {
|
|
18
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
19
|
+
return undefined;
|
|
20
|
+
const out = {};
|
|
21
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
22
|
+
const e = CooldownEntry.safeParse(v);
|
|
23
|
+
if (e.success)
|
|
24
|
+
out[k] = e.data;
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
14
28
|
const WireState = z
|
|
15
29
|
.object({
|
|
16
30
|
schema_version: z.number().int().optional(),
|
|
@@ -39,7 +53,15 @@ const WireState = z
|
|
|
39
53
|
wakes: z.array(z.any()).optional(),
|
|
40
54
|
solo_agent: z.string().optional(),
|
|
41
55
|
steps: z.array(z.object({ text: z.string(), done: z.boolean() })).optional(),
|
|
42
|
-
failure: z
|
|
56
|
+
failure: z
|
|
57
|
+
.object({
|
|
58
|
+
agent: z.string(),
|
|
59
|
+
error: z.string().optional(),
|
|
60
|
+
attempts: z.number(),
|
|
61
|
+
tried: z.array(z.object({ runtime: z.enum(RUNTIMES), model: z.string().optional(), kind: z.enum(ERROR_KINDS).optional(), error: z.string().optional() })).optional(),
|
|
62
|
+
})
|
|
63
|
+
.optional(),
|
|
64
|
+
cooldowns: z.unknown().transform(lenientCooldowns).optional(),
|
|
43
65
|
step_base: z.array(z.object({ text: z.string(), done: z.boolean() })).optional(),
|
|
44
66
|
name: z.string().optional(),
|
|
45
67
|
reopened: z.array(z.object({ at: z.string() })).optional(),
|
package/dist/scaffold.js
CHANGED
|
@@ -86,9 +86,6 @@ team:
|
|
|
86
86
|
lead: lead
|
|
87
87
|
dispatcher:
|
|
88
88
|
max_rounds: 0 # 0 = no limit on wake-ups; set a number to stop a run after that many
|
|
89
|
-
max_parallel: 1 # >1 needs non-overlapping "owns" for every non-lead agent, and a git repo
|
|
90
|
-
max_concurrent_runs: 1 # >1 lets several runs of this project be active at once, each in its own git worktree (needs a git repo); every run's work stays on its own branch
|
|
91
|
-
workspace_mode: auto # auto = a git worktree per non-lead agent when max_parallel > 1; shared | worktree to force
|
|
92
89
|
wake_timeout_sec: 600
|
|
93
90
|
retry: 2 # an agent that fails is called again up to this many times; if it still fails the run stops (resume it after fixing the cause)
|
|
94
91
|
log_max_bytes: 8388608 # log.jsonl is renamed to log.1.jsonl past this size; 0 = never
|
|
@@ -101,11 +98,9 @@ agents:
|
|
|
101
98
|
can_edit_agent_md: true
|
|
102
99
|
fe-member:
|
|
103
100
|
can_message: [lead]
|
|
104
|
-
# owns: ["src/web/**"]
|
|
105
101
|
# memory: { project: agents/fe-member/memory }
|
|
106
102
|
qa-member:
|
|
107
103
|
can_message: [lead]
|
|
108
|
-
# owns: ["tests/**"]
|
|
109
104
|
`;
|
|
110
105
|
fs.writeFileSync(pp.config, yaml);
|
|
111
106
|
const common = path.join(pp.shared, "common", "COMMON.md");
|