amicus 4.2.1 → 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.
Files changed (82) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +46 -1
  3. package/README.md +8 -4
  4. package/bin/amicus.js +5 -0
  5. package/electron/ipc-workspace.js +283 -0
  6. package/electron/main.js +27 -0
  7. package/electron/preload-workspace.js +40 -0
  8. package/electron/workspace-shell.js +85 -0
  9. package/electron/workspace-ui/index.html +111 -0
  10. package/electron/workspace-ui/live-model.js +101 -0
  11. package/electron/workspace-ui/md-lite.js +119 -0
  12. package/electron/workspace-ui/workspace-app.js +240 -0
  13. package/electron/workspace-ui/workspace-matrix.js +212 -0
  14. package/electron/workspace-ui/workspace-panels.js +226 -0
  15. package/electron/workspace-ui/workspace-render.js +271 -0
  16. package/electron/workspace-ui/workspace-verbs.js +247 -0
  17. package/electron/workspace-ui/workspace.css +172 -0
  18. package/package.json +1 -1
  19. package/schemas/council-run-live.schema.json +57 -0
  20. package/schemas/council-run.schema.json +14 -0
  21. package/schemas/event.schema.json +15 -0
  22. package/schemas/progress.schema.json +37 -0
  23. package/schemas/run-live.schema.json +15 -0
  24. package/schemas/spend.schema.json +26 -1
  25. package/schemas/wave-live.schema.json +15 -0
  26. package/skills/second-opinion/MODEL-NOTES.md +53 -5
  27. package/src/cli-handlers-council-run.js +86 -8
  28. package/src/cli-handlers-run.js +26 -0
  29. package/src/cli-handlers-spend.js +94 -32
  30. package/src/cli-handlers-watch.js +116 -0
  31. package/src/cli.js +58 -1
  32. package/src/council/briefings.js +35 -2
  33. package/src/council/run-budget.js +224 -0
  34. package/src/council/run-chair.js +10 -2
  35. package/src/council/run-debate.js +5 -1
  36. package/src/council/run-launch.js +58 -7
  37. package/src/council/run-stages.js +30 -3
  38. package/src/council/run.js +44 -15
  39. package/src/headless.js +356 -15
  40. package/src/mcp-council-awareness.js +98 -3
  41. package/src/mcp-council-run.js +28 -4
  42. package/src/mcp-notify.js +54 -0
  43. package/src/mcp-server.js +51 -1
  44. package/src/mcp-spend.js +125 -0
  45. package/src/mcp-tools.js +39 -0
  46. package/src/mcp-wait.js +28 -2
  47. package/src/observe/council-legs.js +183 -0
  48. package/src/observe/events.js +156 -0
  49. package/src/observe/follow.js +26 -0
  50. package/src/observe/live-doc.js +56 -0
  51. package/src/observe/on-complete.js +117 -0
  52. package/src/observe/watch-render.js +168 -0
  53. package/src/opencode-client.js +15 -3
  54. package/src/sidecar/child-sessions.js +198 -0
  55. package/src/sidecar/continue.js +32 -0
  56. package/src/sidecar/conversation-mirror.js +111 -37
  57. package/src/sidecar/fallback-chains.js +65 -0
  58. package/src/sidecar/fanout-budget.js +71 -0
  59. package/src/sidecar/fanout-leg-fallback.js +189 -0
  60. package/src/sidecar/fanout-leg.js +81 -27
  61. package/src/sidecar/fanout-retry.js +208 -0
  62. package/src/sidecar/fanout-validate.js +42 -4
  63. package/src/sidecar/fanout.js +54 -41
  64. package/src/sidecar/progress.js +5 -0
  65. package/src/sidecar/resume.js +12 -0
  66. package/src/sidecar/start.js +13 -1
  67. package/src/sidecar/tool-part.js +196 -0
  68. package/src/sidecar/workspace-window.js +62 -0
  69. package/src/spend-query.js +119 -0
  70. package/src/utils/env-num.js +42 -0
  71. package/src/utils/error-classify.js +31 -0
  72. package/src/utils/model-tiers.js +1 -1
  73. package/src/utils/path-fence.js +82 -0
  74. package/src/utils/pricing.js +98 -9
  75. package/src/utils/spend-ledger.js +24 -1
  76. package/src/workspace/artifact-guard.js +187 -0
  77. package/src/workspace/blind-mode.js +32 -0
  78. package/src/workspace/fold-format.js +95 -0
  79. package/src/workspace/live-normalize.js +156 -0
  80. package/src/workspace/matrix-model.js +94 -0
  81. package/src/workspace/run-detail.js +223 -0
  82. package/src/workspace/run-scan.js +148 -0
