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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +46 -1
- package/README.md +8 -4
- package/bin/amicus.js +5 -0
- 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 +57 -0
- package/schemas/council-run.schema.json +14 -0
- package/schemas/event.schema.json +15 -0
- package/schemas/progress.schema.json +37 -0
- package/schemas/run-live.schema.json +15 -0
- package/schemas/spend.schema.json +26 -1
- package/schemas/wave-live.schema.json +15 -0
- package/skills/second-opinion/MODEL-NOTES.md +53 -5
- package/src/cli-handlers-council-run.js +86 -8
- package/src/cli-handlers-run.js +26 -0
- package/src/cli-handlers-spend.js +94 -32
- package/src/cli-handlers-watch.js +116 -0
- package/src/cli.js +58 -1
- package/src/council/briefings.js +35 -2
- package/src/council/run-budget.js +224 -0
- package/src/council/run-chair.js +10 -2
- package/src/council/run-debate.js +5 -1
- package/src/council/run-launch.js +58 -7
- package/src/council/run-stages.js +30 -3
- package/src/council/run.js +44 -15
- package/src/headless.js +356 -15
- package/src/mcp-council-awareness.js +98 -3
- package/src/mcp-council-run.js +28 -4
- package/src/mcp-notify.js +54 -0
- package/src/mcp-server.js +51 -1
- package/src/mcp-spend.js +125 -0
- package/src/mcp-tools.js +39 -0
- package/src/mcp-wait.js +28 -2
- package/src/observe/council-legs.js +183 -0
- package/src/observe/events.js +156 -0
- package/src/observe/follow.js +26 -0
- package/src/observe/live-doc.js +56 -0
- package/src/observe/on-complete.js +117 -0
- package/src/observe/watch-render.js +168 -0
- package/src/opencode-client.js +15 -3
- package/src/sidecar/child-sessions.js +198 -0
- package/src/sidecar/continue.js +32 -0
- package/src/sidecar/conversation-mirror.js +111 -37
- package/src/sidecar/fallback-chains.js +65 -0
- package/src/sidecar/fanout-budget.js +71 -0
- package/src/sidecar/fanout-leg-fallback.js +189 -0
- package/src/sidecar/fanout-leg.js +81 -27
- package/src/sidecar/fanout-retry.js +208 -0
- package/src/sidecar/fanout-validate.js +42 -4
- package/src/sidecar/fanout.js +54 -41
- package/src/sidecar/progress.js +5 -0
- package/src/sidecar/resume.js +12 -0
- package/src/sidecar/start.js +13 -1
- package/src/sidecar/tool-part.js +196 -0
- package/src/sidecar/workspace-window.js +62 -0
- package/src/spend-query.js +119 -0
- package/src/utils/env-num.js +42 -0
- package/src/utils/error-classify.js +31 -0
- package/src/utils/model-tiers.js +1 -1
- package/src/utils/path-fence.js +82 -0
- package/src/utils/pricing.js +98 -9
- package/src/utils/spend-ledger.js +24 -1
- 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
|
@@ -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
|
|
65
|
-
* leg finalize.
|
|
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
|
|
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
|
-
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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 = {
|
|
208
|
+
module.exports = {
|
|
209
|
+
legStatusFromResult, writeLegPatch, runLeg, buildRoutingFailureLeg, runSingleAttempt,
|
|
210
|
+
...require('./fanout-leg-fallback'),
|
|
211
|
+
};
|