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
package/src/headless.js
CHANGED
|
@@ -122,8 +122,22 @@ const USAGE_SETTLE_CALL_TIMEOUT_MS = envNumber('AMICUS_USAGE_SETTLE_CALL_TIMEOUT
|
|
|
122
122
|
* ON EXCEEDING IT the leg COMPLETES anyway — never fails — carrying
|
|
123
123
|
* `toolSettleTimedOut` on the result, the terminal progress record and the
|
|
124
124
|
* error log channel. Owner's standing ruling: fail LOUD, not fail CLOSED.
|
|
125
|
+
*
|
|
126
|
+
* v4.4.1 LC-2 (owner ruling, 2026-07-26): the leg's completion and its partial
|
|
127
|
+
* output are unchanged, but its OpenCode session is now ABORTED at the ceiling
|
|
128
|
+
* (see the finalization block) so it stops billing for work nobody will read.
|
|
125
129
|
*/
|
|
126
130
|
const TOOL_SETTLE_GRACE_MS = envNumber('AMICUS_TOOL_SETTLE_GRACE_MS', 300000);
|
|
131
|
+
/**
|
|
132
|
+
* v4.4.1 LC-2 — how long the ceiling's abort call may take before we stop
|
|
133
|
+
* waiting on it. A hard constant rather than an env knob (the same disposition
|
|
134
|
+
* as src/sidecar/child-sessions.js's bounds): it exists to stop a pathological
|
|
135
|
+
* hang, not to be tuned. The leg is already complete and already paid for when
|
|
136
|
+
* this runs, so an unbounded wait here would hold a finished answer hostage to a
|
|
137
|
+
* best-effort cost optimization — exactly the trade A-8 forbids. Injectable via
|
|
138
|
+
* `options.toolSettleAbortTimeoutMs` so the bound itself is testable.
|
|
139
|
+
*/
|
|
140
|
+
const TOOL_SETTLE_ABORT_TIMEOUT_MS = 5000;
|
|
127
141
|
|
|
128
142
|
/**
|
|
129
143
|
* Race a promise against a timeout. Returns the promise's result, or rejects with
|
|
@@ -258,7 +272,18 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
258
272
|
if (options.mcp) {
|
|
259
273
|
serverOptions.mcp = options.mcp;
|
|
260
274
|
}
|
|
261
|
-
|
|
275
|
+
// v4.4.1 fix wave (F5): this is the OTHER server-start site. It calls
|
|
276
|
+
// startServer directly rather than going through startOpenCodeServer, so
|
|
277
|
+
// the lock-class retry added for the concurrent-start race never covered
|
|
278
|
+
// it — a plain `amicus start` that lost the race still died on the first
|
|
279
|
+
// `database is locked`. "Two separate amicus processes contending" is half
|
|
280
|
+
// that retry's stated justification, and this is one of the two processes.
|
|
281
|
+
// Same bounded, narrow policy: 5 attempts (Step 10.5 widened it from 3),
|
|
282
|
+
// lock-class messages only, final failure rethrown unchanged into the
|
|
283
|
+
// degrade path below.
|
|
284
|
+
const { retryOnLockRace } = require('./utils/server-setup');
|
|
285
|
+
const result = await retryOnLockRace(() => startServer(serverOptions),
|
|
286
|
+
{ retryDelayMs: options.retryDelayMs });
|
|
262
287
|
client = result.client;
|
|
263
288
|
server = result.server;
|
|
264
289
|
logger.debug('Server started', { url: server.url });
|
|
@@ -457,6 +482,8 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
457
482
|
// `=== undefined` rather than `||`: 0 is meaningful (disable the deferral).
|
|
458
483
|
const toolSettleGraceMs = options.toolSettleGraceMs === undefined
|
|
459
484
|
? TOOL_SETTLE_GRACE_MS : options.toolSettleGraceMs;
|
|
485
|
+
const toolSettleAbortTimeoutMs = options.toolSettleAbortTimeoutMs === undefined
|
|
486
|
+
? TOOL_SETTLE_ABORT_TIMEOUT_MS : options.toolSettleAbortTimeoutMs;
|
|
460
487
|
let consecutivePollFailures = 0;
|
|
461
488
|
let pollFailureBail = false;
|
|
462
489
|
let lastAssistantMsgId = null;
|
|
@@ -478,6 +505,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
478
505
|
let toolSettleDeferredSince = null; // ms timestamp of the first deferral, or null
|
|
479
506
|
let toolSettleTimedOut = false; // the grace ceiling was exceeded
|
|
480
507
|
let unsettledAtCeiling = []; // what was still live when it was exceeded
|
|
508
|
+
let toolSettleAborted = false; // LC-2: the ceiling's abort landed (see finalization)
|
|
481
509
|
|
|
482
510
|
/**
|
|
483
511
|
* Should this poll's completion signal be DEFERRED because a tool call has
|
|
@@ -917,6 +945,54 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
917
945
|
}
|
|
918
946
|
}
|
|
919
947
|
|
|
948
|
+
// ---- v4.4.1 LC-2: stop paying for a session nobody will read -------------
|
|
949
|
+
// OWNER RULING (2026-07-26). The leg is complete and runHeadless is returning,
|
|
950
|
+
// so nothing will ever read further session output — the outcome was already
|
|
951
|
+
// discarded by completing. Aborting here does not truncate an answer that
|
|
952
|
+
// would have been used; it stops paying for work nobody will read. The
|
|
953
|
+
// original objection ("stops the bleeding at the cost of truncating a
|
|
954
|
+
// possibly-healthy call") applied to aborting ON the completion route, where
|
|
955
|
+
// the call might still have mattered. Here it cannot.
|
|
956
|
+
//
|
|
957
|
+
// ORDER IS LOAD-BEARING, on BOTH sides:
|
|
958
|
+
// AFTER the child-session walk above — aborting first risks losing the
|
|
959
|
+
// subtree cost data v4.4.0 exists to capture, trading one silent
|
|
960
|
+
// under-report for another.
|
|
961
|
+
// BEFORE server.close() below — the abort is an SDK call and needs a live
|
|
962
|
+
// server. It is not redundant with close(): on a SHARED server (every
|
|
963
|
+
// council run) close() is never called here, and the session would go on
|
|
964
|
+
// billing against a server that outlives this leg.
|
|
965
|
+
//
|
|
966
|
+
// A-8 APPLIES: "never lose the answer" outranks "never report inaccurate
|
|
967
|
+
// usage". This is an optimization layered on an already-successful,
|
|
968
|
+
// already-paid-for leg, so every failure — rejection, hang, or a missing
|
|
969
|
+
// session id — is logged and dropped. Nothing here may alter `completed`,
|
|
970
|
+
// `summary`, `usage` or `error`. `toolSettleAborted: false` is the honest
|
|
971
|
+
// record of "we tried and could not; it may still be billing".
|
|
972
|
+
if (toolSettleTimedOut && sessionId) {
|
|
973
|
+
try {
|
|
974
|
+
const { abortSession } = require('./opencode-client');
|
|
975
|
+
await withTimeout(
|
|
976
|
+
abortSession(client, sessionId, ...dirArgs),
|
|
977
|
+
toolSettleAbortTimeoutMs,
|
|
978
|
+
'abortSession(tool-settle)',
|
|
979
|
+
);
|
|
980
|
+
toolSettleAborted = true;
|
|
981
|
+
// Task 6 review X2: a LANDED abort is the good outcome of a condition
|
|
982
|
+
// that is already logged at `error` (the ceiling itself). Logging the
|
|
983
|
+
// remedy at `warn` reads as a second problem; `info` reads honestly.
|
|
984
|
+
// The FAILED abort below stays at `warn` — that one really is a problem
|
|
985
|
+
// ("it may still be billing").
|
|
986
|
+
logger.info('Aborted the OpenCode session after the tool-settle ceiling', {
|
|
987
|
+
taskId, sessionId, unsettled: unsettledAtCeiling.length,
|
|
988
|
+
});
|
|
989
|
+
} catch (abortErr) {
|
|
990
|
+
toolSettleAborted = false;
|
|
991
|
+
logger.warn('Could not abort the session after the tool-settle ceiling — it may '
|
|
992
|
+
+ 'still be billing', { taskId, sessionId, error: abortErr.message });
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
920
996
|
if (!externalServer) { await server.close(); }
|
|
921
997
|
if (mirror.toolCalls.length > 0) {
|
|
922
998
|
logger.info('Tool calls summary', {
|
|
@@ -958,8 +1034,13 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
958
1034
|
// live GUI reads this file (src/observe/live-doc.js). `unsettledToolCalls` is
|
|
959
1035
|
// a COUNT here (progress.json is a compact snapshot); the full list is on the
|
|
960
1036
|
// returned result for the caller's metadata.
|
|
1037
|
+
//
|
|
1038
|
+
// v4.4.1 LC-2: `toolSettleAborted` rides alongside it — `true` = the session
|
|
1039
|
+
// was told to stop, `false` = it may still be billing. Both are meaningful
|
|
1040
|
+
// ONLY when the ceiling was hit, so neither appears on a clean leg.
|
|
961
1041
|
const settleFlags = toolSettleTimedOut
|
|
962
|
-
? { toolSettleTimedOut: true, unsettledToolCalls: unsettledAtCeiling.length
|
|
1042
|
+
? { toolSettleTimedOut: true, unsettledToolCalls: unsettledAtCeiling.length,
|
|
1043
|
+
toolSettleAborted }
|
|
963
1044
|
: {};
|
|
964
1045
|
// v4.4 B4 (Task 2) + v4.4.1 CA-1: a leg that made a SUBAGENT call has spend
|
|
965
1046
|
// in a CHILD OpenCode session that is billed separately and is NOT rolled
|
|
@@ -981,15 +1062,46 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
981
1062
|
: {}),
|
|
982
1063
|
...(subtree.unknown ? { subtreeUnknown: true } : {}) }
|
|
983
1064
|
: (subagentToolCalls.length > 0 ? { subtreeUnknown: true } : {});
|
|
984
|
-
|
|
1065
|
+
// ---- v4.4.1 LC-3: the terminal stage is DERIVED, never hardcoded ---------
|
|
1066
|
+
// This write sits above BOTH returns below, so EVERY terminal path reaches
|
|
1067
|
+
// it — the external-abort break, the --timeout, the poll-failure bail, the
|
|
1068
|
+
// tool-call wedge — and it used to stamp 'complete' on all of them. The live
|
|
1069
|
+
// workspace reads progress.json directly (src/observe/live-doc.js
|
|
1070
|
+
// enrichLegUsage), so an aborted or errored leg rendered with a green check
|
|
1071
|
+
// until metadata.json landed. Fix the WRITER: src/observe/council-legs.js
|
|
1072
|
+
// already prefers metadata.json for a terminal leg and is NOT the problem.
|
|
1073
|
+
//
|
|
1074
|
+
// resolveTerminalState is the codebase's single source of truth for the
|
|
1075
|
+
// (completed, timedOut, aborted, error) → status mapping, and it is what
|
|
1076
|
+
// start.js / continue.js / resume.js / finalizeHeadlessResult will run on
|
|
1077
|
+
// THIS function's return value to stamp metadata.json. Deriving the stage
|
|
1078
|
+
// from it — rather than re-deriving a second, hand-rolled expression here —
|
|
1079
|
+
// is what guarantees progress.json's stage and metadata.json's status cannot
|
|
1080
|
+
// disagree. It also covers the two cases a hand-rolled `aborted ? … :
|
|
1081
|
+
// sessionError ? …` would get wrong: a TIMED-OUT leg (which would still have
|
|
1082
|
+
// read 'complete'), and the F1 case where a session error arrived alongside
|
|
1083
|
+
// usable output and the leg legitimately returns completed (which would have
|
|
1084
|
+
// read a false 'error').
|
|
1085
|
+
//
|
|
1086
|
+
// `failedWithNoUsableOutput` is hoisted out of the `if` below so the stage
|
|
1087
|
+
// and the returned shape are decided by ONE predicate and cannot drift.
|
|
1088
|
+
const failedWithNoUsableOutput = !!(sessionError && (!mirror.output || pollFailureBail || toolStalled));
|
|
1089
|
+
const { resolveTerminalState } = require('./sidecar/session-finalize');
|
|
1090
|
+
const terminalStage = resolveTerminalState({
|
|
1091
|
+
completed,
|
|
1092
|
+
timedOut,
|
|
1093
|
+
aborted,
|
|
1094
|
+
error: failedWithNoUsableOutput ? sessionError : null,
|
|
1095
|
+
}).status;
|
|
1096
|
+
try { writeProgress(sessionDir, terminalStage, { usage: { ...usage, ...subtreeProgress }, ...settleFlags }); }
|
|
985
1097
|
catch (progressErr) {
|
|
986
1098
|
logger.debug('terminal progress write failed (best-effort)', { taskId, error: progressErr.message });
|
|
987
1099
|
}
|
|
988
1100
|
const settleResult = toolSettleTimedOut
|
|
989
|
-
? { toolSettleTimedOut: true, unsettledToolCalls: unsettledAtCeiling }
|
|
1101
|
+
? { toolSettleTimedOut: true, unsettledToolCalls: unsettledAtCeiling, toolSettleAborted }
|
|
990
1102
|
: {};
|
|
991
1103
|
|
|
992
|
-
if (
|
|
1104
|
+
if (failedWithNoUsableOutput) {
|
|
993
1105
|
return {
|
|
994
1106
|
summary: mirror.output ? extractSummary(mirror.output, foldNonce) : '',
|
|
995
1107
|
completed: false,
|
|
@@ -1036,7 +1148,64 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
1036
1148
|
}
|
|
1037
1149
|
if (watchdog) { watchdog.cancel(); }
|
|
1038
1150
|
if (uninstallSignals) { uninstallSignals(); }
|
|
1039
|
-
|
|
1151
|
+
// ⚠️ v4.4.1 M2: guarded — this used to be a bare `await server.close()` sitting directly
|
|
1152
|
+
// above A3's terminal-write block, OUTSIDE any try, in the one place a close failure is
|
|
1153
|
+
// least affordable: the error handler. A rejection here would have skipped the terminal
|
|
1154
|
+
// progress write below entirely AND replaced the original `error` (the one A3 exists to
|
|
1155
|
+
// preserve) with this close failure instead. `close()` has already done its job of freeing
|
|
1156
|
+
// the port by the time we get here; a failure to close cleanly is not this handler's
|
|
1157
|
+
// problem to propagate, so it is logged and swallowed, same discipline as every other
|
|
1158
|
+
// best-effort close in this file (see the signal handler above).
|
|
1159
|
+
if (!externalServer) {
|
|
1160
|
+
try { await server.close(); } catch (closeErr) {
|
|
1161
|
+
logger.debug('server.close() failed in the outer exception handler (best-effort)', {
|
|
1162
|
+
taskId, error: closeErr.message,
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
// ⚠️ v4.4.1 A3 — the last hole in LC-3's story. LC-3 made the SUCCESS path's terminal
|
|
1167
|
+
// progress write derive its stage from resolveTerminalState instead of hardcoding 'complete',
|
|
1168
|
+
// so an aborted/errored/timed-out leg stopped rendering with a green check. This path — the
|
|
1169
|
+
// outer exception handler — wrote NO terminal progress record at all, so progress.json kept
|
|
1170
|
+
// whatever non-terminal stage the last flush left on it (usually 'receiving') while the
|
|
1171
|
+
// caller's finalizeHeadlessResult stamped metadata.json 'error' off the return value below.
|
|
1172
|
+
// The live workspace and `amicus watch` read progress.json DIRECTLY (live-doc.js
|
|
1173
|
+
// enrichLegUsage, council-legs.js), so a leg that exploded rendered as still-streaming
|
|
1174
|
+
// forever: exactly the stale-state class LC-3 closed one path over.
|
|
1175
|
+
//
|
|
1176
|
+
// ⚠️ This runs INSIDE an error handler: it must never throw and must never mask the original
|
|
1177
|
+
// error, so the whole thing sits in its own try and its failure is a debug line, exactly like
|
|
1178
|
+
// the success path's write. The stage comes from the same single source of truth that path
|
|
1179
|
+
// uses, so progress.json's stage and metadata.json's status still cannot disagree.
|
|
1180
|
+
//
|
|
1181
|
+
// ⚠️ The prior `usage` is READ BACK and re-attached deliberately. writeProgress REBUILDS
|
|
1182
|
+
// progress.json from `{stage, stageLabel, updatedAt, ...extra}` — it does not merge — so a
|
|
1183
|
+
// bare terminal write would silently delete whatever real spend the last 'receiving' flush had
|
|
1184
|
+
// already recorded, trading a stale-stage bug for a cost-under-report on exactly the legs that
|
|
1185
|
+
// failed. There are no settled totals on this path (that is what the exception cost us), so
|
|
1186
|
+
// carrying the last known usage forward unchanged is the honest maximum.
|
|
1187
|
+
//
|
|
1188
|
+
// v4.4.1 M3 — scope of "the last known ones": `usage` ONLY. That same 'receiving' flush also
|
|
1189
|
+
// wrote `p.extra` (e.g. `messagesReceived`), and that is deliberately left to drop here, not
|
|
1190
|
+
// carried forward too — the same call LC-3's success-path terminal write already made (see
|
|
1191
|
+
// that block's comment above): readProgress() derives `messages` from conversation.jsonl's
|
|
1192
|
+
// assistant entries directly and only falls back to `messagesReceived` when there are none, so
|
|
1193
|
+
// restating a stale count on an exception — where conversation.jsonl is the more truthful,
|
|
1194
|
+
// already-mirrored source — could only disagree with the file it exists to summarize.
|
|
1195
|
+
try {
|
|
1196
|
+
let priorUsage = null;
|
|
1197
|
+
try {
|
|
1198
|
+
const prior = JSON.parse(fs.readFileSync(path.join(sessionDir, 'progress.json'), 'utf-8'));
|
|
1199
|
+
if (prior && prior.usage) { priorUsage = prior.usage; }
|
|
1200
|
+
} catch { /* no readable prior record: write the terminal stage without usage */ }
|
|
1201
|
+
const { resolveTerminalState } = require('./sidecar/session-finalize');
|
|
1202
|
+
const stage = resolveTerminalState({ error: error.message }).status;
|
|
1203
|
+
writeProgress(sessionDir, stage, priorUsage ? { usage: priorUsage } : {});
|
|
1204
|
+
} catch (progressErr) {
|
|
1205
|
+
logger.debug('terminal progress write failed after exception (best-effort)', {
|
|
1206
|
+
taskId, error: progressErr.message,
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1040
1209
|
const { emptyUsageTotals } = require('./utils/pricing');
|
|
1041
1210
|
return {
|
|
1042
1211
|
summary: '',
|
|
@@ -34,6 +34,7 @@ const path = require('path');
|
|
|
34
34
|
const { readProgress, isStalled } = require('../sidecar/progress');
|
|
35
35
|
const { enrichLegUsage, TERMINAL } = require('./live-doc');
|
|
36
36
|
const { roleFor } = require('../council/run-stages');
|
|
37
|
+
const { logger } = require('../utils/logger');
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
40
|
* A leg's council role. The chair stage is the one case alias identity
|
|
@@ -54,6 +55,37 @@ const { roleFor } = require('../council/run-stages');
|
|
|
54
55
|
function legRole({ bench, critic, lenses, stageName, modelInput }) {
|
|
55
56
|
if (!modelInput) { return null; }
|
|
56
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
|
+
}
|
|
57
89
|
return roleFor({ models: bench, critic, lenses }, modelInput);
|
|
58
90
|
}
|
|
59
91
|
|
|
@@ -71,15 +103,30 @@ function buildLegRow(project, legId, runCtx) {
|
|
|
71
103
|
const { getSessionDir } = require('../session-manager');
|
|
72
104
|
const legDir = getSessionDir(project, legId);
|
|
73
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.
|
|
74
114
|
try { meta = JSON.parse(fs.readFileSync(path.join(legDir, 'metadata.json'), 'utf-8')); }
|
|
75
|
-
catch {
|
|
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
|
+
}
|
|
76
120
|
// Truthful null, never metadata.model as a fallback: showing the resolved
|
|
77
121
|
// id where the alias was expected is exactly the F36 bug (blind mode would
|
|
78
122
|
// leak the real model id instead of degrading to an em-dash).
|
|
79
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 });
|
|
80
127
|
const row = {
|
|
81
128
|
taskId: legId, model: meta.model || null, status: meta.status || 'unknown',
|
|
82
|
-
modelInput, role
|
|
129
|
+
modelInput, role,
|
|
83
130
|
};
|
|
84
131
|
let stalledMs = null;
|
|
85
132
|
let p = null;
|
|
@@ -91,7 +138,17 @@ function buildLegRow(project, legId, runCtx) {
|
|
|
91
138
|
row.lastActivityAt = p.lastActivityAt;
|
|
92
139
|
row.stalled = row.status === 'running' && isStalled(p.lastActivityMs);
|
|
93
140
|
if (row.stalled) { stalledMs = p.lastActivityMs; }
|
|
94
|
-
} catch {
|
|
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
|
+
}
|
|
95
152
|
|
|
96
153
|
// council review C3: this is a SEPARATE try from readProgress's above, on
|
|
97
154
|
// purpose. The old code wrapped both in one try, so a pricing-resolution
|
package/src/observe/live-doc.js
CHANGED
|
@@ -15,7 +15,24 @@
|
|
|
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
|
/**
|
|
21
38
|
* Attach read-time-resolved usage to a leg from its raw progress usage.
|
|
@@ -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) {
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
|
|
43
43
|
const { getChildren, getMessages } = require('../opencode-client');
|
|
44
44
|
const { createMirrorState, mirrorUsageOnly } = require('./conversation-mirror');
|
|
45
|
-
const { sumPerMessageUsage
|
|
45
|
+
const { sumPerMessageUsage } = require('../utils/pricing');
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
48
|
* Bounds. Hard constants rather than env knobs: they exist to stop a pathological
|
|
@@ -88,7 +88,6 @@ function usageOfSession(messages) {
|
|
|
88
88
|
// what they are. A session with neither tokens nor cost is not free either:
|
|
89
89
|
// we saw nothing, and nothing is not zero.
|
|
90
90
|
priced: totals.costReported > 0,
|
|
91
|
-
observed: hasObservedTokens(totals.tokens),
|
|
92
91
|
};
|
|
93
92
|
}
|
|
94
93
|
|
|
@@ -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 };
|