@@ -0,0 +1,65 @@
1
+ // src/sidecar/fallback-chains.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module fallback-chains
6
+ * Opt-in cheaper-model fallback chains (spec 6.2, resolved Q2). Default OFF;
7
+ * per-run --fallback/--no-fallback overrides config. Explicit chains win;
8
+ * otherwise a default chain is DERIVED from model-tiers by walking DOWN the
9
+ * failed model's vendor tiers (frontier -> balanced -> economy), keeping only
10
+ * entries CHEAPER than the failed model. Gateway-only + unknown vendors get
11
+ * no default chain (the leg fails as today). Chain entries may be aliases or
12
+ * full ids, including openrouter/... (so "same model, other gateway" is
13
+ * expressible).
14
+ */
15
+
16
+ const { resolveTier, TIER_ORDER } = require('../utils/model-tiers');
17
+
18
+ const DEFAULT_MAX_SUBSTITUTIONS = 2;
19
+
20
+ /** Merge user config `fallbacks` with the per-run flag (flag wins). */
21
+ function resolveFallbackConfig({ flagFallback, config } = {}) {
22
+ const fb = (config && config.fallbacks) || {};
23
+ let enabled = fb.enabled === true;
24
+ if (flagFallback === true) { enabled = true; }
25
+ if (flagFallback === false) { enabled = false; }
26
+ return {
27
+ enabled,
28
+ maxSubstitutions: Number.isInteger(fb.maxSubstitutions) ? fb.maxSubstitutions : DEFAULT_MAX_SUBSTITUTIONS,
29
+ chains: fb.chains || {},
30
+ };
31
+ }
32
+
33
+ /** Vendor segment for tier lookup; a bare alias returns itself (config key). */
34
+ function vendorOf(model) {
35
+ let id = String(model || '');
36
+ if (id.startsWith('openrouter/')) { id = id.slice('openrouter/'.length); }
37
+ const slash = id.indexOf('/');
38
+ return slash > 0 ? id.slice(0, slash) : id;
39
+ }
40
+
41
+ /** Explicit chain, else the tier-walk default. A substitute must be cheaper. */
42
+ function deriveChain(model, { config, catalog } = {}) {
43
+ const chains = (config && config.chains) || {};
44
+ // explicit chain keyed by the bare alias OR the vendor
45
+ const key = chains[model] ? model : (chains[vendorOf(model)] ? vendorOf(model) : null);
46
+ if (key) { return chains[key].slice(); }
47
+
48
+ // tier-walk default: frontier -> balanced -> economy (most -> least capable)
49
+ const vendor = vendorOf(model);
50
+ const ordered = [];
51
+ for (const tier of [...TIER_ORDER].reverse()) {
52
+ const id = resolveTier(vendor, tier, catalog || []);
53
+ if (id && !ordered.includes(id)) { ordered.push(id); } // do NOT drop the failed model here
54
+ }
55
+ // Keep only tiers CHEAPER than the failed model (strictly after its position
56
+ // in the most->least-capable ladder). This drops the failed model AND
57
+ // everything more capable — a substitute must be cheaper (spec 6.2). If the
58
+ // failed model is not a current tier pick (e.g. a slightly-stale id), fall
59
+ // back to the full vendor ladder minus the exact model (best-effort;
60
+ // bounded misclassification cost).
61
+ const failedIdx = ordered.indexOf(model);
62
+ return failedIdx === -1 ? ordered.filter((id) => id !== model) : ordered.slice(failedIdx + 1);
63
+ }
64
+
65
+ module.exports = { resolveFallbackConfig, deriveChain, vendorOf, DEFAULT_MAX_SUBSTITUTIONS };
@@ -0,0 +1,71 @@
1
+ // src/sidecar/fanout-budget.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module sidecar/fanout-budget
6
+ * runFanout's pre-flight spend gate (§1b), extracted from src/sidecar/fanout.js —
7
+ * which sits three lines under the 300-line size gate and had no room for the
8
+ * v4.4 reservation seam. Behaviour of the extracted half is unchanged; the
9
+ * reservation is the only addition.
10
+ *
11
+ * WHY THE RESERVATION EXISTS (v4.4 cost-council finding 1). `checkBudget`
12
+ * compares the wave's pre-flight ESTIMATE against a `maxCost` NUMBER that the
13
+ * caller read at some earlier moment. The council driver launches Stage-1's seat
14
+ * wave and critic wave concurrently under a single `Promise.all`, and each
15
+ * launcher read `remainingBudget()` before EITHER wave's legs had been recorded
16
+ * — so both observed the full, unreduced allowance and both could pass a ceiling
17
+ * that only one of them fits under. The read is not the claim.
18
+ *
19
+ * `options.reserveBudget(estimate) -> boolean` closes that: it is a SYNCHRONOUS
20
+ * read-and-claim against the allowance not already claimed by a sibling wave
21
+ * that is mid-launch. Being synchronous is the whole guarantee — the event loop
22
+ * cannot interleave two callers inside it, so the second caller necessarily sees
23
+ * the first caller's claim. See src/council/run-budget.js for the ledger.
24
+ *
25
+ * It is OPT-IN: every non-council caller (the `amicus fanout` CLI, `amicus run`)
26
+ * omits it and gets the byte-identical pre-v4.4 gate.
27
+ */
28
+
29
+ /**
30
+ * @param {Array<{modelInput,model,pricing}>} okLegs legs that actually routed
31
+ * @param {object} options runFanout options (maxCost, maxCostPerMtok, noCostGate,
32
+ * promptMeta/prompt, and the optional `reserveBudget` claim function)
33
+ * @returns {{ok:true, estimate?:number}|{ok:false, message:string, hint:string}}
34
+ */
35
+ function preflightBudget(okLegs, options) {
36
+ // `--no-cost-gate` is a WHOLE-RUN opt-out of BOTH guards (an intentional
37
+ // o3-class council), so it must also skip the reservation — otherwise the
38
+ // council would still serialize its allowance for a ceiling it has disowned.
39
+ if (options.noCostGate) { return { ok: true }; }
40
+
41
+ const { checkBudget, formatBudgetError } = require('./budget');
42
+ const { loadConfig } = require('../utils/config');
43
+ const cfg = loadConfig() || {};
44
+ const maxCostPerMtok = options.maxCostPerMtok !== undefined ? options.maxCostPerMtok : cfg.maxCostPerMtok;
45
+ const promptChars = (options.promptMeta && options.promptMeta.chars)
46
+ || (options.prompt ? options.prompt.length : 0);
47
+ const maxCost = options.maxCost !== null && options.maxCost !== undefined ? options.maxCost : cfg.maxCost;
48
+
49
+ const budget = checkBudget(okLegs, { maxCostPerMtok, maxCost, promptChars });
50
+ if (!budget.ok) {
51
+ return { ok: false, message: 'Error: budget gate refused the wave', hint: formatBudgetError(budget) };
52
+ }
53
+
54
+ // Claim AFTER the hard per-$/Mtok threshold has passed: a wave that is going
55
+ // to be refused for an over-priced model must not consume allowance on its
56
+ // way out and starve a sibling that would have fit.
57
+ const estimate = budget.breakdown.totalEstCost;
58
+ if (typeof options.reserveBudget === 'function' && !options.reserveBudget(estimate)) {
59
+ return {
60
+ ok: false,
61
+ message: 'Error: budget gate refused the wave',
62
+ hint: `Budget gate: estimated total $${estimate.toFixed(4)} does not fit the --max-cost `
63
+ + 'allowance still unclaimed by concurrently launching waves (estimate, not guaranteed).\n'
64
+ + 'The run continues with the waves that did launch. Override: --max-cost <$> to raise the '
65
+ + 'ceiling, or --no-cost-gate to disable both guards.',
66
+ };
67
+ }
68
+ return { ok: true, estimate };
69
+ }
70
+
71
+ module.exports = { preflightBudget };
@@ -0,0 +1,189 @@
1
+ // src/sidecar/fanout-leg-fallback.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module fanout-leg-fallback
6
+ * Cheaper-model fallback substitution (spec 6.2), split out of fanout-leg.js
7
+ * to keep both files ≤300 lines (mirrors the fanout.js/fanout-validate.js
8
+ * split). Owns the substitution loop + its two bookkeeping helpers.
9
+ * `runSingleAttempt` is lazy-required from ./fanout-leg (function-scoped, to
10
+ * avoid a load-time circular require — fanout-leg.js requires this module for
11
+ * its thin runLeg wrapper).
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const { logger } = require('../utils/logger');
16
+ const { classifyLegError, isRetryable } = require('../utils/error-classify');
17
+ const { deriveChain } = require('./fallback-chains');
18
+
19
+ /**
20
+ * Append ONE attributed ledger row for a single attempt (spec 6.2/7.1). At
21
+ * `attempt:0` this is byte-identical to today's pre-fallback appendSpend row
22
+ * (same fields, sourced from `leg.attempt`/`leg.substitutedFor`/
23
+ * `leg.retryOfWaveId`, all undefined -> omitted for a plain leg). A
24
+ * substitution (`attempt` param > 0, the LOOP's attempt index) overrides
25
+ * `row.attempt`/`row.substitutedFor` with the substitution's own values.
26
+ * Best-effort — never throws, never affects the leg. `deps.spendDir` (tests)
27
+ * routes the write to a scratch dir; production leaves it undefined.
28
+ */
29
+ function recordAttemptSpend({ doc, leg, currentModel, legId, waveId, project, attempt, originalModel, routeGateway }, deps = {}) {
30
+ const usage = doc && doc.usage;
31
+ if (!usage) { return; }
32
+ try {
33
+ const { appendSpend } = deps.spendLedger || require('../utils/spend-ledger');
34
+ // Resolved legs carry the router's gateway on `leg.gateway` (attempt 0);
35
+ // a substitution carries the substitute's resolved gateway on
36
+ // `routeGateway` (threaded in by the loop, incl. v4.2 'local').
37
+ const gateway = routeGateway || (leg && leg.gateway) ||
38
+ (String(currentModel).startsWith('openrouter/') ? 'openrouter' : 'direct');
39
+ const row = {
40
+ taskId: legId, waveId, model: currentModel, mode: 'leg', usage,
41
+ op: 'leg', status: doc.status, gateway,
42
+ councilRunId: leg && leg.councilRunId, councilName: leg && leg.councilName,
43
+ project, attempt: leg && leg.attempt, substitutedFor: leg && leg.substitutedFor,
44
+ retryOfWaveId: leg && leg.retryOfWaveId,
45
+ };
46
+ if (attempt > 0) { row.attempt = attempt; row.substitutedFor = originalModel; }
47
+ appendSpend(row, deps.spendDir ? { dir: deps.spendDir } : undefined);
48
+ } catch { /* best-effort */ }
49
+ }
50
+
51
+ /**
52
+ * Fold token totals + cost amount across a leg's attempts[]. A single-attempt
53
+ * leg returns that attempt's usage verbatim (no behavior change for non-fallback
54
+ * legs). Multi-attempt sums tokens.* and cost.amount, tagging source 'mixed'
55
+ * when attempts' sources differ — matching formatCost's `~` behavior so a summed
56
+ * cost never reads as authoritative.
57
+ */
58
+ function sumAttemptUsage(attempts) {
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 = {};
63
+ let amount = 0;
64
+ let anyCost = false;
65
+ const sources = new Set();
66
+ for (const a of withUsage) {
67
+ for (const [k, v] of Object.entries(a.usage.tokens || {})) {
68
+ tokens[k] = (tokens[k] || 0) + (v || 0);
69
+ }
70
+ if (a.usage.cost && typeof a.usage.cost.amount === 'number') {
71
+ amount += a.usage.cost.amount;
72
+ anyCost = true;
73
+ if (a.usage.cost.source) { sources.add(a.usage.cost.source); }
74
+ }
75
+ }
76
+ const cost = anyCost
77
+ ? { amount, currency: 'USD', source: sources.size > 1 ? 'mixed' : (sources.values().next().value || 'reported') }
78
+ : null;
79
+ return { tokens, cost };
80
+ }
81
+
82
+ /**
83
+ * Per-$/Mtok hard-cap check for ONE fallback substitute candidate (spec
84
+ * §6.2). Minimal inline version of budget.js's checkBudget threshold, applied
85
+ * to a single model instead of a leg list. Unpriced (direct-provider,
86
+ * pricing:null) candidates are never treated as over-cap — unknown cost is
87
+ * not exceeded cost.
88
+ */
89
+ function isOverBudgetSubstitute(modelId, maxCostPerMtok) {
90
+ const { lookupPricing } = require('../utils/pricing');
91
+ const { DEFAULT_MAX_COST_PER_MTOK } = require('./budget');
92
+ const pricing = lookupPricing(modelId);
93
+ if (!pricing) { return false; }
94
+ const cap = (typeof maxCostPerMtok === 'number' && maxCostPerMtok > 0) ? maxCostPerMtok : DEFAULT_MAX_COST_PER_MTOK;
95
+ return Math.max(pricing.prompt * 1e6, pricing.completion * 1e6) > cap;
96
+ }
97
+
98
+ /**
99
+ * runLeg with opt-in cheaper-model substitution (spec 6.2). Runs the primary;
100
+ * on a classified rate-limit/overload failure with chain + budget remaining,
101
+ * substitutes the next chain model in the SAME leg dir under the SAME legId
102
+ * (conversation appends; metadata.model tracks the current model, modelInput
103
+ * stays the original). Best-effort bookkeeping (attempts/events/ledger) never
104
+ * fails the leg.
105
+ */
106
+ async function runLegWithFallback(args, deps = {}) {
107
+ const { leg, legId, waveId, project, fallback, catalog } = args;
108
+ const runOnce = deps.runOnce || ((a) => require('./fanout-leg').runSingleAttempt(a, deps));
109
+ const resolveRoute = deps.resolveRoute ||
110
+ ((r) => require('../utils/route-launch').resolveRouteForLaunch(r));
111
+ const { getSessionDir } = require('../session-manager');
112
+ const { appendEvent } = require('../observe/events');
113
+ const waveDir = getSessionDir(project, waveId);
114
+ // In production the wave dir already exists (fanout.js creates it before
115
+ // any leg launches); this is a defensive no-op there and only matters for
116
+ // a direct/unit-test caller of runLegWithFallback. appendEvent itself never
117
+ // creates directories (spec: best-effort, no side effects beyond the file).
118
+ try { fs.mkdirSync(waveDir, { recursive: true, mode: 0o700 }); } catch { /* best-effort */ }
119
+
120
+ const chain = deriveChain(leg.model, { config: { chains: fallback.chains }, catalog });
121
+ const attempts = [];
122
+ let currentModel = leg.model;
123
+ let currentGateway = leg.gateway;
124
+ let reasonClass = null;
125
+ let attempt = 0; // count of actual runs beyond the primary (bounded by maxSubstitutions)
126
+ let chainIdx = 0; // chain cursor: candidates tried OR skipped-for-budget, monotonic
127
+ let last;
128
+
129
+ for (;;) {
130
+ const startedAt = new Date().toISOString();
131
+ // `model` is threaded BOTH at the top level (test/injected runOnce fakes
132
+ // destructure it directly, e.g. `async ({ model }) => ...`) and nested
133
+ // under `leg.model` (the real runSingleAttempt only reads the latter).
134
+ last = await runOnce({ ...args, leg: { ...leg, model: currentModel }, model: currentModel,
135
+ attempt, substitutedFor: attempt > 0 ? leg.model : undefined });
136
+ attempts.push({ model: currentModel, status: last.status, usage: last.usage || null,
137
+ startedAt, completedAt: new Date().toISOString(), reason: last.reason || last.error || null });
138
+
139
+ // record this attempt's spend (one row per attempt — spec 6.2)
140
+ recordAttemptSpend({ doc: last, leg, currentModel, legId, waveId, project, attempt,
141
+ originalModel: leg.model, routeGateway: currentGateway }, deps);
142
+
143
+ if (last.status === 'complete') { break; }
144
+ // runSingleAttempt exposes the failure text on both `.reason` (its own
145
+ // alias) and `.error` (the buildRunResult field); an injected runOnce may
146
+ // set only one — read either.
147
+ const cls = classifyLegError(last.reason || last.error);
148
+ if (!fallback.enabled || !isRetryable(cls) || attempt >= fallback.maxSubstitutions || chainIdx >= chain.length) { break; }
149
+
150
+ // Walk the chain forward from chainIdx: an over-cap candidate is skipped
151
+ // (spec §6.2 per-$/Mtok guard) and the walk tries the NEXT entry; a
152
+ // route-resolution failure stops the whole loop outright (spec 8).
153
+ let substituteId = null;
154
+ let acceptedGateway = null;
155
+ let routeFailed = false;
156
+ while (chainIdx < chain.length) {
157
+ const next = chain[chainIdx];
158
+ chainIdx += 1;
159
+ let route;
160
+ try { route = await resolveRoute({ model: next, gatewayMode: undefined, source: 'fallback', allowSelection: false, validateModel: false }); }
161
+ catch { route = { kind: 'error' }; }
162
+ // RouteResult is keyed by `.kind` ('resolved'|'selection_required'|'error') — no `.ok` field.
163
+ if (!route || route.kind !== 'resolved') { routeFailed = true; break; }
164
+ const candidateId = route.executableId || route.model || next;
165
+ if (isOverBudgetSubstitute(candidateId, args.maxCostPerMtok)) {
166
+ logger.warn('Fallback substitute over the per-$/Mtok cap — trying the next chain entry', { legId, candidate: candidateId });
167
+ continue;
168
+ }
169
+ substituteId = candidateId;
170
+ acceptedGateway = route.gateway;
171
+ break;
172
+ }
173
+ if (routeFailed || !substituteId) { break; }
174
+
175
+ reasonClass = cls;
176
+ appendEvent(waveDir, { event: 'leg-fallback', id: waveId, legId,
177
+ fromModel: currentModel, toModel: substituteId, reason: cls, attempt: attempt + 1 });
178
+ currentModel = substituteId;
179
+ currentGateway = acceptedGateway;
180
+ attempt += 1;
181
+ }
182
+
183
+ const finalUsage = sumAttemptUsage(attempts);
184
+ const doc = { ...last, legId, model: currentModel, modelInput: leg.modelInput, usage: finalUsage, attempts };
185
+ if (attempt > 0) { doc.fallback = { from: leg.model, reason: reasonClass, attempts: attempt }; }
186
+ return doc;
187
+ }
188
+
189
+ module.exports = { runLegWithFallback, recordAttemptSpend, sumAttemptUsage };
@@ -4,7 +4,10 @@
4
4
  /**
5
5
  * @module fanout-leg
6
6
  * Per-leg helpers extracted from fanout.js to keep both files ≤300 lines.
7
- * Exports: legStatusFromResult, writeLegPatch, runLeg
7
+ * Exports: legStatusFromResult, writeLegPatch, runLeg, runSingleAttempt,
8
+ * buildRoutingFailureLeg (+ runLegWithFallback/recordAttemptSpend/
9
+ * sumAttemptUsage, re-exported from ./fanout-leg-fallback — split out to keep
10
+ * THIS file under the size gate; see that module for the substitution loop).
8
11
  */
