amicus 4.3.0 → 4.4.1

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 (94) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +64 -0
  3. package/README.md +6 -3
  4. package/docs/DISTRIBUTION.md +234 -0
  5. package/docs/ROADMAP.md +200 -0
  6. package/docs/SHIMS.md +62 -0
  7. package/docs/architecture.md +104 -0
  8. package/docs/configuration.md +371 -0
  9. package/docs/council.md +911 -0
  10. package/docs/doc-system.md +92 -0
  11. package/docs/electron-testing.md +471 -0
  12. package/docs/jsdoc-setup.md +75 -0
  13. package/docs/opencode-integration.md +114 -0
  14. package/docs/publishing.md +60 -0
  15. package/docs/schemas.md +55 -0
  16. package/docs/testing.md +589 -0
  17. package/docs/troubleshooting.md +298 -0
  18. package/docs/usage.md +699 -0
  19. package/electron/fold.js +1 -1
  20. package/electron/ipc-workspace.js +283 -0
  21. package/electron/main.js +31 -1
  22. package/electron/preload-workspace.js +40 -0
  23. package/electron/setup-ui-aliases.js +6 -6
  24. package/electron/workspace-shell.js +85 -0
  25. package/electron/workspace-ui/index.html +111 -0
  26. package/electron/workspace-ui/live-model.js +112 -0
  27. package/electron/workspace-ui/md-lite.js +163 -0
  28. package/electron/workspace-ui/workspace-app.js +240 -0
  29. package/electron/workspace-ui/workspace-matrix.js +249 -0
  30. package/electron/workspace-ui/workspace-panels.js +237 -0
  31. package/electron/workspace-ui/workspace-render.js +277 -0
  32. package/electron/workspace-ui/workspace-verbs.js +293 -0
  33. package/electron/workspace-ui/workspace.css +172 -0
  34. package/package.json +8 -3
  35. package/schemas/council-run-live.schema.json +25 -1
  36. package/schemas/council-run.schema.json +34 -0
  37. package/schemas/progress.schema.json +26 -1
  38. package/schemas/spend.schema.json +52 -4
  39. package/skills/second-opinion/MODEL-NOTES.md +53 -5
  40. package/src/cli-handlers-council-run.js +25 -3
  41. package/src/cli-handlers-spend.js +50 -5
  42. package/src/cli-handlers-watch.js +48 -10
  43. package/src/cli.js +4 -2
  44. package/src/council/briefings-debate.js +27 -7
  45. package/src/council/briefings-stage2.js +155 -25
  46. package/src/council/briefings.js +59 -3
  47. package/src/council/findings.js +236 -9
  48. package/src/council/parse-stage2.js +10 -2
  49. package/src/council/report.js +19 -8
  50. package/src/council/run-assemble.js +42 -1
  51. package/src/council/run-budget.js +277 -0
  52. package/src/council/run-chair.js +4 -1
  53. package/src/council/run-debate.js +4 -2
  54. package/src/council/run-finalize.js +102 -0
  55. package/src/council/run-launch.js +73 -7
  56. package/src/council/run-server.js +248 -0
  57. package/src/council/run-stage2.js +118 -0
  58. package/src/council/run-stages.js +148 -113
  59. package/src/council/run-state.js +23 -1
  60. package/src/council/run.js +52 -53
  61. package/src/council/tally.js +10 -0
  62. package/src/headless.js +519 -17
  63. package/src/mcp-council-awareness.js +53 -3
  64. package/src/observe/council-legs.js +240 -0
  65. package/src/observe/live-doc.js +39 -4
  66. package/src/observe/watch-render.js +23 -1
  67. package/src/opencode-client.js +15 -3
  68. package/src/sidecar/child-sessions.js +197 -0
  69. package/src/sidecar/conversation-mirror.js +111 -37
  70. package/src/sidecar/fanout-budget.js +71 -0
  71. package/src/sidecar/fanout-leg-fallback.js +69 -21
  72. package/src/sidecar/fanout-leg.js +29 -1
  73. package/src/sidecar/fanout-signals.js +61 -0
  74. package/src/sidecar/fanout-wave-io.js +75 -0
  75. package/src/sidecar/fanout.js +65 -81
  76. package/src/sidecar/progress-fields.js +26 -4
  77. package/src/sidecar/progress.js +8 -1
  78. package/src/sidecar/session-utils.js +23 -14
  79. package/src/sidecar/tool-part.js +196 -0
  80. package/src/sidecar/workspace-window.js +62 -0
  81. package/src/spend-query.js +33 -6
  82. package/src/utils/env-num.js +42 -0
  83. package/src/utils/lifecycle.js +37 -1
  84. package/src/utils/path-fence.js +120 -0
  85. package/src/utils/pricing.js +114 -9
  86. package/src/utils/server-setup.js +79 -1
  87. package/src/utils/spend-ledger.js +24 -3
  88. package/src/workspace/artifact-guard.js +208 -0
  89. package/src/workspace/blind-mode.js +32 -0
  90. package/src/workspace/fold-format.js +124 -0
  91. package/src/workspace/live-normalize.js +169 -0
  92. package/src/workspace/matrix-model.js +94 -0
  93. package/src/workspace/run-detail.js +229 -0
  94. package/src/workspace/run-scan.js +148 -0
