@enderfga/claw-orchestrator 4.1.2 → 4.4.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 +11 -7
- package/dist/src/autoloop/dispatcher.js +28 -4
- package/dist/src/autoloop/dispatcher.js.map +1 -1
- package/dist/src/autoloop/messages.js +16 -3
- package/dist/src/autoloop/messages.js.map +1 -1
- package/dist/src/autoloop/runner.js +24 -5
- package/dist/src/autoloop/runner.js.map +1 -1
- package/dist/src/autoloop/types.d.ts +6 -0
- package/dist/src/consensus.d.ts +9 -0
- package/dist/src/consensus.js +11 -3
- package/dist/src/consensus.js.map +1 -1
- package/dist/src/council.d.ts +3 -0
- package/dist/src/council.js +26 -2
- package/dist/src/council.js.map +1 -1
- package/dist/src/embedded-server.js +64 -6
- package/dist/src/embedded-server.js.map +1 -1
- package/dist/src/fanout.d.ts +81 -0
- package/dist/src/fanout.js +146 -0
- package/dist/src/fanout.js.map +1 -0
- package/dist/src/inbox-manager.js +6 -1
- package/dist/src/inbox-manager.js.map +1 -1
- package/dist/src/index.js +214 -4
- package/dist/src/index.js.map +1 -1
- package/dist/src/openai-compat.js +7 -1
- package/dist/src/openai-compat.js.map +1 -1
- package/dist/src/persistent-codex-app-session.d.ts +34 -0
- package/dist/src/persistent-codex-app-session.js +112 -3
- package/dist/src/persistent-codex-app-session.js.map +1 -1
- package/dist/src/persistent-codex-session.d.ts +8 -0
- package/dist/src/persistent-codex-session.js +39 -2
- package/dist/src/persistent-codex-session.js.map +1 -1
- package/dist/src/persistent-custom-session.js +31 -5
- package/dist/src/persistent-custom-session.js.map +1 -1
- package/dist/src/persistent-opencode-session.js +3 -1
- package/dist/src/persistent-opencode-session.js.map +1 -1
- package/dist/src/persistent-session.d.ts +8 -0
- package/dist/src/persistent-session.js +69 -8
- package/dist/src/persistent-session.js.map +1 -1
- package/dist/src/proxy/anthropic-adapter.js +9 -2
- package/dist/src/proxy/anthropic-adapter.js.map +1 -1
- package/dist/src/proxy/handler.d.ts +3 -0
- package/dist/src/proxy/handler.js +5 -1
- package/dist/src/proxy/handler.js.map +1 -1
- package/dist/src/session-manager.d.ts +63 -0
- package/dist/src/session-manager.js +265 -38
- package/dist/src/session-manager.js.map +1 -1
- package/dist/src/types.d.ts +18 -1
- package/dist/src/ultraapp/fix-on-failure-session.js +4 -1
- package/dist/src/ultraapp/fix-on-failure-session.js.map +1 -1
- package/dist/src/ultraapp/fix-on-failure.js +9 -2
- package/dist/src/ultraapp/fix-on-failure.js.map +1 -1
- package/dist/src/ultraapp/json-patch.js.map +1 -1
- package/dist/src/ultraapp/patcher.js +23 -3
- package/dist/src/ultraapp/patcher.js.map +1 -1
- package/openclaw.plugin.json +10 -0
- package/package.json +2 -2
- package/skills/SKILL.md +9 -3
- package/skills/references/claude-cli-tracking.md +3 -1
- package/skills/references/multi-engine.md +10 -5
- package/skills/references/sessions.md +18 -0
- package/skills/references/tools.md +63 -1
|
@@ -115,6 +115,7 @@ import { PersistentCustomSession } from './persistent-custom-session.js';
|
|
|
115
115
|
import { overrideModelPricing, } from './types.js';
|
|
116
116
|
import { resolveAlias, isClaudeModel } from './models.js';
|
|
117
117
|
import { Council } from './council.js';
|
|
118
|
+
import { Fanout } from './fanout.js';
|
|
118
119
|
import { AutoloopRunner } from './autoloop/runner.js';
|
|
119
120
|
import { ClaudeAgentDispatcher } from './autoloop/dispatcher.js';
|
|
120
121
|
import { DEFAULT_PUSH_POLICY } from './autoloop/types.js';
|
|
@@ -432,6 +433,16 @@ export class SessionManager {
|
|
|
432
433
|
catch {
|
|
433
434
|
/* prior failure shouldn't block this caller */
|
|
434
435
|
}
|
|
436
|
+
// The prior-chain await can sleep arbitrarily long. In that window a
|
|
437
|
+
// concurrent stopSession() may have stopped this session and removed it
|
|
438
|
+
// from the map. Re-check before writing, so we fail cleanly instead of
|
|
439
|
+
// calling send() on a detached/stopped session (TOCTOU on the sessions map).
|
|
440
|
+
if (this.sessions.get(name) !== managed) {
|
|
441
|
+
releaseChain();
|
|
442
|
+
if (managed.sendChain === link)
|
|
443
|
+
managed.sendChain = undefined;
|
|
444
|
+
throw new Error(`Session '${name}' was stopped while a prior turn was in flight`);
|
|
445
|
+
}
|
|
435
446
|
try {
|
|
436
447
|
managed.lastActivity = Date.now();
|
|
437
448
|
const sendOpts = {
|
|
@@ -443,20 +454,26 @@ export class SessionManager {
|
|
|
443
454
|
if (options.plan)
|
|
444
455
|
sendOpts.plan = true;
|
|
445
456
|
if (options.onEvent || options.onChunk) {
|
|
457
|
+
// A throwing user callback must not corrupt the turn or leave the
|
|
458
|
+
// sendChain unreleased — isolate each invocation.
|
|
459
|
+
const safe = (fn) => {
|
|
460
|
+
try {
|
|
461
|
+
fn();
|
|
462
|
+
}
|
|
463
|
+
catch (err) {
|
|
464
|
+
this.logger.warn?.(`sendMessage stream callback threw: ${err.message}`);
|
|
465
|
+
}
|
|
466
|
+
};
|
|
446
467
|
sendOpts.callbacks = {
|
|
447
468
|
onText: (text) => {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
if (options.onEvent)
|
|
451
|
-
options.onEvent({ type: 'text', result: text });
|
|
469
|
+
safe(() => options.onChunk?.(text));
|
|
470
|
+
safe(() => options.onEvent?.({ type: 'text', result: text }));
|
|
452
471
|
},
|
|
453
472
|
onToolUse: (event) => {
|
|
454
|
-
|
|
455
|
-
options.onEvent({ type: 'tool_use', ...event });
|
|
473
|
+
safe(() => options.onEvent?.({ type: 'tool_use', ...event }));
|
|
456
474
|
},
|
|
457
475
|
onToolResult: (event) => {
|
|
458
|
-
|
|
459
|
-
options.onEvent({ type: 'tool_result', ...event });
|
|
476
|
+
safe(() => options.onEvent?.({ type: 'tool_result', ...event }));
|
|
460
477
|
},
|
|
461
478
|
};
|
|
462
479
|
}
|
|
@@ -860,6 +877,17 @@ export class SessionManager {
|
|
|
860
877
|
for (const [, timer] of this.ultrareviewPollers)
|
|
861
878
|
clearInterval(timer);
|
|
862
879
|
this.ultrareviewPollers.clear();
|
|
880
|
+
// Clear council/fanout cleanup timers — their 30-min closures capture `this`
|
|
881
|
+
// and would otherwise fire after shutdown (and council timers, before this
|
|
882
|
+
// fix, were not unref'd so they blocked a clean process exit).
|
|
883
|
+
for (const [, timer] of this.councilCleanupTimers)
|
|
884
|
+
clearTimeout(timer);
|
|
885
|
+
this.councilCleanupTimers.clear();
|
|
886
|
+
this.councils.clear();
|
|
887
|
+
for (const [, timer] of this.fanoutCleanupTimers)
|
|
888
|
+
clearTimeout(timer);
|
|
889
|
+
this.fanoutCleanupTimers.clear();
|
|
890
|
+
this.fanouts.clear();
|
|
863
891
|
// Stop autoloops (graceful: dispatch a terminate envelope so each run
|
|
864
892
|
// shuts down its three persistent agents and cleans up the ledger lock).
|
|
865
893
|
for (const [, ctx] of this.autoloops) {
|
|
@@ -935,6 +963,47 @@ export class SessionManager {
|
|
|
935
963
|
}
|
|
936
964
|
return { ok: true, goal: session.goal ?? null };
|
|
937
965
|
}
|
|
966
|
+
// ─── Codex app-server v2 RPCs (codex-app engine only, Codex 0.137) ────────
|
|
967
|
+
//
|
|
968
|
+
// turn/interrupt, turn/steer, thread/fork, thread/rollback, model/list — the
|
|
969
|
+
// high-value app-server surface beyond /goal. Each requires a `codex-app`
|
|
970
|
+
// session; the discriminator is the presence of the `interrupt` method.
|
|
971
|
+
_getCodexAppSession(name, feature) {
|
|
972
|
+
const managed = this.sessions.get(name);
|
|
973
|
+
if (!managed)
|
|
974
|
+
throw new Error(`Session not found: ${name}`);
|
|
975
|
+
const session = managed.session;
|
|
976
|
+
if (typeof session.interrupt !== 'function') {
|
|
977
|
+
const engine = managed.config.engine || 'claude';
|
|
978
|
+
throw new Error(`Session "${name}" uses engine "${engine}" which does not support ${feature}. ` +
|
|
979
|
+
`Start a session with engine: "codex-app".`);
|
|
980
|
+
}
|
|
981
|
+
return session;
|
|
982
|
+
}
|
|
983
|
+
async codexInterrupt(name) {
|
|
984
|
+
const r = await this._getCodexAppSession(name, 'turn/interrupt').interrupt();
|
|
985
|
+
return { ok: true, ...r };
|
|
986
|
+
}
|
|
987
|
+
async codexSteer(name, text) {
|
|
988
|
+
const r = await this._getCodexAppSession(name, 'turn/steer').steer(text);
|
|
989
|
+
return { ok: true, ...r };
|
|
990
|
+
}
|
|
991
|
+
async codexForkThread(name) {
|
|
992
|
+
const r = await this._getCodexAppSession(name, 'thread/fork').forkThread();
|
|
993
|
+
return { ok: true, ...r };
|
|
994
|
+
}
|
|
995
|
+
async codexRollback(name, numTurns) {
|
|
996
|
+
await this._getCodexAppSession(name, 'thread/rollback').rollback(numTurns);
|
|
997
|
+
return { ok: true, numTurns };
|
|
998
|
+
}
|
|
999
|
+
async codexModels(name) {
|
|
1000
|
+
const models = await this._getCodexAppSession(name, 'model/list').listModels();
|
|
1001
|
+
return { ok: true, models };
|
|
1002
|
+
}
|
|
1003
|
+
async codexThreads(name, opts = {}) {
|
|
1004
|
+
const r = await this._getCodexAppSession(name, 'thread/list').listThreads(opts);
|
|
1005
|
+
return { ok: true, ...r };
|
|
1006
|
+
}
|
|
938
1007
|
// ─── Claude /goal helpers (CLI 2.1.139, claude engine only) ────────
|
|
939
1008
|
//
|
|
940
1009
|
// Claude Code's /goal slash command works in non-interactive stream-json
|
|
@@ -984,6 +1053,34 @@ export class SessionManager {
|
|
|
984
1053
|
});
|
|
985
1054
|
return { stdout, stderr };
|
|
986
1055
|
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Wraps `claude agents --json` — lists Claude Code background agent sessions
|
|
1058
|
+
* (state/model/title/progress). One-shot spawn; not tied to a managed session.
|
|
1059
|
+
* `all` adds `--all` (include completed); `cwd` scopes to a directory.
|
|
1060
|
+
*/
|
|
1061
|
+
async claudeAgentsList(opts = {}) {
|
|
1062
|
+
const args = ['agents', '--json'];
|
|
1063
|
+
if (opts.all)
|
|
1064
|
+
args.push('--all');
|
|
1065
|
+
if (opts.cwd)
|
|
1066
|
+
args.push('--cwd', path.resolve(opts.cwd));
|
|
1067
|
+
const { stdout } = await execFileAsync(this.pluginConfig.claudeBin, args, {
|
|
1068
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
1069
|
+
timeout: 30_000,
|
|
1070
|
+
});
|
|
1071
|
+
let agents = [];
|
|
1072
|
+
const trimmed = stdout.trim();
|
|
1073
|
+
if (trimmed) {
|
|
1074
|
+
try {
|
|
1075
|
+
const parsed = JSON.parse(trimmed);
|
|
1076
|
+
agents = Array.isArray(parsed) ? parsed : (parsed.agents ?? []);
|
|
1077
|
+
}
|
|
1078
|
+
catch {
|
|
1079
|
+
throw new Error(`claude agents --json returned non-JSON output: ${trimmed.slice(0, 200)}`);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
return { ok: true, agents };
|
|
1083
|
+
}
|
|
987
1084
|
// ─── Codex one-shot wrappers ──────────────────────────────────────────
|
|
988
1085
|
_codexBin() {
|
|
989
1086
|
return process.env.CODEX_BIN || 'codex';
|
|
@@ -1090,6 +1187,16 @@ export class SessionManager {
|
|
|
1090
1187
|
if (scopes.length > 1) {
|
|
1091
1188
|
throw new Error('codexReview: --uncommitted, --base, and --commit are mutually exclusive');
|
|
1092
1189
|
}
|
|
1190
|
+
// Validate git refs: reject leading-dash (argument injection) and shell/path
|
|
1191
|
+
// metacharacters. args go through execFile (no shell) but a '--flag'-shaped
|
|
1192
|
+
// value could still be misread by codex's parser.
|
|
1193
|
+
const GIT_REF = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
|
|
1194
|
+
if (opts.base != null && !GIT_REF.test(opts.base)) {
|
|
1195
|
+
throw new Error(`codexReview: invalid base ref '${opts.base}'`);
|
|
1196
|
+
}
|
|
1197
|
+
if (opts.commit != null && !GIT_REF.test(opts.commit)) {
|
|
1198
|
+
throw new Error(`codexReview: invalid commit ref '${opts.commit}'`);
|
|
1199
|
+
}
|
|
1093
1200
|
const args = ['review'];
|
|
1094
1201
|
if (opts.uncommitted)
|
|
1095
1202
|
args.push('--uncommitted');
|
|
@@ -1313,13 +1420,17 @@ export class SessionManager {
|
|
|
1313
1420
|
_isKnownCliProcess(pid) {
|
|
1314
1421
|
// Match known CLI binaries by basename to avoid false positives
|
|
1315
1422
|
// (e.g., 'agent' must not match 'ssh-agent' or 'gpg-agent')
|
|
1423
|
+
// Anchor each name to executable/path position ((?:^|[/\s])name(?:[\s/]|$))
|
|
1424
|
+
// so a hyphenated lookalike ('vim claude-notes.md', 'ssh-agent') can never
|
|
1425
|
+
// match, while the real binary ('claude', '/usr/local/bin/claude',
|
|
1426
|
+
// 'node /x/claude/cli.js') still does. \b alone treated '-' as a boundary.
|
|
1316
1427
|
const knownPatterns = [
|
|
1317
|
-
/\
|
|
1318
|
-
/\
|
|
1319
|
-
/\
|
|
1320
|
-
/\
|
|
1321
|
-
/\
|
|
1322
|
-
/(?:^|\/)agent\s/, // 'agent' as
|
|
1428
|
+
/(?:^|[/\s])claude(?:[\s/]|$)/, // claude CLI
|
|
1429
|
+
/(?:^|[/\s])codex(?:[\s/]|$)/, // codex CLI
|
|
1430
|
+
/(?:^|[/\s])gemini(?:[\s/]|$)/, // gemini CLI
|
|
1431
|
+
/(?:^|[/\s])cursor-agent(?:[\s/]|$)/, // cursor-agent CLI
|
|
1432
|
+
/(?:^|[/\s])opencode(?:[\s/]|$)/, // opencode CLI (sst/opencode)
|
|
1433
|
+
/(?:^|\/)agent(?:[\s/]|$)/, // 'agent' only as executable/after a slash (not ssh-agent)
|
|
1323
1434
|
];
|
|
1324
1435
|
try {
|
|
1325
1436
|
const cmd = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
|
|
@@ -1402,7 +1513,7 @@ export class SessionManager {
|
|
|
1402
1513
|
/* individual kill failed */
|
|
1403
1514
|
}
|
|
1404
1515
|
// Give process time to shut down, then SIGKILL
|
|
1405
|
-
setTimeout(() => {
|
|
1516
|
+
const orphanSigkill = setTimeout(() => {
|
|
1406
1517
|
try {
|
|
1407
1518
|
process.kill(pid, 0);
|
|
1408
1519
|
process.kill(-pid, 'SIGKILL');
|
|
@@ -1418,6 +1529,7 @@ export class SessionManager {
|
|
|
1418
1529
|
/* already dead */
|
|
1419
1530
|
}
|
|
1420
1531
|
}, STOP_SIGKILL_DELAY_MS);
|
|
1532
|
+
orphanSigkill.unref(); // force-kill fallback must not keep the loop alive
|
|
1421
1533
|
}
|
|
1422
1534
|
catch {
|
|
1423
1535
|
// Process already dead — nothing to do
|
|
@@ -1526,8 +1638,18 @@ export class SessionManager {
|
|
|
1526
1638
|
this.councils.delete(id);
|
|
1527
1639
|
this.councilCleanupTimers.delete(id);
|
|
1528
1640
|
}, RESULT_TTL_MS);
|
|
1641
|
+
// Don't let a pending 30-min cleanup timer keep the process alive or block shutdown.
|
|
1642
|
+
timer.unref();
|
|
1529
1643
|
this.councilCleanupTimers.set(id, timer);
|
|
1530
1644
|
}
|
|
1645
|
+
/** Clear and forget a cleanup timer (used on abort/shutdown so it can't fire late). */
|
|
1646
|
+
_clearCleanupTimer(map, id) {
|
|
1647
|
+
const t = map.get(id);
|
|
1648
|
+
if (t) {
|
|
1649
|
+
clearTimeout(t);
|
|
1650
|
+
map.delete(id);
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1531
1653
|
councilStatus(id) {
|
|
1532
1654
|
const council = this.councils.get(id);
|
|
1533
1655
|
return council?.getSession();
|
|
@@ -1569,6 +1691,8 @@ export class SessionManager {
|
|
|
1569
1691
|
throw new Error(`Council '${id}' not found`);
|
|
1570
1692
|
council.abort();
|
|
1571
1693
|
this.councils.delete(id);
|
|
1694
|
+
// Drop the orphaned cleanup timer so it doesn't fire later on a deleted council.
|
|
1695
|
+
this._clearCleanupTimer(this.councilCleanupTimers, id);
|
|
1572
1696
|
}
|
|
1573
1697
|
councilInject(id, message) {
|
|
1574
1698
|
const council = this.councils.get(id);
|
|
@@ -1600,6 +1724,55 @@ export class SessionManager {
|
|
|
1600
1724
|
this._scheduleCouncilCleanup(id); // reset TTL — council may be restarted
|
|
1601
1725
|
return result;
|
|
1602
1726
|
}
|
|
1727
|
+
// ─── Fan-out (parallel multi-engine task, no consensus) ────────────────
|
|
1728
|
+
fanouts = new Map();
|
|
1729
|
+
fanoutCleanupTimers = new Map();
|
|
1730
|
+
/**
|
|
1731
|
+
* Start a fan-out: run the task across N engine/model agents in parallel and
|
|
1732
|
+
* collect their answers (optional synthesis). Runs in the background; poll
|
|
1733
|
+
* with fanoutStatus. Distinct from council — no rounds, votes, or worktrees.
|
|
1734
|
+
*/
|
|
1735
|
+
fanoutStart(config) {
|
|
1736
|
+
if (!config.agents?.length)
|
|
1737
|
+
throw new Error('fanoutStart: at least one agent is required');
|
|
1738
|
+
const names = config.agents.map((a) => a.name);
|
|
1739
|
+
if (new Set(names).size !== names.length) {
|
|
1740
|
+
throw new Error('fanoutStart: agent names must be unique (they form session names)');
|
|
1741
|
+
}
|
|
1742
|
+
const fanout = new Fanout(config, this, this.logger);
|
|
1743
|
+
const session = fanout.init();
|
|
1744
|
+
this.fanouts.set(session.id, fanout);
|
|
1745
|
+
fanout
|
|
1746
|
+
.run()
|
|
1747
|
+
.catch((err) => this.logger.error(`Fanout ${session.id} failed:`, err))
|
|
1748
|
+
.finally(() => this._scheduleFanoutCleanup(session.id));
|
|
1749
|
+
return session;
|
|
1750
|
+
}
|
|
1751
|
+
fanoutStatus(id) {
|
|
1752
|
+
const fanout = this.fanouts.get(id);
|
|
1753
|
+
if (!fanout)
|
|
1754
|
+
throw new Error(`Fanout '${id}' not found`);
|
|
1755
|
+
return fanout.getSession();
|
|
1756
|
+
}
|
|
1757
|
+
fanoutAbort(id) {
|
|
1758
|
+
const fanout = this.fanouts.get(id);
|
|
1759
|
+
if (!fanout)
|
|
1760
|
+
throw new Error(`Fanout '${id}' not found`);
|
|
1761
|
+
fanout.abort();
|
|
1762
|
+
this._scheduleFanoutCleanup(id);
|
|
1763
|
+
}
|
|
1764
|
+
_scheduleFanoutCleanup(id) {
|
|
1765
|
+
const existing = this.fanoutCleanupTimers.get(id);
|
|
1766
|
+
if (existing)
|
|
1767
|
+
clearTimeout(existing);
|
|
1768
|
+
const timer = setTimeout(() => {
|
|
1769
|
+
this.fanouts.delete(id);
|
|
1770
|
+
this.fanoutCleanupTimers.delete(id);
|
|
1771
|
+
}, RESULT_TTL_MS);
|
|
1772
|
+
if (typeof timer.unref === 'function')
|
|
1773
|
+
timer.unref();
|
|
1774
|
+
this.fanoutCleanupTimers.set(id, timer);
|
|
1775
|
+
}
|
|
1603
1776
|
// ─── Inbox (cross-session messaging) — delegated to InboxManager ────
|
|
1604
1777
|
get _sessionLookup() {
|
|
1605
1778
|
return {
|
|
@@ -1644,7 +1817,7 @@ export class SessionManager {
|
|
|
1644
1817
|
this.stopSession(sessionName).catch((err) => {
|
|
1645
1818
|
this.logger.error(`Failed to stop ultraplan session '${sessionName}':`, err);
|
|
1646
1819
|
});
|
|
1647
|
-
setTimeout(() => {
|
|
1820
|
+
const ttlTimer = setTimeout(() => {
|
|
1648
1821
|
// Mark as error if still running at TTL expiry
|
|
1649
1822
|
const plan = this.ultraplans.get(id);
|
|
1650
1823
|
if (plan?.status === 'running') {
|
|
@@ -1655,6 +1828,7 @@ export class SessionManager {
|
|
|
1655
1828
|
}
|
|
1656
1829
|
this.ultraplans.delete(id);
|
|
1657
1830
|
}, RESULT_TTL_MS);
|
|
1831
|
+
ttlTimer.unref(); // don't block process exit on a 30-min TTL timer
|
|
1658
1832
|
});
|
|
1659
1833
|
return result;
|
|
1660
1834
|
}
|
|
@@ -1805,40 +1979,76 @@ export class SessionManager {
|
|
|
1805
1979
|
persona: 'You are a memory reviewer. Focus on: memory leaks, circular references, large object retention, stream handling.',
|
|
1806
1980
|
},
|
|
1807
1981
|
];
|
|
1808
|
-
const agents = reviewAngles.slice(0, agentCount).map((a) => ({
|
|
1809
|
-
...a,
|
|
1810
|
-
model: opts?.model,
|
|
1811
|
-
}));
|
|
1812
1982
|
const maxMinutes = Math.min(25, Math.max(5, opts?.maxDurationMinutes || 10));
|
|
1813
1983
|
const focus = opts?.focus || 'Find bugs, security issues, and code quality problems';
|
|
1814
|
-
const
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1984
|
+
const reviewInstruction = `# Code Review Task\n\nReview the codebase in this project. ${focus}.\n\n` +
|
|
1985
|
+
`Examine the code from your specialty angle and report bugs found with file paths and line numbers.`;
|
|
1986
|
+
// Cross-engine review: round-robin the requested engines across reviewers
|
|
1987
|
+
// (default claude-only — unchanged behavior). Each reviewer's persona is
|
|
1988
|
+
// its prompt; per-agent failures are isolated by the fan-out runner.
|
|
1989
|
+
const engines = opts?.engines?.length ? opts.engines : ['claude'];
|
|
1990
|
+
const agents = reviewAngles.slice(0, agentCount).map((a, i) => ({
|
|
1991
|
+
name: a.name,
|
|
1992
|
+
engine: engines[i % engines.length],
|
|
1993
|
+
model: opts?.model,
|
|
1994
|
+
prompt: `${a.persona}\n\n${reviewInstruction}`,
|
|
1995
|
+
// Review is read-only: keep reviewers out of edit mode so they analyse and
|
|
1996
|
+
// report without modifying the very code they review. (Unlike council,
|
|
1997
|
+
// fan-out shares the project dir — there is no worktree to sandbox edits.
|
|
1998
|
+
// `plan` constrains the claude engine; non-claude reviewers, which are
|
|
1999
|
+
// opt-in via `engines`, run under their engine's default sandbox.)
|
|
2000
|
+
permissionMode: 'plan',
|
|
2001
|
+
}));
|
|
2002
|
+
let fanoutSession;
|
|
2003
|
+
try {
|
|
2004
|
+
fanoutSession = this.fanoutStart({
|
|
2005
|
+
task: reviewInstruction,
|
|
2006
|
+
projectDir: cwd,
|
|
2007
|
+
agents,
|
|
2008
|
+
synthesize: true,
|
|
2009
|
+
agentTimeoutMs: maxMinutes * 60 * 1000,
|
|
2010
|
+
maxTurnsPerAgent: 20,
|
|
2011
|
+
});
|
|
2012
|
+
}
|
|
2013
|
+
catch (err) {
|
|
2014
|
+
// Fan-out failed to even start (e.g. validation) — surface it on the
|
|
2015
|
+
// stored result instead of leaving it frozen at 'running'.
|
|
2016
|
+
result.status = 'error';
|
|
2017
|
+
result.error = err.message;
|
|
2018
|
+
result.endTime = new Date().toISOString();
|
|
2019
|
+
setTimeout(() => this.ultrareviews.delete(id), RESULT_TTL_MS);
|
|
2020
|
+
return result;
|
|
2021
|
+
}
|
|
2022
|
+
// `councilId` is kept for the UltrareviewResult contract; it now holds the
|
|
2023
|
+
// fan-out id (an opaque run id used only by ultrareview_status).
|
|
2024
|
+
result.councilId = fanoutSession.id;
|
|
2025
|
+
// Poll the fan-out for completion (store ref for shutdown cleanup).
|
|
1825
2026
|
const pollInterval = setInterval(() => {
|
|
1826
2027
|
try {
|
|
1827
|
-
const status = this.
|
|
2028
|
+
const status = this.fanoutStatus(fanoutSession.id);
|
|
1828
2029
|
if (!status || status.status === 'running')
|
|
1829
2030
|
return;
|
|
1830
2031
|
clearInterval(pollInterval);
|
|
1831
2032
|
this.ultrareviewPollers.delete(id);
|
|
1832
2033
|
result.status = status.status === 'error' ? 'error' : 'completed';
|
|
1833
2034
|
result.endTime = new Date().toISOString();
|
|
1834
|
-
//
|
|
1835
|
-
if (status.
|
|
1836
|
-
result.findings = status.
|
|
2035
|
+
// Prefer the synthesis pass; fall back to joining successful results.
|
|
2036
|
+
if (status.synthesis) {
|
|
2037
|
+
result.findings = status.synthesis;
|
|
2038
|
+
}
|
|
2039
|
+
else if (status.results.length > 0) {
|
|
2040
|
+
result.findings = status.results
|
|
2041
|
+
.filter((r) => r.ok)
|
|
2042
|
+
.map((r) => `## ${r.agent}\n\n${r.output}`)
|
|
2043
|
+
.join('\n\n---\n\n');
|
|
2044
|
+
}
|
|
2045
|
+
{
|
|
2046
|
+
const ttlDelete = setTimeout(() => this.ultrareviews.delete(id), RESULT_TTL_MS);
|
|
2047
|
+
ttlDelete.unref();
|
|
1837
2048
|
}
|
|
1838
|
-
setTimeout(() => this.ultrareviews.delete(id), RESULT_TTL_MS);
|
|
1839
2049
|
}
|
|
1840
2050
|
catch {
|
|
1841
|
-
//
|
|
2051
|
+
// Fan-out may have been cleaned up; stop polling.
|
|
1842
2052
|
clearInterval(pollInterval);
|
|
1843
2053
|
this.ultrareviewPollers.delete(id);
|
|
1844
2054
|
}
|
|
@@ -1851,6 +2061,10 @@ export class SessionManager {
|
|
|
1851
2061
|
}
|
|
1852
2062
|
// ─── Autoloop (three-agent architecture) ───────────────────────────
|
|
1853
2063
|
autoloops = new Map();
|
|
2064
|
+
// runIds currently being torn down by autoloopDelete. Guards against a
|
|
2065
|
+
// concurrent autoloopStart recreating the same id (or autoloopChat using a
|
|
2066
|
+
// dispatcher mid-shutdown) during the async delete window.
|
|
2067
|
+
_deletingAutoloops = new Set();
|
|
1854
2068
|
/**
|
|
1855
2069
|
* Start a v2 autoloop in chat mode. Creates the Planner persistent session,
|
|
1856
2070
|
* returns the run handle. Coder/Reviewer are NOT started until S3's
|
|
@@ -1860,6 +2074,9 @@ export class SessionManager {
|
|
|
1860
2074
|
if (this.autoloops.has(opts.runId)) {
|
|
1861
2075
|
throw new Error(`Autoloop with id '${opts.runId}' already exists`);
|
|
1862
2076
|
}
|
|
2077
|
+
if (this._deletingAutoloops.has(opts.runId)) {
|
|
2078
|
+
throw new Error(`Autoloop with id '${opts.runId}' is being deleted`);
|
|
2079
|
+
}
|
|
1863
2080
|
const ledgerDir = path.join(opts.workspace, 'tasks', opts.runId);
|
|
1864
2081
|
if (!fs.existsSync(ledgerDir)) {
|
|
1865
2082
|
fs.mkdirSync(ledgerDir, { recursive: true });
|
|
@@ -1948,7 +2165,7 @@ export class SessionManager {
|
|
|
1948
2165
|
*/
|
|
1949
2166
|
async autoloopChat(runId, text) {
|
|
1950
2167
|
const ctx = this.autoloops.get(runId);
|
|
1951
|
-
if (!ctx)
|
|
2168
|
+
if (!ctx || this._deletingAutoloops.has(runId))
|
|
1952
2169
|
throw new Error(`Autoloop run '${runId}' not found`);
|
|
1953
2170
|
let reply = '';
|
|
1954
2171
|
const onReply = (...args) => {
|
|
@@ -2085,6 +2302,16 @@ export class SessionManager {
|
|
|
2085
2302
|
* Returns true if anything was removed (in-memory entry OR registry row).
|
|
2086
2303
|
*/
|
|
2087
2304
|
async autoloopDelete(runId) {
|
|
2305
|
+
// Fence the async teardown so a concurrent start/chat can't race on this id.
|
|
2306
|
+
this._deletingAutoloops.add(runId);
|
|
2307
|
+
try {
|
|
2308
|
+
return await this._autoloopDeleteInner(runId);
|
|
2309
|
+
}
|
|
2310
|
+
finally {
|
|
2311
|
+
this._deletingAutoloops.delete(runId);
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
async _autoloopDeleteInner(runId) {
|
|
2088
2315
|
const ctx = this.autoloops.get(runId);
|
|
2089
2316
|
let touched = false;
|
|
2090
2317
|
if (ctx) {
|