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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +64 -0
- package/README.md +6 -3
- package/docs/DISTRIBUTION.md +234 -0
- package/docs/ROADMAP.md +200 -0
- package/docs/SHIMS.md +62 -0
- package/docs/architecture.md +104 -0
- package/docs/configuration.md +371 -0
- package/docs/council.md +911 -0
- package/docs/doc-system.md +92 -0
- package/docs/electron-testing.md +471 -0
- package/docs/jsdoc-setup.md +75 -0
- package/docs/opencode-integration.md +114 -0
- package/docs/publishing.md +60 -0
- package/docs/schemas.md +55 -0
- package/docs/testing.md +589 -0
- package/docs/troubleshooting.md +298 -0
- package/docs/usage.md +699 -0
- package/electron/fold.js +1 -1
- package/electron/ipc-workspace.js +283 -0
- package/electron/main.js +31 -1
- package/electron/preload-workspace.js +40 -0
- package/electron/setup-ui-aliases.js +6 -6
- package/electron/workspace-shell.js +85 -0
- package/electron/workspace-ui/index.html +111 -0
- package/electron/workspace-ui/live-model.js +112 -0
- package/electron/workspace-ui/md-lite.js +163 -0
- package/electron/workspace-ui/workspace-app.js +240 -0
- package/electron/workspace-ui/workspace-matrix.js +249 -0
- package/electron/workspace-ui/workspace-panels.js +237 -0
- package/electron/workspace-ui/workspace-render.js +277 -0
- package/electron/workspace-ui/workspace-verbs.js +293 -0
- package/electron/workspace-ui/workspace.css +172 -0
- package/package.json +8 -3
- package/schemas/council-run-live.schema.json +25 -1
- package/schemas/council-run.schema.json +34 -0
- package/schemas/progress.schema.json +26 -1
- package/schemas/spend.schema.json +52 -4
- package/skills/second-opinion/MODEL-NOTES.md +53 -5
- package/src/cli-handlers-council-run.js +25 -3
- package/src/cli-handlers-spend.js +50 -5
- package/src/cli-handlers-watch.js +48 -10
- package/src/cli.js +4 -2
- package/src/council/briefings-debate.js +27 -7
- package/src/council/briefings-stage2.js +155 -25
- package/src/council/briefings.js +59 -3
- package/src/council/findings.js +236 -9
- package/src/council/parse-stage2.js +10 -2
- package/src/council/report.js +19 -8
- package/src/council/run-assemble.js +42 -1
- package/src/council/run-budget.js +277 -0
- package/src/council/run-chair.js +4 -1
- package/src/council/run-debate.js +4 -2
- package/src/council/run-finalize.js +102 -0
- package/src/council/run-launch.js +73 -7
- package/src/council/run-server.js +248 -0
- package/src/council/run-stage2.js +118 -0
- package/src/council/run-stages.js +148 -113
- package/src/council/run-state.js +23 -1
- package/src/council/run.js +52 -53
- package/src/council/tally.js +10 -0
- package/src/headless.js +519 -17
- package/src/mcp-council-awareness.js +53 -3
- package/src/observe/council-legs.js +240 -0
- package/src/observe/live-doc.js +39 -4
- package/src/observe/watch-render.js +23 -1
- package/src/opencode-client.js +15 -3
- package/src/sidecar/child-sessions.js +197 -0
- package/src/sidecar/conversation-mirror.js +111 -37
- package/src/sidecar/fanout-budget.js +71 -0
- package/src/sidecar/fanout-leg-fallback.js +69 -21
- package/src/sidecar/fanout-leg.js +29 -1
- package/src/sidecar/fanout-signals.js +61 -0
- package/src/sidecar/fanout-wave-io.js +75 -0
- package/src/sidecar/fanout.js +65 -81
- package/src/sidecar/progress-fields.js +26 -4
- package/src/sidecar/progress.js +8 -1
- package/src/sidecar/session-utils.js +23 -14
- package/src/sidecar/tool-part.js +196 -0
- package/src/sidecar/workspace-window.js +62 -0
- package/src/spend-query.js +33 -6
- package/src/utils/env-num.js +42 -0
- package/src/utils/lifecycle.js +37 -1
- package/src/utils/path-fence.js +120 -0
- package/src/utils/pricing.js +114 -9
- package/src/utils/server-setup.js +79 -1
- package/src/utils/spend-ledger.js +24 -3
- package/src/workspace/artifact-guard.js +208 -0
- package/src/workspace/blind-mode.js +32 -0
- package/src/workspace/fold-format.js +124 -0
- package/src/workspace/live-normalize.js +169 -0
- package/src/workspace/matrix-model.js +94 -0
- package/src/workspace/run-detail.js +229 -0
- 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 };
|
package/src/sidecar/fanout.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
59
|
+
const { installWaveAbort } = require('./fanout-signals');
|
|
74
60
|
const { getSessionDir } = require('../session-manager');
|
|
75
|
-
const { emitWaveStarted
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
210
|
-
({ client, server } =
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
273
|
-
|
|
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
|
-
|
|
289
|
-
|
|
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
|
|
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
|
|
75
|
+
if (TERMINAL_PROGRESS_STAGES.has(progressStage)) { return 'folding'; }
|
|
57
76
|
return 'starting';
|
|
58
77
|
}
|
|
59
78
|
|
|
60
|
-
module.exports = {
|
|
79
|
+
module.exports = {
|
|
80
|
+
sanitizePreview, latestAssistantPreview, deriveStage, COARSE_STAGES,
|
|
81
|
+
TERMINAL_PROGRESS_STAGES,
|
|
82
|
+
};
|
package/src/sidecar/progress.js
CHANGED
|
@@ -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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
277
|
+
return { client, server };
|
|
278
|
+
}, { retryDelayMs: options.retryDelayMs });
|
|
270
279
|
}
|
|
271
280
|
|
|
272
281
|
module.exports = {
|