@@ -0,0 +1,61 @@
1
+ // src/sidecar/fanout-signals.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module fanout-signals
6
+ * A fan-out wave's signal-abort handler, extracted from fanout.js for the
7
+ * 300-line size gate (v4.4.1 fix wave, finding F3 needed room in fanout.js).
8
+ * Pure move plus the force-exit reaper described below — same markers, same
9
+ * ordering, same watchdog window.
10
+ *
11
+ * The contract fanout.js relies on: mark the wave and every leg aborted, then
12
+ * let NORMAL control flow finalize. Legs see their abort marker within one poll
13
+ * (~2s) and settle, so the caller still writes wave.json and emits a parseable
14
+ * aborted document. The force-exit watchdog is only a backstop for a wedged leg.
15
+ */
16
+
17
+ const { logger } = require('../utils/logger');
18
+ const { installSignalAbort, markAborted } = require('../utils/session-abort');
19
+ const { armExitWatchdog, exitReaping } = require('../utils/lifecycle');
20
+
21
+ /** Force-exit backstop window; comfortably outlives close()'s ~2s escalation. */
22
+ const WAVE_FORCE_EXIT_MS = 10000;
23
+
24
+ /**
25
+ * @param {{waveId: string, waveDir: string, legDirs: string[], server: object,
26
+ * externalServer: boolean}} args
27
+ * @returns {{uninstall: Function, signal: () => (string|null)}} `signal()` is a
28
+ * GETTER — the handler mutates it between awaits, so a snapshot would miss it.
29
+ */
30
+ function installWaveAbort({ waveId, waveDir, legDirs, server, externalServer }) {
31
+ let signalled = null;
32
+ const uninstall = installSignalAbort({
33
+ onAbort: (signal) => {
34
+ const code = signal === 'SIGINT' ? 130 : 143;
35
+ if (signalled) { process.exit(code); } // second signal: exit NOW
36
+ signalled = signal;
37
+ logger.warn('Signal received — aborting wave', { waveId, signal });
38
+ markAborted(waveDir, signal);
39
+ for (const dir of legDirs) { markAborted(dir, signal); }
40
+ // close() is async (B06 escalation); this handler stays sync, so
41
+ // fire-and-forget with a rejection guard.
42
+ // ⚠️ close site 1 of 2 — NEVER an injected server: it belongs to the
43
+ // council run, whose own signal handler tears it down in finalize().
44
+ // Closing it here would kill every sibling and later wave in the run.
45
+ if (!externalServer) { try { server.close().catch(() => {}); } catch { /* best-effort */ } }
46
+ // ⚠️ …but a FORCE exit must not orphan it either (F3). We no longer close
47
+ // an injected server above, so if this watchdog fires before the owner's
48
+ // finalize() runs, the OpenCode process outlives the parent — still
49
+ // holding the SQLite lock this whole task exists to stop contending on.
50
+ // exitReaping SIGTERMs its Go pid on the way out. An OWNED server needs
51
+ // no hook: close() above already signalled it.
52
+ armExitWatchdog(code, WAVE_FORCE_EXIT_MS, {
53
+ log: (m, meta) => logger.debug(m, meta),
54
+ ...(externalServer ? { exit: exitReaping(server) } : {}),
55
+ });
56
+ },
57
+ });
58
+ return { uninstall, signal: () => signalled };
59
+ }
60
+
61
+ module.exports = { installWaveAbort, WAVE_FORCE_EXIT_MS };
@@ -0,0 +1,75 @@
1
+ // src/sidecar/fanout-wave-io.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module fanout-wave-io
6
+ * On-disk lifecycle of a WAVE document, split out of fanout.js to keep that
7
+ * file under the 300-line gate (v4.4.1 Task 0.5, which needed room for the
8
+ * external-server seam). Pure move — same writes, same order, same atomicity:
9
+ *
10
+ * writeWaveMetadata metadata.json read-merge-write, abort-wins
11
+ * writeWaveDoc wave.json, atomic (tmp + rename)
12
+ * finishWave the terminal path every completed wave funnels through:
13
+ * persist → checkpoint metadata → emit wave-terminal →
14
+ * fire --on-complete → print
15
+ *
16
+ * `finishWave` had two byte-identical copies in fanout.js (the all-legs-failed-
17
+ * to-route short circuit and the normal aggregation); they are one function here
18
+ * so a future change to the terminal contract cannot land on only one of them.
19
+ */
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const { writeFileAtomic } = require('../utils/atomic-write');
24
+
25
+ /**
26
+ * Write/merge wave metadata (preserves fields an MCP pre-spawn handler wrote).
27
+ * Abort-wins: once existing status is 'aborted', a patch cannot demote it back
28
+ * to a softer status (same precedence rule as writeLegPatch — a signal/abort
29
+ * marker must never lose a write race against an in-flight init/finalize).
30
+ */
31
+ function writeWaveMetadata(waveDir, patch) {
32
+ const metaPath = path.join(waveDir, 'metadata.json');
33
+ let existing = {};
34
+ if (fs.existsSync(metaPath)) {
35
+ try { existing = JSON.parse(fs.readFileSync(metaPath, 'utf-8')); } catch { /* corrupt → rewrite */ }
36
+ }
37
+ const safePatch = { ...patch };
38
+ if (existing.status === 'aborted' && safePatch.status && safePatch.status !== 'aborted') {
39
+ delete safePatch.status;
40
+ }
41
+ const merged = { ...existing, ...safePatch };
42
+ writeFileAtomic(metaPath, JSON.stringify(merged, null, 2), { mode: 0o600 });
43
+ return merged;
44
+ }
45
+
46
+ /**
47
+ * Persist a wave document atomically (tmp + rename).
48
+ * @returns {string} the wave.json path
49
+ */
50
+ function writeWaveDoc(waveDir, wave) {
51
+ const wavePath = path.join(waveDir, 'wave.json');
52
+ writeFileAtomic(wavePath, JSON.stringify(wave, null, 2), { mode: 0o600 });
53
+ return wavePath;
54
+ }
55
+
56
+ /**
57
+ * Terminal path for a wave that produced leg documents: persist wave.json,
58
+ * checkpoint metadata.json, emit wave-terminal, fire --on-complete, print.
59
+ * @param {{wave: object, waveDir: string, waveId: string, project: string,
60
+ * exitCode: number, completedAt: string, follow: object|null, emit: Function,
61
+ * onComplete?: string, onCompleteDeps?: object}} args
62
+ * @returns {Promise<{wave: object, exitCode: number}>}
63
+ */
64
+ async function finishWave({ wave, waveDir, waveId, project, exitCode, completedAt, follow, emit, onComplete, onCompleteDeps }) {
65
+ const { emitWaveTerminal } = require('../observe/events');
66
+ const wavePath = writeWaveDoc(waveDir, wave);
67
+ writeWaveMetadata(waveDir, { status: wave.status, completedAt });
68
+ emitWaveTerminal(waveDir, waveId, { status: wave.status, counts: wave.counts, usage: wave.usage, exitCode }, follow);
69
+ await require('../observe/on-complete').fireWaveOnComplete(onComplete, wave,
70
+ { waveId, waveDir, wavePath, exitCode, project }, onCompleteDeps);
71
+ emit(wave);
72
+ return { wave, exitCode };
73
+ }
74
+
75
+ module.exports = { writeWaveMetadata, writeWaveDoc, finishWave };
@@ -16,7 +16,10 @@ const { logger } = require('../utils/logger');
16
16
  const { runLeg, buildRoutingFailureLeg } = require('./fanout-leg');
