@miraland-labs/conduit-bridge 0.16.28 → 0.16.30

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/dist/cli.js CHANGED
@@ -440,7 +440,10 @@ async function driversCommand() {
440
440
  for (const lane of laneStatuses(config)) {
441
441
  const when = lane.observed_at ? ` observed=${lane.observed_at}` : "";
442
442
  const reset = lane.resets_at ? ` resets=${lane.resets_at}` : lane.allowance === "unavailable" ? " resets=unknown" : "";
443
- console.log(` ${lane.id.padEnd(14)} ${lane.state.padEnd(8)} fuel=${lane.fuel} allowance=${lane.allowance}${when}${reset} (${lane.label})`);
443
+ // A degraded provider never refuses, so allowance stays "unknown" while every turn times out.
444
+ // Printing the count is the only way an operator sees why dispatch keeps avoiding this lane.
445
+ const slow = lane.timeouts ? ` timeouts=${lane.timeouts} (demoted)` : "";
446
+ console.log(` ${lane.id.padEnd(14)} ${lane.state.padEnd(8)} fuel=${lane.fuel} allowance=${lane.allowance}${when}${reset}${slow} (${lane.label})`);
444
447
  }
445
448
  const blocked = laneDispatchBlock(laneStatuses(config));
446
449
  console.log(blocked
package/dist/driver.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { boundedTail } from "./ensure-test-evidence.js";
2
3
  import { resolveAgentTimeout } from "./execution-budget.js";
3
4
  import { existsSync } from "node:fs";
4
5
  import { mkdir, readFile, rm, rmdir, writeFile } from "node:fs/promises";
@@ -523,7 +524,7 @@ export const claudeCodeDriver = {
523
524
  }
524
525
  const sessionId = message?.session_id ?? null;
525
526
  if (code !== 0 || !message || message.is_error) {
526
- return { status: "failed", resultText: message?.result ?? null, sessionId, error: (message?.result || stderr || `agent exited with code ${code}`).slice(0, 20_000) };
527
+ return { status: "failed", resultText: message?.result ?? null, sessionId, error: boundedTail(message?.result || stderr || `agent exited with code ${code}`, 20_000) };
527
528
  }
528
529
  return { status: "completed", resultText: message.result ?? "", sessionId };
529
530
  },
@@ -624,7 +625,7 @@ export const codexDriver = {
624
625
  const parsed = parseCodexJsonl(stdout);
625
626
  const resultText = parsed.resultText ?? (stdout || null);
626
627
  if (code !== 0) {
627
- return { status: "failed", resultText, sessionId: parsed.sessionId, error: (stderr || stdout || `codex exited with code ${code}`).slice(0, 20_000) };
628
+ return { status: "failed", resultText, sessionId: parsed.sessionId, error: boundedTail(stderr || stdout || `codex exited with code ${code}`, 20_000) };
628
629
  }
629
630
  return { status: "completed", resultText, sessionId: parsed.sessionId };
630
631
  },
@@ -756,7 +757,7 @@ export const cursorDriver = {
756
757
  const { code, stdout, stderr } = configured;
757
758
  const parsed = parseCursorOutput(stdout);
758
759
  if (code !== 0 || parsed.isError) {
759
- return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, error: (stderr || parsed.resultText || `cursor agent exited with code ${code}`).slice(0, 20_000) };
760
+ return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, error: boundedTail(stderr || parsed.resultText || `cursor agent exited with code ${code}`, 20_000) };
760
761
  }
761
762
  return { status: "completed", resultText: parsed.resultText, sessionId: parsed.sessionId };
762
763
  },
@@ -923,7 +924,7 @@ export const openCodeDriver = {
923
924
  const { code, stdout, stderr } = await withOpenCodePermissions(input.workspace, () => execute(input.executable ?? "opencode", args, input.workspace, agentTurnTimeoutMs(input), fuelSource === "conduit" ? input.fuel : undefined, fuelSource));
924
925
  const parsed = parseOpenCodeOutput(stdout);
925
926
  if (code !== 0 || parsed.isError) {
926
- return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, error: (stderr || parsed.resultText || `opencode exited with code ${code}`).slice(0, 20_000) };
927
+ return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, error: boundedTail(stderr || parsed.resultText || `opencode exited with code ${code}`, 20_000) };
927
928
  }
928
929
  return { status: "completed", resultText: parsed.resultText, sessionId: parsed.sessionId };
