chorus-codes 0.8.25 → 0.8.27

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 (162) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +4 -4
  3. package/.next/build-manifest.json +2 -2
  4. package/.next/prerender-manifest.json +3 -3
  5. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  6. package/.next/server/app/_global-error.html +1 -1
  7. package/.next/server/app/_global-error.rsc +1 -1
  8. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  9. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  13. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  14. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  15. package/.next/server/app/_not-found.html +1 -1
  16. package/.next/server/app/_not-found.rsc +2 -2
  17. package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  18. package/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  19. package/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  20. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  21. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  22. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  23. package/.next/server/app/connect/page.js +1 -1
  24. package/.next/server/app/connect/page.js.nft.json +1 -1
  25. package/.next/server/app/connect/page_client-reference-manifest.js +1 -1
  26. package/.next/server/app/demo/[scenario]/page.js +1 -1
  27. package/.next/server/app/demo/[scenario]/page.js.nft.json +1 -1
  28. package/.next/server/app/demo/[scenario]/page_client-reference-manifest.js +1 -1
  29. package/.next/server/app/new/page.js +1 -1
  30. package/.next/server/app/new/page.js.nft.json +1 -1
  31. package/.next/server/app/new/page_client-reference-manifest.js +1 -1
  32. package/.next/server/app/new.html +1 -1
  33. package/.next/server/app/new.rsc +3 -3
  34. package/.next/server/app/new.segments/_full.segment.rsc +3 -3
  35. package/.next/server/app/new.segments/_head.segment.rsc +1 -1
  36. package/.next/server/app/new.segments/_index.segment.rsc +2 -2
  37. package/.next/server/app/new.segments/_tree.segment.rsc +2 -2
  38. package/.next/server/app/new.segments/new/__PAGE__.segment.rsc +2 -2
  39. package/.next/server/app/new.segments/new.segment.rsc +1 -1
  40. package/.next/server/app/onboarding/page.js +1 -1
  41. package/.next/server/app/onboarding/page.js.nft.json +1 -1
  42. package/.next/server/app/onboarding/page_client-reference-manifest.js +1 -1
  43. package/.next/server/app/onboarding.html +1 -1
  44. package/.next/server/app/onboarding.rsc +3 -3
  45. package/.next/server/app/onboarding.segments/_full.segment.rsc +3 -3
  46. package/.next/server/app/onboarding.segments/_head.segment.rsc +1 -1
  47. package/.next/server/app/onboarding.segments/_index.segment.rsc +2 -2
  48. package/.next/server/app/onboarding.segments/_tree.segment.rsc +2 -2
  49. package/.next/server/app/onboarding.segments/onboarding/__PAGE__.segment.rsc +2 -2
  50. package/.next/server/app/onboarding.segments/onboarding.segment.rsc +1 -1
  51. package/.next/server/app/page.js +2 -2
  52. package/.next/server/app/page.js.nft.json +1 -1
  53. package/.next/server/app/page_client-reference-manifest.js +1 -1
  54. package/.next/server/app/personas/page.js +1 -1
  55. package/.next/server/app/personas/page.js.nft.json +1 -1
  56. package/.next/server/app/personas/page_client-reference-manifest.js +1 -1
  57. package/.next/server/app/personas.html +1 -1
  58. package/.next/server/app/personas.rsc +3 -3
  59. package/.next/server/app/personas.segments/_full.segment.rsc +3 -3
  60. package/.next/server/app/personas.segments/_head.segment.rsc +1 -1
  61. package/.next/server/app/personas.segments/_index.segment.rsc +2 -2
  62. package/.next/server/app/personas.segments/_tree.segment.rsc +2 -2
  63. package/.next/server/app/personas.segments/personas/__PAGE__.segment.rsc +2 -2
  64. package/.next/server/app/personas.segments/personas.segment.rsc +1 -1
  65. package/.next/server/app/runs/[runId]/page.js +2 -2
  66. package/.next/server/app/runs/[runId]/page.js.nft.json +1 -1
  67. package/.next/server/app/runs/[runId]/page_client-reference-manifest.js +1 -1
  68. package/.next/server/app/runs/page.js +2 -2
  69. package/.next/server/app/runs/page.js.nft.json +1 -1
  70. package/.next/server/app/runs/page_client-reference-manifest.js +1 -1
  71. package/.next/server/app/settings/page.js +3 -3
  72. package/.next/server/app/settings/page.js.nft.json +1 -1
  73. package/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  74. package/.next/server/app/settings/permissions/page.js +1 -1
  75. package/.next/server/app/settings/permissions/page.js.nft.json +1 -1
  76. package/.next/server/app/settings/permissions/page_client-reference-manifest.js +1 -1
  77. package/.next/server/app/settings.html +1 -1
  78. package/.next/server/app/settings.rsc +3 -3
  79. package/.next/server/app/settings.segments/_full.segment.rsc +3 -3
  80. package/.next/server/app/settings.segments/_head.segment.rsc +1 -1
  81. package/.next/server/app/settings.segments/_index.segment.rsc +2 -2
  82. package/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
  83. package/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +2 -2
  84. package/.next/server/app/settings.segments/settings.segment.rsc +1 -1
  85. package/.next/server/app/templates/page.js +2 -2
  86. package/.next/server/app/templates/page.js.nft.json +1 -1
  87. package/.next/server/app/templates/page_client-reference-manifest.js +1 -1
  88. package/.next/server/app/templates.html +1 -1
  89. package/.next/server/app/templates.rsc +3 -3
  90. package/.next/server/app/templates.segments/_full.segment.rsc +3 -3
  91. package/.next/server/app/templates.segments/_head.segment.rsc +1 -1
  92. package/.next/server/app/templates.segments/_index.segment.rsc +2 -2
  93. package/.next/server/app/templates.segments/_tree.segment.rsc +2 -2
  94. package/.next/server/app/templates.segments/templates/__PAGE__.segment.rsc +2 -2
  95. package/.next/server/app/templates.segments/templates.segment.rsc +1 -1
  96. package/.next/server/app-paths-manifest.json +4 -4
  97. package/.next/server/chunks/189.js +1 -0
  98. package/.next/server/chunks/{144.js → 21.js} +1 -1
  99. package/.next/server/chunks/{668.js → 313.js} +1 -1
  100. package/.next/server/chunks/325.js +1 -1
  101. package/.next/server/chunks/681.js +1 -1
  102. package/.next/server/middleware-build-manifest.js +1 -1
  103. package/.next/server/pages/404.html +1 -1
  104. package/.next/server/pages/500.html +1 -1
  105. package/.next/server/server-reference-manifest.json +1 -1
  106. package/.next/static/chunks/{245-e103518e1c6037c0.js → 245-203bd8285e6b0858.js} +1 -1
  107. package/.next/static/chunks/249-2e840495c38ee022.js +25 -0
  108. package/.next/static/chunks/641-2908cb9553b8753a.js +1 -0
  109. package/.next/static/chunks/690-092c26db4082d49a.js +1 -0
  110. package/.next/static/chunks/app/connect/{page-a3a0af374f90ad4c.js → page-ad4409761e870bd0.js} +1 -1
  111. package/.next/static/chunks/app/demo/[scenario]/{page-6a0e4aec4bb96fee.js → page-39673968f543c473.js} +1 -1
  112. package/.next/static/chunks/app/new/{page-b96d75506030acf8.js → page-b5f609ab9413ac00.js} +1 -1
  113. package/.next/static/chunks/app/onboarding/{page-4be5a1d944e32672.js → page-8b0850fef487abdc.js} +1 -1
  114. package/.next/static/chunks/app/{page-35375a7c8b3d117a.js → page-9bdbaad592d0ce56.js} +1 -1
  115. package/.next/static/chunks/app/personas/{page-3884f8907107a4e6.js → page-440c6033a773100c.js} +1 -1
  116. package/.next/static/chunks/app/runs/[runId]/{page-b5bcf0c093389207.js → page-ffc36f12f1b63ebe.js} +1 -1
  117. package/.next/static/chunks/app/runs/{page-376175c1ac803558.js → page-6962ea572c9e4b74.js} +1 -1
  118. package/.next/static/chunks/app/settings/page-ad7180ee0d142704.js +25 -0
  119. package/.next/static/chunks/app/settings/permissions/{page-c90795aa9299bbe8.js → page-cd767401ac71a29c.js} +1 -1
  120. package/.next/static/chunks/app/templates/page-0112ab3c7ab5185d.js +1 -0
  121. package/.next/static/css/df4972a256406ec7.css +3 -0
  122. package/.next/trace +20 -20
  123. package/.next/trace-build +1 -1
  124. package/README.md +22 -0
  125. package/bin/chorus.mjs +87 -5
  126. package/dist/cli/commands/diagnose.js +255 -0
  127. package/dist/cli/commands/diagnose.js.map +1 -0
  128. package/dist/cli/crash-hook.js +135 -0
  129. package/dist/cli/crash-hook.js.map +1 -0
  130. package/dist/cli/index.js +2 -0
  131. package/dist/cli/index.js.map +1 -1
  132. package/dist/daemon/cli-semaphore.js +266 -0
  133. package/dist/daemon/cli-semaphore.js.map +1 -0
  134. package/dist/daemon/routes/settings.js +39 -0
  135. package/dist/daemon/routes/settings.js.map +1 -1
  136. package/dist/daemon/runner/doer-driver.js +243 -214
  137. package/dist/daemon/runner/doer-driver.js.map +1 -1
  138. package/dist/daemon/runner/fallback-registry.js +110 -0
  139. package/dist/daemon/runner/fallback-registry.js.map +1 -0
  140. package/dist/daemon/runner/reviewer-driver.js +345 -256
  141. package/dist/daemon/runner/reviewer-driver.js.map +1 -1
  142. package/dist/daemon/runner/template-fallback.js +22 -8
  143. package/dist/daemon/runner/template-fallback.js.map +1 -1
  144. package/dist/daemon/runner.js +19 -0
  145. package/dist/daemon/runner.js.map +1 -1
  146. package/dist/lib/db/chats.js +28 -0
  147. package/dist/lib/db/chats.js.map +1 -1
  148. package/dist/lib/db/connection.js +6 -0
  149. package/dist/lib/db/connection.js.map +1 -1
  150. package/dist/lib/db/schema.sql +6 -0
  151. package/dist/lib/settings/concurrency.js +101 -0
  152. package/dist/lib/settings/concurrency.js.map +1 -0
  153. package/package.json +1 -1
  154. package/.next/server/chunks/946.js +0 -1
  155. package/.next/static/chunks/116-8bf7e014066cedde.js +0 -25
  156. package/.next/static/chunks/15-d438a2b057302bed.js +0 -1
  157. package/.next/static/chunks/641-60721f44faf711b9.js +0 -1
  158. package/.next/static/chunks/app/settings/page-1792a3e289409b2d.js +0 -25
  159. package/.next/static/chunks/app/templates/page-1449b0aea2e7cb68.js +0 -1
  160. package/.next/static/css/d2bb161eb5bee944.css +0 -3
  161. /package/.next/static/{47MSfijpKVM6q0E0ye9Mr → p1K3tWuW1MBx6L7K5jGPV}/_buildManifest.js +0 -0
  162. /package/.next/static/{47MSfijpKVM6q0E0ye9Mr → p1K3tWuW1MBx6L7K5jGPV}/_ssgManifest.js +0 -0
