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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +32 -0
- package/README.md +3 -1
- 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/main.js +4 -1
- package/electron/setup-ui-aliases.js +6 -6
- package/electron/workspace-ui/live-model.js +12 -1
- package/electron/workspace-ui/md-lite.js +52 -8
- package/electron/workspace-ui/workspace-matrix.js +46 -9
- package/electron/workspace-ui/workspace-panels.js +14 -3
- package/electron/workspace-ui/workspace-render.js +7 -1
- package/electron/workspace-ui/workspace-verbs.js +48 -2
- package/package.json +8 -3
- package/schemas/council-run.schema.json +20 -0
- package/schemas/progress.schema.json +12 -0
- package/schemas/spend.schema.json +52 -4
- package/src/cli-handlers-spend.js +20 -2
- package/src/cli-handlers-watch.js +11 -0
- 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 +24 -1
- 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 +64 -11
- 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 +29 -1
- package/src/council/run-server.js +248 -0
- package/src/council/run-stage2.js +118 -0
- package/src/council/run-stages.js +132 -111
- package/src/council/run-state.js +23 -1
- package/src/council/run.js +44 -46
- package/src/council/tally.js +10 -0
- package/src/headless.js +175 -6
- package/src/observe/council-legs.js +60 -3
- package/src/observe/live-doc.js +18 -1
- package/src/observe/watch-render.js +4 -1
- package/src/sidecar/child-sessions.js +1 -2
- package/src/sidecar/fanout-leg-fallback.js +69 -21
- package/src/sidecar/fanout-leg.js +6 -0
- package/src/sidecar/fanout-signals.js +61 -0
- package/src/sidecar/fanout-wave-io.js +75 -0
- package/src/sidecar/fanout.js +61 -70
- 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/spend-query.js +17 -5
- package/src/utils/lifecycle.js +37 -1
- package/src/utils/path-fence.js +39 -1
- package/src/utils/pricing.js +26 -10
- package/src/utils/server-setup.js +79 -1
- package/src/utils/spend-ledger.js +24 -3
- package/src/workspace/artifact-guard.js +22 -1
- package/src/workspace/fold-format.js +33 -4
- package/src/workspace/live-normalize.js +28 -15
- 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 };
|
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
|
};
|
|
@@ -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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
203
|
-
({ client, server } =
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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
|
|
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 = {
|
package/src/spend-query.js
CHANGED
|
@@ -60,25 +60,32 @@ function rowKey(row, dimension) {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
|
-
* Group rows into {key, amount, tokens, runs, unpricedRows,
|
|
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
|
}
|
package/src/utils/lifecycle.js
CHANGED
|
@@ -41,4 +41,40 @@ function armExitWatchdog(code = 0, ms = 1500, deps = {}) {
|
|
|
41
41
|
return t;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
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 };
|
package/src/utils/path-fence.js
CHANGED
|
@@ -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
|
|
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
|
package/src/utils/pricing.js
CHANGED
|
@@ -63,24 +63,37 @@ function lookupPricing(modelId) {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
|
-
* Did we actually OBSERVE
|
|
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.
|
|
72
|
-
*
|
|
73
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
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' }; }
|