929
930
  },
@@ -988,7 +989,7 @@ export const kiroDriver = {
988
989
  args.push(input.prompt);
989
990
  const { code, stdout, stderr } = await execute(executable, args, input.workspace, agentTurnTimeoutMs(input), undefined, "local");
990
991
  if (code !== 0) {
991
- return { status: "failed", resultText: stdout || null, sessionId: null, error: (stderr || stdout || `kiro-cli exited with code ${code}`).slice(0, 20_000) };
992
+ return { status: "failed", resultText: stdout || null, sessionId: null, error: boundedTail(stderr || stdout || `kiro-cli exited with code ${code}`, 20_000) };
992
993
  }
993
994
  return { status: "completed", resultText: stdout, sessionId: null };
994
995
  },
@@ -1036,7 +1037,7 @@ export const antigravityDriver = {
1036
1037
  // agy print mode emits plain text and does not surface a resumable id, so rework resume is not wired.
1037
1038
  const { code, stdout, stderr } = await execute(input.executable ?? "agy", antigravityRunArgs({ prompt: input.prompt, grants: input.grants }, mode), input.workspace, agentTurnTimeoutMs(input), undefined, "local");
1038
1039
  if (code !== 0) {
1039
- return { status: "failed", resultText: stdout || null, sessionId: null, error: (stderr || stdout || `agy exited with code ${code}`).slice(0, 20_000) };
1040
+ return { status: "failed", resultText: stdout || null, sessionId: null, error: boundedTail(stderr || stdout || `agy exited with code ${code}`, 20_000) };
1040
1041
  }
1041
1042
  return { status: "completed", resultText: stdout, sessionId: null };
1042
1043
  },
@@ -1089,7 +1090,7 @@ export const piDriver = {
1089
1090
  status: "failed",
1090
1091
  resultText: parsed.resultText,
1091
1092
  sessionId: parsed.sessionId,
1092
- error: (stderr || parsed.resultText || `pi exited with code ${code}`).slice(0, 20_000),
1093
+ error: boundedTail(stderr || parsed.resultText || `pi exited with code ${code}`, 20_000),
1093
1094
  };
1094
1095
  }
1095
1096
  return { status: "completed", resultText: parsed.resultText, sessionId: parsed.sessionId };
@@ -1221,7 +1222,7 @@ export const grokDriver = {
1221
1222
  const { code, stdout, stderr } = await execute(executable, grokRunArgs(input), input.workspace, agentTurnTimeoutMs(input), undefined, "local");
1222
1223
  const parsed = parseGrokOutput(stdout);
1223
1224
  if (code !== 0 || parsed.isError) {
1224
- return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, error: (stderr || parsed.resultText || `grok exited with code ${code}`).slice(0, 20_000) };
1225
+ return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, error: boundedTail(stderr || parsed.resultText || `grok exited with code ${code}`, 20_000) };
1225
1226
  }
1226
1227
  return { status: "completed", resultText: parsed.resultText, sessionId: parsed.sessionId };
1227
1228
  },
@@ -1290,7 +1291,7 @@ function execute(executable, args, cwd, timeoutMs, fuel, fuelSource = "conduit",
1290
1291
  stdout,
1291
1292
  // Never let vendor stderr appended before SIGTERM turn a local timer into a quota refusal.
1292
1293
  // This stable prefix is classified by Bridge and the control plane as execution timeout.
1293
- stderr: timedOut ? `agent_turn_timeout: agent turn exceeded ${timeoutMs}ms` : stderr.slice(0, 20_000),
1294
+ stderr: timedOut ? `agent_turn_timeout: agent turn exceeded ${timeoutMs}ms` : boundedTail(stderr, 20_000),
1294
1295
  });
1295
1296
  });
1296
1297
  if (stdinText !== undefined && child.stdin) {
package/dist/drivers.js CHANGED
@@ -55,10 +55,21 @@ export function normalizeDrivers(raw) {
55
55
  const state = lane.state === "online" ? "online" : "offline";
56
56
  const fuel = lane.fuel === "local" || lane.fuel === "conduit" ? lane.fuel : undefined;
57
57
  const quota = normalizeQuota(lane.quota);
58
- out[id] = { state, ...(fuel ? { fuel } : {}), ...(quota ? { quota } : {}) };
58
+ const outcome = normalizeOutcome(lane.outcome);
59
+ out[id] = { state, ...(fuel ? { fuel } : {}), ...(quota ? { quota } : {}), ...(outcome ? { outcome } : {}) };
59
60
  }
60
61
  return out;
61
62
  }
