amicus 4.4.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 (75) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +32 -0
  3. package/README.md +3 -1
  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/main.js +4 -1
  21. package/electron/setup-ui-aliases.js +6 -6
  22. package/electron/workspace-ui/live-model.js +12 -1
  23. package/electron/workspace-ui/md-lite.js +52 -8
  24. package/electron/workspace-ui/workspace-matrix.js +46 -9
  25. package/electron/workspace-ui/workspace-panels.js +14 -3
  26. package/electron/workspace-ui/workspace-render.js +7 -1
  27. package/electron/workspace-ui/workspace-verbs.js +48 -2
  28. package/package.json +8 -3
  29. package/schemas/council-run.schema.json +20 -0
  30. package/schemas/progress.schema.json +12 -0
  31. package/schemas/spend.schema.json +52 -4
  32. package/src/cli-handlers-spend.js +20 -2
  33. package/src/cli-handlers-watch.js +11 -0
  34. package/src/cli.js +4 -2
  35. package/src/council/briefings-debate.js +27 -7
  36. package/src/council/briefings-stage2.js +155 -25
  37. package/src/council/briefings.js +24 -1
  38. package/src/council/findings.js +236 -9
  39. package/src/council/parse-stage2.js +10 -2
  40. package/src/council/report.js +19 -8
  41. package/src/council/run-assemble.js +42 -1
  42. package/src/council/run-budget.js +64 -11
  43. package/src/council/run-chair.js +4 -1
  44. package/src/council/run-debate.js +4 -2
  45. package/src/council/run-finalize.js +102 -0
  46. package/src/council/run-launch.js +29 -1
  47. package/src/council/run-server.js +248 -0
  48. package/src/council/run-stage2.js +118 -0
  49. package/src/council/run-stages.js +132 -111
  50. package/src/council/run-state.js +23 -1
  51. package/src/council/run.js +44 -46
  52. package/src/council/tally.js +10 -0
  53. package/src/headless.js +175 -6
  54. package/src/observe/council-legs.js +60 -3
  55. package/src/observe/live-doc.js +18 -1
  56. package/src/observe/watch-render.js +4 -1
  57. package/src/sidecar/child-sessions.js +1 -2
  58. package/src/sidecar/fanout-leg-fallback.js +69 -21
  59. package/src/sidecar/fanout-leg.js +6 -0
  60. package/src/sidecar/fanout-signals.js +61 -0
  61. package/src/sidecar/fanout-wave-io.js +75 -0
  62. package/src/sidecar/fanout.js +61 -70
  63. package/src/sidecar/progress-fields.js +26 -4
  64. package/src/sidecar/progress.js +8 -1
  65. package/src/sidecar/session-utils.js +23 -14
  66. package/src/spend-query.js +17 -5
  67. package/src/utils/lifecycle.js +37 -1
  68. package/src/utils/path-fence.js +39 -1
  69. package/src/utils/pricing.js +26 -10
  70. package/src/utils/server-setup.js +79 -1
  71. package/src/utils/spend-ledger.js +24 -3
  72. package/src/workspace/artifact-guard.js +22 -1
  73. package/src/workspace/fold-format.js +33 -4
  74. package/src/workspace/live-normalize.js +28 -15
  75. package/src/workspace/run-detail.js +7 -1
@@ -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
  };
@@ -162,14 +156,9 @@ async function runFanout(options) {
162
156
  const wave = buildWaveResult({
163
157
  waveId, legs: legDocs, promptMeta: options.promptMeta || null, createdAt, completedAt, notices,
164
158
  });
165
- const wavePath = path.join(waveDir, 'wave.json');
166
- writeFileAtomic(wavePath, JSON.stringify(wave, null, 2), { mode: 0o600 });
167
- writeWaveMetadata(waveDir, { status: wave.status, completedAt });
168
- const routingExitCode = waveExitCode(wave.status);
169
- emitWaveTerminal(waveDir, waveId, { status: wave.status, counts: wave.counts, usage: wave.usage, exitCode: routingExitCode }, follow);
170
- await require('../observe/on-complete').fireWaveOnComplete(options.onComplete, wave, { waveId, waveDir, wavePath, exitCode: routingExitCode, project }, options.onCompleteDeps);
171
- emit(wave);
172
- 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 });
173
162
  }