17
17
  const { parseModelsList, DEFAULT_MAX_LEGS, validateFanoutModels } = require('./fanout-validate');
18
18
  const { ERROR_CODES } = require('../utils/error-doc');
19
- const { writeFileAtomic } = require('../utils/atomic-write');
19
+ // Wave-document persistence lives in ./fanout-wave-io (size-gate split, v4.4.1
20
+ // Task 0.5). writeWaveMetadata is re-exported below — fanout-retry.js and the
21
+ // fanout tests import it from here.
22
+ const { writeWaveMetadata, writeWaveDoc, finishWave } = require('./fanout-wave-io');
20
23
 
21
24
  /**
22
25
  * Derive leg task IDs: <waveId>-1 .. <waveId>-N (matches TASK_ID_PATTERN).
@@ -28,27 +31,6 @@ function deriveLegIds(waveId, count) {
28
31
  return Array.from({ length: count }, (_, i) => `${waveId}-${i + 1}`);
29
32
  }
30
33
 
31
- /**
32
- * Write/merge wave metadata (preserves fields an MCP pre-spawn handler wrote).
33
- * Abort-wins: once existing status is 'aborted', a patch cannot demote it back
34
- * to a softer status (same precedence rule as writeLegPatch — a signal/abort
35
- * marker must never lose a write race against an in-flight init/finalize).
36
- */
37
- function writeWaveMetadata(waveDir, patch) {
38
- const metaPath = path.join(waveDir, 'metadata.json');
39
- let existing = {};
40
- if (fs.existsSync(metaPath)) {
41
- try { existing = JSON.parse(fs.readFileSync(metaPath, 'utf-8')); } catch { /* corrupt → rewrite */ }
42
- }
43
- const safePatch = { ...patch };
44
- if (existing.status === 'aborted' && safePatch.status && safePatch.status !== 'aborted') {
45
- delete safePatch.status;
46
- }
47
- const merged = { ...existing, ...safePatch };
48
- writeFileAtomic(metaPath, JSON.stringify(merged, null, 2), { mode: 0o600 });
49
- return merged;
50
- }
51
-
52
34
  /**
53
35
  * Run a fan-out wave. Spec §4.3.
54
36
  * @param {object} options - models, prompt, promptMeta, waveId?, project, agent?,
@@ -59,7 +41,11 @@ function writeWaveMetadata(waveDir, patch) {
59
41
  * with routing.prefer, applied per leg), json?, client?, quiet? (suppress
60
42
  * stdout — tests), councilRunId? / councilName? (v4.3 §7.2: stamped onto legs),
61
43
  * fallback? / catalog? (v4.3 Task 18 §6.2: opt-in substitution; off/absent unchanged),
62
- * retryContexts? / retryOfWaveId? (v4.3 Task 19: --retry-failed relaunch seam; absent -> byte-identical)
44
+ * retryContexts? / retryOfWaveId? (v4.3 Task 19: --retry-failed relaunch seam; absent -> byte-identical),
45
+ * server? + serverClient? (v4.4.1 Task 0.5: an ALREADY-STARTED OpenCode server
46
+ * to run this wave's legs on. Both or neither. When supplied this wave never
47
+ * starts a server and never closes one — see the seam comment in step 4.
48
+ * NOT `client`, which is the client TYPE string on this function.)
63
49
  * @returns {Promise<{wave: object, exitCode: number}>} Never rejects for leg errors.
64
50
  */