63
+ /** Same rule as quota: a malformed outcome is dropped, never allowed to hold a lane back. */
64
+ function normalizeOutcome(raw) {
65
+ if (!raw || typeof raw !== "object")
66
+ return undefined;
67
+ const value = raw;
68
+ if (!Number.isFinite(value.consecutive_timeouts) || typeof value.last_timeout_at !== "string")
69
+ return undefined;
70
+ const count = Math.max(0, Math.floor(value.consecutive_timeouts));
71
+ return { consecutive_timeouts: count, last_timeout_at: value.last_timeout_at };
72
+ }
62
73
  /** Drop a malformed quota rather than fail the whole config: a bad record must not dark a lane. */
63
74
  function normalizeQuota(raw) {
64
75
  if (!raw || typeof raw !== "object")
@@ -159,11 +170,14 @@ export function laneStatuses(config, now = Date.now()) {
159
170
  const allowance = !local || !quota
160
171
  ? "unknown"
161
172
  : quota.exhausted ? "unavailable" : "available";
173
+ const outcome = normalizeDrivers(config.drivers)[lane.id]?.outcome;
162
174
  return {
163
175
  ...lane,
164
176
  allowance,
165
177
  observed_at: quota?.observed_at,
166
178
  resets_at: quota?.resets_at ?? undefined,
179
+ ...(laneTimedOutRecently(config, lane.id, now) ? { timeouts: outcome?.consecutive_timeouts } : {}),
180
+ // Timeouts demote but never make a lane ineligible: see pickDriverForClaim.
167
181
  eligible: lane.state === "online" && allowance !== "unavailable",
168
182
  };
169
183
  });
@@ -178,6 +192,53 @@ export function laneDispatchBlock(statuses) {
178
192
  return "every lane is offline";
179
193
  return "every online lane has an allowance the provider refused";
180
194
  }
195
+ /** Timeouts in a row before a lane stops being preferred. One is noise; two is a pattern. */
196
+ export const TIMEOUT_SUPPRESSION_THRESHOLD = 2;
197
+ /** How long a timeout pattern is believed. Short, because the cause is often the budget, not the lane. */
198
+ export const TIMEOUT_SUPPRESSION_MS = 60 * 60_000;
199
+ /** Record a turn that ended at the execution ceiling. Never touches the quota record. */
200
+ export function recordDriverTimeout(config, driverId, now = Date.now()) {
201
+ if (!isSupportedDriverId(driverId))
202
+ return config;
203
+ const drivers = normalizeDrivers(config.drivers);
204
+ const lane = drivers[driverId] ?? { state: "offline" };
205
+ const previous = lane.outcome?.consecutive_timeouts ?? 0;
206
+ drivers[driverId] = {
207
+ ...lane,
208
+ outcome: { consecutive_timeouts: previous + 1, last_timeout_at: new Date(now).toISOString() },
209
+ };
210
+ return { ...config, drivers };
211
+ }
212
+ /**
213
+ * Clear what the last runs said about this lane.
214
+ *
215
+ * A run that finished is the strongest evidence there is, and it is worth more than any older claim
216
+ * — a lane that just worked is not spent and is not slow.
217
+ */
218
+ export function clearDriverOutcome(config, driverId) {
219
+ if (!isSupportedDriverId(driverId))
220
+ return config;
221
+ const drivers = normalizeDrivers(config.drivers);
222
+ const lane = drivers[driverId];
223
+ if (!lane?.outcome)
224
+ return config;
225
+ const { outcome: _cleared, ...rest } = lane;
226
+ drivers[driverId] = rest;
227
+ return { ...config, drivers };
228
+ }
229
+ /**
230
+ * Whether this lane's recent timeouts should push it behind others.
231
+ *
232
+ * Expires on its own, because a timeout usually means the budget was too small for the work rather
233
+ * than that the lane is bad, and the next package may be smaller.
234
+ */
235
+ export function laneTimedOutRecently(config, driverId, now = Date.now()) {
236
+ const outcome = normalizeDrivers(config.drivers)[driverId]?.outcome;
237
+ if (!outcome || outcome.consecutive_timeouts < TIMEOUT_SUPPRESSION_THRESHOLD)
238
+ return false;
239
+ const seen = Date.parse(outcome.last_timeout_at);
240
+ return !Number.isFinite(seen) || now - seen <= TIMEOUT_SUPPRESSION_MS;
241
+ }
181
242
  export function onlineDriverIds(config) {
182
243
  const drivers = normalizeDrivers(config.drivers);
183
244
  return SUPPORTED_AGENTS.map((agent) => agent.id).filter((id) => drivers[id]?.state === "online");
@@ -290,14 +351,17 @@ export function pickDriverForClaim(config, processOnlineIds, eligible = () => tr
290
351
  load.set(active.driverId, (load.get(active.driverId) ?? 0) + 1);
291
352
  }
292
353
  }
