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
|
@@ -15,8 +15,14 @@
|
|
|
15
15
|
const fs = require('fs');
|
|
16
16
|
const path = require('path');
|
|
17
17
|
const runState = require('./council/run-state');
|
|
18
|
+
// The pointer-containment fence. Lives in a dependency-free leaf module
|
|
19
|
+
// (src/utils/path-fence.js) precisely so any surface can require it —
|
|
20
|
+
// requiring it here adds no cycle and keeps ONE implementation of the check
|
|
21
|
+
// shared with the v4.4 workspace reads.
|
|
22
|
+
const { containsOnDisk } = require('./utils/path-fence');
|
|
18
23
|
const { RUNNING_VERSION } = require('./utils/version-info');
|
|
19
24
|
const { enrichLegUsage, markLive, rollupWaveUsage } = require('./observe/live-doc');
|
|
25
|
+
const { buildLegRows } = require('./observe/council-legs');
|
|
20
26
|
|
|
21
27
|
/**
|
|
22
28
|
* Every wave a stage launched: the primary `waveId` plus the recorded
|
|
@@ -94,6 +100,30 @@ function legUsage(project, legId) {
|
|
|
94
100
|
return enrichLegUsage({ model }, progressUsage);
|
|
95
101
|
}
|
|
96
102
|
|
|
103
|
+
/**
|
|
104
|
+
* readPointer PLUS the containment check the pointer file itself cannot
|
|
105
|
+
* provide. runState.readPointer validates `council-<id>.json`'s {runId, runDir}
|
|
106
|
+
* JSON only for truthiness (run-state.js:133-139), so a tampered or stale
|
|
107
|
+
* pointer can point runDir anywhere on disk — and the two callers below do not
|
|
108
|
+
* merely READ from it, they runState.checkpoint() INTO it (crash detection and
|
|
109
|
+
* abort), which makes an unfenced pointer a write primitive at an
|
|
110
|
+
* attacker-chosen path. A real runDir is always nested inside the project:
|
|
111
|
+
* src/mcp-council-run.js:109 rejects an outDir outside it at creation time, so
|
|
112
|
+
* nothing legitimate is refused here.
|
|
113
|
+
*
|
|
114
|
+
* Fails to null — the SAME "not a council run" signal an absent/corrupt pointer
|
|
115
|
+
* already produces, so amicus_status / amicus_abort keep their existing
|
|
116
|
+
* "Session <id> not found in project <cwd>" error contract (mcp-server.js:586,
|
|
117
|
+
* :1005) and `amicus abort` keeps falling through to its own not-found path.
|
|
118
|
+
* No new error shape, and — because the fence runs before readRun — no read or
|
|
119
|
+
* write ever reaches the escaping directory.
|
|
120
|
+
* @returns {{runId: string, runDir: string}|null}
|
|
121
|
+
*/
|
|
122
|
+
function readFencedPointer(project, taskId) {
|
|
123
|
+
const ptr = runState.readPointer(project, taskId);
|
|
124
|
+
return ptr && containsOnDisk(project, ptr.runDir) ? ptr : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
97
127
|
function elapsedOf(run) {
|
|
98
128
|
const end = run.completedAt || new Date().toISOString();
|
|
99
129
|
const ms = Math.max(0, new Date(end).getTime() - new Date(run.createdAt || end).getTime());
|
|
@@ -102,7 +132,7 @@ function elapsedOf(run) {
|
|
|
102
132
|
|
|
103
133
|
/** Status payload for a council runId, or null when the id is not a council run. */
|
|
104
134
|
function buildCouncilStatusPayload(project, taskId) {
|
|
105
|
-
const ptr =
|
|
135
|
+
const ptr = readFencedPointer(project, taskId);
|
|
106
136
|
if (!ptr) { return null; }
|
|
107
137
|
const run = runState.readRun(ptr.runDir);
|
|
108
138
|
if (!run) { return null; }
|
|
@@ -132,14 +162,19 @@ function buildCouncilStatusPayload(project, taskId) {
|
|
|
132
162
|
// least one sub-wave record exists on disk.
|
|
133
163
|
// Cost-by-seat rides the same loop, read-time from progress.json only (A8) —
|
|
134
164
|
// usageLegs stays empty (no `usage` on the payload) until a leg has actually
|
|
135
|
-
// flushed usage; a leg with none yet contributes nothing (N3).
|
|
165
|
+
// flushed usage; a leg with none yet contributes nothing (N3). allLegIds
|
|
166
|
+
// collects every leg id seen regardless of usage — the row builder below
|
|
167
|
+
// needs just-started legs too (DE-ROT F01: the naive `payload.legs =
|
|
168
|
+
// usageLegs` would silently drop them).
|
|
136
169
|
const usageLegs = [];
|
|
170
|
+
const allLegIds = [];
|
|
137
171
|
for (const waveId of active && active.project ? subWaveIds(active) : []) {
|
|
138
172
|
const c = countWaveLegs(active.project, waveId);
|
|
139
173
|
if (!c) { continue; }
|
|
140
174
|
legsTotal = (legsTotal || 0) + c.total;
|
|
141
175
|
legsComplete = (legsComplete || 0) + c.complete;
|
|
142
176
|
for (const legId of waveLegIds(active.project, waveId)) {
|
|
177
|
+
allLegIds.push(legId);
|
|
143
178
|
const enriched = legUsage(active.project, legId);
|
|
144
179
|
if (enriched.usage) { usageLegs.push(enriched); }
|
|
145
180
|
}
|
|
@@ -152,6 +187,15 @@ function buildCouncilStatusPayload(project, taskId) {
|
|
|
152
187
|
version: RUNNING_VERSION,
|
|
153
188
|
};
|
|
154
189
|
if (usageLegs.length) { payload.usage = rollupWaveUsage(usageLegs); }
|
|
190
|
+
if (allLegIds.length) {
|
|
191
|
+
// F34/F36: bench/critic/lenses are the alias-valued fields legRole needs
|
|
192
|
+
// (roleFor's rule); stageName lets it treat the chair stage as its own
|
|
193
|
+
// case rather than matching on alias (see council-legs.js's legRole doc).
|
|
194
|
+
const runCtx = { bench: run.bench, critic: run.critic, lenses: run.lenses, stageName: active.name };
|
|
195
|
+
const built = buildLegRows(active.project, allLegIds, runCtx);
|
|
196
|
+
payload.legs = built.rows;
|
|
197
|
+
if (built.stalled) { payload.stalled = true; payload.stalledForSeconds = built.stalledForSeconds; }
|
|
198
|
+
}
|
|
155
199
|
if (run.error) { payload.reason = `${run.error.code}: ${run.error.message}`; }
|
|
156
200
|
return markLive(payload);
|
|
157
201
|
}
|
|
@@ -161,6 +205,12 @@ function listCouncilRuns(project) {
|
|
|
161
205
|
const { sanitizePreview } = require('./sidecar/progress-fields');
|
|
162
206
|
const out = [];
|
|
163
207
|
for (const ptr of runState.listPointers(project)) {
|
|
208
|
+
// Same fence as readFencedPointer, applied per enumerated pointer
|
|
209
|
+
// (listPointers parses the files itself and is no stricter about runDir).
|
|
210
|
+
// Skipping is the right failure mode here: an escaping pointer degrades
|
|
211
|
+
// exactly like the unreadable-run.json case below, so one tampered pointer
|
|
212
|
+
// can never blank the rest of the list.
|
|
213
|
+
if (!containsOnDisk(project, ptr.runDir)) { continue; }
|
|
164
214
|
const run = runState.readRun(ptr.runDir);
|
|
165
215
|
if (!run) { continue; }
|
|
166
216
|
let briefing = '';
|
|
@@ -204,7 +254,7 @@ function cascadeWave(project, waveId) {
|
|
|
204
254
|
* @returns {null|{notFound?: true}|{alreadyTerminal: true, status}|{aborted: true, cascaded: number}}
|
|
205
255
|
*/
|
|
206
256
|
function abortCouncilRun(project, taskId) {
|
|
207
|
-
const ptr =
|
|
257
|
+
const ptr = readFencedPointer(project, taskId);
|
|
208
258
|
if (!ptr) { return null; }
|
|
209
259
|
const run = runState.readRun(ptr.runDir);
|
|
210
260
|
if (!run) { return null; }
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// src/observe/council-legs.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module observe/council-legs
|
|
6
|
+
* Per-leg row builder for the composed council live doc
|
|
7
|
+
* (buildCouncilStatusPayload in src/mcp-council-awareness.js). Split out of
|
|
8
|
+
* that file to stay under the 300-line gate (DE-ROT Task 0.5, closes F01:
|
|
9
|
+
* `usageLegs` was computed then discarded — no `legs[]` ever reached the
|
|
10
|
+
* payload, so the live Seats panel had no data source).
|
|
11
|
+
*
|
|
12
|
+
* One row per leg id, built UNCONDITIONALLY: unlike the usage rollup (gated
|
|
13
|
+
* on `enriched.usage` in buildCouncilStatusPayload), a live seats panel needs
|
|
14
|
+
* just-started legs — the ones with no usage yet — just as much as priced
|
|
15
|
+
* ones. Field names mirror the wave branch (src/mcp-server.js:592-608) so
|
|
16
|
+
* live-normalize.js (Task 14) has one vocabulary to map, not two.
|
|
17
|
+
*
|
|
18
|
+
* `modelInput` + `role` (F36/F34 correction): a live leg's `model` is the
|
|
19
|
+
* RESOLVED executable id (metadata.model), never the council ALIAS that
|
|
20
|
+
* run.json's bench/chair/critic/lenses and roleFor's rule are keyed on — so
|
|
21
|
+
* deriving role from `model` is a silent no-op (Role column permanently
|
|
22
|
+
* em-dash) and blind mode's labelOf(alias) lookup never matches (real model
|
|
23
|
+
* id leaks). The alias IS on disk per-leg, though: every council leg goes
|
|
24
|
+
* through src/sidecar/fanout-leg.js's runSingleAttempt, which calls
|
|
25
|
+
* `writeLegPatch(legDir, { parentWave, modelInput })` synchronously,
|
|
26
|
+
* immediately after leg creation (fanout-leg.js:101) — well before any
|
|
27
|
+
* status poll could reasonably observe it missing. So this module reads
|
|
28
|
+
* `modelInput` straight off the leg's own metadata.json, no run.json join
|
|
29
|
+
* needed for the alias itself.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const fs = require('fs');
|
|
33
|
+
const path = require('path');
|
|
34
|
+
const { readProgress, isStalled } = require('../sidecar/progress');
|
|
35
|
+
const { enrichLegUsage, TERMINAL } = require('./live-doc');
|
|
36
|
+
const { roleFor } = require('../council/run-stages');
|
|
37
|
+
const { logger } = require('../utils/logger');
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A leg's council role. The chair stage is the one case alias identity
|
|
41
|
+
* cannot resolve: run-chair.js's fallback chain (ch1/ch2 = run.chair's own
|
|
42
|
+
* alias, ch3 = a DIFFERENT ledger-promoted alias, ch4 = whichever succeeded)
|
|
43
|
+
* means a chair leg's modelInput does not reliably equal run.json's `chair`
|
|
44
|
+
* field while the chain is still in flight (that field is only checkpointed
|
|
45
|
+
* once the WHOLE chain resolves, src/council/run-chair.js:122) — so alias
|
|
46
|
+
* matching would miss ch3/ch4 mid-run. The stage that owns the leg is the
|
|
47
|
+
* authoritative signal instead (plan's F34 correction: "derive role from the
|
|
48
|
+
* stage that owns the leg"). Every other stage (stage1/stage2/debate-*)
|
|
49
|
+
* reuses roleFor (src/council/run-stages.js) keyed on modelInput — a
|
|
50
|
+
* model's seat/critic/lens identity is stable whether it's reviewing
|
|
51
|
+
* (stage1) or judging (stage2), and a repair/debate leg relaunches the SAME
|
|
52
|
+
* alias as its origin leg, so the identity carries through unchanged.
|
|
53
|
+
* @returns {string|null} null when modelInput is unknown (truthful — never a guess)
|
|
54
|
+
*/
|
|
55
|
+
function legRole({ bench, critic, lenses, stageName, modelInput }) {
|
|
56
|
+
if (!modelInput) { return null; }
|
|
57
|
+
if (stageName === 'chair') { return 'chair'; }
|
|
58
|
+
// ⚠️ v4.4.1 LC-4: roleFor's LENS branch does `o.models.indexOf(alias)`, so a
|
|
59
|
+
// run.json carrying truthy `lenses` with a missing or non-array `bench` throws
|
|
60
|
+
// a TypeError. This function runs on every status poll, so one malformed
|
|
61
|
+
// run.json took out three surfaces at once: `amicus status`, the amicus_status
|
|
62
|
+
// MCP tool, and `amicus watch`. It was unreachable only because
|
|
63
|
+
// src/council/run.js:72 happens to write `bench` and `lenses` together — an
|
|
64
|
+
// argument that rests entirely on one writer never changing.
|
|
65
|
+
//
|
|
66
|
+
// A role we cannot compute is `null` — the module's existing, documented
|
|
67
|
+
// degradation (an em-dash in the Role column), never a guess and never a
|
|
68
|
+
// throw. The non-lens branch never touches `models`, so it stays exact.
|
|
69
|
+
//
|
|
70
|
+
// ⚠️ v4.4.1 A2: the guard above was ASYMMETRIC — it validated `bench` and took `lenses` on
|
|
71
|
+
// trust, but roleFor's lens branch indexes BOTH (`o.lenses[o.models.indexOf(alias)]`). Neither
|
|
72
|
+
// remaining shape throws, because slug() coerces with String(), so both produced a confident
|
|
73
|
+
// LIE instead of a crash: `lenses: 'security'` with `bench: ['gpt']` indexes the STRING and
|
|
74
|
+
// yields `lens:s`, and a `lenses` array shorter than `bench` yields `lens:undefined`. The
|
|
75
|
+
// length pairing is a real, enforced invariant, not an assumption — cli-handlers-council-run.js:170
|
|
76
|
+
// refuses `--lenses` unless it has exactly one lens per seat — so a run.json that violates it is
|
|
77
|
+
// malformed, and the honest answer for a malformed pairing is the same `null` (an em-dash in the
|
|
78
|
+
// Role column) that LC-4 established, never a guess.
|
|
79
|
+
if (lenses && (!Array.isArray(bench) || !Array.isArray(lenses) || lenses.length !== bench.length)) {
|
|
80
|
+
logger.debug('leg role unresolved: run.json lenses/bench are not a matched pair', {
|
|
81
|
+
modelInput, stageName,
|
|
82
|
+
benchType: bench === null ? 'null' : typeof bench,
|
|
83
|
+
lensesType: lenses === null ? 'null' : typeof lenses,
|
|
84
|
+
benchLength: Array.isArray(bench) ? bench.length : null,
|
|
85
|
+
lensesLength: Array.isArray(lenses) ? lenses.length : null,
|
|
86
|
+
});
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
return roleFor({ models: bench, critic, lenses }, modelInput);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* One composed row for a leg, plus the ms-since-activity behind its `stalled`
|
|
94
|
+
* flag (or null when not stalled/not yet measurable) so the caller can roll
|
|
95
|
+
* several legs' staleness into one run-level summary without re-parsing
|
|
96
|
+
* `lastActivityAt` back into a timestamp.
|
|
97
|
+
* @param {string} project
|
|
98
|
+
* @param {string} legId
|
|
99
|
+
* @param {{bench: string[], critic: string|null, lenses: string[]|null, stageName: string}} runCtx
|
|
100
|
+
* @returns {{row: object, stalledMs: number|null}}
|
|
101
|
+
*/
|
|
102
|
+
function buildLegRow(project, legId, runCtx) {
|
|
103
|
+
const { getSessionDir } = require('../session-manager');
|
|
104
|
+
const legDir = getSessionDir(project, legId);
|
|
105
|
+
let meta = {};
|
|
106
|
+
// ⚠️ v4.4.1 LC-9: the catch stays ALL-OR-NOTHING on purpose (Appendix A-9) —
|
|
107
|
+
// it mirrors the wave branch, and a half-parsed metadata object is worse than
|
|
108
|
+
// an empty one. What was wrong was the SILENCE: a corrupt metadata.json, an
|
|
109
|
+
// EACCES on the leg dir, and "the file doesn't exist yet" were indistinguishable
|
|
110
|
+
// and left no trace anywhere, so a leg with corrupt metadata rendered as a
|
|
111
|
+
// just-started leg forever. `code` is what separates them — ENOENT is the
|
|
112
|
+
// ordinary just-started case, anything else is a real fault. Logging only; the
|
|
113
|
+
// branching is untouched.
|
|
114
|
+
try { meta = JSON.parse(fs.readFileSync(path.join(legDir, 'metadata.json'), 'utf-8')); }
|
|
115
|
+
catch (metaErr) {
|
|
116
|
+
logger.debug('leg metadata.json unreadable — rendering the leg with base fields only', {
|
|
117
|
+
legId, code: metaErr.code || null, error: metaErr.message,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
// Truthful null, never metadata.model as a fallback: showing the resolved
|
|
121
|
+
// id where the alias was expected is exactly the F36 bug (blind mode would
|
|
122
|
+
// leak the real model id instead of degrading to an em-dash).
|
|
123
|
+
const modelInput = meta.modelInput || null;
|
|
124
|
+
// LC-4: hoisted out of the object literal below so the guard inside legRole is
|
|
125
|
+
// the only thing standing between a malformed run.json and three live surfaces.
|
|
126
|
+
const role = legRole({ ...runCtx, modelInput });
|
|
127
|
+
const row = {
|
|
128
|
+
taskId: legId, model: meta.model || null, status: meta.status || 'unknown',
|
|
129
|
+
modelInput, role,
|
|
130
|
+
};
|
|
131
|
+
let stalledMs = null;
|
|
132
|
+
let p = null;
|
|
133
|
+
try {
|
|
134
|
+
p = readProgress(legDir);
|
|
135
|
+
row.messages = p.messages;
|
|
136
|
+
row.stage = p.stage;
|
|
137
|
+
row.latestPreview = p.latestPreview;
|
|
138
|
+
row.lastActivityAt = p.lastActivityAt;
|
|
139
|
+
row.stalled = row.status === 'running' && isStalled(p.lastActivityMs);
|
|
140
|
+
if (row.stalled) { stalledMs = p.lastActivityMs; }
|
|
141
|
+
} catch (progressErr) {
|
|
142
|
+
// ⚠️ v4.4.1 LC-9: same deal as the metadata catch above — all-or-nothing by
|
|
143
|
+
// design (A-9), silent by accident. readProgress swallows a malformed
|
|
144
|
+
// progress.json itself, so reaching here means the leg DIR could not be
|
|
145
|
+
// stat'd/read at all (EACCES, a vanished session dir) or readProgress threw
|
|
146
|
+
// on a shape it could not handle — neither of which is "hasn't started yet",
|
|
147
|
+
// and both of which previously left the row indistinguishable from one.
|
|
148
|
+
logger.debug('leg progress unreadable — rendering the leg with base fields only', {
|
|
149
|
+
legId, code: progressErr.code || null, error: progressErr.message,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// council review C3: this is a SEPARATE try from readProgress's above, on
|
|
154
|
+
// purpose. The old code wrapped both in one try, so a pricing-resolution
|
|
155
|
+
// failure (an unknown model, a corrupt catalog row) landed in the exact same
|
|
156
|
+
// catch as "progress.json doesn't exist yet" — an operator (or the workspace's
|
|
157
|
+
// cost-by-seat panel) could not tell "pricing lookup failed" from "hasn't
|
|
158
|
+
// billed yet"; both rendered as a permanently blank cost cell. `usageError`
|
|
159
|
+
// makes the failure mode truthful and distinguishable, additive to the row
|
|
160
|
+
// (N3's undefined-key discipline still holds: only set usage/usageError when
|
|
161
|
+
// there is something to say).
|
|
162
|
+
//
|
|
163
|
+
// v4.4 B3 (diagnosis §4/§7.3): for a TERMINAL leg, metadata.json's `usage`
|
|
164
|
+
// block wins over the progress.json snapshot. progress.json's usage is stamped
|
|
165
|
+
// ONLY on 'receiving' flushes — which fire on text/tool/reasoning GROWTH, i.e.
|
|
166
|
+
// always strictly before OpenCode's finalization stamp — so on real paid runs
|
|
167
|
+
// 31 of 35 legs ended with an all-zero snapshot while metadata.json held
|
|
168
|
+
// thousands of real tokens and a reported cost. Reading the snapshot for a
|
|
169
|
+
// finished leg made every completed seat look free in the live doc.
|
|
170
|
+
// headless.js now also writes a terminal 'complete' progress record carrying
|
|
171
|
+
// the settled usage, which fixes the DATA; this makes the READER prefer the
|
|
172
|
+
// authoritative source either way, including for every leg already on disk.
|
|
173
|
+
// A still-RUNNING leg keeps reading progress.json — metadata.usage does not
|
|
174
|
+
// exist until the leg finalizes, and read-time resolution is what keeps a live
|
|
175
|
+
// in-flight cost current (live-doc.js's stated design).
|
|
176
|
+
if (TERMINAL.has(row.status) && meta.usage && meta.usage.cost) {
|
|
177
|
+
row.usage = meta.usage;
|
|
178
|
+
} else if (p && p.usage) {
|
|
179
|
+
try {
|
|
180
|
+
const enriched = enrichLegUsage(row, p.usage);
|
|
181
|
+
if (enriched.usage) { row.usage = enriched.usage; }
|
|
182
|
+
} catch (err) {
|
|
183
|
+
row.usageError = err.message;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { row, stalledMs };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Rows for every leg id, plus a run-level stall rollup. A council run fans
|
|
191
|
+
* legs across several parallel sub-waves at once (seat wave, chair chain,
|
|
192
|
+
* lens/critic solos), so — unlike the single-leg wave branch, which only
|
|
193
|
+
* ever flags its own row — a run-level banner needs one summary rather than
|
|
194
|
+
* making the caller inspect every row.
|
|
195
|
+
*
|
|
196
|
+
* The rollup means exactly what the workspace's dead-run banner claims ("no
|
|
197
|
+
* leg activity for Xm — the run may be dead"): the run is stalled only when
|
|
198
|
+
* EVERY still-running leg is stalled. A single stalled leg alongside another
|
|
199
|
+
* leg that is actively producing (message/cost climbing) must NOT set the
|
|
200
|
+
* flag — that was the old (max-based) bug, observed live: `glm` sat stalled
|
|
201
|
+
* for two minutes while `qwen-coder` was visibly working, and the banner
|
|
202
|
+
* fired anyway. A leg that has already finished (any non-'running' status)
|
|
203
|
+
* is terminal — it is excluded from the "every leg" population entirely: it
|
|
204
|
+
* can neither keep the run "healthy" by counting as active, nor drag it
|
|
205
|
+
* "dead" by counting as stalled. Per-leg `row.stalled` is untouched by this —
|
|
206
|
+
* it stays the accurate, per-row signal it already was.
|
|
207
|
+
*
|
|
208
|
+
* When the run genuinely is stalled, `stalledForSeconds` is the SHORTEST
|
|
209
|
+
* idle duration among the stalled running legs, not the longest: that is the
|
|
210
|
+
* honest answer to "how long has the whole run been quiet" — something was
|
|
211
|
+
* still happening as recently as the most-recently-active (but still
|
|
212
|
+
* over-threshold) leg's last activity.
|
|
213
|
+
* @param {string} project
|
|
214
|
+
* @param {string[]} legIds
|
|
215
|
+
* @param {{bench: string[], critic: string|null, lenses: string[]|null, stageName: string}} runCtx
|
|
216
|
+
* run.json's alias-valued fields (bench/critic/lenses) + the active stage's
|
|
217
|
+
* name, threaded through to legRole — this module never reads run.json itself.
|
|
218
|
+
* @returns {{rows: object[], stalled?: true, stalledForSeconds?: number}}
|
|
219
|
+
*/
|
|
220
|
+
function buildLegRows(project, legIds, runCtx) {
|
|
221
|
+
const rows = [];
|
|
222
|
+
let runningCount = 0;
|
|
223
|
+
const stalledRunningMs = [];
|
|
224
|
+
for (const legId of legIds) {
|
|
225
|
+
const { row, stalledMs } = buildLegRow(project, legId, runCtx);
|
|
226
|
+
rows.push(row);
|
|
227
|
+
if (row.status === 'running') {
|
|
228
|
+
runningCount += 1;
|
|
229
|
+
if (stalledMs !== null) { stalledRunningMs.push(stalledMs); }
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const out = { rows };
|
|
233
|
+
if (runningCount > 0 && stalledRunningMs.length === runningCount) {
|
|
234
|
+
out.stalled = true;
|
|
235
|
+
out.stalledForSeconds = Math.floor(Math.min(...stalledRunningMs) / 1000);
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
module.exports = { buildLegRows };
|
package/src/observe/live-doc.js
CHANGED
|
@@ -15,13 +15,48 @@
|
|
|
15
15
|
|
|
16
16
|
const { resolveUsage, sumWaveUsage } = require('../utils/pricing');
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
// ⚠️ v4.4.1 A1: 'timeout' AND 'timed-out' — the codebase genuinely has two spellings for one
|
|
19
|
+
// state, written by two different producers, and this set has to cover both.
|
|
20
|
+
// 'timeout' — src/utils/result-schema.js:23 statusFromResult, i.e. the LEG/wave-document and
|
|
21
|
+
// `--json` run-document vocabulary. Correct, still emitted, stays.
|
|
22
|
+
// 'timed-out' — src/sidecar/session-finalize.js:21 resolveTerminalState, i.e. what actually
|
|
23
|
+
// lands in a session's metadata.json `status` and (since LC-3) progress.json's
|
|
24
|
+
// terminal stage. It was MISSING here, and this set is the one every observability
|
|
25
|
+
// reader consults, so three real consequences followed: `amicus watch <taskId>` on
|
|
26
|
+
// a timed-out single session never exited (watch-render.js:138 polls until
|
|
27
|
+
// TERMINAL.has(doc.status), and amicus_status stamps metadata.status straight onto
|
|
28
|
+
// the doc — mcp-server.js:687); a timed-out leg skipped the "prefer metadata.usage
|
|
29
|
+
// over the stale progress.json snapshot" branch in council-legs.js:162 and reported
|
|
30
|
+
// an under-counted cost; and markLive kept stamping view:'live' on a finished
|
|
31
|
+
// single-session doc.
|
|
32
|
+
// NOTE this is deliberately NOT the same list as src/utils/result-schema.js:13 TERMINAL_STATUSES
|
|
33
|
+
// (the leg set, no 'partial'). Two mirrors of THIS list exist — src/workspace/run-detail.js:26 and
|
|
34
|
+
// electron/workspace-ui/live-model.js:14 — byte-identical, held by drift pins. Edit all three.
|
|
35
|
+
const TERMINAL = new Set(['complete', 'partial', 'error', 'crashed', 'aborted', 'timeout', 'timed-out', 'idle-timeout']);
|
|
19
36
|
|
|
20
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* Attach read-time-resolved usage to a leg from its raw progress usage.
|
|
39
|
+
*
|
|
40
|
+
* v4.4.1 CA-1: the terminal progress record carries the leg's enumerated CHILD
|
|
41
|
+
* (subagent) session spend as `usage.subtree` / `usage.subtreeUnknown`
|
|
42
|
+
* (src/headless.js). Those must be forwarded, not dropped: the live workspace
|
|
43
|
+
* and `amicus watch` read progress.json directly, so silently keeping only
|
|
44
|
+
* {tokens, cost} here would make the GUI's cost-by-seat and its wave rollup
|
|
45
|
+
* disagree with run.json by exactly the child-session amount — reintroducing
|
|
46
|
+
* the under-report one surface down from where it was fixed.
|
|
47
|
+
*/
|
|
21
48
|
function enrichLegUsage(leg, progressUsage) {
|
|
22
49
|
if (!progressUsage || !progressUsage.tokens) { return leg; }
|
|
23
|
-
const resolved = resolveUsage({
|
|
24
|
-
|
|
50
|
+
const resolved = resolveUsage({
|
|
51
|
+
model: leg.model,
|
|
52
|
+
usageTotals: progressUsage,
|
|
53
|
+
subtree: progressUsage.subtree,
|
|
54
|
+
subtreeUnknown: progressUsage.subtreeUnknown,
|
|
55
|
+
});
|
|
56
|
+
const usage = { tokens: resolved.tokens, cost: resolved.cost };
|
|
57
|
+
if (resolved.subtree) { usage.subtree = resolved.subtree; }
|
|
58
|
+
if (resolved.subtreeUnknown) { usage.subtreeUnknown = true; }
|
|
59
|
+
return { ...leg, usage };
|
|
25
60
|
}
|
|
26
61
|
|
|
27
62
|
/** Stamp view:'live' on a non-terminal composed doc; no-op when terminal. */
|
|
@@ -29,7 +29,10 @@ const legCost = (leg) => (leg.usage && leg.usage.cost ? formatCost(leg.usage.cos
|
|
|
29
29
|
const legTokens = (leg) => (leg.usage && leg.usage.tokens ? `${leg.usage.tokens.input || 0}/${leg.usage.tokens.output || 0}` : DASH);
|
|
30
30
|
const truncate = (s, n) => { const t = String(s || ''); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
// `partial` (v4.4.1 F6): a stage that FINISHED but lost seats. Without its own
|
|
33
|
+
// mark it fell through to `pending`, so a degraded Stage 1 rendered as if it had
|
|
34
|
+
// not started — the opposite of reporting loudly.
|
|
35
|
+
const STAGE_MARK = { complete: '✓', running: '▶', partial: '⚠', pending: '·' };
|
|
33
36
|
|
|
34
37
|
/** The in-place refresh block for a composed wave/council/solo doc. */
|
|
35
38
|
function renderTable(doc, width = 100) {
|
|
@@ -93,6 +96,25 @@ function emitJsonChange(doc, prevText) {
|
|
|
93
96
|
* @returns {Promise<number>} exit code
|
|
94
97
|
*/
|
|
95
98
|
async function runWatchLoop(target, args, project, deps = {}) {
|
|
99
|
+
// Pointer-containment fence, defence in depth. cli-handlers-watch.js's
|
|
100
|
+
// resolveWatchTarget already refuses a council pointer whose runDir escapes
|
|
101
|
+
// the project, but this loop is exported, takes `target` from its caller, and
|
|
102
|
+
// opens events.jsonl straight out of target.runDir below — so it re-checks
|
|
103
|
+
// rather than trusting the hand-off. Reuses the shared fence
|
|
104
|
+
// (src/utils/path-fence.js) and reports through the SAME failJson
|
|
105
|
+
// BAD_SESSION envelope handleWatch uses for an unresolvable id, so a --json
|
|
106
|
+
// caller still gets exactly one typed error doc.
|
|
107
|
+
if (target.kind === 'council') {
|
|
108
|
+
const { containsOnDisk } = require('../utils/path-fence');
|
|
109
|
+
if (!containsOnDisk(project, target.runDir)) {
|
|
110
|
+
const { failJson, ERROR_CODES } = require('../utils/error-doc');
|
|
111
|
+
return failJson(!!args.json, {
|
|
112
|
+
code: ERROR_CODES.BAD_SESSION,
|
|
113
|
+
message: `watch: run directory for '${target.id}' resolves outside project ${project}`,
|
|
114
|
+
hint: 'Pass --project if the run was launched elsewhere.',
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
96
118
|
const intervalSec = Math.max(0.5, Number(args.interval) || 2);
|
|
97
119
|
const statusFn = deps.statusFn || ((id, p) => require('../mcp-server').handlers.amicus_status({ taskId: id }, p));
|
|
98
120
|
const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
package/src/opencode-client.js
CHANGED
|
@@ -328,15 +328,27 @@ async function createChildSession(client, parentId) {
|
|
|
328
328
|
}
|
|
329
329
|
|
|
330
330
|
/**
|
|
331
|
-
* Get child sessions for a parent session
|
|
331
|
+
* Get child (subagent) sessions for a parent session.
|
|
332
|
+
*
|
|
333
|
+
* ⚠️ v4.4.1 LC-7: this was the ONE per-session call that did not thread
|
|
334
|
+
* `directoryQuery(directory)`, unlike getMessages / createSession /
|
|
335
|
+
* abortSession. It went unnoticed because nothing called it — on a SHARED
|
|
336
|
+
* server (one server, many projects) an un-scoped call is the exact
|
|
337
|
+
* "session not found" failure mode issue #47 fixed everywhere else, and it
|
|
338
|
+
* would have broken child-session cost attribution on precisely the
|
|
339
|
+
* configuration that makes attribution matter. Fixed with CA-1, its first
|
|
340
|
+
* consumer (src/sidecar/child-sessions.js).
|
|
332
341
|
*
|
|
333
342
|
* @param {import('@opencode-ai/sdk').OpencodeClient} client - SDK client
|
|
334
343
|
* @param {string} parentId - Parent session ID
|
|
344
|
+
* @param {string} [directory] - Optional project directory to scope the call to.
|
|
345
|
+
* Omitting it keeps the call byte-for-byte identical to before.
|
|
335
346
|
* @returns {Promise<Array>} Array of child sessions
|
|
336
347
|
*/
|
|
337
|
-
async function getChildren(client, parentId) {
|
|
348
|
+
async function getChildren(client, parentId, directory) {
|
|
338
349
|
const result = await client.session.children({
|
|
339
|
-
path: { id: parentId }
|
|
350
|
+
path: { id: parentId },
|
|
351
|
+
...directoryQuery(directory)
|
|
340
352
|
});
|
|
341
353
|
|
|
342
354
|
return result.data || [];
|