65
51
  async function runFanout(options) {
@@ -70,9 +56,9 @@ async function runFanout(options) {
70
56
  const { buildContext } = require('./context-builder');
71
57
  const { buildPrompts } = require('../prompt-builder');
72
58
  const { generateFoldNonce } = require('../utils/fold-marker');
73
- const { installSignalAbort, markAborted } = require('../utils/session-abort');
59
+ const { installWaveAbort } = require('./fanout-signals');
74
60
  const { getSessionDir } = require('../session-manager');
75
- const { emitWaveStarted, emitWaveTerminal } = require('../observe/events');
61
+ const { emitWaveStarted } = require('../observe/events'); // wave-terminal is emitted by finishWave
76
62
 
77
63
  const project = options.project || process.cwd();
78
64
  const createdAt = new Date().toISOString();
@@ -88,9 +74,17 @@ async function runFanout(options) {
88
74
  console.log(formatWaveHuman(doc));
89
75
  }
90
76
  };
91
- const errorWave = (waveId, message) => {
77
+ // v4.4.1 Task 0.5: a wave that dies BEFORE its legs still owes the run a
78
+ // wave.json. Backlog C1 covered the pre-`try` throw; a server that never
79
+ // started was its uncovered sibling — run v441plan01's four dead seats left a
80
+ // `reason` in metadata.json, no wave.json, and stage1 recorded 'complete'.
81
+ // waveDir is optional (only the post-creation caller has one).
82
+ const errorWave = (waveId, message, waveDir) => {
92
83
  const doc = buildWaveResult({ waveId: waveId || null, legs: [], promptMeta: options.promptMeta || null, createdAt, completedAt: new Date().toISOString(), status: 'error' });
93
84
  doc.error = message;
85
+ doc.reason = message; // classifier alias, same as fanout-leg.js's run docs
86
+ // best-effort: an unwritable wave dir must not mask the real error
87
+ if (waveDir) { try { writeWaveDoc(waveDir, doc); } catch { /* ignore */ } }
94
88
  emit(doc);
95
89
  return { wave: doc, exitCode: 1 };
96
90
  };
@@ -132,17 +126,10 @@ async function runFanout(options) {
132
126
 
133
127
  // 1b. Budget gate (pre-creation; refuse before spending). Only legs that
134
128
  // will actually run cost anything — a leg that never routed never spends.
135
- if (!options.noCostGate) {
136
- const { checkBudget, formatBudgetError } = require('./budget');
137
- const { loadConfig } = require('../utils/config');
138
- const cfg = loadConfig() || {};
139
- const maxCostPerMtok = options.maxCostPerMtok !== undefined ? options.maxCostPerMtok : cfg.maxCostPerMtok;
140
- const promptChars = (options.promptMeta && options.promptMeta.chars) || (options.prompt ? options.prompt.length : 0);
141
- const budget = checkBudget(okLegs, { maxCostPerMtok, maxCost: options.maxCost !== null && options.maxCost !== undefined ? options.maxCost : cfg.maxCost, promptChars });
142
- if (!budget.ok) {
143
- return failPre(ERROR_CODES.BUDGET_EXCEEDED, 'Error: budget gate refused the wave', formatBudgetError(budget));
144
- }
145
- }
129
+ // Lives in ./fanout-budget so the v4.4 concurrency reservation seam has room;
130
+ // that module's docblock carries the why.
131
+ const preflight = require('./fanout-budget').preflightBudget(okLegs, options);
132
+ if (!preflight.ok) { return failPre(ERROR_CODES.BUDGET_EXCEEDED, preflight.message, preflight.hint); }
146
133
 
147
134
  // 2. Wave record
148
135
  const waveId = options.waveId || generateTaskId();
@@ -169,14 +156,9 @@ async function runFanout(options) {
169
156
  const wave = buildWaveResult({
170
157
  waveId, legs: legDocs, promptMeta: options.promptMeta || null, createdAt, completedAt, notices,
171
158
  });
172
- const wavePath = path.join(waveDir, 'wave.json');
173
- writeFileAtomic(wavePath, JSON.stringify(wave, null, 2), { mode: 0o600 });
174
- writeWaveMetadata(waveDir, { status: wave.status, completedAt });
175
- const routingExitCode = waveExitCode(wave.status);
176
- emitWaveTerminal(waveDir, waveId, { status: wave.status, counts: wave.counts, usage: wave.usage, exitCode: routingExitCode }, follow);
177
- await require('../observe/on-complete').fireWaveOnComplete(options.onComplete, wave, { waveId, waveDir, wavePath, exitCode: routingExitCode, project }, options.onCompleteDeps);
178
- emit(wave);
179
- return { wave, exitCode: routingExitCode };
159
+ return finishWave({ wave, waveDir, waveId, project, completedAt, follow, emit,
160
+ exitCode: waveExitCode(wave.status),
161
+ onComplete: options.onComplete, onCompleteDeps: options.onCompleteDeps });
180
162
  }
181
163
 
182
164
  // 3. Context + prompts built ONCE (model-independent)
@@ -205,37 +187,41 @@ async function runFanout(options) {
205
187
  mcp: options.mcp, mcpConfig: options.mcpConfig, clientType: options.client,
206
188
  noMcp: options.noMcp, excludeMcp: options.excludeMcp,
207
189
  });
190
+ // ⚠️ v4.4.1 Task 0.5 — the external-server seam runHeadless has carried since
191
+ // v4.0 (src/headless.js:245): a caller that already owns a server passes it in
192
+ // and we must NOT close it. Added here because a council run launches its
193
+ // Stage-1 seat wave and its critic solo under ONE Promise.all (run-stages.js:83)
194
+ // and two concurrent startOpenCodeServer calls race on OpenCode's SQLite —
195
+ // run v441plan01 lost four of five seats in 736ms to `database is locked`.
196
+ // NAME DIVERGENCE, deliberate: runHeadless spells the pair client+server, but
197
+ // runFanout's `options.client` is ALREADY the client TYPE string (buildMcpConfig
198
+ // above, buildContext, buildPrompts, cli-handlers-run.js `client: args.client`),
199
+ // so the SDK client is `options.serverClient`. Both or neither: a half-injection
200
+ // falls back to owning a server rather than running clientless.
201
+ const externalServer = !!(options.server && options.serverClient);
208
202
  let client, server;
209
- try {
210
- ({ client, server } = await startOpenCodeServer(mcpServers, { models: validated.serverModels || okLegs.map(l => l.model) }));
211
- } catch (err) {
212
- writeWaveMetadata(waveDir, { status: 'error', reason: err.message, completedAt: new Date().toISOString() });
213
- return errorWave(waveId, `Failed to start server: ${err.message}`);
203
+ if (externalServer) {
204
+ ({ serverClient: client, server } = options);
205
+ logger.debug('Using external server (shared server mode)', { waveId, url: server.url });
206
+ } else {
207
+ try {
208
+ ({ client, server } = await startOpenCodeServer(mcpServers, { models: validated.serverModels || okLegs.map(l => l.model) }));
209
+ } catch (err) {
210
+ writeWaveMetadata(waveDir, { status: 'error', reason: err.message, completedAt: new Date().toISOString() });
211
+ return errorWave(waveId, `Failed to start server: ${err.message}`, waveDir);
212
+ }
214
213
  }
215
- if (server.goPid) { writeWaveMetadata(waveDir, { goPid: server.goPid }); }
214
+ // Only an OWNED server's pid belongs in this wave's metadata: mcp-server.js's
215
+ // wave abort SIGTERMs `metadata.goPid` as "the orchestrator + its OWNED
216
+ // OpenCode server", which on an injected server would kill every sibling wave.
217
+ if (!externalServer && server.goPid) { writeWaveMetadata(waveDir, { goPid: server.goPid }); }
216
218
 
217
- // 5. Signal abort: mark wave + all legs aborted, close the server, then let
218
- // NORMAL control flow finalize legs see their abort marker within one poll
219
- // (~2s) and settle, so step 7 still writes wave.json and emits a parseable
220
- // aborted document. An unref'd force-exit watchdog backstops a wedged leg.
219
+ // 5. Signal abort (./fanout-signals owns the handler size gate): mark wave +
220
+ // all legs aborted, close an OWNED server, then let NORMAL control flow
221
+ // finalize, so step 7 still writes wave.json and emits a parseable aborted
222
+ // document. An unref'd force-exit watchdog backstops a wedged leg.
221
223
  const legDirs = legIds.map(id => getSessionDir(project, id));
222
- let signalled = null;
223
- const uninstallSignals = installSignalAbort({
224
- onAbort: (signal) => {
225
- const code = signal === 'SIGINT' ? 130 : 143;
226
- if (signalled) { process.exit(code); } // second signal: exit NOW
227
- signalled = signal;
228
- logger.warn('Signal received — aborting wave', { waveId, signal });
229
- markAborted(waveDir, signal);
230
- for (const dir of legDirs) { markAborted(dir, signal); }
231
- // close() is async (B06 escalation); this handler stays sync, so
232
- // fire-and-forget with a rejection guard. The 10s exit watchdog below
233
- // comfortably outlives the ~2s escalation grace inside close().
234
- try { server.close().catch(() => {}); } catch { /* best-effort */ }
235
- const { armExitWatchdog } = require('../utils/lifecycle');
236
- armExitWatchdog(code, 10000, { log: (m, meta) => logger.debug(m, meta) });
237
- },
238
- });
224
+ const waveAbort = installWaveAbort({ waveId, waveDir, legDirs, server, externalServer });
239
225
 
240
226
  // 6. Launch all ROUTABLE legs concurrently (runLeg never rejects). A leg that
241
227
  // failed to route (leg.ok === false) resolves to an error run doc in its own
@@ -269,26 +255,24 @@ async function runFanout(options) {
269
255
  }));
270
256
  } finally {
271
257
  heartbeat.stop();
272
- uninstallSignals();
273
- try { await server.close(); } catch { /* already closed on signal */ }
258
+ waveAbort.uninstall();
259
+ // ⚠️ close site 2 of 2 (`grep -n "server.close()" src/sidecar/fanout.js`).
260
+ // An injected server outlives this wave by design — the owner closes it once.
261
+ if (!externalServer) { try { await server.close(); } catch { /* already closed on signal */ } }
274
262
  }