354
+ // Demote, never exclude. A lane held out of selection can never run the work that would prove it
355
+ // well again, and suppressing the last candidate would idle the machine over a claim about the
356
+ // past. Ordering is enough: a healthy lane wins while one exists, and a suppressed lane is still
357
+ // picked when it is all there is.
358
+ const rank = (id) => (laneTimedOutRecently(config, id) ? 1 : 0);
293
359
  let best = online[0];
294
- let bestLoad = load.get(best) ?? 0;
295
360
  for (const id of online.slice(1)) {
296
- const n = load.get(id) ?? 0;
297
- if (n < bestLoad) {
361
+ const better = rank(id) < rank(best)
362
+ || (rank(id) === rank(best) && (load.get(id) ?? 0) < (load.get(best) ?? 0));
363
+ if (better)
298
364
  best = id;
299
- bestLoad = n;
300
- }
301
365
  }
302
366
  return best;
303
367
  }
@@ -84,13 +84,10 @@ export async function captureVerificationFailure(input) {
84
84
  }
85
85
  if (result.code === 0)
86
86
  continue;
87
- const lines = [
88
- `$ ${command}`,
89
- ...(result.stdout.trim() ? result.stdout.trim().split("\n") : []),
90
- ...(result.stderr.trim() ? result.stderr.trim().split("\n") : []),
91
- `exit ${result.code}`,
92
- ].map((line) => line.slice(0, 4_000)).slice(0, 120);
93
- const text = lines.join("\n").slice(0, 12_000);
87
+ // Same rule as the pre-delivery gate: this is the diagnosis a repair brief is written from, so
88
+ // it must keep the end. It carried its own head-truncating copy of that logic until the two were
89
+ // shown to be the same bug.
90
+ const text = boundedFailureDetail(command, result.stdout.trim() ? result.stdout.trim().split("\n") : [], result.stderr.trim() ? result.stderr.trim().split("\n") : [], result.code, DIAGNOSIS_DETAIL_MAX_CHARS);
94
91
  if (text.trim())
95
92
  return text;
96
93
  }
@@ -99,8 +96,52 @@ export async function captureVerificationFailure(input) {
99
96
  }
100
97
  /** Longest failure detail Bridge will attach. Enough to diagnose; short enough to store and read. */
101
98
  export const FAILURE_DETAIL_MAX_CHARS = 4_000;
99
+ /** A recovered diagnosis may be longer: Conductor writes a repair brief from it. */
100
+ export const DIAGNOSIS_DETAIL_MAX_CHARS = 12_000;
102
101
  /** Leading lines kept for orientation before the elision. */
103
102
  const FAILURE_DETAIL_HEAD_LINES = 3;
