amicus 4.3.0 → 4.4.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 +32 -0
- package/README.md +4 -3
- package/electron/ipc-workspace.js +283 -0
- package/electron/main.js +27 -0
- package/electron/preload-workspace.js +40 -0
- package/electron/workspace-shell.js +85 -0
- package/electron/workspace-ui/index.html +111 -0
- package/electron/workspace-ui/live-model.js +101 -0
- package/electron/workspace-ui/md-lite.js +119 -0
- package/electron/workspace-ui/workspace-app.js +240 -0
- package/electron/workspace-ui/workspace-matrix.js +212 -0
- package/electron/workspace-ui/workspace-panels.js +226 -0
- package/electron/workspace-ui/workspace-render.js +271 -0
- package/electron/workspace-ui/workspace-verbs.js +247 -0
- package/electron/workspace-ui/workspace.css +172 -0
- package/package.json +1 -1
- package/schemas/council-run-live.schema.json +25 -1
- package/schemas/council-run.schema.json +14 -0
- package/schemas/progress.schema.json +14 -1
- package/skills/second-opinion/MODEL-NOTES.md +53 -5
- package/src/cli-handlers-council-run.js +25 -3
- package/src/cli-handlers-spend.js +32 -5
- package/src/cli-handlers-watch.js +37 -10
- package/src/council/briefings.js +35 -2
- package/src/council/run-budget.js +224 -0
- package/src/council/run-launch.js +44 -6
- package/src/council/run-stages.js +17 -3
- package/src/council/run.js +12 -11
- package/src/headless.js +347 -14
- package/src/mcp-council-awareness.js +53 -3
- package/src/observe/council-legs.js +183 -0
- package/src/observe/live-doc.js +21 -3
- package/src/observe/watch-render.js +19 -0
- package/src/opencode-client.js +15 -3
- package/src/sidecar/child-sessions.js +198 -0
- package/src/sidecar/conversation-mirror.js +111 -37
- package/src/sidecar/fanout-budget.js +71 -0
- package/src/sidecar/fanout-leg.js +23 -1
- package/src/sidecar/fanout.js +4 -11
- package/src/sidecar/tool-part.js +196 -0
- package/src/sidecar/workspace-window.js +62 -0
- package/src/spend-query.js +21 -6
- package/src/utils/env-num.js +42 -0
- package/src/utils/path-fence.js +82 -0
- package/src/utils/pricing.js +98 -9
- package/src/workspace/artifact-guard.js +187 -0
- package/src/workspace/blind-mode.js +32 -0
- package/src/workspace/fold-format.js +95 -0
- package/src/workspace/live-normalize.js +156 -0
- package/src/workspace/matrix-model.js +94 -0
- package/src/workspace/run-detail.js +223 -0
- package/src/workspace/run-scan.js +148 -0
package/src/headless.js
CHANGED
|
@@ -13,8 +13,14 @@ const { ensurePortAvailable } = require('./utils/server-setup');
|
|
|
13
13
|
const { mapAgentToOpenCode } = require('./utils/agent-mapping');
|
|
14
14
|
const { writeProgress } = require('./sidecar/progress');
|
|
15
15
|
const { writeFileAtomic } = require('./utils/atomic-write');
|
|
16
|
-
const { createMirrorState, mirrorMessages, logMessage, getPendingToolCalls
|
|
16
|
+
const { createMirrorState, mirrorMessages, logMessage, getPendingToolCalls,
|
|
17
|
+
getLiveToolCalls, mirrorUsageOnly, allAssistantUsagePresent } = require('./sidecar/conversation-mirror');
|
|
17
18
|
const { buildFoldMarker, trailingFoldMarkerRegex, generateFoldNonce } = require('./utils/fold-marker');
|
|
19
|
+
// v4.4 (cost-council finding 3): `Number(process.env.X) || DEFAULT` cannot express
|
|
20
|
+
// an explicit `0`, and `0` is the DOCUMENTED disable switch for every knob below
|
|
21
|
+
// that uses this helper. See src/utils/env-num.js for why the older `||` knobs are
|
|
22
|
+
// deliberately left alone.
|
|
23
|
+
const { envNumber } = require('./utils/env-num');
|
|
18
24
|
|
|
19
25
|
/**
|
|
20
26
|
* Fold marker that the agent outputs when done.
|
|
@@ -73,6 +79,51 @@ const STABLE_IDLE_POLLS = Number(process.env.AMICUS_STABLE_IDLE_POLLS) || 30;
|
|
|
73
79
|
const POLL_CALL_TIMEOUT_MS = Number(process.env.AMICUS_POLL_CALL_TIMEOUT_MS) || 30000; // per getMessages call (used by a later task)
|
|
74
80
|
const MAX_CONSECUTIVE_POLL_FAILURES = Number(process.env.AMICUS_MAX_CONSECUTIVE_POLL_FAILURES) || 15; // ≈30s at 2s polls
|
|
75
81
|
const TOOL_CALL_STALL_MS = Number(process.env.AMICUS_TOOL_CALL_STALL_MS) || 180000; // B53: wedged tool call w/ no progress
|
|
82
|
+
/**
|
|
83
|
+
* v4.4 B1 — bounded post-loop usage reconciliation. The fold-marker (:~540) and
|
|
84
|
+
* SDK-idle (:~568) fast paths break WITHOUT requiring `info.time.completed`, but
|
|
85
|
+
* OpenCode stamps `info.tokens`/`info.cost` at message finalization — so those
|
|
86
|
+
* exits can win the race against the provider's usage payload and report a leg
|
|
87
|
+
* as free. Measured on real paid legs: $0.00759441096 lost by 155 ms and
|
|
88
|
+
* $0.00690565716 by 29 ms. 3 × 400 ms bounds the worst case at ~1.2 s of extra
|
|
89
|
+
* wall time on a leg that already finished, and the loop breaks early the moment
|
|
90
|
+
* every assistant message carries usage (the common case: one extra read).
|
|
91
|
+
* Set AMICUS_USAGE_SETTLE_POLLS to 0 to disable the re-poll entirely.
|
|
92
|
+
*/
|
|
93
|
+
const USAGE_SETTLE_POLLS = envNumber('AMICUS_USAGE_SETTLE_POLLS', 3);
|
|
94
|
+
const USAGE_SETTLE_INTERVAL_MS = envNumber('AMICUS_USAGE_SETTLE_INTERVAL_MS', 400);
|
|
95
|
+
/** Deliberately much tighter than POLL_CALL_TIMEOUT_MS: the leg is already
|
|
96
|
+
* finished, so a hung settle read must not add 30 s × 3 to a run's wall time.
|
|
97
|
+
* 0 means "no extra timer" (withTimeout passes the promise through untouched). */
|
|
98
|
+
const USAGE_SETTLE_CALL_TIMEOUT_MS = envNumber('AMICUS_USAGE_SETTLE_CALL_TIMEOUT_MS', 5000);
|
|
99
|
+
/**
|
|
100
|
+
* v4.4 B4 part 1 — how long a completion signal may be DEFERRED while a tool
|
|
101
|
+
* call has not yet reached a terminal `state.status`.
|
|
102
|
+
*
|
|
103
|
+
* THE DEFECT THIS BOUNDS. `council-wsgate02/wsgate02-s1-3` was declared
|
|
104
|
+
* `complete` by the STABLE_IDLE_POLLS gate at 04:36:09.700 on **166 characters**
|
|
105
|
+
* of reasoning preamble, while its `task` tool call ran until 04:38:19.061 —
|
|
106
|
+
* 129 s later — and its session went on to bill $0.14279 of parent spend plus a
|
|
107
|
+
* $0.47105 child session. 166 characters were adjudicated as a peer review.
|
|
108
|
+
*
|
|
109
|
+
* WHY IT MUST BE BOUNDED. 9 of the 1,307 tool parts persisted in this machine's
|
|
110
|
+
* OpenCode database are stuck non-terminal forever: `time_updated` within
|
|
111
|
+
* milliseconds of `time_created`, all from killed sessions that never wrote a
|
|
112
|
+
* terminal status. A stale `running` can therefore outlive everything, so an
|
|
113
|
+
* unbounded wait is not an option.
|
|
114
|
+
*
|
|
115
|
+
* WHY 5 MINUTES. The measured duration of the real subagent call that exposed
|
|
116
|
+
* this is **190.6 s** (`task`, 04:35:08.427 → 04:38:19.061) — already longer
|
|
117
|
+
* than B53's 180 s TOOL_CALL_STALL_MS, so anything at that scale would kill a
|
|
118
|
+
* healthy `task` leg 10 s short of its answer. 300 s clears the measured case
|
|
119
|
+
* with margin and still lands far inside the 15-minute default `--timeout`.
|
|
120
|
+
* Set to 0 to disable the deferral entirely (pre-v4.4 behaviour).
|
|
121
|
+
*
|
|
122
|
+
* ON EXCEEDING IT the leg COMPLETES anyway — never fails — carrying
|
|
123
|
+
* `toolSettleTimedOut` on the result, the terminal progress record and the
|
|
124
|
+
* error log channel. Owner's standing ruling: fail LOUD, not fail CLOSED.
|
|
125
|
+
*/
|
|
126
|
+
const TOOL_SETTLE_GRACE_MS = envNumber('AMICUS_TOOL_SETTLE_GRACE_MS', 300000);
|
|
76
127
|
|
|
77
128
|
/**
|
|
78
129
|
* Race a promise against a timeout. Returns the promise's result, or rejects with
|
|
@@ -397,6 +448,15 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
397
448
|
const pollCallTimeoutMs = options.pollCallTimeoutMs || POLL_CALL_TIMEOUT_MS;
|
|
398
449
|
const maxConsecutivePollFailures = options.maxConsecutivePollFailures || MAX_CONSECUTIVE_POLL_FAILURES;
|
|
399
450
|
const toolCallStallMs = options.toolCallStallMs || TOOL_CALL_STALL_MS;
|
|
451
|
+
// `=== undefined` rather than `||`: 0 is a meaningful value (disable the
|
|
452
|
+
// v4.4 B1 settle re-poll entirely) and must survive injection.
|
|
453
|
+
const usageSettlePolls = options.usageSettlePolls === undefined
|
|
454
|
+
? USAGE_SETTLE_POLLS : options.usageSettlePolls;
|
|
455
|
+
const usageSettleIntervalMs = options.usageSettleIntervalMs === undefined
|
|
456
|
+
? USAGE_SETTLE_INTERVAL_MS : options.usageSettleIntervalMs;
|
|
457
|
+
// `=== undefined` rather than `||`: 0 is meaningful (disable the deferral).
|
|
458
|
+
const toolSettleGraceMs = options.toolSettleGraceMs === undefined
|
|
459
|
+
? TOOL_SETTLE_GRACE_MS : options.toolSettleGraceMs;
|
|
400
460
|
let consecutivePollFailures = 0;
|
|
401
461
|
let pollFailureBail = false;
|
|
402
462
|
let lastAssistantMsgId = null;
|
|
@@ -408,6 +468,61 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
408
468
|
let lastReasoningLength = 0; // B53: track reasoning-output growth to detect thinking
|
|
409
469
|
let lastProgressAt = Date.now(); // B53: last poll where `progressed` was true
|
|
410
470
|
let toolStalled = false; // B53: distinct from completed/timedOut/aborted — see resolveTerminalState
|
|
471
|
+
let lastSettledToolCount = 0; // B4: tool calls observed reaching a terminal status
|
|
472
|
+
|
|
473
|
+
// ---- v4.4 B4 part 1: the tool-settle deferral -----------------------------
|
|
474
|
+
// Recomputed once per poll (see the loop body) so every completion gate in a
|
|
475
|
+
// single poll reads ONE consistent answer.
|
|
476
|
+
let liveTools = []; // POSITIVELY 'pending'/'running' — gates completion
|
|
477
|
+
let pendingTools = []; // not-yet-terminal incl. unknown shape — feeds B53
|
|
478
|
+
let toolSettleDeferredSince = null; // ms timestamp of the first deferral, or null
|
|
479
|
+
let toolSettleTimedOut = false; // the grace ceiling was exceeded
|
|
480
|
+
let unsettledAtCeiling = []; // what was still live when it was exceeded
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Should this poll's completion signal be DEFERRED because a tool call has
|
|
484
|
+
* not reached a terminal `state.status`?
|
|
485
|
+
*
|
|
486
|
+
* Keyed on the REAL SDK shape (src/sidecar/tool-part.js): terminal is
|
|
487
|
+
* `state.status === 'completed' | 'error'`. It is deliberately NOT keyed on a
|
|
488
|
+
* `tool_result` part — OpenCode emits no such part type (36 `tool_use` records
|
|
489
|
+
* and 0 `tool_result` records across the 35 recorded legs), so the diagnosis's
|
|
490
|
+
* proposed `pendingToolCalls` gate would have hung every tool-using leg.
|
|
491
|
+
*
|
|
492
|
+
* It reads `getLiveToolCalls`, NOT `getPendingToolCalls`: a leg is only ever
|
|
493
|
+
* held open on POSITIVE evidence that OpenCode is still working ('pending' /
|
|
494
|
+
* 'running'). A tool part carrying no `state` at all is unknown, not live, and
|
|
495
|
+
* must not defer anything — deferring on an absence of evidence is exactly how
|
|
496
|
+
* this gate would hang. B53 still owns that no-evidence case.
|
|
497
|
+
*
|
|
498
|
+
* @param {string} exitPath which completion gate is asking (for the logs)
|
|
499
|
+
* @returns {boolean} true = keep polling; false = complete now
|
|
500
|
+
*/
|
|
501
|
+
const deferForUnsettledTools = (exitPath) => {
|
|
502
|
+
if (toolSettleTimedOut) { return false; } // ceiling blown — never defer again
|
|
503
|
+
if (!(toolSettleGraceMs > 0)) { return false; } // 0 = disabled (escape hatch)
|
|
504
|
+
if (liveTools.length === 0) { return false; }
|
|
505
|
+
if (toolSettleDeferredSince === null) {
|
|
506
|
+
toolSettleDeferredSince = Date.now();
|
|
507
|
+
logger.info('Deferring leg completion — tool call(s) still executing', {
|
|
508
|
+
taskId, exitPath, live: liveTools.length,
|
|
509
|
+
tools: liveTools.map(t => `${t.name}:${t.status}`).join(','), toolSettleGraceMs,
|
|
510
|
+
});
|
|
511
|
+
return true;
|
|
512
|
+
}
|
|
513
|
+
if ((Date.now() - toolSettleDeferredSince) <= toolSettleGraceMs) { return true; }
|
|
514
|
+
// The ceiling. Complete the leg (keep whatever output it produced) and make
|
|
515
|
+
// the uncertainty impossible to miss — never fail it closed.
|
|
516
|
+
toolSettleTimedOut = true;
|
|
517
|
+
unsettledAtCeiling = liveTools.slice();
|
|
518
|
+
logger.error('Tool call(s) did not settle within the grace window — completing leg '
|
|
519
|
+
+ 'anyway; its OpenCode session may STILL be working and BILLING', {
|
|
520
|
+
taskId, sessionId, exitPath, toolSettleGraceMs,
|
|
521
|
+
unsettled: unsettledAtCeiling.length,
|
|
522
|
+
tools: unsettledAtCeiling.map(t => `${t.name}@${t.firstSeenAt}`).join(','),
|
|
523
|
+
});
|
|
524
|
+
return false;
|
|
525
|
+
};
|
|
411
526
|
|
|
412
527
|
while (!completed && (Date.now() - startTime) < timeoutMs) {
|
|
413
528
|
watchdog.touch();
|
|
@@ -459,6 +574,14 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
459
574
|
));
|
|
460
575
|
const currentAssistantMsgId = mr.currentAssistantMsgId;
|
|
461
576
|
const assistantFinished = mr.assistantFinished;
|
|
577
|
+
// v4.4 B4 part 1: evaluate tool liveness ONCE per poll, before any
|
|
578
|
+
// completion gate reads it. Clearing the deferral here (rather than
|
|
579
|
+
// inside deferForUnsettledTools) matters: the gates only run when a
|
|
580
|
+
// completion signal fires, so a leg that resumes working after a
|
|
581
|
+
// deferral would otherwise keep B53 suppressed on a stale timestamp.
|
|
582
|
+
pendingTools = getPendingToolCalls(mirror);
|
|
583
|
+
liveTools = getLiveToolCalls(mirror);
|
|
584
|
+
if (liveTools.length === 0) { toolSettleDeferredSince = null; }
|
|
462
585
|
if (mr.sessionError) {
|
|
463
586
|
sessionError = mr.sessionError;
|
|
464
587
|
logger.error('Session error detected in assistant message', { sessionId, message: mr.sessionError });
|
|
@@ -477,7 +600,11 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
477
600
|
// marker on its own line mid-output (echoing a prior sidecar, these
|
|
478
601
|
// instructions, or scraped content) — only the exact nonced marker,
|
|
479
602
|
// with nothing but blank lines after it, is a completion signal.
|
|
480
|
-
|
|
603
|
+
// v4.4 B4: a fold marker WITHOUT info.time.completed means OpenCode has
|
|
604
|
+
// not finalized the message, so a tool call may still be live and billing
|
|
605
|
+
// (this is the same window B1's usage race lives in). Defer, bounded.
|
|
606
|
+
if (findTrailingFoldMarker(mirror.output, foldNonce) !== -1
|
|
607
|
+
&& !deferForUnsettledTools('fold-marker')) {
|
|
481
608
|
completed = true;
|
|
482
609
|
break;
|
|
483
610
|
}
|
|
@@ -514,7 +641,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
514
641
|
'getSessionStatus'
|
|
515
642
|
);
|
|
516
643
|
const s = (statusData && statusData.type) ? statusData : (statusData && statusData[sessionId]);
|
|
517
|
-
if (s && s.type === 'idle') {
|
|
644
|
+
if (s && s.type === 'idle' && !deferForUnsettledTools('sdk-idle')) {
|
|
518
645
|
logger.debug('Session reported idle by SDK — completing', { sessionId });
|
|
519
646
|
completed = true;
|
|
520
647
|
break;
|
|
@@ -543,9 +670,14 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
543
670
|
// the stall clock resets instead of falsely firing "Tool call stalled".
|
|
544
671
|
const reasoningActivity = mirror.reasoningOutput.length > lastReasoningLength;
|
|
545
672
|
lastReasoningLength = mirror.reasoningOutput.length;
|
|
673
|
+
// v4.4 B4: a tool call REACHING a terminal status is real activity. Before
|
|
674
|
+
// the shape fix this could never be observed (pending never cleared), so a
|
|
675
|
+
// multi-tool leg's stall clock only reset on text growth.
|
|
676
|
+
const settleActivity = mirror.settledToolCallIds.size > lastSettledToolCount;
|
|
677
|
+
lastSettledToolCount = mirror.settledToolCallIds.size;
|
|
546
678
|
|
|
547
679
|
const progressed = outputGrew || toolActivity || resultActivity || messageActivity
|
|
548
|
-
|| newAssistant || reasoningActivity;
|
|
680
|
+
|| newAssistant || reasoningActivity || settleActivity;
|
|
549
681
|
if (progressed) { lastProgressAt = Date.now(); }
|
|
550
682
|
|
|
551
683
|
// B53: a wedged tool call (tool_use emitted, result never arrives) otherwise
|
|
@@ -555,9 +687,19 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
555
687
|
// (text/tool/result/message/new-assistant) has been observed for the stall
|
|
556
688
|
// window — this cannot false-positive during active streaming (progress
|
|
557
689
|
// resets the clock every poll) and cannot fire without a wedged tool.
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
690
|
+
//
|
|
691
|
+
// v4.4 B4: SKIPPED while a tool-settle deferral is active. B53 was written
|
|
692
|
+
// against `pendingToolCalls`, which could never clear (no tool_result part
|
|
693
|
+
// exists), so its 180 s window was never calibrated against real tool
|
|
694
|
+
// durations — the measured `task` call that exposed this defect ran 190.6 s,
|
|
695
|
+
// so B53 would kill a healthy subagent leg 10 s short of its answer, and
|
|
696
|
+
// kill it CLOSED. Once a completion signal has fired, the bounded settle
|
|
697
|
+
// grace owns that decision and ends in a LOUD completion instead. B53's
|
|
698
|
+
// actual target — a wedge with NO output, where the idle gate never engages
|
|
699
|
+
// and therefore no deferral is ever active — is untouched.
|
|
700
|
+
if (pendingTools.length > 0 && toolSettleDeferredSince === null
|
|
701
|
+
&& (Date.now() - lastProgressAt) > toolCallStallMs) {
|
|
702
|
+
const stalled = pendingTools[0];
|
|
561
703
|
const pendingSeconds = Math.round((Date.now() - Date.parse(stalled.firstSeenAt)) / 1000);
|
|
562
704
|
sessionError = `Tool call stalled: ${stalled.name} pending ${pendingSeconds}s with no result or output`;
|
|
563
705
|
logger.error('Tool call stalled — no progress within threshold', {
|
|
@@ -580,7 +722,20 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
580
722
|
if (currentAssistantMsgId !== null && mirror.output.length > 0) {
|
|
581
723
|
stablePolls++;
|
|
582
724
|
const threshold = assistantFinished ? stableFinishedPolls : stableIdlePolls;
|
|
583
|
-
|
|
725
|
+
// v4.4 B4 part 1 — THE MEASURED DEFECT SITE. This is the gate that
|
|
726
|
+
// declared `wsgate02-s1-3` complete on 166 characters of preamble 129 s
|
|
727
|
+
// before its `task` tool finished. Deferred (bounded) when a tool call
|
|
728
|
+
// is still live.
|
|
729
|
+
//
|
|
730
|
+
// The `assistantFinished` branch is deliberately NOT deferred:
|
|
731
|
+
// OpenCode finalizes an assistant message only AFTER its tool calls
|
|
732
|
+
// end, so `time.completed` structurally implies settled. VERIFIED on
|
|
733
|
+
// the defect leg itself — task end 04:38:19.061, message
|
|
734
|
+
// time.completed 04:38:19.301 — and on both recorded multi-tool legs,
|
|
735
|
+
// whose last tool ended 62.3 s and 14.8 s before the leg completed.
|
|
736
|
+
// Gating it would add pure hang risk for no truth gained.
|
|
737
|
+
if (stablePolls >= threshold
|
|
738
|
+
&& !(!assistantFinished && deferForUnsettledTools('stable-idle'))) {
|
|
584
739
|
logger.debug('Session appears complete (idle)', { stablePolls, assistantFinished });
|
|
585
740
|
completed = true;
|
|
586
741
|
break;
|
|
@@ -644,16 +799,132 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
644
799
|
|
|
645
800
|
watchdog.cancel();
|
|
646
801
|
if (uninstallSignals) { uninstallSignals(); }
|
|
647
|
-
if (!externalServer) { await server.close(); }
|
|
648
802
|
|
|
649
|
-
//
|
|
803
|
+
// ---- v4.4 B1: bounded post-loop usage reconciliation ----------------------
|
|
804
|
+
// MUST run before server.close() — the client needs a live server — and MUST
|
|
805
|
+
// NOT re-mirror text: mirrorMessages() would append the already-captured
|
|
806
|
+
// assistant output to conversation.jsonl a second time, so this uses the
|
|
807
|
+
// usage-only pass (src/sidecar/conversation-mirror.js mirrorUsageOnly).
|
|
808
|
+
//
|
|
809
|
+
// Strictly best-effort: every failure mode leaves the leg's completion
|
|
810
|
+
// verdict, summary and error exactly as the loop decided them. B2 is the
|
|
811
|
+
// safety net underneath — when the re-poll still sees nothing, the leg
|
|
812
|
+
// resolves to `unknown`, never a fabricated $0.
|
|
813
|
+
//
|
|
814
|
+
// Skipped when there is nothing to settle: an aborted leg (the caller pulled
|
|
815
|
+
// the plug), a leg that bailed on consecutive poll failures (the server is
|
|
816
|
+
// gone — three more reads would only burn the settle timeout), and a leg that
|
|
817
|
+
// errored with no output at all (no assistant message was ever billed).
|
|
818
|
+
// Deliberately NOT restricted to `completed`: a timed-out or tool-stalled leg
|
|
819
|
+
// spent real money too, and its usage is just as worth capturing.
|
|
820
|
+
const canSettleUsage = !aborted && !pollFailureBail && !(sessionError && !mirror.output);
|
|
821
|
+
if (canSettleUsage && usageSettlePolls > 0) {
|
|
822
|
+
for (let i = 0; i < usageSettlePolls; i++) {
|
|
823
|
+
// v4.4 (cost-council finding 2): the boundary covers the WHOLE loop body,
|
|
824
|
+
// not just the network read. It previously wrapped only `withTimeout(...)`,
|
|
825
|
+
// leaving `mirrorUsageOnly` and `allAssistantUsagePresent` — which inspect
|
|
826
|
+
// an untrusted snapshot shape — outside it. A throw there escaped
|
|
827
|
+
// runHeadless entirely and DISCARDED a leg whose answer was already
|
|
828
|
+
// captured and already paid for: the most expensive possible outcome for
|
|
829
|
+
// a path whose entire job is a nice-to-have usage top-up. "Best-effort"
|
|
830
|
+
// has to mean the effort, not just its first statement.
|
|
831
|
+
let done = false;
|
|
832
|
+
try {
|
|
833
|
+
const settled = await withTimeout(
|
|
834
|
+
getMessages(client, sessionId, ...dirArgs),
|
|
835
|
+
Math.min(pollCallTimeoutMs, USAGE_SETTLE_CALL_TIMEOUT_MS),
|
|
836
|
+
'getMessages(usage-settle)',
|
|
837
|
+
);
|
|
838
|
+
mirrorUsageOnly(settled, mirror);
|
|
839
|
+
done = allAssistantUsagePresent(settled);
|
|
840
|
+
if (!done && i < usageSettlePolls - 1) {
|
|
841
|
+
await new Promise(resolve => setTimeout(resolve, usageSettleIntervalMs));
|
|
842
|
+
}
|
|
843
|
+
} catch (settleErr) {
|
|
844
|
+
// Same disposition as the pre-existing network-failure branch: stop
|
|
845
|
+
// settling, keep every dollar already mirrored, and leave the loop's
|
|
846
|
+
// completion verdict, summary and error untouched. B2 remains the net
|
|
847
|
+
// underneath — no observation resolves to `unknown`, never a fake $0.
|
|
848
|
+
logger.debug('usage-settle re-poll failed (best-effort, leg unaffected)', {
|
|
849
|
+
taskId, attempt: i + 1, error: settleErr.message,
|
|
850
|
+
});
|
|
851
|
+
break;
|
|
852
|
+
}
|
|
853
|
+
if (done) { break; }
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Log summary of tool calls for debugging.
|
|
858
|
+
// v4.4 B4: this used to filter on `t.name === 'Task'` and was DEAD twice over —
|
|
859
|
+
// the mirror read `part.name` (the real shape has `part.tool`) so every name
|
|
860
|
+
// was undefined, and OpenCode's tool is named `task` in lowercase anyway.
|
|
861
|
+
const { isSubagentToolCall } = require('./sidecar/tool-part');
|
|
862
|
+
const subagentToolCalls = mirror.toolCalls.filter(isSubagentToolCall);
|
|
863
|
+
|
|
864
|
+
// ---- v4.4.1 CA-1: enumerate CHILD (subagent) session spend ---------------
|
|
865
|
+
// MUST run before server.close() — the walk needs a live server. A `task`
|
|
866
|
+
// call spawns a child OpenCode session that OpenCode bills separately and
|
|
867
|
+
// does NOT roll into this session's cost; amicus never looked, so $0.492506
|
|
868
|
+
// across the four recorded paid runs was invisible to every total the
|
|
869
|
+
// product prints. Safe to do at finalization only because dcb0792 stopped a
|
|
870
|
+
// `task` part going terminal while its child session is still live — before
|
|
871
|
+
// that, walking here would have captured a partial child cost and traded a
|
|
872
|
+
// silent zero for a silent floor.
|
|
873
|
+
//
|
|
874
|
+
// Run for EVERY leg, not only ones whose tool calls looked like `task`: the
|
|
875
|
+
// name-string proxy (src/sidecar/tool-part.js) was verified 1:1 on a
|
|
876
|
+
// 37-session corpus and nowhere else, so a child created by some other
|
|
877
|
+
// mechanism would be a silent under-count wearing a costExact badge — the
|
|
878
|
+
// exact defect the flag exists to kill. Skipped only when there is nothing
|
|
879
|
+
// to ask (same predicate as the usage settle: the server is gone, or the
|
|
880
|
+
// caller pulled the plug), in which case the leg falls back to the proxy and
|
|
881
|
+
// honestly reports its subtree as unknown.
|
|
882
|
+
let subtree = null;
|
|
883
|
+
if (canSettleUsage) {
|
|
884
|
+
try {
|
|
885
|
+
const { collectSubtreeUsage, subtreeIsUnknown } = require('./sidecar/child-sessions');
|
|
886
|
+
const walked = await collectSubtreeUsage(client, sessionId, {
|
|
887
|
+
directory,
|
|
888
|
+
callTimeoutMs: Math.min(pollCallTimeoutMs, USAGE_SETTLE_CALL_TIMEOUT_MS),
|
|
889
|
+
logger,
|
|
890
|
+
});
|
|
891
|
+
subtree = {
|
|
892
|
+
sessions: walked.sessions.length,
|
|
893
|
+
tokens: walked.tokens,
|
|
894
|
+
costReported: walked.costReported,
|
|
895
|
+
// The honesty verdict is decided HERE, where both observations live —
|
|
896
|
+
// the walk's own completeness and the `task`-call evidence. See
|
|
897
|
+
// subtreeIsUnknown for why a failed walk with no evidence of a
|
|
898
|
+
// subagent must NOT flag (an older server would otherwise mark every
|
|
899
|
+
// leg of every run inexact forever).
|
|
900
|
+
unknown: subtreeIsUnknown({
|
|
901
|
+
walkComplete: walked.complete,
|
|
902
|
+
sessionsFound: walked.sessions.length,
|
|
903
|
+
subagentCalls: subagentToolCalls.length,
|
|
904
|
+
}),
|
|
905
|
+
};
|
|
906
|
+
if (walked.sessions.length > 0) {
|
|
907
|
+
logger.info('Child session spend attributed to this leg', {
|
|
908
|
+
taskId, sessions: walked.sessions.map((s) => s.id),
|
|
909
|
+
costReported: walked.costReported, subtreeUnknown: subtree.unknown,
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
} catch (subtreeErr) {
|
|
913
|
+
// Cannot happen by construction (the collector swallows its own
|
|
914
|
+
// failures), but a throw here must never cost a leg its answer.
|
|
915
|
+
subtree = null;
|
|
916
|
+
logger.debug('subtree enumeration failed (best-effort)', { taskId, error: subtreeErr.message });
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
if (!externalServer) { await server.close(); }
|
|
650
921
|
if (mirror.toolCalls.length > 0) {
|
|
651
922
|
logger.info('Tool calls summary', {
|
|
652
923
|
totalToolCalls: mirror.toolCalls.length,
|
|
653
|
-
taskToolCalls:
|
|
654
|
-
subagentTypes:
|
|
655
|
-
.filter(t => t.
|
|
656
|
-
.map(t => ({ type: t.input.subagent_type, model: t.input.model || 'inherited' }))
|
|
924
|
+
taskToolCalls: subagentToolCalls.length,
|
|
925
|
+
subagentTypes: subagentToolCalls
|
|
926
|
+
.filter(t => t.input && (t.input.subagent_type || t.input.description))
|
|
927
|
+
.map(t => ({ type: t.input.subagent_type || t.input.description, model: (t.input && t.input.model) || 'inherited' }))
|
|
657
928
|
});
|
|
658
929
|
}
|
|
659
930
|
|
|
@@ -666,6 +937,58 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
666
937
|
const { sumPerMessageUsage } = require('./utils/pricing');
|
|
667
938
|
const usage = sumPerMessageUsage(mirror.usageByMsg);
|
|
668
939
|
|
|
940
|
+
// ---- v4.4 B3: one TERMINAL progress record carrying the settled usage ----
|
|
941
|
+
// progress.json's `usage` block was previously stamped only on 'receiving'
|
|
942
|
+
// flushes, which fire on text/tool/reasoning GROWTH — always strictly before
|
|
943
|
+
// OpenCode's finalization stamp — and writeProgress rebuilds the file from
|
|
944
|
+
// scratch, so it could not preserve an earlier snapshot either. Net effect on
|
|
945
|
+
// real runs: 31 of 35 legs ended with an all-zero usage snapshot while their
|
|
946
|
+
// metadata.json held thousands of real tokens. That snapshot is what the LIVE
|
|
947
|
+
// workspace GUI reads (src/observe/live-doc.js enrichLegUsage → resolveUsage),
|
|
948
|
+
// so every completed leg rendered as free.
|
|
949
|
+
//
|
|
950
|
+
// `messagesReceived` is deliberately omitted: readProgress() derives `messages`
|
|
951
|
+
// from conversation.jsonl's assistant entries and only falls back to this field
|
|
952
|
+
// when there are none, so re-stating it here would add nothing and could only
|
|
953
|
+
// disagree with the file it is meant to summarize.
|
|
954
|
+
//
|
|
955
|
+
// v4.4 B4: when the settle grace was exceeded the leg completed with tool
|
|
956
|
+
// calls still live, so its reported cost is a FLOOR and its session may still
|
|
957
|
+
// be billing. That must travel with the leg, not just sit in a log line — the
|
|
958
|
+
// live GUI reads this file (src/observe/live-doc.js). `unsettledToolCalls` is
|
|
959
|
+
// a COUNT here (progress.json is a compact snapshot); the full list is on the
|
|
960
|
+
// returned result for the caller's metadata.
|
|
961
|
+
const settleFlags = toolSettleTimedOut
|
|
962
|
+
? { toolSettleTimedOut: true, unsettledToolCalls: unsettledAtCeiling.length }
|
|
963
|
+
: {};
|
|
964
|
+
// v4.4 B4 (Task 2) + v4.4.1 CA-1: a leg that made a SUBAGENT call has spend
|
|
965
|
+
// in a CHILD OpenCode session that is billed separately and is NOT rolled
|
|
966
|
+
// into the parent session's cost. `subtree` carries what the walk MEASURED;
|
|
967
|
+
// `subtreeUnknown` is what it could not. The proxy count survives as the
|
|
968
|
+
// fallback for the case where the walk could not run at all (see the
|
|
969
|
+
// enumeration block above) — src/sidecar/tool-part.js isSubagentToolCall
|
|
970
|
+
// has the 1:1 evidence for it.
|
|
971
|
+
const subtreeFlags = subagentToolCalls.length > 0
|
|
972
|
+
? { subagentToolCalls: subagentToolCalls.length }
|
|
973
|
+
: {};
|
|
974
|
+
const subtreeResult = subtree ? { subtree } : {};
|
|
975
|
+
// The live workspace reads progress.json directly (src/observe/live-doc.js
|
|
976
|
+
// enrichLegUsage), so the attribution has to travel on BOTH channels or the
|
|
977
|
+
// GUI's cost-by-seat silently disagrees with run.json.
|
|
978
|
+
const subtreeProgress = subtree
|
|
979
|
+
? { ...(subtree.sessions > 0
|
|
980
|
+
? { subtree: { sessions: subtree.sessions, tokens: subtree.tokens, costReported: subtree.costReported } }
|
|
981
|
+
: {}),
|
|
982
|
+
...(subtree.unknown ? { subtreeUnknown: true } : {}) }
|
|
983
|
+
: (subagentToolCalls.length > 0 ? { subtreeUnknown: true } : {});
|
|
984
|
+
try { writeProgress(sessionDir, 'complete', { usage: { ...usage, ...subtreeProgress }, ...settleFlags }); }
|
|
985
|
+
catch (progressErr) {
|
|
986
|
+
logger.debug('terminal progress write failed (best-effort)', { taskId, error: progressErr.message });
|
|
987
|
+
}
|
|
988
|
+
const settleResult = toolSettleTimedOut
|
|
989
|
+
? { toolSettleTimedOut: true, unsettledToolCalls: unsettledAtCeiling }
|
|
990
|
+
: {};
|
|
991
|
+
|
|
669
992
|
if (sessionError && (!mirror.output || pollFailureBail || toolStalled)) {
|
|
670
993
|
return {
|
|
671
994
|
summary: mirror.output ? extractSummary(mirror.output, foldNonce) : '',
|
|
@@ -675,6 +998,9 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
675
998
|
taskId,
|
|
676
999
|
toolCalls: mirror.toolCalls,
|
|
677
1000
|
usage,
|
|
1001
|
+
...settleResult,
|
|
1002
|
+
...subtreeFlags,
|
|
1003
|
+
...subtreeResult,
|
|
678
1004
|
error: sessionError
|
|
679
1005
|
};
|
|
680
1006
|
}
|
|
@@ -687,6 +1013,9 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
687
1013
|
taskId,
|
|
688
1014
|
toolCalls: mirror.toolCalls, // Include tool calls in result for verification
|
|
689
1015
|
usage,
|
|
1016
|
+
...settleResult,
|
|
1017
|
+
...subtreeFlags,
|
|
1018
|
+
...subtreeResult,
|
|
690
1019
|
exitCode: 0
|
|
691
1020
|
};
|
|
692
1021
|
|
|
@@ -808,4 +1137,8 @@ module.exports = {
|
|
|
808
1137
|
POLL_CALL_TIMEOUT_MS,
|
|
809
1138
|
MAX_CONSECUTIVE_POLL_FAILURES,
|
|
810
1139
|
TOOL_CALL_STALL_MS,
|
|
1140
|
+
USAGE_SETTLE_POLLS,
|
|
1141
|
+
USAGE_SETTLE_INTERVAL_MS,
|
|
1142
|
+
USAGE_SETTLE_CALL_TIMEOUT_MS,
|
|
1143
|
+
TOOL_SETTLE_GRACE_MS,
|
|
811
1144
|
};
|
|
@@ -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; }
|