@@ -45,23 +45,38 @@ const cli_precheck_js_1 = require("../../lib/cli-precheck.js");
45
45
  const index_js_1 = require("../../lib/db/index.js");
46
46
  const permissions_js_1 = require("../../lib/settings/permissions.js");
47
47
  const transport_js_1 = require("../../lib/settings/transport.js");
48
+ const concurrency_js_1 = require("../../lib/settings/concurrency.js");
49
+ const cli_semaphore_js_1 = require("../cli-semaphore.js");
48
50
  const index_js_2 = require("../agents/index.js");
49
51
  const output_watcher_js_1 = require("../output-watcher.js");
50
52
  const participantAborts = __importStar(require("../participant-aborts.js"));
51
53
  const prompt_builder_js_1 = require("./prompt-builder.js");
52
54
  const reviewer_js_1 = require("./reviewer.js");
55
+ const fallback_registry_js_1 = require("./fallback-registry.js");
53
56
  const run_with_fallback_js_1 = require("./run-with-fallback.js");
54
57
  const sanitize_name_js_1 = require("./sanitize-name.js");
55
58
  const swap_sidecar_js_1 = require("./swap-sidecar.js");
56
59
  const template_fallback_js_1 = require("./template-fallback.js");
57
60
  const verdict_js_1 = require("./verdict.js");