103
+ /** Head kept for orientation when a raw message must be shortened. */
104
+ const TAIL_HEAD_CHARS = 200;
105
+ /**
106
+ * Keep a bounded number of lines without losing the last ones.
107
+ *
108
+ * Used for the evidence stored when verification *passes*. The proof that a suite passed is its
109
+ * summary — `Ran 500 tests` and `OK` — and that is the final line. Keeping the first hundred lines
110
+ * of a long verbose run stored a screen of `... ok` and discarded the only line that settles it.
111
+ *
112
+ * Returns the input unchanged when it already fits, so nothing short is ever decorated.
113
+ */
114
+ export function boundedDetailLines(lines, maxLines) {
115
+ if (lines.length <= maxLines)
116
+ return lines;
117
+ const head = Math.min(3, Math.max(0, maxLines - 2));
118
+ const tail = maxLines - head - 1;
119
+ const dropped = lines.length - head - tail;
120
+ return [
121
+ ...lines.slice(0, head),
122
+ `… ${dropped} line${dropped === 1 ? "" : "s"} omitted …`,
123
+ ...lines.slice(lines.length - tail),
124
+ ];
125
+ }
126
+ /**
127
+ * Shorten a raw diagnostic message while keeping the end.
128
+ *
129
+ * For a message with no line structure worth preserving — an agent's stderr, a check's failure text —
130
+ * the same rule holds as for command output: the beginning is a banner and the end is the reason.
131
+ * Keeping a short head preserves the label or error code that usually leads the string.
132
+ *
133
+ * Returns the input unchanged when it already fits, so a short message is never decorated.
134
+ */
135
+ export function boundedTail(text, maxChars) {
136
+ if (text.length <= maxChars)
137
+ return text;
138
+ const marker = "\n… truncated …\n";
139
+ const head = Math.min(TAIL_HEAD_CHARS, Math.floor((maxChars - marker.length) / 4));
140
+ const tail = maxChars - marker.length - head;
141
+ if (tail <= 0)
142
+ return text.slice(text.length - maxChars);
143
+ return `${text.slice(0, head)}${marker}${text.slice(text.length - tail)}`;
144
+ }
104
145
  /**
105
146
  * A failure detail that keeps the part which explains the failure.
106
147
  *
@@ -119,24 +160,39 @@ export function boundedFailureDetail(command, stdoutLines, stderrLines, code, ma
119
160
  const body = [...stdoutLines, ...stderrLines].map(cap);
120
161
  const first = `$ ${command}`;
121
162
  const last = `exit ${code}`;
122
- // The exit code is never a candidate for elision: it is the one line that always carries meaning.
123
- const fixed = `${first}\n${last}`.length;
124
- const budget = Math.max(0, maxChars - fixed - 1);
125
- const head = body.slice(0, FAILURE_DETAIL_HEAD_LINES);
126
- const rest = body.slice(FAILURE_DETAIL_HEAD_LINES);
163
+ const marker = (dropped) => `… ${dropped} line${dropped === 1 ? "" : "s"} omitted …`;
164
+ // The command and the exit code are never candidates for elision, and every separator the join
165
+ // will insert is charged for. Adding the head lines without charging them is what let a 200-char
166
+ // budget produce 218 characters.
167
+ let budget = maxChars - first.length - last.length - 2;
168
+ if (budget <= 0)
169
+ return [first, last].join("\n");
170
+ // Head is a convenience for orientation, so it may take only a small share and is dropped entirely
171
+ // when it does not fit. The tail is the diagnosis and gets whatever remains.
172
+ const headBudget = Math.floor(budget / 4);
173
+ const head = [];
174
+ let headUsed = 0;
175
+ for (const line of body.slice(0, FAILURE_DETAIL_HEAD_LINES)) {
176
+ if (headUsed + line.length + 1 > headBudget)
177
+ break;
178
+ head.push(line);
179
+ headUsed += line.length + 1;
180
+ }
181
+ budget -= headUsed;
182
+ const rest = body.slice(head.length);
127
183
  const tail = [];
128
- let used = head.join("\n").length;
184
+ let tailUsed = 0;
129
185
  for (let index = rest.length - 1; index >= 0; index -= 1) {
130
186
  const line = rest[index];
131
- if (used + line.length + 1 > budget)
187
+ // Reserve room for the marker while anything is still being dropped.
188
+ const reserve = index > 0 ? marker(index).length + 1 : 0;
189
+ if (tailUsed + line.length + 1 + reserve > budget)
132
190
  break;
133
191
  tail.unshift(line);
134
- used += line.length + 1;
192
+ tailUsed += line.length + 1;
135
193
  }
136
194
  const dropped = rest.length - tail.length;
137
- const middle = dropped > 0
138
- ? [...head, `… ${dropped} line${dropped === 1 ? "" : "s"} omitted …`, ...tail]
139
- : [...head, ...tail];
195
+ const middle = dropped > 0 ? [...head, marker(dropped), ...tail] : [...head, ...tail];
140
196
  return [first, ...middle, last].join("\n");
141
197
  }
142
198
  async function defaultRunCommand(command, workspace) {
@@ -217,18 +273,33 @@ export async function ensureTestEvidence(input) {
217
273
  const stderrLines = result.stderr.trim() ? result.stderr.trim().split("\n") : [];
218
274
  if (result.code !== 0) {
219
275
  // Prefix must match FINALIZE_CONTRACT_PATTERN ("Agent report") — not Bridge-fault laundering.
220
- throw new Error(`Agent report: Verification failed (${command}): ${boundedFailureDetail(command, stdoutLines, stderrLines, result.code)}`);
276
+ const detail = boundedFailureDetail(command, stdoutLines, stderrLines, result.code);
277
+ // One line an operator can grep when a stored failure looks head-truncated. Reading the shape
278
+ // back out of the database could not distinguish "the fix did not run" from "something
279
+ // downstream re-truncated it"; this says which, at the moment it is produced.
280
+ console.error(JSON.stringify({
281
+ event: "verification_failure_detail",
282
+ command,
283
+ exit_code: result.code,
284
+ stdout_lines: stdoutLines.length,
285
+ stderr_lines: stderrLines.length,
286
+ detail_chars: detail.length,
287
+ elided: detail.includes("omitted"),
288
+ ends_with_exit: detail.trimEnd().endsWith(`exit ${result.code}`),
289
+ }));
290
+ throw new Error(`Agent report: Verification failed (${command}): ${detail}`);
221
291
  }
222
292
  // Empty exit-0 is not proof — a no-op recipe (`@true`) must not clear required_evidence: test.
223
293
  if (stdoutLines.length === 0 && stderrLines.length === 0) {
224
294
  throw new Error(`Agent report: Verification produced no output for test evidence (${command}). A bounded test command must print observable stdout or stderr.`);
225
295
  }
226
- const details = [
296
+ const rawDetails = [
227
297
  `$ ${command}`,
228
298
  ...stdoutLines,
229
299
  ...stderrLines,
230
300
  `exit ${result.code}`,
231
- ].map((line) => line.slice(0, 4_000)).slice(0, 100);
301
+ ].map((line) => line.slice(0, 4_000));
302
+ const details = boundedDetailLines(rawDetails, 100);
232
303
  if (details.join("\n").trim().length < TEST_EVIDENCE_DETAILS_MIN) {
233
304
  throw new Error(`Agent report: Verification produced insufficient output for test evidence (${command})`);
234
305
  }
package/dist/execution.js CHANGED
@@ -6,7 +6,7 @@ import { ConduitRequestError } from "./client.js";
6
6
  import { redactSecrets, saveDriverQuota } from "./config.js";
7
7
  import { DRIVERS, agentReportTemplate, buildAssignmentPrompt, evidenceKinds, extractAgentReportJsonText, fuelEndpoint, normalizeEvidenceDigest, parseAgentReport, parseJsonObjectCandidate, pickModelCandidate, requireStampedExecutionClass, tierForRisk } from "./driver.js";
8
8
  import { assertClassFloor } from "./execution-class.js";
9
- import { pickDriverForClaim, recordDriverQuota, resolveDriverFuel, resolveDriverFuelProvenance, supportsReadOnlyDiagnosis } from "./drivers.js";
9
+ import { pickDriverForClaim, recordDriverQuota, resolveDriverFuel, resolveDriverFuelProvenance, supportsReadOnlyDiagnosis, clearDriverOutcome, recordDriverTimeout } from "./drivers.js";
10
10
  import { attemptWorktreePath, createAttemptWorktree, proveResumeWorktree, quarantineAttemptWorktree, removeAttemptWorktree, } from "./attempt-worktree.js";
11
11
  import { execFile } from "node:child_process";
12
12
  import { promisify } from "node:util";
@@ -231,7 +231,11 @@ export function classifyFinalizeFailure(message) {
231
231
  * leave the lane eligible.
232
232
  */