174
163
 
175
164
  // 3. Context + prompts built ONCE (model-independent)
@@ -198,37 +187,41 @@ async function runFanout(options) {
198
187
  mcp: options.mcp, mcpConfig: options.mcpConfig, clientType: options.client,
199
188
  noMcp: options.noMcp, excludeMcp: options.excludeMcp,
200
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);
201
202
  let client, server;
202
- try {
203
- ({ client, server } = await startOpenCodeServer(mcpServers, { models: validated.serverModels || okLegs.map(l => l.model) }));
204
- } catch (err) {
205
- writeWaveMetadata(waveDir, { status: 'error', reason: err.message, completedAt: new Date().toISOString() });
206
- 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
+ }
207
213
  }
208
- 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 }); }
209
218
 
210
- // 5. Signal abort: mark wave + all legs aborted, close the server, then let
211
- // NORMAL control flow finalize legs see their abort marker within one poll
212
- // (~2s) and settle, so step 7 still writes wave.json and emits a parseable
213
- // 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.
214
223
  const legDirs = legIds.map(id => getSessionDir(project, id));
215
- let signalled = null;
216
- const uninstallSignals = installSignalAbort({
217
- onAbort: (signal) => {
218
- const code = signal === 'SIGINT' ? 130 : 143;
219
- if (signalled) { process.exit(code); } // second signal: exit NOW
220
- signalled = signal;
221
- logger.warn('Signal received — aborting wave', { waveId, signal });
222
- markAborted(waveDir, signal);
223
- for (const dir of legDirs) { markAborted(dir, signal); }
224
- // close() is async (B06 escalation); this handler stays sync, so
225
- // fire-and-forget with a rejection guard. The 10s exit watchdog below
226
- // comfortably outlives the ~2s escalation grace inside close().
227
- try { server.close().catch(() => {}); } catch { /* best-effort */ }
228
- const { armExitWatchdog } = require('../utils/lifecycle');
229
- armExitWatchdog(code, 10000, { log: (m, meta) => logger.debug(m, meta) });
230
- },
231
- });
224
+ const waveAbort = installWaveAbort({ waveId, waveDir, legDirs, server, externalServer });
232
225
 
233
226
  // 6. Launch all ROUTABLE legs concurrently (runLeg never rejects). A leg that
234
227
  // failed to route (leg.ok === false) resolves to an error run doc in its own
@@ -262,26 +255,24 @@ async function runFanout(options) {
262
255
  }));
263
256
  } finally {
264
257
  heartbeat.stop();
265
- uninstallSignals();
266
- 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 */ } }
267
262
  }
268
263
 
269
264
  // 7. Aggregate, persist (atomic: tmp + rename), finalize, emit
270
265
  const completedAt = new Date().toISOString();
266
+ const signalled = waveAbort.signal();
271
267
  const wave = buildWaveResult({
272
268
  waveId, legs: legDocs, promptMeta: options.promptMeta || null, createdAt, completedAt,
273
269
  status: signalled ? 'aborted' : null, notices,
274
270
  });
275
- const wavePath = path.join(waveDir, 'wave.json');
276
- writeFileAtomic(wavePath, JSON.stringify(wave, null, 2), { mode: 0o600 });
277
- writeWaveMetadata(waveDir, { status: wave.status, completedAt });
278
271
  const exitCode = signalled
279
272
  ? (signalled === 'SIGINT' ? 130 : 143)
280
273
  : waveExitCode(wave.status);
