@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 +4 -1
- package/dist/driver.js +10 -9
- package/dist/drivers.js +70 -6
- package/dist/ensure-test-evidence.js +92 -21
- package/dist/execution.js +21 -2
- package/dist/investigation.js +4 -3
- package/package.json +1 -1
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
|
-
|
|
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}
|
|
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}
|
|
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}
|
|
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}
|
|
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}
|
|
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}
|
|
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}
|
|
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}
|
|
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
|
|
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
|
-
|
|
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
|
|
297
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
184
|
+
let tailUsed = 0;
|
|
129
185
|
for (let index = rest.length - 1; index >= 0; index -= 1) {
|
|
130
186
|
const line = rest[index];
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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))
|
|
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
|
-
|
|
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;
|
package/dist/investigation.js
CHANGED
|
@@ -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)
|
|
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}
|
|
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)
|
|
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.
|
|
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": {
|