amicus 4.4.0 → 4.5.0
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 +162 -0
- package/README.md +17 -2
- package/bin/amicus.js +10 -0
- package/docs/DISTRIBUTION.md +234 -0
- package/docs/ROADMAP.md +226 -0
- package/docs/SHIMS.md +62 -0
- package/docs/architecture.md +104 -0
- package/docs/configuration.md +395 -0
- package/docs/council.md +970 -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 +56 -0
- package/docs/testing.md +589 -0
- package/docs/troubleshooting.md +298 -0
- package/docs/usage.md +849 -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-app.js +39 -17
- package/electron/workspace-ui/workspace-matrix.js +46 -9
- package/electron/workspace-ui/workspace-panels.js +88 -19
- package/electron/workspace-ui/workspace-render.js +17 -1
- package/electron/workspace-ui/workspace-verbs.js +48 -2
- package/package.json +8 -3
- package/schemas/council-run-live.schema.json +1 -1
- package/schemas/council-run.schema.json +34 -0
- package/schemas/error.schema.json +1 -1
- package/schemas/event.schema.json +1 -1
- package/schemas/pack.schema.json +30 -0
- package/schemas/progress.schema.json +13 -1
- package/schemas/run-live.schema.json +1 -1
- package/schemas/run.schema.json +2 -1
- package/schemas/spend.schema.json +52 -4
- package/schemas/wave-live.schema.json +1 -1
- package/schemas/wave.schema.json +2 -1
- package/skills/second-opinion/SKILL.md +5 -0
- package/src/cli-handlers-council-run.js +51 -8
- package/src/cli-handlers-pack.js +238 -0
- package/src/cli-handlers-run.js +36 -8
- package/src/cli-handlers-spend.js +20 -2
- package/src/cli-handlers-template.js +53 -0
- package/src/cli-handlers-watch.js +11 -0
- package/src/cli.js +68 -5
- 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 +199 -9
- package/src/council/parse-stage2.js +10 -2
- package/src/council/presets-cli.js +23 -11
- 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 +134 -110
- package/src/council/run-state.js +40 -1
- package/src/council/run.js +45 -47
- package/src/council/tally.js +10 -0
- package/src/headless.js +180 -7
- package/src/mcp-council-run.js +108 -4
- package/src/mcp-server.js +203 -7
- package/src/mcp-tools.js +15 -5
- 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/pack/pack-cli.js +38 -0
- package/src/pack/pack-forward.js +96 -0
- package/src/pack/pack-resolve.js +297 -0
- package/src/pack/pack-store.js +130 -0
- package/src/pack/pack-validate.js +113 -0
- 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 +82 -74
- package/src/sidecar/progress-fields.js +26 -4
- package/src/sidecar/progress.js +42 -1
- package/src/sidecar/session-utils.js +23 -14
- package/src/sidecar/start.js +5 -4
- package/src/sidecar/workspace-auto-open.js +69 -0
- package/src/sidecar/workspace-window.js +46 -1
- package/src/spend-query.js +17 -5
- package/src/template/apply.js +88 -0
- package/src/template/render.js +86 -0
- package/src/template/store.js +106 -0
- package/src/utils/config.js +65 -25
- package/src/utils/error-doc.js +5 -0
- 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/result-schema-rebuild.js +1 -0
- package/src/utils/result-schema.js +8 -2
- package/src/utils/server-setup.js +79 -1
- package/src/utils/spend-ledger.js +24 -3
- package/src/workspace/artifact-guard.js +66 -7
- package/src/workspace/fold-format.js +33 -4
- package/src/workspace/live-normalize.js +28 -15
- package/src/workspace/run-detail.js +13 -1
|
@@ -48,35 +48,83 @@ function recordAttemptSpend({ doc, leg, currentModel, legId, waveId, project, at
|
|
|
48
48
|
} catch { /* best-effort */ }
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/** Add up a list of token blocks, key by key. */
|
|
52
|
+
function foldTokens(blocks) {
|
|
53
|
+
const tokens = {};
|
|
54
|
+
for (const t of blocks) {
|
|
55
|
+
for (const [k, v] of Object.entries(t || {})) { tokens[k] = (tokens[k] || 0) + (v || 0); }
|
|
56
|
+
}
|
|
57
|
+
return tokens;
|
|
58
|
+
}
|
|
59
|
+
|
|
51
60
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* cost never reads as authoritative.
|
|
61
|
+
* Add up a list of resolved cost objects, tagging source 'mixed' when they
|
|
62
|
+
* differ — matching formatCost's `~` behavior so a summed cost never reads as
|
|
63
|
+
* authoritative. `none` is returned when not one of them carried a number,
|
|
64
|
+
* because a sum of nothing is not $0.
|
|
57
65
|
*/
|
|
58
|
-
function
|
|
59
|
-
const withUsage = (attempts || []).filter(a => a.usage && a.usage.tokens);
|
|
60
|
-
if (withUsage.length === 0) { return null; }
|
|
61
|
-
if (withUsage.length === 1) { return withUsage[0].usage; }
|
|
62
|
-
const tokens = {};
|
|
66
|
+
function foldCosts(costs, none) {
|
|
63
67
|
let amount = 0;
|
|
64
68
|
let anyCost = false;
|
|
65
69
|
const sources = new Set();
|
|
66
|
-
for (const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
if (a.usage.cost && typeof a.usage.cost.amount === 'number') {
|
|
71
|
-
amount += a.usage.cost.amount;
|
|
70
|
+
for (const c of costs) {
|
|
71
|
+
if (c && typeof c.amount === 'number') {
|
|
72
|
+
amount += c.amount;
|
|
72
73
|
anyCost = true;
|
|
73
|
-
if (
|
|
74
|
+
if (c.source) { sources.add(c.source); }
|
|
74
75
|
}
|
|
75
76
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
:
|
|
79
|
-
|
|
77
|
+
if (!anyCost) { return none; }
|
|
78
|
+
return { amount, currency: 'USD',
|
|
79
|
+
source: sources.size > 1 ? 'mixed' : (sources.values().next().value || 'reported') };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Fold a leg's attempts[] into ONE usage block. A single-attempt leg returns that
|
|
84
|
+
* attempt's usage verbatim (no behavior change for non-fallback legs).
|
|
85
|
+
*
|
|
86
|
+
* ⚠️ v4.4.1 A1. This used to `return { tokens, cost }` — silently DISCARDING every
|
|
87
|
+
* other key resolveUsage puts on a usage block. The casualty was `subtreeUnknown`:
|
|
88
|
+
* a leg that fell back to a substitute AND left an unattributable subagent subtree
|
|
89
|
+
* lost the flag here, `sumWaveUsage` never counted it in `subtreeUnknownLegs`, and
|
|
90
|
+
* run.json reported `costExact: true` for a total that was not — precisely the lie
|
|
91
|
+
* `costExact` exists to prevent, and reachable only on the fallback path. So the
|
|
92
|
+
* fold now starts from a merge of every attempt's usage and overwrites only the
|
|
93
|
+
* keys it has a real opinion about; anything added to the block later survives by
|
|
94
|
+
* default instead of being dropped by omission.
|
|
95
|
+
*
|
|
96
|
+
* HOW EACH KIND OF KEY FOLDS, and why:
|
|
97
|
+
* - `tokens` / `cost` — SUMMED. They are per-attempt measurements of one leg's
|
|
98
|
+
* total consumption; every attempt really was billed.
|
|
99
|
+
* - `subtreeUnknown` — OR'd. It is a claim about EXACTNESS, not a quantity: if
|
|
100
|
+
* even one attempt left a subtree it could not account for, the leg's total is
|
|
101
|
+
* a floor, and that stays true no matter how exact the other attempts were.
|
|
102
|
+
* Any other fold (last-wins, or requiring every attempt to agree) would let a
|
|
103
|
+
* later clean attempt erase an earlier attempt's admitted gap.
|
|
104
|
+
* - `subtree` — SUMMED, not last-wins. Two attempts can each have walked and
|
|
105
|
+
* PRICED child sessions, and both spent real money; keeping only the last one's
|
|
106
|
+
* measurement would re-open the same under-report one level down, which
|
|
107
|
+
* sumWaveUsage's CA-1 docblock explicitly refuses to make.
|
|
108
|
+
* - anything else — last attempt wins, which is what the merge already does.
|
|
109
|
+
*/
|
|
110
|
+
function sumAttemptUsage(attempts) {
|
|
111
|
+
const withUsage = (attempts || []).filter(a => a.usage && a.usage.tokens);
|
|
112
|
+
if (withUsage.length === 0) { return null; }
|
|
113
|
+
if (withUsage.length === 1) { return withUsage[0].usage; }
|
|
114
|
+
const usages = withUsage.map(a => a.usage);
|
|
115
|
+
const out = Object.assign({}, ...usages);
|
|
116
|
+
out.tokens = foldTokens(usages.map(u => u.tokens));
|
|
117
|
+
out.cost = foldCosts(usages.map(u => u.cost), null);
|
|
118
|
+
const subtrees = usages.map(u => u.subtree).filter(Boolean);
|
|
119
|
+
if (subtrees.length > 0) {
|
|
120
|
+
out.subtree = {
|
|
121
|
+
sessions: subtrees.reduce((n, s) => n + (s.sessions || 0), 0),
|
|
122
|
+
tokens: foldTokens(subtrees.map(s => s.tokens)),
|
|
123
|
+
cost: foldCosts(subtrees.map(s => s.cost), { amount: null, currency: 'USD', source: 'unknown' }),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (usages.some(u => u.subtreeUnknown)) { out.subtreeUnknown = true; }
|
|
127
|
+
return out;
|
|
80
128
|
}
|
|
81
129
|
|
|
82
130
|
/**
|
|
@@ -162,6 +162,12 @@ async function runSingleAttempt({ leg, legId, waveId, project, directory, follow
|
|
|
162
162
|
// OpenCode session may have kept working (and billing) afterwards. Travels
|
|
163
163
|
// with the leg so it is readable long after the run's stderr is gone.
|
|
164
164
|
toolSettleTimedOut: (result && result.toolSettleTimedOut) || undefined,
|
|
165
|
+
// v4.4.1 LC-2: whether the ceiling's abort landed. Deliberately NOT
|
|
166
|
+
// `|| undefined` like the flag above — a `false` here is the whole point
|
|
167
|
+
// ("we tried to stop it and could not; it may still be billing") and must
|
|
168
|
+
// survive onto disk. runHeadless sets it only when the ceiling was hit, so
|
|
169
|
+
// passing it through unchanged keeps a clean leg carrying neither field.
|
|
170
|
+
toolSettleAborted: result ? result.toolSettleAborted : undefined,
|
|
165
171
|
};
|
|
166
172
|
let finalMeta = legPatch;
|
|
167
173
|
if (legDir) {
|
|
@@ -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,17 @@ 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
|
+
* pack? (v4.5 Task 13: {name,version,hash,source} record when launched via
|
|
46
|
+
* --pack; absent/null -> omitted from wave metadata.json/wave.json, not stored as null.
|
|
47
|
+
* v4.5 final-review F2: when absent, wave.json still inherits a pack the caller
|
|
48
|
+
* pre-seeded onto this wave dir's metadata.json before calling runFanout — see
|
|
49
|
+
* `metaPack` below. That is how an MCP-spawned child, which never receives
|
|
50
|
+
* --pack itself, still ends up with the pack on its wave.json),
|
|
51
|
+
* server? + serverClient? (v4.4.1 Task 0.5: an ALREADY-STARTED OpenCode server
|
|
52
|
+
* to run this wave's legs on. Both or neither. When supplied this wave never
|
|
53
|
+
* starts a server and never closes one — see the seam comment in step 4.
|
|
54
|
+
* NOT `client`, which is the client TYPE string on this function.)
|
|
63
55
|
* @returns {Promise<{wave: object, exitCode: number}>} Never rejects for leg errors.
|
|
64
56
|
*/
|
|
65
57
|
async function runFanout(options) {
|
|
@@ -70,9 +62,9 @@ async function runFanout(options) {
|
|
|
70
62
|
const { buildContext } = require('./context-builder');
|
|
71
63
|
const { buildPrompts } = require('../prompt-builder');
|
|
72
64
|
const { generateFoldNonce } = require('../utils/fold-marker');
|
|
73
|
-
const {
|
|
65
|
+
const { installWaveAbort } = require('./fanout-signals');
|
|
74
66
|
const { getSessionDir } = require('../session-manager');
|
|
75
|
-
const { emitWaveStarted
|
|
67
|
+
const { emitWaveStarted } = require('../observe/events'); // wave-terminal is emitted by finishWave
|
|
76
68
|
|
|
77
69
|
const project = options.project || process.cwd();
|
|
78
70
|
const createdAt = new Date().toISOString();
|
|
@@ -88,9 +80,17 @@ async function runFanout(options) {
|
|
|
88
80
|
console.log(formatWaveHuman(doc));
|
|
89
81
|
}
|
|
90
82
|
};
|
|
91
|
-
|
|
92
|
-
|
|
83
|
+
// v4.4.1 Task 0.5: a wave that dies BEFORE its legs still owes the run a
|
|
84
|
+
// wave.json. Backlog C1 covered the pre-`try` throw; a server that never
|
|
85
|
+
// started was its uncovered sibling — run v441plan01's four dead seats left a
|
|
86
|
+
// `reason` in metadata.json, no wave.json, and stage1 recorded 'complete'.
|
|
87
|
+
// waveDir is optional (only the post-creation caller has one).
|
|
88
|
+
const errorWave = (waveId, message, waveDir) => {
|
|
89
|
+
const doc = buildWaveResult({ waveId: waveId || null, legs: [], promptMeta: options.promptMeta || null, pack: options.pack, createdAt, completedAt: new Date().toISOString(), status: 'error' });
|
|
93
90
|
doc.error = message;
|
|
91
|
+
doc.reason = message; // classifier alias, same as fanout-leg.js's run docs
|
|
92
|
+
// best-effort: an unwritable wave dir must not mask the real error
|
|
93
|
+
if (waveDir) { try { writeWaveDoc(waveDir, doc); } catch { /* ignore */ } }
|
|
94
94
|
emit(doc);
|
|
95
95
|
return { wave: doc, exitCode: 1 };
|
|
96
96
|
};
|
|
@@ -143,13 +143,24 @@ async function runFanout(options) {
|
|
|
143
143
|
const waveDir = getSessionDir(project, waveId);
|
|
144
144
|
fs.mkdirSync(waveDir, { recursive: true, mode: 0o700 });
|
|
145
145
|
fs.writeFileSync(path.join(waveDir, 'briefing.md'), options.prompt, { mode: 0o600 });
|
|
146
|
-
writeWaveMetadata(waveDir, {
|
|
146
|
+
const waveMeta = writeWaveMetadata(waveDir, {
|
|
147
147
|
taskId: waveId, type: 'wave', status: 'running', mode: 'headless',
|
|
148
148
|
models: legs.map(l => (l.ok ? l.model : l.modelInput)), legs: legIds,
|
|
149
149
|
briefing: String(options.prompt).slice(0, 200),
|
|
150
150
|
promptMeta: options.promptMeta || null,
|
|
151
|
+
...(options.pack ? { pack: options.pack } : {}), // v4.5 Task 13: absent-not-null.
|
|
151
152
|
pid: process.pid, project, createdAt,
|
|
152
153
|
});
|
|
154
|
+
// v4.5 final-review F2: an MCP-spawned child never gets --pack (single-
|
|
155
|
+
// resolution rule), but mcp-server.js pre-seeds THIS wave dir's
|
|
156
|
+
// metadata.json with the pack it already resolved in-process before
|
|
157
|
+
// spawning the child. writeWaveMetadata read-merges (fanout-wave-io.js),
|
|
158
|
+
// so its RETURN VALUE already carries that pre-seeded pack when
|
|
159
|
+
// options.pack is absent here — inherit from it below rather than
|
|
160
|
+
// re-reading the file (mirrors the inherit idiom in
|
|
161
|
+
// result-schema-rebuild.js:93, which reads meta.pack off a metadata.json
|
|
162
|
+
// it loaded for an unrelated reason).
|
|
163
|
+
const metaPack = waveMeta.pack;
|
|
153
164
|
emitWaveStarted(waveDir, waveId, legs.map(l => (l.ok ? l.model : l.modelInput)), legIds, follow);
|
|
154
165
|
|
|
155
166
|
// 2b. All legs failed to route (#61 perf): no leg will ever touch the
|
|
@@ -160,16 +171,11 @@ async function runFanout(options) {
|
|
|
160
171
|
const legDocs = legs.map((leg, i) => buildRoutingFailureLeg({ leg, legId: legIds[i], waveId, quiet: options.quiet }));
|
|
161
172
|
const completedAt = new Date().toISOString();
|
|
162
173
|
const wave = buildWaveResult({
|
|
163
|
-
waveId, legs: legDocs, promptMeta: options.promptMeta || null, createdAt, completedAt, notices,
|
|
174
|
+
waveId, legs: legDocs, promptMeta: options.promptMeta || null, pack: options.pack || metaPack, createdAt, completedAt, notices,
|
|
164
175
|
});
|
|
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 };
|
|
176
|
+
return finishWave({ wave, waveDir, waveId, project, completedAt, follow, emit,
|
|
177
|
+
exitCode: waveExitCode(wave.status),
|
|
178
|
+
onComplete: options.onComplete, onCompleteDeps: options.onCompleteDeps });
|
|
173
179
|
}
|
|
174
180
|
|
|
175
181
|
// 3. Context + prompts built ONCE (model-independent)
|
|
@@ -198,37 +204,41 @@ async function runFanout(options) {
|
|
|
198
204
|
mcp: options.mcp, mcpConfig: options.mcpConfig, clientType: options.client,
|
|
199
205
|
noMcp: options.noMcp, excludeMcp: options.excludeMcp,
|
|
200
206
|
});
|
|
207
|
+
// ⚠️ v4.4.1 Task 0.5 — the external-server seam runHeadless has carried since
|
|
208
|
+
// v4.0 (src/headless.js:245): a caller that already owns a server passes it in
|
|
209
|
+
// and we must NOT close it. Added here because a council run launches its
|
|
210
|
+
// Stage-1 seat wave and its critic solo under ONE Promise.all (run-stages.js:83)
|
|
211
|
+
// and two concurrent startOpenCodeServer calls race on OpenCode's SQLite —
|
|
212
|
+
// run v441plan01 lost four of five seats in 736ms to `database is locked`.
|
|
213
|
+
// NAME DIVERGENCE, deliberate: runHeadless spells the pair client+server, but
|
|
214
|
+
// runFanout's `options.client` is ALREADY the client TYPE string (buildMcpConfig
|
|
215
|
+
// above, buildContext, buildPrompts, cli-handlers-run.js `client: args.client`),
|
|
216
|
+
// so the SDK client is `options.serverClient`. Both or neither: a half-injection
|
|
217
|
+
// falls back to owning a server rather than running clientless.
|
|
218
|
+
const externalServer = !!(options.server && options.serverClient);
|
|
201
219
|
let client, server;
|
|
202
|
-
|
|
203
|
-
({ client, server } =
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
220
|
+
if (externalServer) {
|
|
221
|
+
({ serverClient: client, server } = options);
|
|
222
|
+
logger.debug('Using external server (shared server mode)', { waveId, url: server.url });
|
|
223
|
+
} else {
|
|
224
|
+
try {
|
|
225
|
+
({ client, server } = await startOpenCodeServer(mcpServers, { models: validated.serverModels || okLegs.map(l => l.model) }));
|
|
226
|
+
} catch (err) {
|
|
227
|
+
writeWaveMetadata(waveDir, { status: 'error', reason: err.message, completedAt: new Date().toISOString() });
|
|
228
|
+
return errorWave(waveId, `Failed to start server: ${err.message}`, waveDir);
|
|
229
|
+
}
|
|
207
230
|
}
|
|
208
|
-
|
|
231
|
+
// Only an OWNED server's pid belongs in this wave's metadata: mcp-server.js's
|
|
232
|
+
// wave abort SIGTERMs `metadata.goPid` as "the orchestrator + its OWNED
|
|
233
|
+
// OpenCode server", which on an injected server would kill every sibling wave.
|
|
234
|
+
if (!externalServer && server.goPid) { writeWaveMetadata(waveDir, { goPid: server.goPid }); }
|
|
209
235
|
|
|
210
|
-
// 5. Signal abort
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
236
|
+
// 5. Signal abort (./fanout-signals owns the handler — size gate): mark wave +
|
|
237
|
+
// all legs aborted, close an OWNED server, then let NORMAL control flow
|
|
238
|
+
// finalize, so step 7 still writes wave.json and emits a parseable aborted
|
|
239
|
+
// document. An unref'd force-exit watchdog backstops a wedged leg.
|
|
214
240
|
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
|
-
});
|
|
241
|
+
const waveAbort = installWaveAbort({ waveId, waveDir, legDirs, server, externalServer });
|
|
232
242
|
|
|
233
243
|
// 6. Launch all ROUTABLE legs concurrently (runLeg never rejects). A leg that
|
|
234
244
|
// failed to route (leg.ok === false) resolves to an error run doc in its own
|
|
@@ -262,26 +272,24 @@ async function runFanout(options) {
|
|
|
262
272
|
}));
|
|
263
273
|
} finally {
|
|
264
274
|
heartbeat.stop();
|
|
265
|
-
|
|
266
|
-
|
|
275
|
+
waveAbort.uninstall();
|
|
276
|
+
// ⚠️ close site 2 of 2 (`grep -n "server.close()" src/sidecar/fanout.js`).
|
|
277
|
+
// An injected server outlives this wave by design — the owner closes it once.
|
|
278
|
+
if (!externalServer) { try { await server.close(); } catch { /* already closed on signal */ } }
|
|
267
279
|
}
|
|
268
280
|
|
|
269
281
|
// 7. Aggregate, persist (atomic: tmp + rename), finalize, emit
|
|
270
282
|
const completedAt = new Date().toISOString();
|
|
283
|
+
const signalled = waveAbort.signal();
|
|
271
284
|
const wave = buildWaveResult({
|
|
272
|
-
waveId, legs: legDocs, promptMeta: options.promptMeta || null, createdAt, completedAt,
|
|
285
|
+
waveId, legs: legDocs, promptMeta: options.promptMeta || null, pack: options.pack || metaPack, createdAt, completedAt,
|
|
273
286
|
status: signalled ? 'aborted' : null, notices,
|
|
274
287
|
});
|
|
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
288
|
const exitCode = signalled
|
|
279
289
|
? (signalled === 'SIGINT' ? 130 : 143)
|
|
280
290
|
: waveExitCode(wave.status);
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
emit(wave);
|
|
284
|
-
return { wave, exitCode };
|
|
291
|
+
return finishWave({ wave, waveDir, waveId, project, exitCode, completedAt, follow, emit,
|
|
292
|
+
onComplete: options.onComplete, onCompleteDeps: options.onCompleteDeps });
|
|
285
293
|
}
|
|
286
294
|
|
|
287
295
|
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
|
/**
|
|
@@ -123,6 +130,39 @@ function writeProgress(sessionDir, stage, extra = {}) {
|
|
|
123
130
|
writeFileAtomic(progressPath, JSON.stringify(data), { mode: 0o600 });
|
|
124
131
|
}
|
|
125
132
|
|
|
133
|
+
/**
|
|
134
|
+
* FR-1 (v4.5): stamp a TERMINAL stage into progress.json on a failure path,
|
|
135
|
+
* preserving previously recorded usage. Extracted from headless.js's A3 outer
|
|
136
|
+
* catch so the early-return paths and the exception path can never drift.
|
|
137
|
+
*
|
|
138
|
+
* writeProgress REBUILDS progress.json (no merge) — a bare terminal write would
|
|
139
|
+
* delete whatever real spend the last flush recorded, trading a stale-stage bug
|
|
140
|
+
* for a cost under-report on exactly the legs that failed. So the prior usage is
|
|
141
|
+
* read back and re-attached; `extra` fields beyond usage are deliberately
|
|
142
|
+
* dropped (readProgress derives message counts from conversation.jsonl).
|
|
143
|
+
*
|
|
144
|
+
* Runs on error paths: must never throw and never mask the original error.
|
|
145
|
+
*
|
|
146
|
+
* @param {string} sessionDir
|
|
147
|
+
* @param {string} errorMessage - the failure being recorded (stage derives from it)
|
|
148
|
+
* @returns {boolean} true if the terminal record was written
|
|
149
|
+
*/
|
|
150
|
+
function writeTerminalProgressSafe(sessionDir, errorMessage) {
|
|
151
|
+
try {
|
|
152
|
+
let priorUsage = null;
|
|
153
|
+
try {
|
|
154
|
+
const prior = JSON.parse(fs.readFileSync(path.join(sessionDir, 'progress.json'), 'utf-8'));
|
|
155
|
+
if (prior && prior.usage) { priorUsage = prior.usage; }
|
|
156
|
+
} catch { /* no readable prior record: write the terminal stage without usage */ }
|
|
157
|
+
const { resolveTerminalState } = require('./session-finalize');
|
|
158
|
+
const stage = resolveTerminalState({ error: errorMessage }).status;
|
|
159
|
+
writeProgress(sessionDir, stage, priorUsage ? { usage: priorUsage } : {});
|
|
160
|
+
return true;
|
|
161
|
+
} catch {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
126
166
|
/**
|
|
127
167
|
* Read progress from a session's conversation.jsonl and progress.json files.
|
|
128
168
|
*
|
|
@@ -235,6 +275,7 @@ function readProgress(sessionDir) {
|
|
|
235
275
|
module.exports = {
|
|
236
276
|
readProgress,
|
|
237
277
|
writeProgress,
|
|
278
|
+
writeTerminalProgressSafe,
|
|
238
279
|
extractLatest,
|
|
239
280
|
computeLastActivity,
|
|
240
281
|
STAGE_LABELS,
|