9
12
 
10
13
  const fs = require('fs');
@@ -61,29 +64,40 @@ function buildRoutingFailureLeg({ leg, legId, waveId, quiet }) {
61
64
  }
62
65
 
63
66
  /**
64
- * Run one leg end-to-end: session record runHeadless (shared server) →
65
- * leg finalize. Never throws always resolves to a run document.
67
+ * Run ONE leg attempt end-to-end: session record -> runHeadless (shared
68
+ * server) -> leg finalize. Faithful extraction of the pre-fallback `runLeg`
69
+ * body — same setup, same never-throws try/finally, same leg-started/
70
+ * leg-terminal emits (with `follow` + the M4 own-try/catch), same `directory`
71
+ * threading into runHeadless. Never throws — always resolves to a run
72
+ * document. Does NOT append spend (the caller owns that: `runLeg` appends
73
+ * once; `runLegWithFallback` appends per attempt via recordAttemptSpend).
74
+ * Adds `.reason` (alias of buildRunResult's `.error`) and `.legId` so the
75
+ * fallback loop reads a stable shape without re-deriving them.
66
76
  */
67
- async function runLeg({ leg, legId, waveId, project, systemPrompt, userMessage, timeoutMs, agent, client, server, summaryLength, reasoning, quiet, foldNonce, directory }) {
77
+ async function runSingleAttempt({ leg, legId, waveId, project, directory, follow, systemPrompt, userMessage, timeoutMs, agent, client, server, summaryLength, reasoning, quiet, foldNonce }) {
68
78
  const { IdleWatchdog } = require('../utils/idle-watchdog');
69
79
  const { markAborted } = require('../utils/session-abort');
70
80
  const { runHeadless } = require('../headless');
71
81
  const { SessionPaths, saveInitialContext } = require('./session-utils');
72
- const { buildRunResult } = require('../utils/result-schema');
82
+ const { buildRunResult, durationBetween } = require('../utils/result-schema');
73
83
  const { createSessionMetadata } = require('./start');
84
+ const { emitLegStarted, emitLegTerminal } = require('../observe/events');
85
+ const { getSessionDir } = require('../session-manager');
74
86
 
75
- // Setup + run under ONE try so ANY throw (session record creation, initial
76
- // context write, watchdog arm, or the poll loop itself) becomes an error run
77
- // document — the wave still aggregates and writes wave.json. This function
78
- // must NEVER throw / reject for a leg error (fanout.js relies on this in its
79
- // Promise.all so one leg cannot sink the whole wave).
80
87
  let legDir = null;
81
88
  let watchdog = null;
82
89
  let result;
90
+ // Captured inside the try below so a getSessionDir throw (invalid taskId)
91
+ // still becomes an error run document like every other setup failure; used
92
+ // again AFTER the try/finally closes to emit leg-terminal (M4: that emit is
93
+ // unguarded by the try, so it must never depend on something that could throw).
94
+ let waveDir = null;
83
95
  try {
84
96
  legDir = createSessionMetadata(legId, project, {
85
97
  model: leg.model, prompt: userMessage, noUi: true, agent: agent || 'build',
86
98
  });
99
+ waveDir = getSessionDir(project, waveId);
100
+ emitLegStarted(waveDir, waveId, legId, leg.model, leg.modelInput, follow);
87
101
  writeLegPatch(legDir, { parentWave: waveId, modelInput: leg.modelInput });
88
102
  saveInitialContext(legDir, systemPrompt, userMessage);
89
103
 
@@ -118,15 +132,25 @@ async function runLeg({ leg, legId, waveId, project, systemPrompt, userMessage,
118
132
  const status = legStatusFromResult(result);
119
133
  const summary = result.summary || null;
120
134
  const { resolveUsage } = require('../utils/pricing');
121
- const usage = result && result.usage ? resolveUsage({ model: leg.model, usageTotals: result.usage }) : null;
122
- // B24: cross-run spend ledger one row per leg (mirrors start.js's single-run
123
- // append). Best-effort; never let ledger bookkeeping affect the leg's own result.
124
- if (usage) {
125
- try {
126
- const { appendSpend } = require('../utils/spend-ledger');
127
- appendSpend({ taskId: legId, waveId, model: leg.model, mode: 'leg', usage });
128
- } catch { /* best-effort */ }
129
- }
135
+ // v4.4 Task 2 (B4) + v4.4.1 CA-1: a leg that made a SUBAGENT (`task`) call has
136
+ // spend in a child OpenCode session that is billed separately and is NOT
137
+ // rolled into the parent session's cost. runHeadless now WALKS those sessions
138
+ // (src/sidecar/child-sessions.js), so `subtreeUnknown` narrows from "this leg
139
+ // called `task`, therefore assume the worst" to what it should always have
140
+ // meant: the walk could not account for the subtree.
141
+ //
142
+ // The walk is authoritative when it ran — including in the direction that
143
+ // CLEARS the flag, which the name-string proxy could never do (backlog CA-5:
144
+ // a tool merely NAMED `task` that spawns nothing used to make an exact run
145
+ // report itself inexact). The proxy survives only as the fallback for a leg
146
+ // where the walk could not run at all.
147
+ const walked = result && result.subtree;
148
+ const subtreeUnknown = walked
149
+ ? !!walked.unknown
150
+ : !!(result && result.subagentToolCalls > 0);
151
+ const usage = result && result.usage
152
+ ? resolveUsage({ model: leg.model, usageTotals: result.usage, subtreeUnknown, subtree: walked || undefined })
153
+ : null;
130
154
  // If setup threw before the session dir existed, there is nothing on disk to
131
155
  // finalize — still resolve to an error run document so the wave aggregates.
132
156
  const legPatch = {
@@ -134,24 +158,54 @@ async function runLeg({ leg, legId, waveId, project, systemPrompt, userMessage,
134
158
  reason: result.error || undefined,
135
159
  completedAt: new Date().toISOString(),
136
160
  usage: usage || undefined,
161
+ // v4.4 B4 part 1: the leg completed with tool calls still live, so its
162
+ // OpenCode session may have kept working (and billing) afterwards. Travels
163
+ // with the leg so it is readable long after the run's stderr is gone.
164
+ toolSettleTimedOut: (result && result.toolSettleTimedOut) || undefined,
137
165
  };
138
166
  let finalMeta = legPatch;
139
167
  if (legDir) {
140
- if (summary) {
141
- fs.writeFileSync(SessionPaths.summaryFile(legDir), summary, { mode: 0o600 });
142
- }
168
+ if (summary) { fs.writeFileSync(SessionPaths.summaryFile(legDir), summary, { mode: 0o600 }); }
143
169
  finalMeta = writeLegPatch(legDir, legPatch);
144
170
  }
145
- const effectiveResult = finalMeta.status === 'aborted'
146
- ? { ...result, aborted: true }
147
- : result;
171
+ if (waveDir) {
172
+ try {
173
+ emitLegTerminal(waveDir, waveId, legId, {
174
+ model: leg.model, status: finalMeta.status,
175
+ durationMs: durationBetween(finalMeta.createdAt, finalMeta.completedAt),
176
+ usage: usage || null,
177
+ }, follow);
178
+ } catch { /* best-effort: a missing duration/emit never fails the leg */ }
179
+ }
180
+ const effectiveResult = finalMeta.status === 'aborted' ? { ...result, aborted: true } : result;
148
181
  if (!quiet) {
149
182
  process.stderr.write(`[fanout] leg ${legId} (${leg.modelInput}): ${finalMeta.status}\n`);
150
183
  }
151
- return buildRunResult({
184
+ const doc = buildRunResult({
152
185
  taskId: legId, metadata: finalMeta, result: effectiveResult, summary,
153
186
  modelInput: leg.modelInput, sessionDir: legDir, waveId, usage,
154
187
  });
188
+ doc.reason = doc.error || null; // classifier alias (buildRunResult stores it as .error)
189
+ doc.legId = legId;
190
+ return doc;
191
+ }
192
+
193
+ /**
194
+ * Run one leg end-to-end. Thin wrapper: fallback OFF (the default) is a
195
+ * single `runSingleAttempt` + exactly one spend row (`attempt:0`, byte-
196
+ * identical to the pre-fallback appendSpend row); fallback ON delegates to
197
+ * `runLegWithFallback` (./fanout-leg-fallback). Never throws.
198
+ */
199
+ async function runLeg(args) {
200
+ const { leg, legId, waveId, project, fallback } = args;
201
+ const { runLegWithFallback, recordAttemptSpend } = require('./fanout-leg-fallback');
202
+ if (fallback && fallback.enabled) { return runLegWithFallback(args); }
203
+ const doc = await runSingleAttempt(args);
204
+ recordAttemptSpend({ doc, leg, currentModel: leg.model, legId, waveId, project, attempt: 0, originalModel: leg.model }, {});
205
+ return doc;
155
206
  }
156
207
 
157
- module.exports = { legStatusFromResult, writeLegPatch, runLeg, buildRoutingFailureLeg };
208
+ module.exports = {
209
+ legStatusFromResult, writeLegPatch, runLeg, buildRoutingFailureLeg, runSingleAttempt,
210
+ ...require('./fanout-leg-fallback'),
211
+ };