castle-web-cli 0.4.76 → 0.4.77
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/agent-prompts.d.ts +9 -0
- package/dist/agent-prompts.js +27 -9
- package/dist/agent.d.ts +29 -0
- package/dist/agent.js +718 -244
- package/dist/commonInstructions.d.ts +1 -1
- package/dist/commonInstructions.js +7 -1
- package/dist/filesChanged.d.ts +25 -0
- package/dist/filesChanged.js +140 -0
- package/dist/init.js +1 -1
- package/dist/serve.js +18 -2
- package/dist/shell/assets/{index-DNWEQd4R.js → index-CvHiGhAV.js} +22 -22
- package/dist/shell/assets/{index-DuKq-Grp.css → index-QteLRDnK.css} +1 -1
- package/dist/shell/index.html +2 -2
- package/kits/basic-2d/editors/PlayOnly.jsx +8 -3
- package/kits/basic-2d/editors/SingleEditor.jsx +24 -8
- package/kits/basic-2d/engine/liveReload.js +88 -0
- package/package.json +1 -1
- package/kits/basic-2d/pnpm-workspace.yaml +0 -3
package/dist/agent.js
CHANGED
|
@@ -91,6 +91,24 @@ const MAX_TASK_ATTEMPTS = 3;
|
|
|
91
91
|
// limits. Over-cap tasks stay queued ('waiting') and start, earliest-created
|
|
92
92
|
// first, as running ones finish. Conservative default; override via env.
|
|
93
93
|
const MAX_CONCURRENT_TASKS = Number(process.env.CASTLE_MAX_CONCURRENT_TASKS) || 4;
|
|
94
|
+
// Base backoff (ms) before a crashed task agent is relaunched, scaled by
|
|
95
|
+
// attempt number and jittered (see waitBeforeTaskRetry). Incident (2026-07):
|
|
96
|
+
// the router spawned 4 parallel tasks whose cursor-agent processes all
|
|
97
|
+
// crashed at startup within ~1.3s with a macOS keychain error ("Security
|
|
98
|
+
// command failed: Security process exited with code: 44" -- credential
|
|
99
|
+
// lookup contention when many cursor-agent processes start at once). The old
|
|
100
|
+
// instant retry burned all 3 attempts of every task inside that same ~6s
|
|
101
|
+
// contention window (11 agent processes launched in ~7s); the one retry that
|
|
102
|
+
// happened to land ~2s later succeeded, so growing + jittered spacing
|
|
103
|
+
// between attempts gives the keychain time to clear. Override for tests /
|
|
104
|
+
// impatient devs, same pattern as CASTLE_MAX_CONCURRENT_TASKS above.
|
|
105
|
+
const TASK_RETRY_BACKOFF_BASE_MS = Number(process.env.CASTLE_TASK_RETRY_BACKOFF_MS) || 1500;
|
|
106
|
+
// Minimal spacing enforced between successive task-agent PROCESS LAUNCHES
|
|
107
|
+
// (see staggerTaskSpawn) -- the initial simultaneous spawn is itself part of
|
|
108
|
+
// the same thundering herd as the retry storm above, independent of it.
|
|
109
|
+
// Router turns are unaffected: only one ever runs at a time. Override for
|
|
110
|
+
// tests / impatient devs.
|
|
111
|
+
const TASK_SPAWN_STAGGER_MS = Number(process.env.CASTLE_TASK_SPAWN_STAGGER_MS) || 400;
|
|
94
112
|
const TASK_POLL_MS = 1_000;
|
|
95
113
|
const FENCE_HOLDBACK = "```castle-";
|
|
96
114
|
const RESULT_SUMMARY_CHARS = 600;
|
|
@@ -125,6 +143,35 @@ function visibleLength(raw) {
|
|
|
125
143
|
}
|
|
126
144
|
return raw.length;
|
|
127
145
|
}
|
|
146
|
+
// Parse one ```castle-task fence's body (title line, optional "after:" line,
|
|
147
|
+
// then the prompt) into a directive. Shared by the settle-time full-text
|
|
148
|
+
// extraction below and the mid-stream incremental scanner (runRouterTurnIn),
|
|
149
|
+
// so a fence spawned early behaves identically to one spawned at settle.
|
|
150
|
+
function parseTaskFenceBody(body) {
|
|
151
|
+
const lines = body.replace(/\r/g, "").split("\n");
|
|
152
|
+
const title = (lines.shift() ?? "").trim();
|
|
153
|
+
if (!title)
|
|
154
|
+
return null;
|
|
155
|
+
const after = [];
|
|
156
|
+
while (lines.length > 0) {
|
|
157
|
+
const headerMatch = /^(after):\s*(.*)$/i.exec((lines[0] ?? "").trim());
|
|
158
|
+
if (!headerMatch)
|
|
159
|
+
break;
|
|
160
|
+
lines.shift();
|
|
161
|
+
after.push(...headerMatch[2]
|
|
162
|
+
.split(",")
|
|
163
|
+
.map((s) => s.trim())
|
|
164
|
+
.filter(Boolean));
|
|
165
|
+
}
|
|
166
|
+
return { title, after, prompt: lines.join("\n").trim() };
|
|
167
|
+
}
|
|
168
|
+
// A fresh RegExp per call -- this is matched with manual .exec() loops in
|
|
169
|
+
// TWO independent call sites (settle-time extractDirectives via .replace, and
|
|
170
|
+
// the mid-stream scanNewTaskFences via a lastIndex-seeded loop) that must
|
|
171
|
+
// never share mutable lastIndex state.
|
|
172
|
+
function taskFenceRegex() {
|
|
173
|
+
return /```castle-task[ \t]*\r?\n([\s\S]*?)```/g;
|
|
174
|
+
}
|
|
128
175
|
// Pull ```castle-task fenced directives out of a finished router reply.
|
|
129
176
|
// Block format: title line, then an optional "after:" line, then the prompt.
|
|
130
177
|
function extractDirectives(full) {
|
|
@@ -143,25 +190,10 @@ function extractDirectives(full) {
|
|
|
143
190
|
});
|
|
144
191
|
};
|
|
145
192
|
const withoutDone = listFence(listFence(full, "castle-done", checkoffs), "castle-stop", stops);
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const headers = { after: [] };
|
|
151
|
-
while (lines.length > 0) {
|
|
152
|
-
const headerMatch = /^(after):\s*(.*)$/i.exec((lines[0] ?? "").trim());
|
|
153
|
-
if (!headerMatch)
|
|
154
|
-
break;
|
|
155
|
-
lines.shift();
|
|
156
|
-
headers[headerMatch[1].toLowerCase()] = headerMatch[2]
|
|
157
|
-
.split(",")
|
|
158
|
-
.map((s) => s.trim())
|
|
159
|
-
.filter(Boolean);
|
|
160
|
-
}
|
|
161
|
-
const prompt = lines.join("\n").trim();
|
|
162
|
-
if (title) {
|
|
163
|
-
directives.push({ title, after: headers.after, prompt });
|
|
164
|
-
}
|
|
193
|
+
const cleaned = withoutDone.replace(taskFenceRegex(), (_match, body) => {
|
|
194
|
+
const directive = parseTaskFenceBody(String(body));
|
|
195
|
+
if (directive)
|
|
196
|
+
directives.push(directive);
|
|
165
197
|
return "";
|
|
166
198
|
});
|
|
167
199
|
return {
|
|
@@ -171,6 +203,28 @@ function extractDirectives(full) {
|
|
|
171
203
|
stops,
|
|
172
204
|
};
|
|
173
205
|
}
|
|
206
|
+
// Scan `raw` for ```castle-task fences that have FULLY closed since
|
|
207
|
+
// `fromIndex` -- i.e. their closing ``` has already streamed in -- and parse
|
|
208
|
+
// each into a directive. Returns the index just past the last one consumed,
|
|
209
|
+
// so the next call only looks at genuinely new text. castle-done / castle-
|
|
210
|
+
// stop fences are deliberately NOT scanned here: they stay settle-only (see
|
|
211
|
+
// runRouterTurnIn) since they are cheap and order-sensitive, and acting on a
|
|
212
|
+
// stop/done fence before the reply is even finished streaming would be
|
|
213
|
+
// surprising.
|
|
214
|
+
function scanNewTaskFences(raw, fromIndex) {
|
|
215
|
+
const re = taskFenceRegex();
|
|
216
|
+
re.lastIndex = fromIndex;
|
|
217
|
+
const directives = [];
|
|
218
|
+
let nextIndex = fromIndex;
|
|
219
|
+
let match;
|
|
220
|
+
while ((match = re.exec(raw))) {
|
|
221
|
+
const directive = parseTaskFenceBody(match[1]);
|
|
222
|
+
if (directive)
|
|
223
|
+
directives.push(directive);
|
|
224
|
+
nextIndex = re.lastIndex;
|
|
225
|
+
}
|
|
226
|
+
return { directives, nextIndex };
|
|
227
|
+
}
|
|
174
228
|
// -- agent signals (```signal blocks from task agents) -----------------------
|
|
175
229
|
// A running task agent narrates in prose and periodically emits ONE fenced
|
|
176
230
|
// ```signal block of progress metadata (mirrors djinn's lib/markdown.ts). We
|
|
@@ -372,6 +426,12 @@ const DECK_TREE_EXCLUDE = new Set([
|
|
|
372
426
|
".DS_Store",
|
|
373
427
|
]);
|
|
374
428
|
const DECK_TREE_MAX_ENTRIES = 200;
|
|
429
|
+
// Per-directory listing cap. A successful deck accumulates hundreds of
|
|
430
|
+
// drawings; without this, one big directory exhausts the global budget
|
|
431
|
+
// depth-first and every directory sorting after it (scenes/ included) vanishes
|
|
432
|
+
// from the snapshot entirely. Summarizing the overflow as "(+N more .pxart)"
|
|
433
|
+
// keeps every directory visible and turns the count itself into signal.
|
|
434
|
+
const DECK_TREE_PER_DIR = 15;
|
|
375
435
|
// Shallow orientation snapshot for router/task prompts. Best-effort by design:
|
|
376
436
|
// filesystem hiccups should cost context, not fail an agent turn.
|
|
377
437
|
function buildDeckTree(deckDir) {
|
|
@@ -389,9 +449,8 @@ function buildDeckTree(deckDir) {
|
|
|
389
449
|
return a.isDirectory() ? -1 : 1;
|
|
390
450
|
return a.name.localeCompare(b.name);
|
|
391
451
|
});
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
continue;
|
|
452
|
+
const visible = entries.filter((e) => !DECK_TREE_EXCLUDE.has(e.name));
|
|
453
|
+
for (const entry of visible.slice(0, DECK_TREE_PER_DIR)) {
|
|
395
454
|
if (lines.length >= DECK_TREE_MAX_ENTRIES) {
|
|
396
455
|
lines.push(`${prefix}...`);
|
|
397
456
|
return;
|
|
@@ -402,6 +461,15 @@ function buildDeckTree(deckDir) {
|
|
|
402
461
|
walk(path.join(dir, entry.name), prefix + " ", depth + 1);
|
|
403
462
|
}
|
|
404
463
|
}
|
|
464
|
+
const rest = visible.slice(DECK_TREE_PER_DIR);
|
|
465
|
+
if (rest.length > 0 && lines.length < DECK_TREE_MAX_ENTRIES) {
|
|
466
|
+
// Name the overflow's extension when it's uniform ("+214 more .pxart"),
|
|
467
|
+
// since that tells the reader what kind of files dominate the directory.
|
|
468
|
+
const exts = new Set(rest.map((e) => (e.isDirectory() ? "/" : path.extname(e.name))));
|
|
469
|
+
const [only] = exts;
|
|
470
|
+
const suffix = exts.size === 1 && only && only !== "/" ? ` ${only}` : "";
|
|
471
|
+
lines.push(`${prefix}(+${rest.length} more${suffix})`);
|
|
472
|
+
}
|
|
405
473
|
};
|
|
406
474
|
walk(deckDir, "", 0);
|
|
407
475
|
return lines.join("\n");
|
|
@@ -477,8 +545,19 @@ function shellTouchedCandidates(command) {
|
|
|
477
545
|
}
|
|
478
546
|
return out;
|
|
479
547
|
}
|
|
548
|
+
// Guards every touched-file candidate (shell redirects AND tool file-path
|
|
549
|
+
// args) against junk that isn't plausibly a path. Added because
|
|
550
|
+
// shellTouchedCandidates' redirect regex treats any `>`-plus-token as a
|
|
551
|
+
// write target, so a command merely CONTAINING `>=` (e.g. a numeric
|
|
552
|
+
// comparison inside a quoted inline JS/awk script) false-matches as a
|
|
553
|
+
// redirect to "=" (or "=5" with no space around the `>=`) -- neither looks
|
|
554
|
+
// like a real file. A leading "-" is rejected too, mirroring
|
|
555
|
+
// drawingPathForDrawArg's flag guard above.
|
|
556
|
+
function looksLikeTouchedPath(raw) {
|
|
557
|
+
return /[a-zA-Z0-9]/.test(raw) && raw[0] !== "-" && raw[0] !== "=";
|
|
558
|
+
}
|
|
480
559
|
function normalizeTouchedPath(cwd, raw) {
|
|
481
|
-
if (!raw || raw.includes("\n"))
|
|
560
|
+
if (!raw || raw.includes("\n") || !looksLikeTouchedPath(raw))
|
|
482
561
|
return null;
|
|
483
562
|
const abs = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
|
|
484
563
|
const rel = path.relative(cwd, abs);
|
|
@@ -816,7 +895,10 @@ function runAgentCli(opts) {
|
|
|
816
895
|
function persistTaskFile(tasksDir, task) {
|
|
817
896
|
fs.writeFileSync(path.join(tasksDir, task.id, "task.json"), JSON.stringify(task, null, 2) + "\n");
|
|
818
897
|
}
|
|
819
|
-
// Tasks left "running" by a dead serve are as finished as they will get.
|
|
898
|
+
// Tasks left "running" by a dead serve are as finished as they will get. A
|
|
899
|
+
// persisted "blocked" task is left as-is: it is not "waiting", so maybeStart
|
|
900
|
+
// never reconsiders it and it can't wedge or auto-start; it just sits on the
|
|
901
|
+
// board (blockedBy intact) until the router stops it, same as before restart.
|
|
820
902
|
function loadTasks(tasksDir) {
|
|
821
903
|
const tasks = new Map();
|
|
822
904
|
for (const entry of fs.existsSync(tasksDir) ? fs.readdirSync(tasksDir) : []) {
|
|
@@ -880,15 +962,78 @@ function resolveDeps(tasks, tokens) {
|
|
|
880
962
|
}
|
|
881
963
|
return [...new Set(resolved)];
|
|
882
964
|
}
|
|
965
|
+
export function classifyDeps(tasks, task) {
|
|
966
|
+
const blockedBy = [];
|
|
967
|
+
let waiting = false;
|
|
968
|
+
for (const id of task.after) {
|
|
969
|
+
const dep = tasks.get(id);
|
|
970
|
+
// A dep id that no longer resolves to a task (its row was cleared) or one
|
|
971
|
+
// that finished "done" is satisfied -- nothing left to wait on.
|
|
972
|
+
if (!dep || dep.status === "done")
|
|
973
|
+
continue;
|
|
974
|
+
if (dep.status === "failed" || dep.status === "interrupted") {
|
|
975
|
+
blockedBy.push(dep.title);
|
|
976
|
+
}
|
|
977
|
+
else {
|
|
978
|
+
waiting = true;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
if (blockedBy.length > 0)
|
|
982
|
+
return { kind: "blocked", blockedBy };
|
|
983
|
+
return { kind: waiting ? "waiting" : "ready" };
|
|
984
|
+
}
|
|
985
|
+
// Cap on how much of an upstream task's wrap-up prose rides into a dependent's
|
|
986
|
+
// prompt. resultSummary is already capped at RESULT_SUMMARY_CHARS; this trims
|
|
987
|
+
// further so a multi-dep task doesn't front-load pages of handoff.
|
|
988
|
+
const DEP_SUMMARY_CHARS = 400;
|
|
883
989
|
function depsSummaryFor(tasks, task) {
|
|
884
990
|
if (task.after.length === 0)
|
|
885
991
|
return undefined;
|
|
886
992
|
const lines = task.after
|
|
887
993
|
.map((id) => tasks.get(id))
|
|
888
994
|
.filter((dep) => !!dep)
|
|
889
|
-
.map((dep) =>
|
|
995
|
+
.map((dep) => {
|
|
996
|
+
const parts = [`- "${dep.title}" finished ${dep.status}`];
|
|
997
|
+
if (dep.files && dep.files.length > 0)
|
|
998
|
+
parts.push(` files it touched: ${dep.files.join(", ")}`);
|
|
999
|
+
// The agent's own closing prose is the real handoff -- names it created,
|
|
1000
|
+
// what it wired, what it left undone. The notes file is player-facing
|
|
1001
|
+
// and deliberately stripped of that detail.
|
|
1002
|
+
const summary = dep.resultSummary?.trim();
|
|
1003
|
+
if (summary)
|
|
1004
|
+
parts.push(` its wrap-up: ${summary.slice(-DEP_SUMMARY_CHARS).replace(/\n+/g, " ")}`);
|
|
1005
|
+
if (dep.notes.trim())
|
|
1006
|
+
parts.push(` player notes: ${dep.notes.trim()}`);
|
|
1007
|
+
return parts.join("\n");
|
|
1008
|
+
});
|
|
890
1009
|
return lines.join("\n") || undefined;
|
|
891
1010
|
}
|
|
1011
|
+
function sleep(ms) {
|
|
1012
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1013
|
+
}
|
|
1014
|
+
// Chained-promise gate spacing out task-agent PROCESS LAUNCHES -- deliberately
|
|
1015
|
+
// NOT a queue class, just a promise each launch chains onto. Why: the
|
|
1016
|
+
// 2026-07 keychain-contention incident (see TASK_SPAWN_STAGGER_MS above) was
|
|
1017
|
+
// triggered by several cursor-agent processes starting at the exact same
|
|
1018
|
+
// instant; spacing consecutive launches out by TASK_SPAWN_STAGGER_MS avoids
|
|
1019
|
+
// that without limiting how many can run concurrently once they're up.
|
|
1020
|
+
let taskSpawnGate = Promise.resolve();
|
|
1021
|
+
function staggerTaskSpawn() {
|
|
1022
|
+
const readyToLaunch = taskSpawnGate;
|
|
1023
|
+
taskSpawnGate = readyToLaunch.then(() => sleep(TASK_SPAWN_STAGGER_MS));
|
|
1024
|
+
return readyToLaunch;
|
|
1025
|
+
}
|
|
1026
|
+
// Jittered, attempt-scaled backoff before relaunching a crashed task agent
|
|
1027
|
+
// (see TASK_RETRY_BACKOFF_BASE_MS's comment for why). Checks stopRequested
|
|
1028
|
+
// both before AND after the sleep -- a stop landing mid-backoff must not
|
|
1029
|
+
// relaunch the task. Returns true if the caller should give up retrying.
|
|
1030
|
+
async function waitBeforeTaskRetry(attempt, stopRequested, taskId) {
|
|
1031
|
+
if (stopRequested.has(taskId))
|
|
1032
|
+
return true;
|
|
1033
|
+
const backoffMs = TASK_RETRY_BACKOFF_BASE_MS * attempt + Math.random() * 1000;
|
|
1034
|
+
await sleep(backoffMs);
|
|
1035
|
+
return stopRequested.has(taskId);
|
|
1036
|
+
}
|
|
892
1037
|
async function runTaskAgentIn(ctx, task) {
|
|
893
1038
|
const dir = path.join(ctx.tasksDir, task.id);
|
|
894
1039
|
const relDir = path.relative(ctx.deckDir, dir);
|
|
@@ -902,6 +1047,8 @@ async function runTaskAgentIn(ctx, task) {
|
|
|
902
1047
|
depsSummary: ctx.depsSummary,
|
|
903
1048
|
backend: ctx.backend,
|
|
904
1049
|
deckTree: buildDeckTree(ctx.deckDir),
|
|
1050
|
+
quickReference: ctx.quickReference,
|
|
1051
|
+
siblings: ctx.siblings,
|
|
905
1052
|
});
|
|
906
1053
|
// No /goal wrapper: it makes a fresh evaluator re-check the WHOLE task
|
|
907
1054
|
// prompt (including user-only "done when you reach wave 5"-style play
|
|
@@ -947,6 +1094,7 @@ async function runTaskAgentIn(ctx, task) {
|
|
|
947
1094
|
}
|
|
948
1095
|
};
|
|
949
1096
|
for (let attempt = 1; attempt <= MAX_TASK_ATTEMPTS; attempt++) {
|
|
1097
|
+
await staggerTaskSpawn();
|
|
950
1098
|
result = await runAgentCli({
|
|
951
1099
|
cwd: ctx.deckDir,
|
|
952
1100
|
command: invocation.command,
|
|
@@ -969,8 +1117,11 @@ async function runTaskAgentIn(ctx, task) {
|
|
|
969
1117
|
return result;
|
|
970
1118
|
if (!result.crashed)
|
|
971
1119
|
return result;
|
|
972
|
-
if (attempt < MAX_TASK_ATTEMPTS)
|
|
1120
|
+
if (attempt < MAX_TASK_ATTEMPTS) {
|
|
973
1121
|
ctx.onRetry(attempt + 1);
|
|
1122
|
+
if (await waitBeforeTaskRetry(attempt, ctx.stopRequested, task.id))
|
|
1123
|
+
return result;
|
|
1124
|
+
}
|
|
974
1125
|
}
|
|
975
1126
|
result.error = `agent process kept dying (${MAX_TASK_ATTEMPTS} attempts): ${result.error ?? ""}`;
|
|
976
1127
|
return result;
|
|
@@ -996,7 +1147,15 @@ function startTask(ctx, task) {
|
|
|
996
1147
|
backend: ctx.backend(),
|
|
997
1148
|
claudeModel: ctx.claudeModel(),
|
|
998
1149
|
stopRequested: ctx.stopRequested,
|
|
1150
|
+
quickReference: ctx.quickReference,
|
|
999
1151
|
depsSummary: depsSummaryFor(ctx.tasks, task),
|
|
1152
|
+
// Same visibility rule as the router's board (hide acked+finished rows),
|
|
1153
|
+
// minus this task itself. Snapshot at start -- consistent with the deck
|
|
1154
|
+
// tree, and the prompt says so.
|
|
1155
|
+
siblings: ctx
|
|
1156
|
+
.sorted()
|
|
1157
|
+
.filter((t) => t.id !== task.id && !(t.acknowledged && isTerminal(t.status)))
|
|
1158
|
+
.map((t) => ({ title: t.title, status: t.status, files: t.files })),
|
|
1000
1159
|
onFeed: (entry) => ctx.onFeed(task, entry),
|
|
1001
1160
|
onRetry: (attempt) => ctx.onRetry(task, attempt),
|
|
1002
1161
|
onSignal: (signal) => {
|
|
@@ -1087,12 +1246,14 @@ function startTask(ctx, task) {
|
|
|
1087
1246
|
ctx.rescheduleAll();
|
|
1088
1247
|
});
|
|
1089
1248
|
}
|
|
1090
|
-
// Halt + remove an active task (castle-stop): a waiting one is
|
|
1091
|
-
// cleared off the board immediately; a running one gets its
|
|
1092
|
-
// killed and is cleared when it finalizes (the stopRequested
|
|
1093
|
-
// No-op on terminal tasks.
|
|
1249
|
+
// Halt + remove an active task (castle-stop): a waiting or blocked one is
|
|
1250
|
+
// cancelled and cleared off the board immediately; a running one gets its
|
|
1251
|
+
// agent process killed and is cleared when it finalizes (the stopRequested
|
|
1252
|
+
// path acks it). No-op on terminal tasks. A "blocked" task never got a
|
|
1253
|
+
// process, so it collapses the same way "waiting" does -- this is the only
|
|
1254
|
+
// way a blocked row ever clears (see ROUTER_RULES).
|
|
1094
1255
|
function haltTask(task, children, stopRequested, touch) {
|
|
1095
|
-
if (task.status === "waiting") {
|
|
1256
|
+
if (task.status === "waiting" || task.status === "blocked") {
|
|
1096
1257
|
task.status = "interrupted";
|
|
1097
1258
|
task.acknowledged = true;
|
|
1098
1259
|
touch(task);
|
|
@@ -1125,12 +1286,6 @@ function createTaskStore(opts) {
|
|
|
1125
1286
|
persistTaskFile(tasksDir, task);
|
|
1126
1287
|
opts.onUpdate(task);
|
|
1127
1288
|
}
|
|
1128
|
-
function depsAreSettled(task) {
|
|
1129
|
-
return task.after.every((id) => {
|
|
1130
|
-
const dep = tasks.get(id);
|
|
1131
|
-
return !dep || isTerminal(dep.status);
|
|
1132
|
-
});
|
|
1133
|
-
}
|
|
1134
1289
|
function runningCount() {
|
|
1135
1290
|
let n = 0;
|
|
1136
1291
|
for (const t of tasks.values())
|
|
@@ -1139,8 +1294,20 @@ function createTaskStore(opts) {
|
|
|
1139
1294
|
return n;
|
|
1140
1295
|
}
|
|
1141
1296
|
function maybeStart(task) {
|
|
1142
|
-
if (task.status !== "waiting" || task.acknowledged
|
|
1297
|
+
if (task.status !== "waiting" || task.acknowledged)
|
|
1298
|
+
return;
|
|
1299
|
+
const deps = classifyDeps(tasks, task);
|
|
1300
|
+
if (deps.kind === "waiting")
|
|
1301
|
+
return;
|
|
1302
|
+
// A dep finalized failed/interrupted -- this task can never do its job
|
|
1303
|
+
// (the output it needed never materialized), so it flips to "blocked"
|
|
1304
|
+
// instead of ever starting. Only the router clears it (castle-stop).
|
|
1305
|
+
if (deps.kind === "blocked") {
|
|
1306
|
+
task.status = "blocked";
|
|
1307
|
+
task.blockedBy = deps.blockedBy;
|
|
1308
|
+
touch(task);
|
|
1143
1309
|
return;
|
|
1310
|
+
}
|
|
1144
1311
|
// Concurrency cap: at most MAX_CONCURRENT_TASKS agents run at once. Over-cap
|
|
1145
1312
|
// tasks stay 'waiting' and are restarted -- earliest-created first -- by the
|
|
1146
1313
|
// onFinished sweep below when a running task frees a slot.
|
|
@@ -1168,6 +1335,7 @@ function createTaskStore(opts) {
|
|
|
1168
1335
|
children,
|
|
1169
1336
|
tasks,
|
|
1170
1337
|
stopRequested,
|
|
1338
|
+
quickReference: opts.quickReference,
|
|
1171
1339
|
backend: opts.backend,
|
|
1172
1340
|
claudeModel: opts.claudeModel,
|
|
1173
1341
|
onStarted: opts.onStarted,
|
|
@@ -1243,13 +1411,16 @@ function createTaskStore(opts) {
|
|
|
1243
1411
|
acknowledge(id, false);
|
|
1244
1412
|
}
|
|
1245
1413
|
// The router stops tasks by title or id (castle-stop fence), or "all" to
|
|
1246
|
-
// stop everything still active. Waiting
|
|
1247
|
-
//
|
|
1248
|
-
// via the stopRequested
|
|
1414
|
+
// stop everything still active. Waiting (and blocked -- a waiting task that
|
|
1415
|
+
// will never start) tasks are cancelled outright; running ones get their
|
|
1416
|
+
// agent process killed and finalize as interrupted via the stopRequested
|
|
1417
|
+
// path (see haltTask).
|
|
1249
1418
|
function stop(tokens) {
|
|
1250
1419
|
const ids = meansAll(tokens)
|
|
1251
1420
|
? [...tasks.values()]
|
|
1252
|
-
.filter((t) => t.status === "running" ||
|
|
1421
|
+
.filter((t) => t.status === "running" ||
|
|
1422
|
+
t.status === "waiting" ||
|
|
1423
|
+
t.status === "blocked")
|
|
1253
1424
|
.map((t) => t.id)
|
|
1254
1425
|
: resolveDeps(tasks, tokens);
|
|
1255
1426
|
for (const id of ids) {
|
|
@@ -1303,6 +1474,57 @@ function saveAttachments(attachmentsDir, messageId, images) {
|
|
|
1303
1474
|
}
|
|
1304
1475
|
return saved;
|
|
1305
1476
|
}
|
|
1477
|
+
// Cap on the failure detail shown in a board row -- just enough for the
|
|
1478
|
+
// router to reason about what went wrong, not the full crash dump.
|
|
1479
|
+
const ERROR_PREVIEW_CHARS = 200;
|
|
1480
|
+
// A failed task's resultSummary starts with the error text (see startTask's
|
|
1481
|
+
// finalization block) followed by trailing output; the first non-empty line
|
|
1482
|
+
// is USUALLY the router-relevant part, with two exceptions handled below.
|
|
1483
|
+
//
|
|
1484
|
+
// Exception 1 (pre-existing): a clean-exit self-reported failure (stream-json
|
|
1485
|
+
// `result` event with is_error: true, exit code 0) leaves runAgentCli's
|
|
1486
|
+
// generic "agent exited 0" here with no stderr tail to make it informative --
|
|
1487
|
+
// the agent's own stated reason is the next line (the finalText tail).
|
|
1488
|
+
//
|
|
1489
|
+
// Exception 2: runAgentCli's exit-code wrapper ("agent exited N[: stderr
|
|
1490
|
+
// tail]", possibly re-wrapped as "agent process kept dying (N attempts):
|
|
1491
|
+
// ...") embeds the crashed process's raw, possibly multi-line stderr
|
|
1492
|
+
// verbatim. If that stderr itself contains newlines, the actually
|
|
1493
|
+
// informative line can land one or more lines below this wrapper -- e.g. the
|
|
1494
|
+
// 2026-07 keychain incident, where line 1 was a useless
|
|
1495
|
+
// "agent process kept dying (3 attempts): agent exited 1: cursor-retrieval:
|
|
1496
|
+
// tracing to '/var/folders/.../cursor_retrieval....log'" while the real
|
|
1497
|
+
// "Error: Security command failed: Security process exited with code: 44"
|
|
1498
|
+
// sat right below it. Only these two wrapper shapes get this treatment --
|
|
1499
|
+
// "agent run timed out" and "could not run cursor-agent: <message>" (the
|
|
1500
|
+
// other two runAgentCli error strings) are already the whole story on line 1
|
|
1501
|
+
// and are left alone, same as before.
|
|
1502
|
+
const EXIT_WRAPPER_RE = /^agent (exited \d+|process kept dying)/;
|
|
1503
|
+
function looksLikeErrorLine(line) {
|
|
1504
|
+
return /\berror\b/i.test(line);
|
|
1505
|
+
}
|
|
1506
|
+
function firstErrorLine(resultSummary) {
|
|
1507
|
+
const lines = resultSummary
|
|
1508
|
+
?.split("\n")
|
|
1509
|
+
.map((l) => l.trim())
|
|
1510
|
+
.filter((l) => l.length > 0) ?? [];
|
|
1511
|
+
if (lines.length === 0)
|
|
1512
|
+
return undefined;
|
|
1513
|
+
const first = lines[0];
|
|
1514
|
+
if (first === "agent exited 0") {
|
|
1515
|
+
return (lines[1] ?? first).slice(0, ERROR_PREVIEW_CHARS);
|
|
1516
|
+
}
|
|
1517
|
+
if (!EXIT_WRAPPER_RE.test(first) || looksLikeErrorLine(first)) {
|
|
1518
|
+
return first.slice(0, ERROR_PREVIEW_CHARS);
|
|
1519
|
+
}
|
|
1520
|
+
const betterLine = lines.slice(1).find(looksLikeErrorLine);
|
|
1521
|
+
if (!betterLine)
|
|
1522
|
+
return first.slice(0, ERROR_PREVIEW_CHARS);
|
|
1523
|
+
// Keep the wrapper as context (e.g. "3 attempts") when it fits; otherwise
|
|
1524
|
+
// the real error line must stay fully visible within the cap on its own.
|
|
1525
|
+
const combined = `${first}: ${betterLine}`;
|
|
1526
|
+
return (combined.length <= ERROR_PREVIEW_CHARS ? combined : betterLine).slice(0, ERROR_PREVIEW_CHARS);
|
|
1527
|
+
}
|
|
1306
1528
|
function asPromptTask(task) {
|
|
1307
1529
|
return {
|
|
1308
1530
|
id: task.id,
|
|
@@ -1311,6 +1533,8 @@ function asPromptTask(task) {
|
|
|
1311
1533
|
progress: task.progress,
|
|
1312
1534
|
notes: task.notes,
|
|
1313
1535
|
files: task.files,
|
|
1536
|
+
error: task.status === "failed" ? firstErrorLine(task.resultSummary) : undefined,
|
|
1537
|
+
blockedBy: task.status === "blocked" ? task.blockedBy : undefined,
|
|
1314
1538
|
};
|
|
1315
1539
|
}
|
|
1316
1540
|
function asClientTask(task) {
|
|
@@ -1399,6 +1623,140 @@ function makeAttachmentHandler(attachmentsDir) {
|
|
|
1399
1623
|
return true;
|
|
1400
1624
|
};
|
|
1401
1625
|
}
|
|
1626
|
+
// Classify from the error strings runAgentCli actually produces (see its
|
|
1627
|
+
// child.on("error"), timeout, and close handlers).
|
|
1628
|
+
function classifyRouterFailure(error) {
|
|
1629
|
+
if (error?.startsWith("could not run"))
|
|
1630
|
+
return "spawn";
|
|
1631
|
+
if (error === "agent run timed out")
|
|
1632
|
+
return "timeout";
|
|
1633
|
+
return "exit";
|
|
1634
|
+
}
|
|
1635
|
+
// Plain-language copy for a failed turn. `salvaged` = the turn already
|
|
1636
|
+
// produced something the user can see (streamed text and/or spawned tasks),
|
|
1637
|
+
// so "pick it back up" framing fits; a turn that died with nothing is a clean
|
|
1638
|
+
// hiccup. `willRetry` = the queue is about to re-run this instruction itself.
|
|
1639
|
+
function routerFailureCopy(opts) {
|
|
1640
|
+
if (opts.willRetry) {
|
|
1641
|
+
return "Something went wrong on my end -- give me a moment to try that again.";
|
|
1642
|
+
}
|
|
1643
|
+
const tasksNote = opts.spawnedTasks
|
|
1644
|
+
? " The steps I already kicked off are still running."
|
|
1645
|
+
: "";
|
|
1646
|
+
switch (opts.kind) {
|
|
1647
|
+
case "spawn":
|
|
1648
|
+
return `I couldn't start working on that -- something in this setup isn't right. If this keeps happening, the person running this session needs to take a look.${tasksNote}`;
|
|
1649
|
+
case "timeout":
|
|
1650
|
+
return `That took me too long and I had to stop partway. Send another message and I'll pick it back up.${tasksNote}`;
|
|
1651
|
+
case "exit":
|
|
1652
|
+
return `Something went wrong on my end partway through. Send another message and I'll pick it back up.${tasksNote}`;
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
// Assemble the full stateless prompt for one router turn: rules + deck
|
|
1656
|
+
// context + transcript replay (minus log lines and the in-flight reply) +
|
|
1657
|
+
// the live board + this turn's instruction.
|
|
1658
|
+
function routerTurnPrompt(ctx, instruction, selfMessageId) {
|
|
1659
|
+
return buildRouterPrompt({
|
|
1660
|
+
deckLabel: ctx.deckLabel,
|
|
1661
|
+
quickReference: ctx.quickReference,
|
|
1662
|
+
deckTree: buildDeckTree(ctx.deckDir),
|
|
1663
|
+
messages: ctx.log.messages
|
|
1664
|
+
.filter((m) => m.role !== "log" &&
|
|
1665
|
+
m.id !== selfMessageId &&
|
|
1666
|
+
m.status !== "streaming")
|
|
1667
|
+
.map((m) => ({
|
|
1668
|
+
role: m.role,
|
|
1669
|
+
// Replace a prior turn's raw ```ask JSON with a readable question list
|
|
1670
|
+
// so the model doesn't re-echo the block verbatim.
|
|
1671
|
+
text: m.role === "assistant" ? humanizeAskBlocks(m.text) : m.text,
|
|
1672
|
+
interrupted: m.interrupted,
|
|
1673
|
+
})),
|
|
1674
|
+
// Only the live board -- match what the user sees. Hide tasks that are
|
|
1675
|
+
// BOTH acknowledged AND finished; an active (running/waiting) task always
|
|
1676
|
+
// shows even if somehow acked, so nothing can ever go invisible mid-work.
|
|
1677
|
+
tasks: ctx.taskStore
|
|
1678
|
+
.sorted()
|
|
1679
|
+
.filter((t) => !(t.acknowledged && isTerminal(t.status)))
|
|
1680
|
+
.map(asPromptTask),
|
|
1681
|
+
instruction,
|
|
1682
|
+
});
|
|
1683
|
+
}
|
|
1684
|
+
// A turn killed before producing anything visible (a fold, or a manual Stop
|
|
1685
|
+
// pressed during the pre-text lull) leaves an empty husk that would otherwise
|
|
1686
|
+
// replay into every future transcript as a blank assistant line -- drop it
|
|
1687
|
+
// from the log instead. Decided here, with final knowledge, rather than at
|
|
1688
|
+
// kill time: a text delta can race the SIGKILL and still land in
|
|
1689
|
+
// message.text, in which case this falls through to the normal
|
|
1690
|
+
// interrupted-draft path so the continuation turn can carry it. A message
|
|
1691
|
+
// with spawned taskIds is NEVER dropped even with empty text -- those tasks
|
|
1692
|
+
// are already running and the UI's task chips need this message as their
|
|
1693
|
+
// anchor, so it always takes the interrupted-draft (kept) path instead.
|
|
1694
|
+
function settleInterruptedTurn(ctx, message) {
|
|
1695
|
+
const hasSpawnedTasks = (message.taskIds?.length ?? 0) > 0;
|
|
1696
|
+
if (message.text.trim() === "" && !hasSpawnedTasks) {
|
|
1697
|
+
const idx = ctx.log.messages.indexOf(message);
|
|
1698
|
+
if (idx >= 0)
|
|
1699
|
+
ctx.log.messages.splice(idx, 1);
|
|
1700
|
+
ctx.log.persist();
|
|
1701
|
+
ctx.broadcast({
|
|
1702
|
+
type: "message-done",
|
|
1703
|
+
id: message.id,
|
|
1704
|
+
text: "",
|
|
1705
|
+
status: "done",
|
|
1706
|
+
});
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
// Keep whatever streamed and/or spawned; the continuation turn carries the
|
|
1710
|
+
// text draft (tasks already launched by this turn just stay on the board).
|
|
1711
|
+
message.status = "done";
|
|
1712
|
+
message.interrupted = true;
|
|
1713
|
+
ctx.log.persist();
|
|
1714
|
+
ctx.broadcast({
|
|
1715
|
+
type: "message-done",
|
|
1716
|
+
id: message.id,
|
|
1717
|
+
text: message.text,
|
|
1718
|
+
status: message.status,
|
|
1719
|
+
interrupted: true,
|
|
1720
|
+
taskIds: message.taskIds ?? [],
|
|
1721
|
+
});
|
|
1722
|
+
}
|
|
1723
|
+
// Spawn ```castle-task fences AS EACH ONE completes during streaming, instead
|
|
1724
|
+
// of waiting for the whole reply to settle -- shaves the time-to-first-task
|
|
1725
|
+
// off long replies. Mirrors the settle path's spawn logic exactly: same
|
|
1726
|
+
// directive parsing (parseTaskFenceBody), same in-flight title dedup, and the
|
|
1727
|
+
// same `after:` resolution against the live task board -- which by now
|
|
1728
|
+
// already includes any tasks spawned earlier in this same reply, so an
|
|
1729
|
+
// `after:` referencing an earlier fence's title resolves correctly.
|
|
1730
|
+
function spawnCompletedTaskFences(ctx, message, midStream, raw) {
|
|
1731
|
+
const { directives, nextIndex } = scanNewTaskFences(raw, midStream.scannedUpTo);
|
|
1732
|
+
midStream.scannedUpTo = nextIndex;
|
|
1733
|
+
if (directives.length === 0)
|
|
1734
|
+
return;
|
|
1735
|
+
const inFlight = new Set(ctx.taskStore
|
|
1736
|
+
.sorted()
|
|
1737
|
+
.filter((t) => t.status === "running" || t.status === "waiting")
|
|
1738
|
+
.map((t) => t.title.toLowerCase()));
|
|
1739
|
+
const newIds = [];
|
|
1740
|
+
for (const directive of directives) {
|
|
1741
|
+
const key = directive.title.toLowerCase();
|
|
1742
|
+
if (inFlight.has(key) || midStream.spawnedTitles.has(key))
|
|
1743
|
+
continue;
|
|
1744
|
+
newIds.push(ctx.taskStore.spawnFromDirective(directive, message.id));
|
|
1745
|
+
midStream.spawnedTitles.add(key);
|
|
1746
|
+
}
|
|
1747
|
+
if (newIds.length === 0)
|
|
1748
|
+
return;
|
|
1749
|
+
message.taskIds = [...(message.taskIds ?? []), ...newIds];
|
|
1750
|
+
// No text change (the fence stays hidden by the holdback until the reply
|
|
1751
|
+
// ends) -- this delta only carries the updated taskIds so connected clients
|
|
1752
|
+
// can react (e.g. show task chips) before the turn finishes.
|
|
1753
|
+
ctx.broadcast({
|
|
1754
|
+
type: "message-delta",
|
|
1755
|
+
id: message.id,
|
|
1756
|
+
delta: "",
|
|
1757
|
+
taskIds: message.taskIds,
|
|
1758
|
+
});
|
|
1759
|
+
}
|
|
1402
1760
|
// One router turn: stream a reply message, then spawn the directives it
|
|
1403
1761
|
// emitted (unless a newer user message superseded this turn).
|
|
1404
1762
|
function runRouterTurnIn(ctx, instruction) {
|
|
@@ -1414,6 +1772,7 @@ function runRouterTurnIn(ctx, instruction) {
|
|
|
1414
1772
|
ctx.broadcast({ type: "message-add", message });
|
|
1415
1773
|
let raw = "";
|
|
1416
1774
|
let visibleSent = 0;
|
|
1775
|
+
const midStream = { scannedUpTo: 0, spawnedTitles: new Set() };
|
|
1417
1776
|
// Seed the activity line to "thinking" immediately -- covers the otherwise
|
|
1418
1777
|
// silent spawn + first-token lull (fresh CLI process, prompt processing,
|
|
1419
1778
|
// extended thinking) before any stream event arrives. The stream then
|
|
@@ -1422,28 +1781,7 @@ function runRouterTurnIn(ctx, instruction) {
|
|
|
1422
1781
|
// block later re-emits "thinking".
|
|
1423
1782
|
let lastActivity = "Thinking";
|
|
1424
1783
|
ctx.broadcast({ type: "message-activity", id: message.id, activity: "Thinking" });
|
|
1425
|
-
const prompt =
|
|
1426
|
-
deckLabel: ctx.deckLabel,
|
|
1427
|
-
quickReference: ctx.quickReference,
|
|
1428
|
-
deckTree: buildDeckTree(ctx.deckDir),
|
|
1429
|
-
messages: ctx.log.messages
|
|
1430
|
-
.filter((m) => m.role !== "log" && m.id !== message.id && m.status !== "streaming")
|
|
1431
|
-
.map((m) => ({
|
|
1432
|
-
role: m.role,
|
|
1433
|
-
// Replace a prior turn's raw ```ask JSON with a readable question list
|
|
1434
|
-
// so the model doesn't re-echo the block verbatim.
|
|
1435
|
-
text: m.role === "assistant" ? humanizeAskBlocks(m.text) : m.text,
|
|
1436
|
-
interrupted: m.interrupted,
|
|
1437
|
-
})),
|
|
1438
|
-
// Only the live board -- match what the user sees. Hide tasks that are
|
|
1439
|
-
// BOTH acknowledged AND finished; an active (running/waiting) task always
|
|
1440
|
-
// shows even if somehow acked, so nothing can ever go invisible mid-work.
|
|
1441
|
-
tasks: ctx.taskStore
|
|
1442
|
-
.sorted()
|
|
1443
|
-
.filter((t) => !(t.acknowledged && isTerminal(t.status)))
|
|
1444
|
-
.map(asPromptTask),
|
|
1445
|
-
instruction,
|
|
1446
|
-
});
|
|
1784
|
+
const prompt = routerTurnPrompt(ctx, instruction, message.id);
|
|
1447
1785
|
const backend = ctx.backend();
|
|
1448
1786
|
const invocation = buildAgentInvocation(backend, "router", prompt, ctx.claudeModel());
|
|
1449
1787
|
void runAgentCli({
|
|
@@ -1464,6 +1802,9 @@ function runRouterTurnIn(ctx, instruction) {
|
|
|
1464
1802
|
message.text += slice;
|
|
1465
1803
|
ctx.broadcast({ type: "message-delta", id: message.id, delta: slice });
|
|
1466
1804
|
}
|
|
1805
|
+
// Spawning changes WHEN a fence is acted on, not what is displayed --
|
|
1806
|
+
// the holdback above still hides fenced text until the reply ends.
|
|
1807
|
+
spawnCompletedTaskFences(ctx, message, midStream, raw);
|
|
1467
1808
|
},
|
|
1468
1809
|
onActivity: (activity) => {
|
|
1469
1810
|
if (activity === lastActivity)
|
|
@@ -1474,6 +1815,9 @@ function runRouterTurnIn(ctx, instruction) {
|
|
|
1474
1815
|
})
|
|
1475
1816
|
.then((result) => {
|
|
1476
1817
|
logRouterUsage(backend, result.usage);
|
|
1818
|
+
// Signals the finally -> onSettled(retryable): the turn failed cleanly
|
|
1819
|
+
// enough (transient, nothing salvaged) that the queue may re-run it.
|
|
1820
|
+
let retryable = false;
|
|
1477
1821
|
// The settle path must ALWAYS reach ctx.onSettled() (clears
|
|
1478
1822
|
// routerRunning + flushes pendingSends). A throw here on Node v25 would
|
|
1479
1823
|
// otherwise both freeze the composer and crash the serve, so the whole
|
|
@@ -1482,18 +1826,7 @@ function runRouterTurnIn(ctx, instruction) {
|
|
|
1482
1826
|
try {
|
|
1483
1827
|
const interrupted = epoch !== ctx.currentEpoch() && !result.ok;
|
|
1484
1828
|
if (interrupted) {
|
|
1485
|
-
|
|
1486
|
-
message.status = "done";
|
|
1487
|
-
message.interrupted = true;
|
|
1488
|
-
ctx.log.persist();
|
|
1489
|
-
ctx.broadcast({
|
|
1490
|
-
type: "message-done",
|
|
1491
|
-
id: message.id,
|
|
1492
|
-
text: message.text,
|
|
1493
|
-
status: message.status,
|
|
1494
|
-
interrupted: true,
|
|
1495
|
-
taskIds: [],
|
|
1496
|
-
});
|
|
1829
|
+
settleInterruptedTurn(ctx, message);
|
|
1497
1830
|
return;
|
|
1498
1831
|
}
|
|
1499
1832
|
const { cleaned, directives, checkoffs, stops } = extractDirectives(result.finalText);
|
|
@@ -1501,8 +1834,11 @@ function runRouterTurnIn(ctx, instruction) {
|
|
|
1501
1834
|
ctx.taskStore.checkOff(checkoffs);
|
|
1502
1835
|
if (result.ok && stops.length > 0)
|
|
1503
1836
|
ctx.taskStore.stop(stops);
|
|
1504
|
-
// Drop directives from stale turns,
|
|
1505
|
-
// already in flight (two runs reacting to the same ask)
|
|
1837
|
+
// Drop directives from stale turns, any whose title matches a task
|
|
1838
|
+
// already in flight (two runs reacting to the same ask), and any
|
|
1839
|
+
// already spawned mid-stream by spawnCompletedTaskFences above --
|
|
1840
|
+
// otherwise the settle-time full-text parse would launch a second
|
|
1841
|
+
// copy of the same fence.
|
|
1506
1842
|
const stale = epoch !== ctx.currentEpoch();
|
|
1507
1843
|
const inFlight = new Set(ctx.taskStore
|
|
1508
1844
|
.sorted()
|
|
@@ -1510,12 +1846,34 @@ function runRouterTurnIn(ctx, instruction) {
|
|
|
1510
1846
|
.map((t) => t.title.toLowerCase()));
|
|
1511
1847
|
const toSpawn = stale
|
|
1512
1848
|
? []
|
|
1513
|
-
: directives.filter((d) => !inFlight.has(d.title.toLowerCase())
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1849
|
+
: directives.filter((d) => !inFlight.has(d.title.toLowerCase()) &&
|
|
1850
|
+
!midStream.spawnedTitles.has(d.title.toLowerCase()));
|
|
1851
|
+
const newlySpawnedIds = toSpawn.map((d) => ctx.taskStore.spawnFromDirective(d, message.id));
|
|
1852
|
+
const taskIds = [...(message.taskIds ?? []), ...newlySpawnedIds];
|
|
1853
|
+
if (result.ok) {
|
|
1854
|
+
message.text = cleaned;
|
|
1855
|
+
message.status = "done";
|
|
1856
|
+
}
|
|
1857
|
+
else {
|
|
1858
|
+
const kind = classifyRouterFailure(result.error);
|
|
1859
|
+
// Only a turn that produced NOTHING visible is safe to silently
|
|
1860
|
+
// re-run: with streamed text or spawned tasks in play, a retry
|
|
1861
|
+
// would answer the same instruction twice (and could re-spawn
|
|
1862
|
+
// near-duplicate tasks past the title dedup). Spawn failures are
|
|
1863
|
+
// persistent (the CLI itself won't launch) and timeouts are too
|
|
1864
|
+
// expensive to repeat blind, so only "exit" crashes retry.
|
|
1865
|
+
const salvaged = cleaned !== "" || taskIds.length > 0;
|
|
1866
|
+
retryable = kind === "exit" && !salvaged;
|
|
1867
|
+
const copy = routerFailureCopy({
|
|
1868
|
+
kind,
|
|
1869
|
+
spawnedTasks: taskIds.length > 0,
|
|
1870
|
+
willRetry: retryable && ctx.canAutoRetry(),
|
|
1871
|
+
});
|
|
1872
|
+
message.text = cleaned ? `${cleaned}\n\n${copy}` : copy;
|
|
1873
|
+
message.status = "error";
|
|
1874
|
+
message.errorDetail = result.error ?? "unknown failure";
|
|
1875
|
+
console.error(`[router] turn failed (${kind}): ${message.errorDetail}`);
|
|
1876
|
+
}
|
|
1519
1877
|
if (taskIds.length > 0)
|
|
1520
1878
|
message.taskIds = taskIds;
|
|
1521
1879
|
ctx.log.persist();
|
|
@@ -1525,6 +1883,7 @@ function runRouterTurnIn(ctx, instruction) {
|
|
|
1525
1883
|
text: message.text,
|
|
1526
1884
|
status: message.status,
|
|
1527
1885
|
taskIds: message.taskIds ?? [],
|
|
1886
|
+
errorDetail: message.errorDetail,
|
|
1528
1887
|
});
|
|
1529
1888
|
}
|
|
1530
1889
|
catch (err) {
|
|
@@ -1536,7 +1895,9 @@ function runRouterTurnIn(ctx, instruction) {
|
|
|
1536
1895
|
console.error(`[router] turn callback threw: ${detail}`);
|
|
1537
1896
|
try {
|
|
1538
1897
|
message.status = "error";
|
|
1539
|
-
|
|
1898
|
+
const copy = "Something went wrong on my end partway through. Send another message and I'll pick it back up.";
|
|
1899
|
+
message.text = `${message.text ? message.text + "\n\n" : ""}${copy}`;
|
|
1900
|
+
message.errorDetail = short;
|
|
1540
1901
|
ctx.log.persist();
|
|
1541
1902
|
ctx.broadcast({
|
|
1542
1903
|
type: "message-done",
|
|
@@ -1544,6 +1905,7 @@ function runRouterTurnIn(ctx, instruction) {
|
|
|
1544
1905
|
text: message.text,
|
|
1545
1906
|
status: message.status,
|
|
1546
1907
|
taskIds: message.taskIds ?? [],
|
|
1908
|
+
errorDetail: message.errorDetail,
|
|
1547
1909
|
});
|
|
1548
1910
|
}
|
|
1549
1911
|
catch (inner) {
|
|
@@ -1551,7 +1913,7 @@ function runRouterTurnIn(ctx, instruction) {
|
|
|
1551
1913
|
}
|
|
1552
1914
|
}
|
|
1553
1915
|
finally {
|
|
1554
|
-
ctx.onSettled();
|
|
1916
|
+
ctx.onSettled(retryable);
|
|
1555
1917
|
}
|
|
1556
1918
|
})
|
|
1557
1919
|
.catch((err) => {
|
|
@@ -1644,75 +2006,113 @@ function startChildRegistry(registryPath, groups) {
|
|
|
1644
2006
|
}
|
|
1645
2007
|
};
|
|
1646
2008
|
}
|
|
1647
|
-
//
|
|
1648
|
-
//
|
|
1649
|
-
//
|
|
1650
|
-
//
|
|
1651
|
-
//
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
function broadcastRouterState() {
|
|
1670
|
-
broadcast({
|
|
1671
|
-
type: "router-state",
|
|
1672
|
-
running: routerRunning,
|
|
1673
|
-
queued: queuedSnippets(),
|
|
1674
|
-
});
|
|
2009
|
+
// Restart recovery: load the durable queue mirror, keeping only well-formed
|
|
2010
|
+
// sends that never reached the message log. A send whose id is already in
|
|
2011
|
+
// messages.json was committed by a prior drain (its turn ran, finished or
|
|
2012
|
+
// not -- a logged message is considered handled), so re-queueing it would
|
|
2013
|
+
// double-deliver. Order is preserved.
|
|
2014
|
+
function loadRecoverableSends(pendingPath, committedIds) {
|
|
2015
|
+
const stored = readJsonFile(pendingPath);
|
|
2016
|
+
if (!Array.isArray(stored))
|
|
2017
|
+
return [];
|
|
2018
|
+
const recovered = [];
|
|
2019
|
+
for (const item of stored) {
|
|
2020
|
+
if (item &&
|
|
2021
|
+
typeof item.id === "string" &&
|
|
2022
|
+
typeof item.text === "string" &&
|
|
2023
|
+
Array.isArray(item.attachments) &&
|
|
2024
|
+
!committedIds.has(item.id)) {
|
|
2025
|
+
recovered.push({
|
|
2026
|
+
id: item.id,
|
|
2027
|
+
text: item.text,
|
|
2028
|
+
attachments: item.attachments.filter((a) => typeof a === "string"),
|
|
2029
|
+
});
|
|
2030
|
+
}
|
|
1675
2031
|
}
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
2032
|
+
return recovered;
|
|
2033
|
+
}
|
|
2034
|
+
// A turn's user messages are only "spent" once the turn has produced visible
|
|
2035
|
+
// output -- text or side effects committed. Until then, killing the turn
|
|
2036
|
+
// (fold or manual interrupt) must not lose them: they have to survive and
|
|
2037
|
+
// re-enter the next instruction. A turn that has spawned tasks counts as
|
|
2038
|
+
// having visible output too, even with no text yet -- a launched task agent
|
|
2039
|
+
// cannot be un-launched, so folding it away would abandon in-flight work, not
|
|
2040
|
+
// just discard stale intent.
|
|
2041
|
+
function turnHasVisibleOutput(messages) {
|
|
2042
|
+
return messages.some((m) => m.role === "assistant" &&
|
|
2043
|
+
m.status === "streaming" &&
|
|
2044
|
+
(m.text.trim() !== "" || (m.taskIds?.length ?? 0) > 0));
|
|
2045
|
+
}
|
|
2046
|
+
// Re-queue a turn's already-logged user messages (its `inFlightSends`) onto
|
|
2047
|
+
// the front of the pending queue, marked `logged` so the next drain composes
|
|
2048
|
+
// them into the instruction again without adding a second user bubble. Shared
|
|
2049
|
+
// by the auto-fold path and the manual interrupt path -- both lose the
|
|
2050
|
+
// original messages from instruction composition the same way if a turn is
|
|
2051
|
+
// killed before producing anything visible.
|
|
2052
|
+
function reclaimInFlightSends(pendingSends, inFlightSends) {
|
|
2053
|
+
if (inFlightSends.length === 0)
|
|
2054
|
+
return;
|
|
2055
|
+
pendingSends.unshift(...inFlightSends.map((s) => ({ ...s, logged: true })));
|
|
2056
|
+
}
|
|
2057
|
+
// Mirror the in-memory queue to disk. Called on every mutation (enqueue,
|
|
2058
|
+
// drain, cancel, recover) so a restart never loses an unsent queued message.
|
|
2059
|
+
function persistPendingSends(ctx) {
|
|
2060
|
+
fs.writeFileSync(ctx.pendingPath, JSON.stringify(ctx.state.pendingSends, null, 2) + "\n");
|
|
2061
|
+
}
|
|
2062
|
+
function computeQueuedSnippets(pendingSends) {
|
|
2063
|
+
return pendingSends.map((p) => p.text.trim()).filter(Boolean);
|
|
2064
|
+
}
|
|
2065
|
+
function broadcastQueueState(ctx) {
|
|
2066
|
+
ctx.broadcast({
|
|
2067
|
+
type: "router-state",
|
|
2068
|
+
running: ctx.state.routerRunning,
|
|
2069
|
+
queued: computeQueuedSnippets(ctx.state.pendingSends),
|
|
2070
|
+
});
|
|
2071
|
+
}
|
|
2072
|
+
// Kill all in-flight router child processes, returning any partial draft text
|
|
2073
|
+
// they had streamed (joined) so a manual interrupt can carry it forward.
|
|
2074
|
+
function killRouterChildren(ctx) {
|
|
2075
|
+
const drafts = ctx.messages
|
|
2076
|
+
.filter((m) => m.role === "assistant" && m.status === "streaming")
|
|
2077
|
+
.map((m) => m.text.trim())
|
|
2078
|
+
.filter(Boolean);
|
|
2079
|
+
for (const child of ctx.routerChildren) {
|
|
2080
|
+
try {
|
|
2081
|
+
child.kill("SIGKILL");
|
|
1688
2082
|
}
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
2083
|
+
catch {
|
|
2084
|
+
/* already gone */
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
return drafts.join("\n\n");
|
|
2088
|
+
}
|
|
2089
|
+
function startRouterTurn(ctx, instruction) {
|
|
2090
|
+
ctx.state.lastInstruction = instruction;
|
|
2091
|
+
runRouterTurnIn({
|
|
2092
|
+
deckDir: ctx.deckDir,
|
|
2093
|
+
deckLabel: ctx.deckLabel,
|
|
2094
|
+
quickReference: ctx.quickReference,
|
|
2095
|
+
agentDir: ctx.agentDir,
|
|
2096
|
+
children: ctx.routerChildren,
|
|
2097
|
+
log: ctx.log,
|
|
2098
|
+
broadcast: ctx.broadcast,
|
|
2099
|
+
taskStore: ctx.taskStore,
|
|
2100
|
+
currentEpoch: () => ctx.state.userEpoch,
|
|
2101
|
+
backend: () => ctx.settings.router,
|
|
2102
|
+
claudeModel: () => ctx.settings.claudeModel,
|
|
2103
|
+
canAutoRetry: () => !ctx.state.autoRetryUsed && ctx.state.pendingSends.length === 0,
|
|
2104
|
+
onSettled: (retryable) => onRouterQueueSettled(ctx, retryable),
|
|
2105
|
+
}, instruction);
|
|
2106
|
+
}
|
|
2107
|
+
// Commit each drained send to the message log (skipping ones already logged
|
|
2108
|
+
// -- see PendingSend.logged -- so a re-carried send doesn't double its user
|
|
2109
|
+
// bubble) and collect the instruction pieces: message texts and attachment
|
|
2110
|
+
// paths, in drain order.
|
|
2111
|
+
function commitDrainedSends(drained, log) {
|
|
2112
|
+
const texts = [];
|
|
2113
|
+
const attachmentPaths = [];
|
|
2114
|
+
for (const item of drained) {
|
|
2115
|
+
if (!item.logged) {
|
|
1716
2116
|
const message = {
|
|
1717
2117
|
id: item.id,
|
|
1718
2118
|
role: "user",
|
|
@@ -1723,105 +2123,178 @@ function createRouterQueue(deps) {
|
|
|
1723
2123
|
if (item.attachments.length > 0)
|
|
1724
2124
|
message.attachments = item.attachments;
|
|
1725
2125
|
log.add(message);
|
|
1726
|
-
if (item.text.trim())
|
|
1727
|
-
texts.push(item.text);
|
|
1728
|
-
for (const name of item.attachments) {
|
|
1729
|
-
attachmentPaths.push(path.join(".castle", "agent", "attachments", name));
|
|
1730
|
-
}
|
|
1731
2126
|
}
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
const
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
}
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
2127
|
+
if (item.text.trim())
|
|
2128
|
+
texts.push(item.text);
|
|
2129
|
+
for (const name of item.attachments) {
|
|
2130
|
+
attachmentPaths.push(path.join(".castle", "agent", "attachments", name));
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
return { texts, attachmentPaths };
|
|
2134
|
+
}
|
|
2135
|
+
// Drain the queue into the log as real user messages and start one follow-up
|
|
2136
|
+
// turn addressing them all (a burst batches into a single turn). A pending
|
|
2137
|
+
// interrupted draft from a "send now" / Stop is carried into the instruction.
|
|
2138
|
+
// No-op while a turn is running or the queue is empty.
|
|
2139
|
+
function maybeStartRouterQueueTurn(ctx) {
|
|
2140
|
+
const { state } = ctx;
|
|
2141
|
+
if (state.routerRunning || state.pendingSends.length === 0)
|
|
2142
|
+
return;
|
|
2143
|
+
state.autoRetryUsed = false;
|
|
2144
|
+
state.autoFoldUsed = false;
|
|
2145
|
+
const drained = state.pendingSends.splice(0, state.pendingSends.length);
|
|
2146
|
+
// Kept so a fold or manual interrupt of THIS turn can hand these sends back
|
|
2147
|
+
// to reclaimInFlightSends if it dies before producing anything visible.
|
|
2148
|
+
state.inFlightSends = drained;
|
|
2149
|
+
const { texts, attachmentPaths } = commitDrainedSends(drained, ctx.log);
|
|
2150
|
+
// The queue is now committed to messages.json; clear its durable mirror.
|
|
2151
|
+
persistPendingSends(ctx);
|
|
2152
|
+
const draft = state.pendingInterruptedDraft;
|
|
2153
|
+
state.pendingInterruptedDraft = "";
|
|
2154
|
+
state.routerRunning = true;
|
|
2155
|
+
broadcastQueueState(ctx);
|
|
2156
|
+
startRouterTurn(ctx, userTurnInstruction({
|
|
2157
|
+
messages: texts,
|
|
2158
|
+
interruptedDraft: draft || undefined,
|
|
2159
|
+
attachments: attachmentPaths,
|
|
2160
|
+
}));
|
|
2161
|
+
}
|
|
2162
|
+
// The turn settled: clear the busy flag, broadcast it, then flush anything
|
|
2163
|
+
// that queued mid-turn (a clean end and an interrupt take the same path). A
|
|
2164
|
+
// retryable failure (transient crash, nothing salvaged) re-runs the same
|
|
2165
|
+
// instruction once instead -- unless a queued user message is waiting, in
|
|
2166
|
+
// which case flushing it supersedes the retry (its turn re-covers things).
|
|
2167
|
+
function onRouterQueueSettled(ctx, retryable) {
|
|
2168
|
+
const { state } = ctx;
|
|
2169
|
+
state.routerRunning = false;
|
|
2170
|
+
if (retryable &&
|
|
2171
|
+
!state.autoRetryUsed &&
|
|
2172
|
+
state.pendingSends.length === 0 &&
|
|
2173
|
+
state.lastInstruction) {
|
|
2174
|
+
state.autoRetryUsed = true;
|
|
2175
|
+
state.routerRunning = true;
|
|
2176
|
+
broadcastQueueState(ctx);
|
|
2177
|
+
startRouterTurn(ctx, state.lastInstruction);
|
|
2178
|
+
return;
|
|
2179
|
+
}
|
|
2180
|
+
broadcastQueueState(ctx);
|
|
2181
|
+
maybeStartRouterQueueTurn(ctx);
|
|
2182
|
+
}
|
|
2183
|
+
// Auto-interrupt ("fold") a pre-text router turn into the very message that
|
|
2184
|
+
// triggered it: the turn hasn't produced anything visible yet, so restarting
|
|
2185
|
+
// it fresh -- covering the original message(s) AND the new one -- beats
|
|
2186
|
+
// letting it finish answering stale intent. No draft to park: a pre-text turn
|
|
2187
|
+
// never streamed anything to continue. The killed run's interrupted-settle
|
|
2188
|
+
// branch (settleInterruptedTurn) drops its now-empty message, and its
|
|
2189
|
+
// onSettled -> maybeStartRouterQueueTurn drains the re-queued batch into one
|
|
2190
|
+
// combined instruction.
|
|
2191
|
+
function foldRouterQueueTurn(ctx) {
|
|
2192
|
+
const { state } = ctx;
|
|
2193
|
+
state.userEpoch += 1;
|
|
2194
|
+
killRouterChildren(ctx);
|
|
2195
|
+
reclaimInFlightSends(state.pendingSends, state.inFlightSends);
|
|
2196
|
+
persistPendingSends(ctx);
|
|
2197
|
+
state.autoFoldUsed = true;
|
|
2198
|
+
broadcastQueueState(ctx);
|
|
2199
|
+
}
|
|
2200
|
+
function handleQueueUserMessage(ctx, text, images) {
|
|
2201
|
+
const { state } = ctx;
|
|
2202
|
+
// Mid-run: queue (don't interrupt). It shows as a queued row in the
|
|
2203
|
+
// composer and flushes when the current turn settles. Idle: start now.
|
|
2204
|
+
// Persist at enqueue (assign the final message id, save attachments to
|
|
2205
|
+
// disk, mirror the queue to pending-sends.json) so a restart before the
|
|
2206
|
+
// queue drains can recover the send instead of silently dropping it.
|
|
2207
|
+
const id = nanoid(8);
|
|
2208
|
+
const attachments = saveAttachments(ctx.attachmentsDir, id, images);
|
|
2209
|
+
state.pendingSends.push({ id, text, attachments });
|
|
2210
|
+
persistPendingSends(ctx);
|
|
2211
|
+
if (state.routerRunning) {
|
|
2212
|
+
// The new send was just pushed above, so folding now (which unshifts the
|
|
2213
|
+
// old in-flight batch ahead of it) yields the correct order: [old
|
|
2214
|
+
// messages..., this new one].
|
|
2215
|
+
if (!state.autoFoldUsed && !turnHasVisibleOutput(ctx.messages)) {
|
|
2216
|
+
foldRouterQueueTurn(ctx);
|
|
1778
2217
|
}
|
|
1779
2218
|
else {
|
|
1780
|
-
|
|
2219
|
+
broadcastQueueState(ctx);
|
|
1781
2220
|
}
|
|
1782
2221
|
}
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
const
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
pendingSends.push({
|
|
1809
|
-
id: item.id,
|
|
1810
|
-
text: item.text,
|
|
1811
|
-
attachments: item.attachments.filter((a) => typeof a === "string"),
|
|
1812
|
-
});
|
|
1813
|
-
}
|
|
2222
|
+
else {
|
|
2223
|
+
maybeStartRouterQueueTurn(ctx);
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
// "Send now" (a queued row) / Stop (empty composer): kill the running turn,
|
|
2227
|
+
// capturing its partial draft so the follow-up turn continues it; the killed
|
|
2228
|
+
// run's settle flushes the queue. When idle, just flush (covers a stray
|
|
2229
|
+
// interrupt with messages already queued).
|
|
2230
|
+
function interruptRouterQueue(ctx) {
|
|
2231
|
+
const { state } = ctx;
|
|
2232
|
+
if (state.routerRunning) {
|
|
2233
|
+
// A follow-up already queued ("Send now") means the user wants the turn
|
|
2234
|
+
// continued with more intent; an empty queue means a bare Stop -- abandon
|
|
2235
|
+
// the turn outright. Captured before reclaim, which would otherwise fill
|
|
2236
|
+
// the queue and make every Stop look like a Send now.
|
|
2237
|
+
const hasFollowUp = state.pendingSends.length > 0;
|
|
2238
|
+
state.userEpoch += 1;
|
|
2239
|
+
const draft = killRouterChildren(ctx);
|
|
2240
|
+
// Only a continued turn ("Send now") reclaims its in-flight sends so they
|
|
2241
|
+
// re-enter the next instruction. A bare Stop must NOT reclaim: doing so
|
|
2242
|
+
// re-queues the just-killed message and the settle immediately restarts an
|
|
2243
|
+
// identical turn -- the Stop appears to have no effect.
|
|
2244
|
+
if (hasFollowUp && !turnHasVisibleOutput(ctx.messages)) {
|
|
2245
|
+
reclaimInFlightSends(state.pendingSends, state.inFlightSends);
|
|
2246
|
+
persistPendingSends(ctx);
|
|
1814
2247
|
}
|
|
1815
|
-
|
|
1816
|
-
|
|
2248
|
+
// Only carry the partial draft forward when a queued message will consume
|
|
2249
|
+
// it imminently ("Send now"). A bare Stop (empty composer) must not park
|
|
2250
|
+
// the draft, or it leaks into the next unrelated message.
|
|
2251
|
+
state.pendingInterruptedDraft = hasFollowUp ? draft : "";
|
|
1817
2252
|
}
|
|
1818
|
-
|
|
2253
|
+
else {
|
|
2254
|
+
maybeStartRouterQueueTurn(ctx);
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
function cancelQueuedSend(ctx, index) {
|
|
2258
|
+
const { state } = ctx;
|
|
2259
|
+
if (!Number.isInteger(index) || index < 0 || index >= state.pendingSends.length)
|
|
2260
|
+
return;
|
|
2261
|
+
state.pendingSends.splice(index, 1);
|
|
2262
|
+
persistPendingSends(ctx);
|
|
2263
|
+
broadcastQueueState(ctx);
|
|
2264
|
+
}
|
|
2265
|
+
// Mid-run send queue (mirrors djinn's AltManager.pendingSends): a user message
|
|
2266
|
+
// sent while the router is mid-turn QUEUES instead of interrupting. It flushes
|
|
2267
|
+
// into a single follow-up turn when the current turn settles. An explicit
|
|
2268
|
+
// interrupt ("send now" / Stop) kills the run and flushes early. The epoch
|
|
2269
|
+
// keeps a killed-but-racing run from spawning tasks. Lives in its own factory
|
|
2270
|
+
// so createAgentServer stays within the max-lines budget; the queue-by-default
|
|
2271
|
+
// semantics are unchanged.
|
|
2272
|
+
function createRouterQueue(deps) {
|
|
2273
|
+
const ctx = {
|
|
2274
|
+
...deps,
|
|
2275
|
+
pendingPath: path.join(deps.agentDir, "pending-sends.json"),
|
|
2276
|
+
state: {
|
|
2277
|
+
userEpoch: 0,
|
|
2278
|
+
routerRunning: false,
|
|
2279
|
+
pendingSends: [],
|
|
2280
|
+
inFlightSends: [],
|
|
2281
|
+
pendingInterruptedDraft: "",
|
|
2282
|
+
autoRetryUsed: false,
|
|
2283
|
+
autoFoldUsed: false,
|
|
2284
|
+
lastInstruction: "",
|
|
2285
|
+
},
|
|
2286
|
+
};
|
|
2287
|
+
// Restart recovery: re-enqueue sends that never reached the message log,
|
|
2288
|
+
// so an interrupted serve resumes them exactly once instead of losing them.
|
|
2289
|
+
ctx.state.pendingSends.push(...loadRecoverableSends(ctx.pendingPath, new Set(deps.messages.map((m) => m.id))));
|
|
2290
|
+
persistPendingSends(ctx);
|
|
2291
|
+
maybeStartRouterQueueTurn(ctx);
|
|
1819
2292
|
return {
|
|
1820
|
-
handleUserMessage,
|
|
1821
|
-
interruptRouter,
|
|
1822
|
-
cancelQueued,
|
|
1823
|
-
isRunning: () => routerRunning,
|
|
1824
|
-
queuedSnippets,
|
|
2293
|
+
handleUserMessage: (text, images) => handleQueueUserMessage(ctx, text, images),
|
|
2294
|
+
interruptRouter: () => interruptRouterQueue(ctx),
|
|
2295
|
+
cancelQueued: (index) => cancelQueuedSend(ctx, index),
|
|
2296
|
+
isRunning: () => ctx.state.routerRunning,
|
|
2297
|
+
queuedSnippets: () => computeQueuedSnippets(ctx.state.pendingSends),
|
|
1825
2298
|
};
|
|
1826
2299
|
}
|
|
1827
2300
|
export function createAgentServer(opts) {
|
|
@@ -1870,6 +2343,7 @@ export function createAgentServer(opts) {
|
|
|
1870
2343
|
deckLabel,
|
|
1871
2344
|
tasksDir,
|
|
1872
2345
|
children: taskChildren,
|
|
2346
|
+
quickReference,
|
|
1873
2347
|
backend: () => settings.tasks,
|
|
1874
2348
|
claudeModel: () => settings.claudeModel,
|
|
1875
2349
|
// Task lifecycle stays on the board only -- log lines for it were spam.
|