281
- emitWaveTerminal(waveDir, waveId, { status: wave.status, counts: wave.counts, usage: wave.usage, exitCode }, follow);
282
- await require('../observe/on-complete').fireWaveOnComplete(options.onComplete, wave, { waveId, waveDir, wavePath, exitCode, project }, options.onCompleteDeps);
283
- emit(wave);
284
- return { wave, exitCode };
274
+ return finishWave({ wave, waveDir, waveId, project, exitCode, completedAt, follow, emit,
275
+ onComplete: options.onComplete, onCompleteDeps: options.onCompleteDeps });
285
276
  }
286
277
 
287
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 = {
@@ -60,25 +60,32 @@ function rowKey(row, dimension) {
60
60
  }
61
61
 
62
62
  /**
63
- * Group rows into {key, amount, tokens, runs, unpricedRows, sourceMix},
64
- * most-expensive first.
63
+ * Group rows into {key, amount, tokens, runs, unpricedRows,
64
+ * unattributedSubtreeRows, sourceMix}, most-expensive first.
65
65
  *
66
66
  * v4.4: `amount` deliberately stays a plain number — the published
67
67
  * spend.schema.json pins `groups[].amount` to `type: "number"` — so
68
68
  * `unpricedRows` is how a group says "this figure omits N rows we cannot
69
69
  * price". Without it, a group of entirely unpriced rows was indistinguishable
70
70
  * from a group that genuinely cost $0 (diagnosis §8).
71
+ *
72
+ * v4.4.1 CA-2: `unattributedSubtreeRows` is the SECOND way a figure can be a
73
+ * floor, and `unpricedRows` structurally cannot see it — such a row IS priced,
74
+ * lands in the `r` source bucket, and contributes its own cost to `amount`; what
75
+ * is missing is the child session it spawned. The two counters are incremented
76
+ * BESIDE each other, never instead of: a row can be both.
71
77
  */
72
78
  function groupRows(rows, dimension) {
73
79
  const map = new Map();
74
80
  for (const r of rows) {
75
81
  const key = rowKey(r, dimension);
76
- if (!map.has(key)) { map.set(key, { key, amount: 0, tokens: emptyTokens(), runs: 0, unpricedRows: 0, sourceMix: { reported: 0, estimated: 0, unknown: 0 } }); }
82
+ if (!map.has(key)) { map.set(key, { key, amount: 0, tokens: emptyTokens(), runs: 0, unpricedRows: 0, unattributedSubtreeRows: 0, sourceMix: { reported: 0, estimated: 0, unknown: 0 } }); }
77
83
  const b = map.get(key);
78
84
  b.runs += 1;
79
85
  addTokens(b.tokens, r.tokens);
80
86
  const cost = r.cost || {};
81
87
  if (typeof cost.amount === 'number') { b.amount += cost.amount; } else { b.unpricedRows += 1; }
88
+ if (r.subtreeUnknown) { b.unattributedSubtreeRows += 1; }
82
89
  const src = (cost.source === 'reported' || cost.source === 'estimated') ? cost.source : 'unknown';
83
90
  b.sourceMix[src] += 1;
84
91
  }
@@ -96,7 +103,7 @@ function groupRows(rows, dimension) {
96
103
  * a row); computeWasted intentionally drops it instead.
97
104
  */
98
105
  function computeWasted(rows) {
99
- const out = { amount: 0, tokens: emptyTokens(), runs: 0, unpricedRows: 0, byStatus: {} };
106
+ const out = { amount: 0, tokens: emptyTokens(), runs: 0, unpricedRows: 0, unattributedSubtreeRows: 0, byStatus: {} };
100
107
  for (const r of rows) {
101
108
  if (r.status === 'complete' || !r.status) { continue; }
102
109
  out.runs += 1;
@@ -104,14 +111,19 @@ function computeWasted(rows) {
104
111
  // v4.4: null→0 here is arithmetic, not a claim. `unpricedRows` records how
105
112
  // many failed rows we could not price so "wasted $X" is never mistaken for
106
113
  // the whole loss (see groupRows for why `amount` stays a number).
114
+ // v4.4.1 CA-2: `unattributedSubtreeRows` is the other half of the same
115
+ // understatement — a failed leg that DID resolve its own cost but left a
116
+ // child session unpriced. Counted beside `unpricedRows`, never instead of.
107
117
  const priced = r.cost && typeof r.cost.amount === 'number';
108
118
  const amt = priced ? r.cost.amount : 0;
109
119
  if (!priced) { out.unpricedRows += 1; }
120
+ if (r.subtreeUnknown) { out.unattributedSubtreeRows += 1; }
110
121
  out.amount += amt;
111
- if (!out.byStatus[r.status]) { out.byStatus[r.status] = { amount: 0, runs: 0, unpricedRows: 0 }; }
122
+ if (!out.byStatus[r.status]) { out.byStatus[r.status] = { amount: 0, runs: 0, unpricedRows: 0, unattributedSubtreeRows: 0 }; }
112
123
  out.byStatus[r.status].amount += amt;
113
124
  out.byStatus[r.status].runs += 1;
114
125
  if (!priced) { out.byStatus[r.status].unpricedRows += 1; }
126
+ if (r.subtreeUnknown) { out.byStatus[r.status].unattributedSubtreeRows += 1; }
115
127
  }
116
128
  return out;
117
129
  }
@@ -41,4 +41,40 @@ function armExitWatchdog(code = 0, ms = 1500, deps = {}) {
41
41
  return t;
42
42
  }
43
43
 
44
- module.exports = { isOneShotCommand, armExitWatchdog, ONE_SHOT_COMMANDS };
44
+ /**
45
+ * An `exit` hook for armExitWatchdog that REAPS an OpenCode server this process
46
+ * is using but does not own, on its way out (v4.4.1 fix wave, finding F3).
47
+ *
48
+ * The force-exit path is the one place where "not ours to close" stops being the
49
+ * safe answer. A wave running on an injected server deliberately never closes it
50
+ * — the owner does, once, in its own finalize. But if the watchdog fires first
51
+ * the parent dies anyway, and the Go server survives it: an orphan still holding
52
+ * the OpenCode SQLite lock that the per-run shared server exists to stop
53
+ * contending on. Before the external-server seam, fanout's own close() covered
54
+ * this by accident; this restores it deliberately.
55
+ *
56
+ * SIGTERM only, and synchronous: process.exit cannot await server.close()'s
57
+ * escalation, so this sends the one signal that fits in the window and gets out
58
+ * of the way. Never signals this process, and never lets a dead/absent pid throw
59
+ * — the exit must happen regardless.
60
+ *
61
+ * @param {{goPid?: number|null}|null} server the injected server handle
62
+ * @param {{kill?: Function, exit?: Function}} [deps] test seams
63
+ * @returns {(code: number) => void}
64
+ */
65
+ function exitReaping(server, deps = {}) {
66
+ return (code) => {
67
+ // Resolved at CALL time, not creation time: this hook is built the instant a
68
+ // signal lands and invoked up to 10s later, so binding process.kill/exit
69
+ // early would freeze whatever was installed at signal time.
70
+ const kill = deps.kill || ((p, sig) => process.kill(p, sig));
71
+ const exit = deps.exit || ((c) => process.exit(c));
72
+ const pid = server && server.goPid;
73
+ if (pid && pid !== process.pid) {
74
+ try { kill(pid, 'SIGTERM'); } catch { /* already gone: nothing to reap */ }
75
+ }
76
+ exit(code);
77
+ };
78
+ }
79
+
80
+ module.exports = { isOneShotCommand, armExitWatchdog, exitReaping, ONE_SHOT_COMMANDS };
@@ -7,7 +7,10 @@
7
7
  * for truthiness by src/council/run-state.js's readPointer, so nothing
8
8
  * upstream of this check guarantees it stays inside the project).
9
9
  *
10
- * It is a LEAF: `fs` + `path` and nothing else, no require cycle possible.
10
+ * It is a LEAF: `fs` + `path` at load time and nothing else, no require cycle
11
+ * possible. (SEC-3's debug-mode diagnostic requires src/utils/logger.js lazily,
12
+ * inside the flag branch — logger.js has zero requires of its own, so even that
13
+ * cannot introduce a cycle, and nothing is loaded at all with the flag unset.)
11
14
  * That was first needed inside the v4.4 workspace layer —
12
15
  * src/workspace/artifact-guard.js requires src/workspace/run-scan.js for
13
16
  * readPointer, so if run-scan.js also required artifact-guard.js for this
@@ -30,6 +33,39 @@
30
33
  const fs = require('fs');
31
34
  const path = require('path');
32
35
 
36
+ /**
37
+ * ⚠️ SEC-3: isRealpathContained is a pure string-prefix comparison, and it is only SOUND
38
+ * if both arguments were already resolved through realpathSync. Nothing enforced that, so
39
+ * a caller who forgot got a silently WEAKER check rather than an error — a raw
40
+ * (unresolved) path containing a symlink can prefix-match a directory it does not
41
+ * physically live under.
42
+ *
43
+ * This is a DIAGNOSTIC, opt-in via `AMICUS_DEBUG_FENCE=1`, and it MUST NEVER THROW: the
44
+ * fence must never become the failure it exists to prevent. Every step is wrapped, and a
45
+ * non-existent path is a legitimate argument here (callers fence paths before probing
46
+ * them), so it is not reported. The logger is required lazily so this module keeps its
47
+ * `fs` + `path` load-time surface (see the header) for the 99.99% of calls that run with
48
+ * the flag unset.
49
+ * @param {string} dir already-stringified dirRealPath
50
+ * @param {string} target already-stringified targetRealPath
51
+ */
52
+ function assertResolvedArgs(dir, target) {
53
+ try {
54
+ const { logger } = require('./logger');
55
+ for (const [name, p] of [['dirRealPath', dir], ['targetRealPath', target]]) {
56
+ if (!path.isAbsolute(p)) {
57
+ logger.warn('path-fence: argument is not absolute', { arg: name, value: p });
58
+ continue;
59
+ }
60
+ try {
61
+ if (fs.existsSync(p) && fs.realpathSync(p) !== p) {
62
+ logger.warn('path-fence: argument is not realpath-resolved', { arg: name, value: p });
63
+ }
64
+ } catch { /* unreadable / dangling: not this diagnostic's business */ }
65
+ }
66
+ } catch { /* a broken logger must never break the fence */ }
67
+ }
68
+
33
69
  /**
34
70
  * True when `targetRealPath` is exactly `dirRealPath` or a proper descendant
35
71
  * of it. Both arguments MUST already be resolved through realpathSync — this
@@ -41,6 +77,8 @@ const path = require('path');
41
77
  function isRealpathContained(dirRealPath, targetRealPath) {
42
78
  const dir = String(dirRealPath);
43
79
  const target = String(targetRealPath);
80
+ // SEC-3: debug-mode contract check only — never a gate, never a throw (see above).
81
+ if (process.env.AMICUS_DEBUG_FENCE === '1') { assertResolvedArgs(dir, target); }
44
82
  if (target === dir) { return true; }
45
83
  // ⚠️ COUNCIL REVIEW R2 (A6): when dirRealPath IS a filesystem root, it already
46
84
  // ends in a separator ('/' on POSIX, 'C:\\' on Windows) — blindly appending
@@ -63,24 +63,37 @@ function lookupPricing(modelId) {
63
63
  }
64
64
 
65
65
  /**
66
- * Did we actually OBSERVE any token usage for this leg? (v4.4 B2)
66
+ * Did we actually OBSERVE tokens THIS MODULE'S ESTIMATE CAN PRICE? (v4.4 B2,
67
+ * narrowed by v4.4.1 CA-7)
67
68
  *
68
69
  * This is the predicate that separates "the provider billed us for a $0 tier"
69
70
  * from "we never saw a usage payload at all" — a distinction the old
70
71
  * `pricing && tokens` guard could not make, because it only inspected the
71
- * PRICE. Accepts both the normalized totals shape (cacheRead/cacheWrite, as
72
- * produced by sumPerMessageUsage) and OpenCode's raw per-message shape
73
- * (`cache: {read, write}`), so callers holding either can ask one question.
72
+ * PRICE. It reads the normalized totals shape (input/output, as produced by
73
+ * sumPerMessageUsage) and OpenCode's raw per-message shape alike, because both
74
+ * carry `input`/`output` at the top level.
75
+ *
76
+ * ⚠️ CA-7: deliberately narrower than "did we see any token count at all". The
77
+ * estimate in resolveLegCost prices input/output ONLY, so accepting
78
+ * cacheRead/cacheWrite — and `reasoning`, which this predicate also used to
79
+ * accept and which the estimate likewise never prices — let a leg observed with
80
+ * none of input/output pass the observation gate and resolve to `estimated $0`:
81
+ * the same false-zero class the v4.4 observed-tokens fix exists to kill, in the
82
+ * one corner its predicate did not cover. Such a leg is now `unknown`, which is
83
+ * true, rather than free, which is not. Pricing cache or reasoning tokens
84
+ * properly needs catalog fields that may not exist; when they do, widen this and
85
+ * the estimate together, never one alone.
86
+ *
87
+ * Live-validated 2026-07-26: a real local leg reports `input: 25953, output: 3`,
88
+ * so the shipped v4.2 free-local `$0` promise (a local seat costs nothing but
89
+ * still reports real input/output) is untouched — tests/pricing-local.test.js
90
+ * is the standing guard on that.
74
91
  * @param {object|null|undefined} tokens
75
92
  * @returns {boolean}
76
93
  */
77
94
  function hasObservedTokens(tokens) {
78
95
  if (!tokens || typeof tokens !== 'object') { return false; }
79
- const cache = tokens.cache && typeof tokens.cache === 'object' ? tokens.cache : {};
80
- const cacheRead = tokens.cacheRead || cache.read || 0;
81
- const cacheWrite = tokens.cacheWrite || cache.write || 0;
82
- return (tokens.input || 0) > 0 || (tokens.output || 0) > 0
83
- || (tokens.reasoning || 0) > 0 || cacheRead > 0 || cacheWrite > 0;
96
+ return (tokens.input || 0) > 0 || (tokens.output || 0) > 0;
84
97
  }
85
98
 
86
99
  /** @returns {{amount:number|null, currency:'USD', source:'reported'|'estimated'|'unknown'}} */
@@ -96,7 +109,10 @@ function resolveLegCost({ reportedCost, tokens, pricing }) {
96
109
  // The v4.2 §4.5 free-local-tier carve-out is DELIBERATELY preserved: a local
97
110
  // Ollama/LM Studio seat legitimately costs $0 but still reports tokens, so
98
111
  // hasObservedTokens() is true for it and it still resolves to `estimated $0`.
99
- // Only a leg where we observed no tokens at all falls through to `unknown`.
112
+ // v4.4.1 CA-7: a leg with no input/output falls through to `unknown` even when
113
+ // it reported cache or reasoning tokens — those are real observations, but not
114
+ // ones THIS estimate can turn into a price, so calling the result $0 would be
115
+ // the same fabrication one corner over. See hasObservedTokens above.
100
116
  if (pricing && hasObservedTokens(tokens)) {
101
117
  const est = (tokens.input || 0) * pricing.prompt + (tokens.output || 0) * pricing.completion;
102
118
  if (est >= 0) { return { amount: est, currency: 'USD', source: 'estimated' }; }