233
233
  export async function learnDriverFuel(config, driverId, fuelSource, result) {
234
- const drivers = observeDriverFuel(config, driverId, fuelSource, result).drivers;
234
+ // Fuel is what the vendor said; outcome is what the run did. Both are learned here so every path
235
+ // that already reports a result learns both, and no new call site can forget one of them.
236
+ let next = observeDriverFuel(config, driverId, fuelSource, result);
237
+ next = observeDriverOutcome(next, driverId, result);
238
+ const drivers = next.drivers;
235
239
  if (JSON.stringify(drivers ?? {}) === JSON.stringify(config.drivers ?? {}))
236
240
  return;
237
241
  config.drivers = drivers;
@@ -240,6 +244,21 @@ export async function learnDriverFuel(config, driverId, fuelSource, result) {
240
244
  // A fuel record is worth less than the run it came from: never let a disk fault fail the attempt.
241
245
  await saveDriverQuota(driverId, drivers?.[driverId]?.quota).catch(() => undefined);
242
246
  }
247
+ /**
248
+ * Learn from what the run did, not from what a vendor said about it.
249
+ *
250
+ * A provider that degrades instead of refusing — a spent Cursor subscription still answering from a
251
+ * slow free tier — produces no refusal to learn from, so the quota record stays empty and the lane
252
+ * looks healthy while every turn runs to the ceiling. The timeout itself is the evidence.
253
+ *
254
+ * Recorded separately from quota on purpose. A timeout means the budget was too small or the lane is
255
+ * degraded; a refusal means the allowance is spent. They call for different operator actions.
256
+ */
257
+ export function observeDriverOutcome(config, driverId, result) {
258
+ if (result.status !== "failed")
259
+ return clearDriverOutcome(config, driverId);
260
+ return agentTurnTimedOut(result.error ?? "") ? recordDriverTimeout(config, driverId) : config;
261
+ }
243
262
  export function observeDriverFuel(config, driverId, fuelSource, result, now = Date.now()) {
244
263
  if (fuelSource !== "local")
245
264
  return config;
@@ -6,6 +6,7 @@
6
6
  * the worktree it reads is deleted whichever way the run ends.
7
7
  */
8
8
  import { z } from "zod";
9
+ import { boundedTail } from "./ensure-test-evidence.js";
9
10
  import { createAttemptWorktree, removeAttemptWorktree } from "./attempt-worktree.js";
10
11
  import { buildWorkspaceBrief, ensureCommitAvailable, normalizeRepositoryUrl } from "./brief.js";
11
12
  import { redactSecrets } from "./config.js";
@@ -206,7 +207,7 @@ export async function executeNextInvestigation(client, config, workspace, brief,
206
207
  await learnDriverFuel(config, driver.name, investigationFuel, result);
207
208
  if (result.status === "failed") {
208
209
  const message = result.error ?? "Investigation run failed";
209
- await settle(client, assignment.id, { lease_token: leaseToken, status: "failed", error: redactSecrets(message).slice(0, 2_000), retryable: true });
210
+ await settle(client, assignment.id, { lease_token: leaseToken, status: "failed", error: boundedTail(redactSecrets(message), 2_000), retryable: true });
210
211
  console.error(`Investigation ${assignment.id} failed: ${redactSecrets(message)}`);
211
212
  return true;
212
213
  }
@@ -216,7 +217,7 @@ export async function executeNextInvestigation(client, config, workspace, brief,
216
217
  // The answer may be perfectly good, but it is no longer an answer about the pinned commit.
217
218
  // Report what happened rather than settling a brief whose ground moved under it.
218
219
  await settle(client, assignment.id, {
219
- lease_token: leaseToken, status: "failed", error: `${check.error}: ${check.detail}`.slice(0, 2_000), retryable: true,
220
+ lease_token: leaseToken, status: "failed", error: boundedTail(`${check.error}: ${check.detail}`, 2_000), retryable: true,
220
221
  });
221
222
  console.error(`Investigation ${assignment.id} discarded — ${check.error}: ${check.detail}`);
222
223
  return true;
@@ -247,7 +248,7 @@ export async function executeNextInvestigation(client, config, workspace, brief,
247
248
  await settle(client, assignment.id, {
248
249
  lease_token: leaseToken,
249
250
  status: "failed",
250
- error: redactSecrets(message).slice(0, 2_000),
251
+ error: boundedTail(redactSecrets(message), 2_000),
251
252
  // A malformed brief is worth one more try; a broken worktree is not fixed by repeating it.
252
253
  retryable: message === UNUSABLE_BRIEF,
253
254
  }).catch((settleError) => console.error(`Investigation settle failed: ${redactSecrets(settleError instanceof Error ? settleError.message : "unknown")}`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miraland-labs/conduit-bridge",
3
- "version": "0.16.28",
3
+ "version": "0.16.30",
4
4
  "description": "Conduit Bridge CLI \u2014 join, connect, disconnect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Pi / Kiro / Antigravity / Grok Build agents for a Conduit organization",
5
5
  "type": "module",
6
6
  "bin": {