275
263
 
276
264
  // 7. Aggregate, persist (atomic: tmp + rename), finalize, emit
277
265
  const completedAt = new Date().toISOString();
266
+ const signalled = waveAbort.signal();
278
267
  const wave = buildWaveResult({
279
268
  waveId, legs: legDocs, promptMeta: options.promptMeta || null, createdAt, completedAt,
280
269
  status: signalled ? 'aborted' : null, notices,
281
270
  });
282
- const wavePath = path.join(waveDir, 'wave.json');
283
- writeFileAtomic(wavePath, JSON.stringify(wave, null, 2), { mode: 0o600 });
284
- writeWaveMetadata(waveDir, { status: wave.status, completedAt });
285
271
  const exitCode = signalled
286
272
  ? (signalled === 'SIGINT' ? 130 : 143)
287
273
  : waveExitCode(wave.status);
288
- emitWaveTerminal(waveDir, waveId, { status: wave.status, counts: wave.counts, usage: wave.usage, exitCode }, follow);
289
- await require('../observe/on-complete').fireWaveOnComplete(options.onComplete, wave, { waveId, waveDir, wavePath, exitCode, project }, options.onCompleteDeps);
290
- emit(wave);
291
- return { wave, exitCode };
274
+ return finishWave({ wave, waveDir, waveId, project, exitCode, completedAt, follow, emit,
275
+ onComplete: options.onComplete, onCompleteDeps: options.onCompleteDeps });
292
276
  }