58
- // Cap concurrent reviewer subprocesses per chat. Templates with 4+
59
- // reviewer candidates would otherwise spawn the full set in parallel —
60
- // which in practice means simultaneous LLM-CLI subprocesses each holding
61
- // a shim child + stream parser, plus per-chat cwd. At load=133 last week
62
- // the root cause was unbounded fan-out across re-attached SSE sessions;
63
- // the per-chat ceiling stops a single big template from melting the host.
64
- const REVIEWER_CONCURRENCY = 3;
61
+ /**
62
+ * Local-CLI reviewer concurrency is enforced daemon-wide by
63
+ * `cli-semaphore.ts` global cap (`maxParallelCli`) + per-CLI cap
64
+ * (`perCli['opencode-cli']` etc.). Settings are user-tunable via
65
+ * /settings; defaults are the same numbers we used to hardcode here
66
+ * (3 global, opencode/gemini/kimi capped at 2 each). The semaphore is
67
+ * shared across chats, not per-chat — that's where the OOM risk lives.
68
+ *
69
+ * HTTP-dispatched shims (openrouter and friends) bypass the semaphore
70
+ * entirely — they're network calls and don't consume local resources.
71
+ */
72
+ /**
73
+ * Type guard for shim names that map to our capped CLI lineage keys.
74
+ * Anything that isn't one of these is treated as a non-cap'd lineage
75
+ * (defensive — covers future shim names we forgot to add to CLI_LINEAGES).
76
+ */
77
+ function isCappedLineage(shimName) {
78
+ return concurrency_js_1.CLI_LINEAGES.includes(shimName);
79
+ }
65
80
  async function runReviewers(chatDir, chatId, phase, phaseIdx, round, doerOutput, work, filesBlock, tmuxMgr, errorDetector, onEvent, abortSignal, templateFallbackReviewer) {
66
81
  if (!phase.reviewer || phase.reviewer.candidates.length === 0) {
67
82
  return { agreed: true, summary: '', allFailed: false };
@@ -78,13 +93,14 @@ async function runReviewers(chatDir, chatId, phase, phaseIdx, round, doerOutput,
78
93
  const required = phase.reviewer.require;
79
94
  // Split candidates by transport. HTTP-dispatched shims (openrouter,
80
95
  // future API-only shims) consume zero local CPU/RAM — they're just
81
- // network calls — so capping them at REVIEWER_CONCURRENCY=3 alongside
82
- // local CLI subprocesses is wrong: an openrouter reviewer would sit in
83
- // a "Queued waiting for an open slot" state behind 3 in-flight
84
- // codex/opencode subprocesses, even though firing it adds no resource
85
- // pressure beyond the upstream rate limit. The cap exists to prevent
86
- // the user's box melting under N parallel CLI shim children, not to
87
- // throttle HTTP fan-out.
96
+ // network calls — so they bypass the cli-semaphore entirely and run
97
+ // unbounded parallel. Per-shim rate limiting (e.g. OpenRouter's 429
98
+ // with Retry-After) is the upstream's job; chorus shouldn't double-
99
+ // throttle. Local CLI candidates go through the semaphore which
100
+ // enforces both the global cap and the per-CLI cap; the wait happens
101
+ // INSIDE runReviewer right before spawn so a reviewer that's queued
102
+ // still emits its phase_start / participant cards, just in a
103
+ // "waiting for slot" state.
88
104
  const localCandidateIdxs = [];
89
105
  const httpCandidateIdxs = [];
90
106
  for (let i = 0; i < candidates.length; i++) {
@@ -116,26 +132,15 @@ async function runReviewers(chatDir, chatId, phase, phaseIdx, round, doerOutput,
116
132
  });
117
133
  }
118
134
  }
119
- // Local CLI bucket: shared cursor + REVIEWER_CONCURRENCY worker pool.
120
- // Same diagnostic-transparency rule as before every reviewer runs to
121
- // completion, no cancel-on-consensus.
122
- let localCursor = 0;
123
- async function localWorker() {
124
- while (true) {
125
- if (abortSignal.aborted)
126
- return;
127
- const i = localCursor++;
128
- if (i >= localCandidateIdxs.length)
129
- return;
130
- await runOne(localCandidateIdxs[i]);
131
- }
132
- }
133
- const localWorkerCount = Math.min(REVIEWER_CONCURRENCY, localCandidateIdxs.length);
134
- // HTTP bucket: unbounded parallel — Promise.all over runOne directly.
135
- // Per-shim rate limiting (e.g. OpenRouter's 429 with Retry-After) is
136
- // the upstream's job; chorus shouldn't double-throttle.
135
+ // Both buckets fire in parallel the cli-semaphore inside
136
+ // runReviewer is what enforces the local-CLI caps, so we don't need
137
+ // a worker pool here. Reviewers continue to all run to completion
138
+ // (no cancel-on-consensus); that's the established behaviour from
139
+ // chorus-085 (see memory `feedback_let_all_reviewers_finish`),
140
+ // unchanged by this PR. We're only swapping the worker-pool
141
+ // implementation for a daemon-wide semaphore.
137
142
  await Promise.all([
138
- ...Array.from({ length: localWorkerCount }, () => localWorker()),
143
+ ...localCandidateIdxs.map((i) => runOne(i)),
139
144
  ...httpCandidateIdxs.map((i) => runOne(i)),
140
145
  ]);
141
146
  const agreedCount = reviews.filter((r) => r.outcome === 'agreed').length;
@@ -160,12 +165,13 @@ async function runReviewer(chatDir, chatId, phase, phaseIdx, round, reviewerIdx,
160
165
  const reviewerModel = candidate.models?.[0];
161
166
  const shim = (0, index_js_2.pickShimForVoice)(candidate.lineage, reviewerModel);
162
167
  const agentName = shim.name;
168
+ const isHttp = (0, index_js_2.isHttpDispatchedShim)(shim);
163
169
  // Pre-spawn precheck — same gate as runDoer. A reviewer that fails
164
170
  // precheck returns null, which the phase loop already handles by
165
171
  // counting it toward the all-reviewers-failed threshold and continuing
166
172
  // with the remaining reviewers. HTTP-dispatched shims (openrouter)
167
173
  // skip this — auth is the secrets table, checked inside the shim.
168
- if (!(0, index_js_2.isHttpDispatchedShim)(shim)) {
174
+ if (!isHttp) {
169
175
  const preRev = await (0, cli_precheck_js_1.precheckLineage)(candidate.lineage);
170
176
  if (!preRev.ok) {
171
177
  onEvent({
@@ -187,6 +193,34 @@ async function runReviewer(chatDir, chatId, phase, phaseIdx, round, reviewerIdx,
187
193
  return null;
188
194
  }
189
195
  }
196
+ // Acquire the daemon-wide CLI slot (global + per-lineage). Local CLI
197
+ // only — HTTP-dispatched shims aren't a memory pressure source and
198
+ // bypass the semaphore. The slot is held for the reviewer's entire
199
+ // lifetime, including any per-slot fallback chain — this is
200
+ // conservative when a fallback swaps to a different lineage (we keep
201
+ // the original slot rather than swap), but worst case is over-
202
+ // counting the original lineage's quota during the swap window. The
203
+ // global cap still holds.
204
+ //
205
+ // The abortSignal is passed so a chat cancelled while this reviewer
206
+ // is queued behind the cap doesn't leave a stale waiter blocking the
207
+ // semaphore head forever. On abort, acquire rejects → we return null
208
+ // (treated as a failed reviewer by the phase loop) without spawning.
209
+ //
210
+ // `releaseSlot` is null for HTTP shims and the precheck-failed early-
211
+ // return; the finally block below is robust to that.
212
+ let releaseSlot = null;
213
+ if (!isHttp && isCappedLineage(agentName)) {
214
+ try {
215
+ releaseSlot = await (0, cli_semaphore_js_1.acquire)(agentName, abortSignal);
216
+ }
217
+ catch {
218
+ // Aborted while waiting for slot — don't proceed. The phase loop
219
+ // counts this reviewer as failed which preserves "all-failed"
220
+ // semantics for the chat-level verdict.
221
+ return null;
222
+ }
223
+ }
190
224
  const roundDir = path_1.default.join(chatDir, `round-${round}`);
191
225
  const reviewerDir = path_1.default.join(roundDir, `reviewer-${agentName}-${reviewerIdx}`);
192
226
  if (!fs_1.default.existsSync(reviewerDir)) {
@@ -194,16 +228,41 @@ async function runReviewer(chatDir, chatId, phase, phaseIdx, round, reviewerIdx,
194
228
  }
195
229
  const askFile = path_1.default.join(reviewerDir, 'ask.md');
196
230
  const answerFile = path_1.default.join(reviewerDir, 'answer.md');
197
- // Resolve reviewer persona same fallback + warning pattern as runDoer.
198
- let reviewerPersonaPrompt;
199
- if (candidate.persona) {
200
- const personaId = candidate.persona;
201
- try {
202
- const row = await index_js_1.personas.getById(personaId);
203
- if (row) {
204
- reviewerPersonaPrompt = row.system_prompt;
231
+ // Outer try/finallyguarantees the cli-semaphore slot is returned
232
+ // on every path: headless's nested try/finally for participantAborts,
233
+ // tmux's nested try/finally for the poll interval, AND any thrown
234
+ // error in persona resolution or ask building. `releaseSlot` is null
235
+ // for HTTP shims (acquire was skipped) — the optional-call is the
236
+ // guard.
237
+ try {
238
+ // Resolve reviewer persona — same fallback + warning pattern as runDoer.
239
+ let reviewerPersonaPrompt;
240
+ if (candidate.persona) {
241
+ const personaId = candidate.persona;
242
+ try {
243
+ const row = await index_js_1.personas.getById(personaId);
244
+ if (row) {
245
+ reviewerPersonaPrompt = row.system_prompt;
246
+ }
247
+ else {
248
+ onEvent({
249
+ chatId,
250
+ type: 'cli_warning',
251
+ payload: {
252
+ phaseId: phase.id,
253
+ phaseIdx,
254
+ round,
255
+ role: 'reviewer',
256
+ agent: `${agentName}-${reviewerIdx}`,
257
+ kind: 'persona_missing',
258
+ message: `Reviewer persona "${personaId}" not found in personas table — running with generic prompt. Check the template's reviewer candidate persona field.`,
259
+ },
260
+ ts: Date.now(),
261
+ });
262
+ }
205
263
  }
206
- else {
264
+ catch (err) {
265
+ const message = err instanceof Error ? err.message : String(err);
207
266
  onEvent({
208
267
  chatId,
209
268
  type: 'cli_warning',
@@ -213,184 +272,119 @@ async function runReviewer(chatDir, chatId, phase, phaseIdx, round, reviewerIdx,
213
272
  round,
214
273
  role: 'reviewer',
215
274
  agent: `${agentName}-${reviewerIdx}`,
216
- kind: 'persona_missing',
217
- message: `Reviewer persona "${personaId}" not found in personas table — running with generic prompt. Check the template's reviewer candidate persona field.`,
275
+ kind: 'persona_lookup_failed',
276
+ message: `Reviewer persona lookup for "${personaId}" failed: ${message} — running with generic prompt.`,
218
277
  },
219
278
  ts: Date.now(),
220
279
  });
221
280
  }
222
281
  }
223
- catch (err) {
224
- const message = err instanceof Error ? err.message : String(err);
225
- onEvent({
226
- chatId,
227
- type: 'cli_warning',
228
- payload: {
229
- phaseId: phase.id,
230
- phaseIdx,
231
- round,
232
- role: 'reviewer',
233
- agent: `${agentName}-${reviewerIdx}`,
234
- kind: 'persona_lookup_failed',
235
- message: `Reviewer persona lookup for "${personaId}" failed: ${message} running with generic prompt.`,
236
- },
237
- ts: Date.now(),
238
- });
239
- }
240
- }
241
- const ask = (0, prompt_builder_js_1.buildReviewerAsk)(phase, phaseIdx, round, work, doerOutput, filesBlock, reviewerPersonaPrompt);
242
- fs_1.default.writeFileSync(askFile, ask);
243
- // Per-slot model fallback: when candidate.models lists multiple models
244
- // we try them in order, falling through on `null` (no answer produced).
245
- // The boolean verdict `false` (disagreement) is a real result and stops
246
- // the chain runWithModelFallback only re-tries on literal null.
247
- const transport = await (0, transport_js_1.getTransport)();
248
- if (transport === 'headless' && shim.runHeadless) {
249
- const handle = participantAborts.register(chatId, participantAborts.participantKey('reviewer', agentName, reviewerIdx), abortSignal);
250
- try {
251
- // Compose: this slot's per-slot chain + template-level
252
- // fallback.reviewer (same lineage, dedup'd against this slot AND
253
- // every other reviewer slot in the phase so we don't spawn a
254
- // duplicate voice).
255
- const allReviewerSlots = (phase.reviewer?.candidates ?? []).map((c) => ({
256
- lineage: c.lineage,
257
- models: c.models ?? [],
258
- }));
259
- const thisSlot = {
260
- lineage: candidate.lineage,
261
- models: candidate.models ?? [],
262
- };
263
- const chain = (0, template_fallback_js_1.buildSlotFallbackChain)(thisSlot, allReviewerSlots, templateFallbackReviewer);
264
- return await (0, run_with_fallback_js_1.runWithChainFallback)(chain, async (entry) => {
265
- // Cross-lineage swap: when the entry's lineage differs from the
266
- // slot's primary, re-resolve the shim. The slot's identity
267
- // (agentName, reviewerDir, participant key) stays bound to the
268
- // primary lineage so the cockpit card doesn't re-key mid-run —
269
- // the cli_warning below tells the UI a swap happened.
270
- const entryShim = entry.lineage === candidate.lineage
271
- ? shim
272
- : (0, index_js_2.pickShimForVoice)(entry.lineage, entry.model);
273
- return (0, reviewer_js_1.runReviewerHeadless)({
274
- shim: entryShim,
275
- chatId,
276
- phase,
277
- round,
278
- reviewerIdx,
279
- candidateLineage: entry.lineage,
280
- candidateModel: entry.model,
281
- agentName,
282
- askContent: ask,
283
- answerFile,
284
- reviewerDir,
285
- abortSignal: handle.signal,
286
- onEvent,
287
- });
288
- }, (from, to, fromIdx) => {
289
- const sameLineage = from.lineage === to.lineage;
290
- const reason = sameLineage ? 'model_fallback' : 'lineage_fallback';
291
- const message = sameLineage
292
- ? `Reviewer model "${from.model ?? '(default)'}" produced no answer; retrying with "${to.model ?? '(default)'}".`
293
- : `Reviewer ${from.lineage}/${from.model ?? '(default)'} failed; switching to ${to.lineage}/${to.model ?? '(default)'} (cross-lineage fallback).`;
294
- // Structured daemon-log line. Pairs with the [reviewer] attempt-
295
- // failed line that was just emitted by reviewer.ts: tail the log
296
- // and you see "attempt failed" → "fallback fired" → next
297
- // "attempt failed" or success in order, per slot.
298
- console.warn(`[reviewer] fallback fired chat=${chatId} round=${round} ` +
299
- `slot=${agentName}-${reviewerIdx} reason=${reason} ` +
300
- `from=${from.lineage}/${from.model ?? '(default)'} ` +
301
- `to=${to.lineage}/${to.model ?? '(default)'} ` +
302
- `chain_idx=${fromIdx}`);
303
- onEvent({
304
- chatId,
305
- type: 'cli_warning',
306
- payload: {
307
- phaseId: phase.id,
308
- round,
309
- role: 'reviewer',
310
- agent: `${agentName}-${reviewerIdx}`,
311
- reason,
312
- fromLineage: from.lineage,
313
- toLineage: to.lineage,
314
- fromModel: from.model ?? '(default)',
315
- toModel: to.model ?? '(default)',
316
- fallbackIdx: fromIdx,
317
- message,
318
- },
319
- ts: Date.now(),
320
- });
321
- // Persist a sidecar so swap cards survive page reloads the
322
- // SSE stream shuts off for terminal chats, and phase_events
323
- // packs warnings as opaque text. Mirrors the _stats.json /
324
- // _meta.json pattern: append-only JSON array, read by the
325
- // run-artifacts route at the next refresh tick.
326
- (0, swap_sidecar_js_1.appendSwapSidecar)(reviewerDir, {
327
- round,
328
- phaseId: phase.id,
329
- role: 'reviewer',
330
- agent: `${agentName}-${reviewerIdx}`,
331
- reason,
332
- fromLineage: from.lineage,
333
- toLineage: to.lineage,
334
- fromModel: from.model ?? '(default)',
335
- toModel: to.model ?? '(default)',
336
- fallbackIdx: fromIdx,
337
- ts: Date.now(),
338
- });
339
- });
340
- }
341
- finally {
342
- handle.release();
343
- }
344
- }
345
- // Reviewers don't share sessions across rounds — each round wants a
346
- // fresh perspective on the new doer output. Across-phase reuse never
347
- // makes sense.
348
- const perms = await (0, permissions_js_1.getPermissions)();
349
- const sessionName = (0, sanitize_name_js_1.sanitizeName)(`chorus-${chatId}-${phase.id}-reviewer-${agentName}-${reviewerIdx}`);
350
- const session = await tmuxMgr.acquire({
351
- chatId,
352
- phaseId: phase.id,
353
- role: 'reviewer',
354
- round,
355
- shareSessionAcrossRounds: false,
356
- shareSessionAcrossPhases: false,
357
- shim,
358
- spawnOpts: {
359
- sessionName,
360
- cwd: reviewerDir,
361
- model: candidate.models?.[0],
362
- sandbox: perms.sandboxProfile,
363
- autoApprove: perms.autoApprovePrompts,
364
- networkAccess: perms.networkAccess,
365
- },
366
- agentName: `${agentName}-${reviewerIdx}`,
367
- });
368
- if (shim.clearKeys && shim.clearKeys.length > 0) {
369
- tmuxMgr.sendKeys(session.name, [...shim.clearKeys]);
370
- }
371
- if (shim.preNudge)
372
- shim.preNudge(session.name);
373
- const prompt = shim.formatPrompt({
374
- promptFile: askFile,
375
- answerFile,
376
- task: `Review: ${phase.title}`,
377
- expectDoneSentinel: true,
378
- });
379
- // Wait for the CLI's TUI to finish cold-start before pasting (6s
380
- // covers Codex's slow cold-start). See doer-driver for rationale.
381
- await new Promise((r) => setTimeout(r, 6000));
382
- tmuxMgr.pasteBuffer(session.name, prompt);
383
- await new Promise((r) => setTimeout(r, 500));
384
- tmuxMgr.sendKeys(session.name, ['Enter']);
385
- // Failure-mode polling — same pattern as the doer.
386
- const pollHandle = setInterval(() => {
387
- try {
388
- const pane = tmuxMgr.capturePane(session.name);
389
- const err = errorDetector.inspect(session.name, candidate.lineage, pane);
390
- if (err) {
391
- const recoveryKeys = err.kind === 'permission_prompt' ? shim.recoverKeys?.permission_prompt : undefined;
392
- if (recoveryKeys && recoveryKeys.length > 0) {
393
- tmuxMgr.sendKeys(session.name, [...recoveryKeys]);
282
+ const ask = (0, prompt_builder_js_1.buildReviewerAsk)(phase, phaseIdx, round, work, doerOutput, filesBlock, reviewerPersonaPrompt);
283
+ fs_1.default.writeFileSync(askFile, ask);
284
+ // Per-slot model fallback: when candidate.models lists multiple models
285
+ // we try them in order, falling through on `null` (no answer produced).
286
+ // The boolean verdict `false` (disagreement) is a real result and stops
287
+ // the chain — runWithModelFallback only re-tries on literal null.
288
+ const transport = await (0, transport_js_1.getTransport)();
289
+ if (transport === 'headless' && shim.runHeadless) {
290
+ const handle = participantAborts.register(chatId, participantAborts.participantKey('reviewer', agentName, reviewerIdx), abortSignal);
291
+ try {
292
+ // Compose: this slot's per-slot chain + template-level
293
+ // fallback.reviewer (same lineage, dedup'd against this slot AND
294
+ // every other reviewer slot in the phase so we don't spawn a
295
+ // duplicate voice).
296
+ const allReviewerSlots = (phase.reviewer?.candidates ?? []).map((c) => ({
297
+ lineage: c.lineage,
298
+ models: c.models ?? [],
299
+ }));
300
+ const thisSlot = {
301
+ lineage: candidate.lineage,
302
+ models: candidate.models ?? [],
303
+ };
304
+ const chain = (0, template_fallback_js_1.buildSlotFallbackChain)(thisSlot, allReviewerSlots, templateFallbackReviewer);
305
+ return await (0, run_with_fallback_js_1.runWithChainFallback)(chain, async (entry) => {
306
+ // Cross-slot collision check: another reviewer in this same
307
+ // chat/round may already be running this exact (lineage,
308
+ // model). Common cause is two slots sharing the template-
309
+ // level fallback (e.g. anthropic/claude-sonnet-4-6 at the
310
+ // tail of every slot's chain). Without this, both slots
311
+ // dispatch the same model in parallel wasted cost AND the
312
+ // lineage diversity that's the whole point of multi-LLM
313
+ // peer review collapses. On collision, we return null so
314
+ // runWithChainFallback advances to the next chain entry;
315
+ // emits a cli_warning tagged `fallback_collision` so the
316
+ // cockpit can show why the slot skipped.
317
+ const claimed = (0, fallback_registry_js_1.tryClaim)(chatId, round, entry.lineage, entry.model);
318
+ if (!claimed) {
319
+ console.warn(`[reviewer] fallback collision chat=${chatId} round=${round} ` +
320
+ `slot=${agentName}-${reviewerIdx} ` +
321
+ `target=${entry.lineage}/${entry.model ?? '(default)'} ` +
322
+ `— another slot is already running it; advancing chain`);
323
+ onEvent({
324
+ chatId,
325
+ type: 'cli_warning',
326
+ payload: {
327
+ phaseId: phase.id,
328
+ round,
329
+ role: 'reviewer',
330
+ agent: `${agentName}-${reviewerIdx}`,
331
+ reason: 'fallback_collision',
332
+ fromLineage: entry.lineage,
333
+ toLineage: entry.lineage,
334
+ fromModel: entry.model ?? '(default)',
335
+ toModel: entry.model ?? '(default)',
336
+ message: `Skipping ${entry.lineage}/${entry.model ?? '(default)'} — another reviewer slot is already running it. Advancing to next fallback to preserve lineage diversity.`,
337
+ },
338
+ ts: Date.now(),
339
+ });
340
+ return null;
341
+ }
342
+ try {
343
+ // Cross-lineage swap: when the entry's lineage differs from the
344
+ // slot's primary, re-resolve the shim. The slot's identity
345
+ // (agentName, reviewerDir, participant key) stays bound to the
346
+ // primary lineage so the cockpit card doesn't re-key mid-run —
347
+ // the cli_warning below tells the UI a swap happened.
348
+ const entryShim = entry.lineage === candidate.lineage
349
+ ? shim
350
+ : (0, index_js_2.pickShimForVoice)(entry.lineage, entry.model);
351
+ return await (0, reviewer_js_1.runReviewerHeadless)({
352
+ shim: entryShim,
353
+ chatId,
354
+ phase,
355
+ round,
356
+ reviewerIdx,
357
+ candidateLineage: entry.lineage,
358
+ candidateModel: entry.model,
359
+ agentName,
360
+ askContent: ask,
361
+ answerFile,
362
+ reviewerDir,
363
+ abortSignal: handle.signal,
364
+ onEvent,
365
+ });
366
+ }
367
+ finally {
368
+ // Release whether the attempt succeeded, returned null, or
369
+ // threw — the slot is no longer running this target, so
370
+ // another slot's chain advance can claim it next.
371
+ (0, fallback_registry_js_1.release)(chatId, round, entry.lineage, entry.model);
372
+ }
373
+ }, (from, to, fromIdx) => {
374
+ const sameLineage = from.lineage === to.lineage;
375
+ const reason = sameLineage ? 'model_fallback' : 'lineage_fallback';
376
+ const message = sameLineage
377
+ ? `Reviewer model "${from.model ?? '(default)'}" produced no answer; retrying with "${to.model ?? '(default)'}".`
378
+ : `Reviewer ${from.lineage}/${from.model ?? '(default)'} failed; switching to ${to.lineage}/${to.model ?? '(default)'} (cross-lineage fallback).`;
379
+ // Structured daemon-log line. Pairs with the [reviewer] attempt-
380
+ // failed line that was just emitted by reviewer.ts: tail the log
381
+ // and you see "attempt failed" "fallback fired" → next
382
+ // "attempt failed" or success in order, per slot.
383
+ console.warn(`[reviewer] fallback fired chat=${chatId} round=${round} ` +
384
+ `slot=${agentName}-${reviewerIdx} reason=${reason} ` +
385
+ `from=${from.lineage}/${from.model ?? '(default)'} ` +
386
+ `to=${to.lineage}/${to.model ?? '(default)'} ` +
387
+ `chain_idx=${fromIdx}`);
394
388
  onEvent({
395
389
  chatId,
396
390
  type: 'cli_warning',
@@ -399,59 +393,154 @@ async function runReviewer(chatDir, chatId, phase, phaseIdx, round, reviewerIdx,
399
393
  round,
400
394
  role: 'reviewer',
401
395
  agent: `${agentName}-${reviewerIdx}`,
402
- recovered: err.kind,
403
- keys: [...recoveryKeys],
404
- detail: err.detail,
396
+ reason,
397
+ fromLineage: from.lineage,
398
+ toLineage: to.lineage,
399
+ fromModel: from.model ?? '(default)',
400
+ toModel: to.model ?? '(default)',
401
+ fallbackIdx: fromIdx,
402
+ message,
405
403
  },
406
404
  ts: Date.now(),
407
405
  });
408
- }
409
- else {
410
- // Fire-and-forget see doer-driver for rationale.
411
- (0, cli_health_js_1.recordHealth)({
412
- lineage: candidate.lineage,
413
- status: (0, cli_health_js_1.kindToStatus)(err.kind),
414
- message: err.message,
415
- resetAt: err.resetAt,
416
- }).catch((healthErr) => {
417
- console.error(`[chorus] recordHealth failed for ${candidate.lineage}:`, healthErr);
418
- });
419
- onEvent({
420
- chatId,
421
- type: 'cli_error',
422
- payload: {
423
- phaseId: phase.id,
424
- round,
425
- role: 'reviewer',
426
- agent: `${agentName}-${reviewerIdx}`,
427
- error: err,
428
- },
406
+ // Persist a sidecar so swap cards survive page reloads — the
407
+ // SSE stream shuts off for terminal chats, and phase_events
408
+ // packs warnings as opaque text. Mirrors the _stats.json /
409
+ // _meta.json pattern: append-only JSON array, read by the
410
+ // run-artifacts route at the next refresh tick.
411
+ (0, swap_sidecar_js_1.appendSwapSidecar)(reviewerDir, {
412
+ round,
413
+ phaseId: phase.id,
414
+ role: 'reviewer',
415
+ agent: `${agentName}-${reviewerIdx}`,
416
+ reason,
417
+ fromLineage: from.lineage,
418
+ toLineage: to.lineage,
419
+ fromModel: from.model ?? '(default)',
420
+ toModel: to.model ?? '(default)',
421
+ fallbackIdx: fromIdx,
429
422
  ts: Date.now(),
430
423
  });
431
- }
424
+ });
425
+ }
426
+ finally {
427
+ handle.release();
432
428
  }
433
429
  }
434
- catch {
435
- // ignore
430
+ // Reviewers don't share sessions across rounds — each round wants a
431
+ // fresh perspective on the new doer output. Across-phase reuse never
432
+ // makes sense.
433
+ const perms = await (0, permissions_js_1.getPermissions)();
434
+ const sessionName = (0, sanitize_name_js_1.sanitizeName)(`chorus-${chatId}-${phase.id}-reviewer-${agentName}-${reviewerIdx}`);
435
+ const session = await tmuxMgr.acquire({
436
+ chatId,
437
+ phaseId: phase.id,
438
+ role: 'reviewer',
439
+ round,
440
+ shareSessionAcrossRounds: false,
441
+ shareSessionAcrossPhases: false,
442
+ shim,
443
+ spawnOpts: {
444
+ sessionName,
445
+ cwd: reviewerDir,
446
+ model: candidate.models?.[0],
447
+ sandbox: perms.sandboxProfile,
448
+ autoApprove: perms.autoApprovePrompts,
449
+ networkAccess: perms.networkAccess,
450
+ },
451
+ agentName: `${agentName}-${reviewerIdx}`,
452
+ });
453
+ if (shim.clearKeys && shim.clearKeys.length > 0) {
454
+ tmuxMgr.sendKeys(session.name, [...shim.clearKeys]);
436
455
  }
437
- }, 2000);
438
- try {
439
- const result = await (0, output_watcher_js_1.waitForAnswer)(answerFile, {
440
- timeoutMs: phase.timeoutMs ?? template_schema_js_1.DEFAULT_TMUX_PHASE_TIMEOUT_MS,
441
- doneSentinel: '## DONE',
456
+ if (shim.preNudge)
457
+ shim.preNudge(session.name);
458
+ const prompt = shim.formatPrompt({
459
+ promptFile: askFile,
460
+ answerFile,
461
+ task: `Review: ${phase.title}`,
462
+ expectDoneSentinel: true,
442
463
  });
443
- if (!result.full || result.content.trim().length === 0) {
444
- // Watcher resolved on timeout/silence with no real answer.
464
+ // Wait for the CLI's TUI to finish cold-start before pasting (6s
465
+ // covers Codex's slow cold-start). See doer-driver for rationale.
466
+ await new Promise((r) => setTimeout(r, 6000));
467
+ tmuxMgr.pasteBuffer(session.name, prompt);
468
+ await new Promise((r) => setTimeout(r, 500));
469
+ tmuxMgr.sendKeys(session.name, ['Enter']);
470
+ // Failure-mode polling — same pattern as the doer.
471
+ const pollHandle = setInterval(() => {
472
+ try {
473
+ const pane = tmuxMgr.capturePane(session.name);
474
+ const err = errorDetector.inspect(session.name, candidate.lineage, pane);
475
+ if (err) {
476
+ const recoveryKeys = err.kind === 'permission_prompt' ? shim.recoverKeys?.permission_prompt : undefined;
477
+ if (recoveryKeys && recoveryKeys.length > 0) {
478
+ tmuxMgr.sendKeys(session.name, [...recoveryKeys]);
479
+ onEvent({
480
+ chatId,
481
+ type: 'cli_warning',
482
+ payload: {
483
+ phaseId: phase.id,
484
+ round,
485
+ role: 'reviewer',
486
+ agent: `${agentName}-${reviewerIdx}`,
487
+ recovered: err.kind,
488
+ keys: [...recoveryKeys],
489
+ detail: err.detail,
490
+ },
491
+ ts: Date.now(),
492
+ });
493
+ }
494
+ else {
495
+ // Fire-and-forget — see doer-driver for rationale.
496
+ (0, cli_health_js_1.recordHealth)({
497
+ lineage: candidate.lineage,
498
+ status: (0, cli_health_js_1.kindToStatus)(err.kind),
499
+ message: err.message,
500
+ resetAt: err.resetAt,
501
+ }).catch((healthErr) => {
502
+ console.error(`[chorus] recordHealth failed for ${candidate.lineage}:`, healthErr);
503
+ });
504
+ onEvent({
505
+ chatId,
506
+ type: 'cli_error',
507
+ payload: {
508
+ phaseId: phase.id,
509
+ round,
510
+ role: 'reviewer',
511
+ agent: `${agentName}-${reviewerIdx}`,
512
+ error: err,
513
+ },
514
+ ts: Date.now(),
515
+ });
516
+ }
517
+ }
518
+ }
519
+ catch {
520
+ // ignore
521
+ }
522
+ }, 2000);
523
+ try {
524
+ const result = await (0, output_watcher_js_1.waitForAnswer)(answerFile, {
525
+ timeoutMs: phase.timeoutMs ?? template_schema_js_1.DEFAULT_TMUX_PHASE_TIMEOUT_MS,
526
+ doneSentinel: '## DONE',
527
+ });
528
+ if (!result.full || result.content.trim().length === 0) {
529
+ // Watcher resolved on timeout/silence with no real answer.
530
+ return null;
531
+ }
532
+ return (0, verdict_js_1.verdictFromReviewerText)(result.content);
533
+ }
534
+ catch {
535
+ // Timed out or watcher errored — no valid answer produced.
445
536
  return null;
446
537
  }
447
- return (0, verdict_js_1.verdictFromReviewerText)(result.content);
448
- }
449
- catch {
450
- // Timed out or watcher errored — no valid answer produced.
451
- return null;
538
+ finally {
539
+ clearInterval(pollHandle);
540
+ }
452
541
  }
453
542
  finally {
454
- clearInterval(pollHandle);
543
+ releaseSlot?.();
455
544
  }
456
545
  }
457
546
  //# sourceMappingURL=reviewer-driver.js.map