@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.
Files changed (61) hide show
  1. package/README.md +11 -7
  2. package/dist/src/autoloop/dispatcher.js +28 -4
  3. package/dist/src/autoloop/dispatcher.js.map +1 -1
  4. package/dist/src/autoloop/messages.js +16 -3
  5. package/dist/src/autoloop/messages.js.map +1 -1
  6. package/dist/src/autoloop/runner.js +24 -5
  7. package/dist/src/autoloop/runner.js.map +1 -1
  8. package/dist/src/autoloop/types.d.ts +6 -0
  9. package/dist/src/consensus.d.ts +9 -0
  10. package/dist/src/consensus.js +11 -3
  11. package/dist/src/consensus.js.map +1 -1
  12. package/dist/src/council.d.ts +3 -0
  13. package/dist/src/council.js +26 -2
  14. package/dist/src/council.js.map +1 -1
  15. package/dist/src/embedded-server.js +64 -6
  16. package/dist/src/embedded-server.js.map +1 -1
  17. package/dist/src/fanout.d.ts +81 -0
  18. package/dist/src/fanout.js +146 -0
  19. package/dist/src/fanout.js.map +1 -0
  20. package/dist/src/inbox-manager.js +6 -1
  21. package/dist/src/inbox-manager.js.map +1 -1
  22. package/dist/src/index.js +214 -4
  23. package/dist/src/index.js.map +1 -1
  24. package/dist/src/openai-compat.js +7 -1
  25. package/dist/src/openai-compat.js.map +1 -1
  26. package/dist/src/persistent-codex-app-session.d.ts +34 -0
  27. package/dist/src/persistent-codex-app-session.js +112 -3
  28. package/dist/src/persistent-codex-app-session.js.map +1 -1
  29. package/dist/src/persistent-codex-session.d.ts +8 -0
  30. package/dist/src/persistent-codex-session.js +39 -2
  31. package/dist/src/persistent-codex-session.js.map +1 -1
  32. package/dist/src/persistent-custom-session.js +31 -5
  33. package/dist/src/persistent-custom-session.js.map +1 -1
  34. package/dist/src/persistent-opencode-session.js +3 -1
  35. package/dist/src/persistent-opencode-session.js.map +1 -1
  36. package/dist/src/persistent-session.d.ts +8 -0
  37. package/dist/src/persistent-session.js +69 -8
  38. package/dist/src/persistent-session.js.map +1 -1
  39. package/dist/src/proxy/anthropic-adapter.js +9 -2
  40. package/dist/src/proxy/anthropic-adapter.js.map +1 -1
  41. package/dist/src/proxy/handler.d.ts +3 -0
  42. package/dist/src/proxy/handler.js +5 -1
  43. package/dist/src/proxy/handler.js.map +1 -1
  44. package/dist/src/session-manager.d.ts +63 -0
  45. package/dist/src/session-manager.js +265 -38
  46. package/dist/src/session-manager.js.map +1 -1
  47. package/dist/src/types.d.ts +18 -1
  48. package/dist/src/ultraapp/fix-on-failure-session.js +4 -1
  49. package/dist/src/ultraapp/fix-on-failure-session.js.map +1 -1
  50. package/dist/src/ultraapp/fix-on-failure.js +9 -2
  51. package/dist/src/ultraapp/fix-on-failure.js.map +1 -1
  52. package/dist/src/ultraapp/json-patch.js.map +1 -1
  53. package/dist/src/ultraapp/patcher.js +23 -3
  54. package/dist/src/ultraapp/patcher.js.map +1 -1
  55. package/openclaw.plugin.json +10 -0
  56. package/package.json +2 -2
  57. package/skills/SKILL.md +9 -3
  58. package/skills/references/claude-cli-tracking.md +3 -1
  59. package/skills/references/multi-engine.md +10 -5
  60. package/skills/references/sessions.md +18 -0
  61. 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
- if (options.onChunk)
449
- options.onChunk(text);
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
- if (options.onEvent)
455
- options.onEvent({ type: 'tool_use', ...event });
473
+ safe(() => options.onEvent?.({ type: 'tool_use', ...event }));
456
474
  },
457
475
  onToolResult: (event) => {
458
- if (options.onEvent)
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
- /\bclaude\b/, // claude CLI
1318
- /\bcodex\b/, // codex CLI
1319
- /\bgemini\b/, // gemini CLI
1320
- /\bcursor-agent\b/, // cursor-agent CLI
1321
- /\bopencode\b/, // opencode CLI (sst/opencode)
1322
- /(?:^|\/)agent\s/, // 'agent' as standalone command (not ssh-agent etc.)
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 councilConfig = {
1815
- name: 'ultrareview',
1816
- agents,
1817
- maxRounds: 2, // Review doesn't need many rounds — find bugs, then synthesize
1818
- projectDir: cwd,
1819
- agentTimeoutMs: maxMinutes * 60 * 1000,
1820
- maxTurnsPerAgent: 20,
1821
- };
1822
- const councilSession = this.councilStart(`# Code Review Task\n\nReview the codebase in this project. ${focus}.\n\nEach reviewer: examine the code from your specialty angle, report bugs found with file paths and line numbers. Vote [CONSENSUS: YES] when your review is complete.`, councilConfig);
1823
- result.councilId = councilSession.id;
1824
- // Poll council for completion (store ref for shutdown cleanup)
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.councilStatus(councilSession.id);
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
- // Synthesize findings from all agent responses
1835
- if (status.responses.length > 0) {
1836
- result.findings = status.responses.map((r) => `## ${r.agent}\n\n${r.content}`).join('\n\n---\n\n');
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
- // Council may have been cleaned up; stop polling
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) {