293
277
 
294
278
  module.exports = {
@@ -10,6 +10,23 @@
10
10
  /** Coarse stages surfaced to agents. */
11
11
  const COARSE_STAGES = ['starting', 'generating', 'folding', 'terminal'];
12
12
 
13
+ /**
14
+ * The stages src/headless.js can write on its ONE terminal progress record.
15
+ *
16
+ * ⚠️ v4.4.1 LC-3: this list must stay byte-identical to resolveTerminalState's
17
+ * status vocabulary (src/sidecar/session-finalize.js), because headless.js
18
+ * derives the terminal stage straight from that function. A drift pin in
19
+ * tests/sidecar/progress-fields.test.js asserts it.
20
+ *
21
+ * Before LC-3 the terminal record hardcoded 'complete' on every path, so this
22
+ * set had exactly one member and deriveStage could match the literal. Widening
23
+ * the writer without widening this set would have been the WORSE bug: an
24
+ * aborted/errored/timed-out leg would fall through to 'starting' and a finished
25
+ * leg would read as barely begun for the whole window before metadata.json
26
+ * lands.
27
+ */
28
+ const TERMINAL_PROGRESS_STAGES = new Set(['complete', 'error', 'timed-out', 'aborted']);
29
+
13
30
  /**
14
31
  * Collapse whitespace and defang fence/tag characters so the preview can be
15
32
  * embedded in a one-line JSON status without opening a code fence or tag
@@ -42,8 +59,10 @@ function latestAssistantPreview(entries) {
42
59
  * Map (metadata.status, progress.stage) to the coarse agent-facing stage.
43
60
  * - terminal metadata status -> 'terminal'
44
61
  * - progress 'receiving' -> 'generating'
45
- * - progress 'complete' while metadata still says running -> 'folding'
46
- * (mirror stopped; summary/conflict finalize in flight)
62
+ * - a TERMINAL progress stage while metadata still says running -> 'folding'
63
+ * (mirror stopped; summary/conflict finalize in flight). v4.4.1 LC-3: that is
64
+ * any of TERMINAL_PROGRESS_STAGES, not just 'complete' — an aborted or
65
+ * errored leg is every bit as "done streaming, finalizing" as a clean one.
47
66
  * - anything else -> 'starting'
48
67
  * @param {string|undefined} metadataStatus @param {string|undefined} progressStage
49
68
  * @returns {string}
@@ -53,8 +72,11 @@ function deriveStage(metadataStatus, progressStage) {
53
72
  return 'terminal';
54
73
  }
55
74
  if (progressStage === 'receiving') { return 'generating'; }
56
- if (progressStage === 'complete') { return 'folding'; }
75
+ if (TERMINAL_PROGRESS_STAGES.has(progressStage)) { return 'folding'; }
57
76
  return 'starting';
58
77
  }
59
78
 
60
- module.exports = { sanitizePreview, latestAssistantPreview, deriveStage, COARSE_STAGES };
79
+ module.exports = {
80
+ sanitizePreview, latestAssistantPreview, deriveStage, COARSE_STAGES,
81
+ TERMINAL_PROGRESS_STAGES,
82
+ };
@@ -19,7 +19,14 @@ const STAGE_LABELS = {
19
19
  session_created: 'Session created',
20
20
  prompt_sent: 'Briefing delivered, waiting for response...',
21
21
  receiving: 'Generating response...',
22
- complete: 'Complete'
22
+ complete: 'Complete',
23
+ // v4.4.1 LC-3: the terminal record is no longer always 'complete' — headless.js
24
+ // derives it from resolveTerminalState, so these three are now reachable. Without
25
+ // a label, readProgress's `latest` (which falls back to stageLabel when the leg
26
+ // has no assistant messages yet) would read the raw id: "aborted", not "Aborted".
27
+ aborted: 'Aborted',
28
+ error: 'Failed',
29
+ 'timed-out': 'Timed out'
23
30
  };
24
31
 
25
32
  /**
@@ -229,13 +229,14 @@ async function executeMode(options) {
229
229
  * has no single default `config.model`, so this registers ALL of them in provider.models
230
230
  * instead. Additive alongside the single-model `options.model` path used by owned-server
231
231
  * callers (start/continue); see opencode-client.js's buildServerOptions.
232
+ * @param {number} [options.retryDelayMs] - Test seam: collapse the lock-race backoff.
232
233
  * @returns {Promise<{client: object, server: object}>}
233
234
  * @throws {Error} If server fails to start or health check fails
234
235
  */
235
236
  async function startOpenCodeServer(mcpConfig, options = {}) {
236
237
  const { checkHealth, startServer } = require('../opencode-client');
237
238
  const { ensureNodeModulesBinInPath } = require('../utils/path-setup');
238
- const { ensurePortAvailable } = require('../utils/server-setup');
239
+ const { ensurePortAvailable, retryOnLockRace } = require('../utils/server-setup');
239
240
  const { waitForServer } = require('../headless');
240
241
 
241
242
  ensureNodeModulesBinInPath();
@@ -253,20 +254,28 @@ async function startOpenCodeServer(mcpConfig, options = {}) {
253
254
  if (options.systemPrompt) { serverOptions.systemPrompt = options.systemPrompt; }
254
255
  if (options.agentName) { serverOptions.agentName = options.agentName; }
255
256
 
256
- const { client, server } = await startServer(serverOptions);
257
- logger.debug('OpenCode server started', { url: server.url });
258
-
259
- const ready = await waitForServer(client, checkHealth);
260
- if (!ready) {
261
- // Fire-and-forget: today close() is sync (Promise.resolve wraps a
262
- // non-promise harmlessly); once close() becomes async (bounded
263
- // kill-escalation poll) this guard prevents an unhandled rejection
264
- // from racing the throw below.
265
- Promise.resolve(server.close()).catch(() => {});
266
- throw new Error('OpenCode server failed to become ready');
267
- }
257
+ // v4.4.1 Task 0.5: a LOCK-CLASS start failure is retried (5 attempts,
258
+ // 250/500/1000/2000ms widened from 3/750ms by Step 10.5, see server-setup).
259
+ // The per-run shared server removes the races a single amicus process creates;
260
+ // this covers the ones it cannot — two amicus processes, or a CLI run beside a
261
+ // live MCP server, sharing one OpenCode SQLite database. Nothing else retries:
262
+ // see isLockClassStartFailure in ../utils/server-setup.
263
+ return retryOnLockRace(async () => {
264
+ const { client, server } = await startServer(serverOptions);
265
+ logger.debug('OpenCode server started', { url: server.url });
266
+
267
+ const ready = await waitForServer(client, checkHealth);
268
+ if (!ready) {
269
+ // Fire-and-forget: today close() is sync (Promise.resolve wraps a
270
+ // non-promise harmlessly); once close() becomes async (bounded
271
+ // kill-escalation poll) this guard prevents an unhandled rejection
272
+ // from racing the throw below.
273
+ Promise.resolve(server.close()).catch(() => {});
274
+ throw new Error('OpenCode server failed to become ready');
275
+ }
268
276
 
269
- return { client, server };
277
+ return { client, server };
278
+ }, { retryDelayMs: options.retryDelayMs });
270
279
  }
271
280
 
272
281
  module.exports = {