chorus-codes 0.8.25 → 0.8.26

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