@miraland-labs/conduit-bridge 0.16.26 → 0.16.28
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/README.md +1 -1
- package/dist/cli.js +25 -5
- package/dist/driver.js +32 -10
- package/dist/drivers.js +28 -0
- package/dist/ensure-test-evidence.js +43 -7
- package/dist/execution-budget.js +77 -0
- package/dist/execution-facts.js +2 -0
- package/dist/execution.js +74 -0
- package/dist/ops.js +19 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Local Bridge CLI for [Conduit](https://github.com/miralandlabs/conduit). Connects a computer to one organization, claims work, and drives a local agent.
|
|
4
4
|
|
|
5
|
-
**Package version:** `0.16.
|
|
5
|
+
**Package version:** `0.16.27` — work-package execution budgets reach every agent turn, are bounded by the machine ceiling, and expose their effective source for operations. Local-fuel provider refusals make the affected lane unavailable instead of repeatedly spending attempts; an operator who changes the lane's account or plan can invalidate that observation with `ops quota-clear <driver>`. Bridge discovers bounded repository verification commands, including Make targets `test`, `check`, `verify`, `replay`, `typecheck`, `lint`, and `build`. `ops disconnect` asks before it removes the runner service and clears credentials; pass `--yes` to skip the question. `ops.env` is enrollment/bootstrap defaults only: an explicit workspace does not inherit an old repository, and project switches do not rewrite global project state. `ops enroll` exchanges a single-use token, registers the machine, binds project affinity, provisions fuel keys, brings detected drivers online, and starts the runner in one command. On Linux, `ops install` refuses any effective systemd drop-in. A switch intent remains queued until the target runner heartbeat proves Bound. After every Bridge publish, operators must re-run `ops install` (LaunchAgent pins an absolute `cli.js`).
|
|
6
6
|
|
|
7
7
|
## Prerequisites
|
|
8
8
|
|
package/dist/cli.js
CHANGED
|
@@ -12,8 +12,9 @@ import { BRIDGE_LEASE_CAPACITY, BRIDGE_MAX_LEASE_CAPACITY, clampLeaseCapacity, c
|
|
|
12
12
|
import { runMcp } from "./mcp.js";
|
|
13
13
|
import { detectInstalledClients, localFuelOnlyClients, suggestFuelSource } from "./detect.js";
|
|
14
14
|
import { DRIVERS } from "./driver.js";
|
|
15
|
-
import { driversHeartbeatReport, driverLabel, heartbeatStatusForDrivers, isSupportedDriverId, listDriverLanes, onlineDriverIds, seedDriversFromDetection, seedDriverLanes, setDriverFuel, setDriversOnline, } from "./drivers.js";
|
|
15
|
+
import { driversHeartbeatReport, driverLabel, heartbeatStatusForDrivers, isSupportedDriverId, listDriverLanes, onlineDriverIds, seedDriversFromDetection, seedDriverLanes, setDriverFuel, setDriversOnline, laneDispatchBlock, laneStatuses, } from "./drivers.js";
|
|
16
16
|
import { buildWorkspaceBrief } from "./brief.js";
|
|
17
|
+
import { parseAgentTimeoutMinutes } from "./execution-budget.js";
|
|
17
18
|
import { BOOTSTRAP_RESULTS, buildSovereignExecutionFacts } from "./execution-facts.js";
|
|
18
19
|
import { ensureCheckout } from "./checkout.js";
|
|
19
20
|
import { buildOnShiftIntentProof, maybeApplyOnShiftIntent } from "./on-shift-apply.js";
|
|
@@ -433,12 +434,21 @@ async function driversCommand() {
|
|
|
433
434
|
return;
|
|
434
435
|
}
|
|
435
436
|
console.log(`Computer ${config.machineId} — shared capacity ${config.leaseCapacity} (sum across online lanes, not per IDE)`);
|
|
436
|
-
|
|
437
|
-
|
|
437
|
+
// Allowance is printed beside state because the two disagree exactly when it matters: a lane can
|
|
438
|
+
// be installed, logged in and online while the provider is refusing it, and reporting only the
|
|
439
|
+
// first three reads as "Ready" for a lane dispatch is skipping.
|
|
440
|
+
for (const lane of laneStatuses(config)) {
|
|
441
|
+
const when = lane.observed_at ? ` observed=${lane.observed_at}` : "";
|
|
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})`);
|
|
438
444
|
}
|
|
445
|
+
const blocked = laneDispatchBlock(laneStatuses(config));
|
|
446
|
+
console.log(blocked
|
|
447
|
+
? `Dispatch: BLOCKED — ${blocked}.`
|
|
448
|
+
: `Dispatch: ready — eligible ${laneStatuses(config).filter((lane) => lane.eligible).map((lane) => lane.id).join(", ")}.`);
|
|
439
449
|
const online = onlineDriverIds(config);
|
|
440
450
|
console.log(online.length
|
|
441
|
-
? `
|
|
451
|
+
? `Toggle a lane: ${bridgeUsage("drivers", "online|offline", "<id…>")}`
|
|
442
452
|
: `All offline — no new claims. Bring one online: ${bridgeUsage("drivers", "online", AGENT_PLACEHOLDER)}`);
|
|
443
453
|
return;
|
|
444
454
|
}
|
|
@@ -520,7 +530,17 @@ async function runner() {
|
|
|
520
530
|
}
|
|
521
531
|
const brief = workspace ? await buildWorkspaceBrief(workspace) : null;
|
|
522
532
|
const intervalMs = values.interval ? Math.max(5_000, Number(values.interval)) : 15_000;
|
|
523
|
-
|
|
533
|
+
// Validated at startup rather than at use. Number("abc") is NaN, NaN is not nullish, and the
|
|
534
|
+
// unvalidated value flowed straight past `input.timeoutMs ?? DEFAULT` to become the timer itself.
|
|
535
|
+
let timeoutMs;
|
|
536
|
+
try {
|
|
537
|
+
timeoutMs = parseAgentTimeoutMinutes(values["agent-timeout-minutes"]);
|
|
538
|
+
}
|
|
539
|
+
catch (error) {
|
|
540
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
541
|
+
process.exitCode = 1;
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
524
544
|
const canExecute = Boolean(workspace) && onlineDriverIds(config).length > 0;
|
|
525
545
|
if (!workspace) {
|
|
526
546
|
console.warn(`WARNING: heartbeat only — pass --workspace <repo>. Lanes: ${bridgeUsage("drivers", "online", AGENT_PLACEHOLDER)}`);
|
package/dist/driver.js
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { resolveAgentTimeout } from "./execution-budget.js";
|
|
2
3
|
import { existsSync } from "node:fs";
|
|
3
4
|
import { mkdir, readFile, rm, rmdir, writeFile } from "node:fs/promises";
|
|
4
5
|
import { join } from "node:path";
|
|
5
6
|
import { z } from "zod";
|
|
6
7
|
import { deniedCommands, executionClassPromptRules, parsePiJsonl, projectClaude, projectCoarseMode, projectCodex, projectCursor, projectGrok, projectKiroTools, projectPi, requireStampedExecutionClass, } from "./execution-class.js";
|
|
8
|
+
/**
|
|
9
|
+
* The bound for this turn.
|
|
10
|
+
*
|
|
11
|
+
* One place, so the eight call sites cannot drift apart, and so a package budget has a single seam to
|
|
12
|
+
* arrive through when the contract carries one. `input.timeoutMs` is the machine ceiling the operator
|
|
13
|
+
* configured; `input.maxDurationMs` is what the package asked for and is treated as payload.
|
|
14
|
+
*/
|
|
15
|
+
function agentTurnTimeoutMs(input) {
|
|
16
|
+
return resolveAgentTimeout({ machineCeilingMs: input.timeoutMs, packageBudgetMs: input.maxDurationMs }).timeoutMs;
|
|
17
|
+
}
|
|
7
18
|
export { branchCreateCommands, deniedCommands, isBoundedVerificationCommand, prCreateCommands, requireStampedExecutionClass, } from "./execution-class.js";
|
|
8
19
|
function deliveryLanguageRule(language) {
|
|
9
20
|
if (language === "zh") {
|
|
@@ -502,7 +513,7 @@ export const claudeCodeDriver = {
|
|
|
502
513
|
args.push("--disallowedTools", projected.disallowedTools.join(","));
|
|
503
514
|
if (projected.acceptEdits)
|
|
504
515
|
args.push("--permission-mode", "acceptEdits");
|
|
505
|
-
const { code, stdout, stderr } = await execute(input.executable ?? "claude", args, input.workspace, input
|
|
516
|
+
const { code, stdout, stderr } = await execute(input.executable ?? "claude", args, input.workspace, agentTurnTimeoutMs(input), fuelSource === "conduit" ? input.fuel : undefined, fuelSource);
|
|
506
517
|
let message = null;
|
|
507
518
|
try {
|
|
508
519
|
message = JSON.parse(stdout);
|
|
@@ -609,7 +620,7 @@ export const codexDriver = {
|
|
|
609
620
|
diagnosis: input.workRole === "diagnose",
|
|
610
621
|
});
|
|
611
622
|
// Prompt via stdin avoids ARG_MAX limits on large assignment contracts.
|
|
612
|
-
const { code, stdout, stderr } = await execute(input.executable ?? resolveCodexExecutable(), codexExecArgs({ ...input, networkAccess: projected.networkAccess }, projected.sandbox), input.workspace, input
|
|
623
|
+
const { code, stdout, stderr } = await execute(input.executable ?? resolveCodexExecutable(), codexExecArgs({ ...input, networkAccess: projected.networkAccess }, projected.sandbox), input.workspace, agentTurnTimeoutMs(input), fuelSource === "conduit" ? input.fuel : undefined, fuelSource, input.prompt);
|
|
613
624
|
const parsed = parseCodexJsonl(stdout);
|
|
614
625
|
const resultText = parsed.resultText ?? (stdout || null);
|
|
615
626
|
if (code !== 0) {
|
|
@@ -741,7 +752,7 @@ export const cursorDriver = {
|
|
|
741
752
|
capabilities: input.capabilities ?? [],
|
|
742
753
|
diagnosis: input.workRole === "diagnose",
|
|
743
754
|
});
|
|
744
|
-
const configured = await withCursorPermissions(input.workspace, { allow: projected.allow, deny: projected.deny }, () => execute(executable, cursorRunArgs({ ...input, trustWorkspace, executionClass, force: projected.force }), input.workspace, input
|
|
755
|
+
const configured = await withCursorPermissions(input.workspace, { allow: projected.allow, deny: projected.deny }, () => execute(executable, cursorRunArgs({ ...input, trustWorkspace, executionClass, force: projected.force }), input.workspace, agentTurnTimeoutMs(input), undefined, "local"));
|
|
745
756
|
const { code, stdout, stderr } = configured;
|
|
746
757
|
const parsed = parseCursorOutput(stdout);
|
|
747
758
|
if (code !== 0 || parsed.isError) {
|
|
@@ -909,7 +920,7 @@ export const openCodeDriver = {
|
|
|
909
920
|
}
|
|
910
921
|
const args = openCodeRunArgs(input, agent);
|
|
911
922
|
args.push(input.prompt);
|
|
912
|
-
const { code, stdout, stderr } = await withOpenCodePermissions(input.workspace, () => execute(input.executable ?? "opencode", args, input.workspace, input
|
|
923
|
+
const { code, stdout, stderr } = await withOpenCodePermissions(input.workspace, () => execute(input.executable ?? "opencode", args, input.workspace, agentTurnTimeoutMs(input), fuelSource === "conduit" ? input.fuel : undefined, fuelSource));
|
|
913
924
|
const parsed = parseOpenCodeOutput(stdout);
|
|
914
925
|
if (code !== 0 || parsed.isError) {
|
|
915
926
|
return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, error: (stderr || parsed.resultText || `opencode exited with code ${code}`).slice(0, 20_000) };
|
|
@@ -975,7 +986,7 @@ export const kiroDriver = {
|
|
|
975
986
|
}
|
|
976
987
|
const args = kiroChatArgs(input, trusted);
|
|
977
988
|
args.push(input.prompt);
|
|
978
|
-
const { code, stdout, stderr } = await execute(executable, args, input.workspace, input
|
|
989
|
+
const { code, stdout, stderr } = await execute(executable, args, input.workspace, agentTurnTimeoutMs(input), undefined, "local");
|
|
979
990
|
if (code !== 0) {
|
|
980
991
|
return { status: "failed", resultText: stdout || null, sessionId: null, error: (stderr || stdout || `kiro-cli exited with code ${code}`).slice(0, 20_000) };
|
|
981
992
|
}
|
|
@@ -1023,7 +1034,7 @@ export const antigravityDriver = {
|
|
|
1023
1034
|
return { status: "failed", resultText: null, sessionId: null, error: "No Bridge-mapped Antigravity mode for active grants; refusing to start agent" };
|
|
1024
1035
|
}
|
|
1025
1036
|
// agy print mode emits plain text and does not surface a resumable id, so rework resume is not wired.
|
|
1026
|
-
const { code, stdout, stderr } = await execute(input.executable ?? "agy", antigravityRunArgs({ prompt: input.prompt, grants: input.grants }, mode), input.workspace, input
|
|
1037
|
+
const { code, stdout, stderr } = await execute(input.executable ?? "agy", antigravityRunArgs({ prompt: input.prompt, grants: input.grants }, mode), input.workspace, agentTurnTimeoutMs(input), undefined, "local");
|
|
1027
1038
|
if (code !== 0) {
|
|
1028
1039
|
return { status: "failed", resultText: stdout || null, sessionId: null, error: (stderr || stdout || `agy exited with code ${code}`).slice(0, 20_000) };
|
|
1029
1040
|
}
|
|
@@ -1071,7 +1082,7 @@ export const piDriver = {
|
|
|
1071
1082
|
if (version.code !== 0 || !(version.stdout || version.stderr).trim()) {
|
|
1072
1083
|
return { status: "failed", resultText: null, sessionId: null, error: "pi preflight could not verify the installed CLI version" };
|
|
1073
1084
|
}
|
|
1074
|
-
const { code, stdout, stderr } = await execute(executable, piRunArgs({ prompt: input.prompt, tools: projected.tools, model: input.model }), input.workspace, input
|
|
1085
|
+
const { code, stdout, stderr } = await execute(executable, piRunArgs({ prompt: input.prompt, tools: projected.tools, model: input.model }), input.workspace, agentTurnTimeoutMs(input), undefined, "local");
|
|
1075
1086
|
const parsed = parsePiJsonl(stdout);
|
|
1076
1087
|
if (code !== 0) {
|
|
1077
1088
|
return {
|
|
@@ -1207,7 +1218,7 @@ export const grokDriver = {
|
|
|
1207
1218
|
if (input.grants.includes("test_run") && !(input.verificationCommands?.length)) {
|
|
1208
1219
|
return { status: "failed", resultText: null, sessionId: null, error: "grok preflight found no bounded verification command for the test_run grant" };
|
|
1209
1220
|
}
|
|
1210
|
-
const { code, stdout, stderr } = await execute(executable, grokRunArgs(input), input.workspace, input
|
|
1221
|
+
const { code, stdout, stderr } = await execute(executable, grokRunArgs(input), input.workspace, agentTurnTimeoutMs(input), undefined, "local");
|
|
1211
1222
|
const parsed = parseGrokOutput(stdout);
|
|
1212
1223
|
if (code !== 0 || parsed.isError) {
|
|
1213
1224
|
return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, error: (stderr || parsed.resultText || `grok exited with code ${code}`).slice(0, 20_000) };
|
|
@@ -1247,7 +1258,12 @@ function execute(executable, args, cwd, timeoutMs, fuel, fuelSource = "conduit",
|
|
|
1247
1258
|
});
|
|
1248
1259
|
let stdout = "";
|
|
1249
1260
|
let stderr = "";
|
|
1250
|
-
|
|
1261
|
+
let timedOut = false;
|
|
1262
|
+
const timer = setTimeout(() => {
|
|
1263
|
+
timedOut = true;
|
|
1264
|
+
child.kill("SIGTERM");
|
|
1265
|
+
setTimeout(() => child.kill("SIGKILL"), 10_000).unref();
|
|
1266
|
+
}, timeoutMs);
|
|
1251
1267
|
// Bounded: a noisy or hostile local agent could otherwise grow these strings until the runner
|
|
1252
1268
|
// dies. The report is the last fenced JSON block, so keeping the tail preserves what is parsed
|
|
1253
1269
|
// while discarding the transcript ahead of it. Truncation is recorded, never silent.
|
|
@@ -1269,7 +1285,13 @@ function execute(executable, args, cwd, timeoutMs, fuel, fuelSource = "conduit",
|
|
|
1269
1285
|
clearTimeout(timer);
|
|
1270
1286
|
if (stdoutTruncated)
|
|
1271
1287
|
console.error(`Agent output exceeded ${MAX_AGENT_OUTPUT} bytes; kept the tail for report parsing.`);
|
|
1272
|
-
resolve({
|
|
1288
|
+
resolve({
|
|
1289
|
+
code,
|
|
1290
|
+
stdout,
|
|
1291
|
+
// Never let vendor stderr appended before SIGTERM turn a local timer into a quota refusal.
|
|
1292
|
+
// 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
|
+
});
|
|
1273
1295
|
});
|
|
1274
1296
|
if (stdinText !== undefined && child.stdin) {
|
|
1275
1297
|
// An agent that exits before reading its prompt closes this pipe, and the write then raises
|
package/dist/drivers.js
CHANGED
|
@@ -150,6 +150,34 @@ export function listDriverLanes(config) {
|
|
|
150
150
|
};
|
|
151
151
|
});
|
|
152
152
|
}
|
|
153
|
+
export function laneStatuses(config, now = Date.now()) {
|
|
154
|
+
return listDriverLanes(config).map((lane) => {
|
|
155
|
+
const quota = laneQuota(config, lane.id, now);
|
|
156
|
+
// A conduit-fuelled lane bills through the control plane, so a local refusal record says nothing
|
|
157
|
+
// about whether the next claim can run.
|
|
158
|
+
const local = lane.fuel === "local";
|
|
159
|
+
const allowance = !local || !quota
|
|
160
|
+
? "unknown"
|
|
161
|
+
: quota.exhausted ? "unavailable" : "available";
|
|
162
|
+
return {
|
|
163
|
+
...lane,
|
|
164
|
+
allowance,
|
|
165
|
+
observed_at: quota?.observed_at,
|
|
166
|
+
resets_at: quota?.resets_at ?? undefined,
|
|
167
|
+
eligible: lane.state === "online" && allowance !== "unavailable",
|
|
168
|
+
};
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
/** Why this machine cannot take a claim, or null when at least one lane can. */
|
|
172
|
+
export function laneDispatchBlock(statuses) {
|
|
173
|
+
if (statuses.some((lane) => lane.eligible))
|
|
174
|
+
return null;
|
|
175
|
+
if (!statuses.length)
|
|
176
|
+
return "no driver lane is registered";
|
|
177
|
+
if (!statuses.some((lane) => lane.state === "online"))
|
|
178
|
+
return "every lane is offline";
|
|
179
|
+
return "every online lane has an allowance the provider refused";
|
|
180
|
+
}
|
|
153
181
|
export function onlineDriverIds(config) {
|
|
154
182
|
const drivers = normalizeDrivers(config.drivers);
|
|
155
183
|
return SUPPORTED_AGENTS.map((agent) => agent.id).filter((id) => drivers[id]?.state === "online");
|
|
@@ -97,6 +97,48 @@ export async function captureVerificationFailure(input) {
|
|
|
97
97
|
// Every bounded command passed (or none could start): agent failed for another reason.
|
|
98
98
|
return null;
|
|
99
99
|
}
|
|
100
|
+
/** Longest failure detail Bridge will attach. Enough to diagnose; short enough to store and read. */
|
|
101
|
+
export const FAILURE_DETAIL_MAX_CHARS = 4_000;
|
|
102
|
+
/** Leading lines kept for orientation before the elision. */
|
|
103
|
+
const FAILURE_DETAIL_HEAD_LINES = 3;
|
|
104
|
+
/**
|
|
105
|
+
* A failure detail that keeps the part which explains the failure.
|
|
106
|
+
*
|
|
107
|
+
* This used to keep the first 100 lines and then the first 2,000 characters of the join. A failing
|
|
108
|
+
* command explains itself at the **end** — the assertion, the stack, the summary line, and the exit
|
|
109
|
+
* code — so head-truncation discarded exactly the evidence and kept a screen of `... ok`. A real
|
|
110
|
+
* incident cost three attempts on one task because every report looked like a passing test run cut
|
|
111
|
+
* short, and nobody could see the actual error.
|
|
112
|
+
*
|
|
113
|
+
* So: the command line for orientation, a few opening lines, then the **tail**, and the exit code
|
|
114
|
+
* always. What was dropped is stated rather than silently vanishing, because a reader must be able
|
|
115
|
+
* to tell a short failure from a truncated one.
|
|
116
|
+
*/
|
|
117
|
+
export function boundedFailureDetail(command, stdoutLines, stderrLines, code, maxChars = FAILURE_DETAIL_MAX_CHARS) {
|
|
118
|
+
const cap = (line) => (line.length > 500 ? `${line.slice(0, 500)}…` : line);
|
|
119
|
+
const body = [...stdoutLines, ...stderrLines].map(cap);
|
|
120
|
+
const first = `$ ${command}`;
|
|
121
|
+
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);
|
|
127
|
+
const tail = [];
|
|
128
|
+
let used = head.join("\n").length;
|
|
129
|
+
for (let index = rest.length - 1; index >= 0; index -= 1) {
|
|
130
|
+
const line = rest[index];
|
|
131
|
+
if (used + line.length + 1 > budget)
|
|
132
|
+
break;
|
|
133
|
+
tail.unshift(line);
|
|
134
|
+
used += line.length + 1;
|
|
135
|
+
}
|
|
136
|
+
const dropped = rest.length - tail.length;
|
|
137
|
+
const middle = dropped > 0
|
|
138
|
+
? [...head, `… ${dropped} line${dropped === 1 ? "" : "s"} omitted …`, ...tail]
|
|
139
|
+
: [...head, ...tail];
|
|
140
|
+
return [first, ...middle, last].join("\n");
|
|
141
|
+
}
|
|
100
142
|
async function defaultRunCommand(command, workspace) {
|
|
101
143
|
const argv = argvForBoundedCommand(command);
|
|
102
144
|
const bin = argv[0];
|
|
@@ -175,13 +217,7 @@ export async function ensureTestEvidence(input) {
|
|
|
175
217
|
const stderrLines = result.stderr.trim() ? result.stderr.trim().split("\n") : [];
|
|
176
218
|
if (result.code !== 0) {
|
|
177
219
|
// Prefix must match FINALIZE_CONTRACT_PATTERN ("Agent report") — not Bridge-fault laundering.
|
|
178
|
-
|
|
179
|
-
`$ ${command}`,
|
|
180
|
-
...stdoutLines,
|
|
181
|
-
...stderrLines,
|
|
182
|
-
`exit ${result.code}`,
|
|
183
|
-
].map((line) => line.slice(0, 4_000)).slice(0, 100);
|
|
184
|
-
throw new Error(`Agent report: Verification failed (${command}): ${failedDetails.join("\n").slice(0, 2_000)}`);
|
|
220
|
+
throw new Error(`Agent report: Verification failed (${command}): ${boundedFailureDetail(command, stdoutLines, stderrLines, result.code)}`);
|
|
185
221
|
}
|
|
186
222
|
// Empty exit-0 is not proof — a no-op recipe (`@true`) must not clear required_evidence: test.
|
|
187
223
|
if (stdoutLines.length === 0 && stderrLines.length === 0) {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long one agent turn may run.
|
|
3
|
+
*
|
|
4
|
+
* Twenty minutes is a safety default for ordinary work, not a statement that longer work is
|
|
5
|
+
* ill-formed. A package should be split when its deliverables or authority boundaries are
|
|
6
|
+
* independent — never merely to fit a timer — so a cohesive task that genuinely needs thirty or
|
|
7
|
+
* sixty minutes must be able to say so.
|
|
8
|
+
*
|
|
9
|
+
* Two sources, and the smaller wins: the package asks for what the work needs, the machine caps what
|
|
10
|
+
* this host will tolerate. The operator's ceiling is the one that cannot be argued with by a payload.
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_AGENT_TIMEOUT_MS = 20 * 60_000;
|
|
13
|
+
/**
|
|
14
|
+
* The longest turn any configuration may request.
|
|
15
|
+
*
|
|
16
|
+
* A bound this generous is not a scheduling opinion; it is the point past which a value is far more
|
|
17
|
+
* likely to be a mistake — a millisecond figure pasted where minutes were wanted — than an intention.
|
|
18
|
+
*/
|
|
19
|
+
export const MAX_AGENT_TIMEOUT_MS = 4 * 60 * 60_000;
|
|
20
|
+
export const MIN_AGENT_TIMEOUT_MS = 60_000;
|
|
21
|
+
export class InvalidAgentTimeoutError extends Error {
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Minutes from a command line, validated.
|
|
25
|
+
*
|
|
26
|
+
* `Number("abc")` is `NaN`, and `NaN` is not nullish — so an unvalidated value flowed straight past
|
|
27
|
+
* `input.timeoutMs ?? DEFAULT` and became the timer itself. A typo must fail loudly at startup, not
|
|
28
|
+
* silently produce a turn that never times out or one that fires at once.
|
|
29
|
+
*/
|
|
30
|
+
export function parseAgentTimeoutMinutes(value) {
|
|
31
|
+
if (value === undefined)
|
|
32
|
+
return undefined;
|
|
33
|
+
const minutes = Number(value);
|
|
34
|
+
if (!Number.isFinite(minutes) || minutes <= 0) {
|
|
35
|
+
throw new InvalidAgentTimeoutError(`--agent-timeout-minutes must be a positive number of minutes, got ${JSON.stringify(value)}`);
|
|
36
|
+
}
|
|
37
|
+
const ms = Math.round(minutes * 60_000);
|
|
38
|
+
if (ms < MIN_AGENT_TIMEOUT_MS) {
|
|
39
|
+
throw new InvalidAgentTimeoutError(`--agent-timeout-minutes must be at least ${MIN_AGENT_TIMEOUT_MS / 60_000} minute`);
|
|
40
|
+
}
|
|
41
|
+
if (ms > MAX_AGENT_TIMEOUT_MS) {
|
|
42
|
+
throw new InvalidAgentTimeoutError(`--agent-timeout-minutes must not exceed ${MAX_AGENT_TIMEOUT_MS / 60_000} minutes`);
|
|
43
|
+
}
|
|
44
|
+
return ms;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The effective turn budget, and where it came from.
|
|
48
|
+
*
|
|
49
|
+
* The source is returned rather than inferred later because an operator reading a timed-out attempt
|
|
50
|
+
* needs to know which side set the bound; "it timed out" without that is not actionable.
|
|
51
|
+
*
|
|
52
|
+
* A package budget is payload, so it is bounded on both ends before use. A package asking for more
|
|
53
|
+
* than the machine allows is not an error — the machine simply wins, and the decision records that
|
|
54
|
+
* it was clamped so the difference is visible rather than mysterious.
|
|
55
|
+
*/
|
|
56
|
+
export function resolveAgentTimeout(inputs = {}) {
|
|
57
|
+
const ceiling = usableMs(inputs.machineCeilingMs);
|
|
58
|
+
const requested = usableMs(inputs.packageBudgetMs);
|
|
59
|
+
if (requested === undefined) {
|
|
60
|
+
return ceiling === undefined
|
|
61
|
+
? { timeoutMs: DEFAULT_AGENT_TIMEOUT_MS, source: "default" }
|
|
62
|
+
: { timeoutMs: ceiling, source: "machine" };
|
|
63
|
+
}
|
|
64
|
+
// With no operator ceiling the default is the ceiling: a payload must not be able to lengthen a
|
|
65
|
+
// turn on a host whose operator never opted into longer runs.
|
|
66
|
+
const limit = ceiling ?? DEFAULT_AGENT_TIMEOUT_MS;
|
|
67
|
+
if (requested > limit)
|
|
68
|
+
return { timeoutMs: limit, source: "clamped_to_machine" };
|
|
69
|
+
return { timeoutMs: requested, source: "package" };
|
|
70
|
+
}
|
|
71
|
+
function usableMs(value) {
|
|
72
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
73
|
+
return undefined;
|
|
74
|
+
if (value < MIN_AGENT_TIMEOUT_MS || value > MAX_AGENT_TIMEOUT_MS)
|
|
75
|
+
return undefined;
|
|
76
|
+
return Math.round(value);
|
|
77
|
+
}
|
package/dist/execution-facts.js
CHANGED
|
@@ -123,5 +123,7 @@ export async function buildSovereignExecutionFacts(input) {
|
|
|
123
123
|
verification_commands_digest: verificationCommandsDigest(input.brief?.verification ?? []),
|
|
124
124
|
workspace_clean: input.workspaceClean,
|
|
125
125
|
final_commit: commitOrNull(input.finalCommit),
|
|
126
|
+
...(input.agentTimeoutMs !== undefined ? { agent_timeout_ms: input.agentTimeoutMs } : {}),
|
|
127
|
+
...(input.agentTimeoutSource ? { agent_timeout_source: input.agentTimeoutSource } : {}),
|
|
126
128
|
};
|
|
127
129
|
}
|
package/dist/execution.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { resolveAgentTimeout } from "./execution-budget.js";
|
|
4
5
|
import { ConduitRequestError } from "./client.js";
|
|
5
6
|
import { redactSecrets, saveDriverQuota } from "./config.js";
|
|
6
7
|
import { DRIVERS, agentReportTemplate, buildAssignmentPrompt, evidenceKinds, extractAgentReportJsonText, fuelEndpoint, normalizeEvidenceDigest, parseAgentReport, parseJsonObjectCandidate, pickModelCandidate, requireStampedExecutionClass, tierForRisk } from "./driver.js";
|
|
@@ -94,8 +95,43 @@ export const SUBSCRIPTION_EXHAUSTED_PATTERN = /\b(?:usage|rate|session)[ _-]?lim
|
|
|
94
95
|
* sentence in the same string.
|
|
95
96
|
*/
|
|
96
97
|
export function subscriptionExhausted(message) {
|
|
98
|
+
if (agentTurnTimedOut(message))
|
|
99
|
+
return false;
|
|
97
100
|
return SUBSCRIPTION_EXHAUSTED_PATTERN.test(message);
|
|
98
101
|
}
|
|
102
|
+
export function agentTurnTimedOut(message) {
|
|
103
|
+
return /^agent_turn_timeout:/i.test(message.trim());
|
|
104
|
+
}
|
|
105
|
+
function timeoutFailureBody(resolution, attemptId) {
|
|
106
|
+
const machineBound = resolution.source === "default"
|
|
107
|
+
|| resolution.source === "machine"
|
|
108
|
+
|| resolution.source === "clamped_to_machine";
|
|
109
|
+
const minutes = Math.round(resolution.timeoutMs / 60_000);
|
|
110
|
+
return {
|
|
111
|
+
failure: machineBound ? {
|
|
112
|
+
code: "agent_turn_timeout",
|
|
113
|
+
class: "environment",
|
|
114
|
+
disposition: "hold",
|
|
115
|
+
responsible_party: "computer_operator",
|
|
116
|
+
message: `The agent turn reached this computer's ${minutes}-minute execution limit.`,
|
|
117
|
+
next_action: "The computer operator can raise the Bridge agent timeout when this cohesive package needs longer. The product owner does not need to write technical constraints or split the package merely to fit the timer.",
|
|
118
|
+
diagnostic_detail: `agent_turn_timeout: ${resolution.timeoutMs}ms; source=${resolution.source}`,
|
|
119
|
+
} : {
|
|
120
|
+
code: "agent_turn_timeout",
|
|
121
|
+
class: "platform",
|
|
122
|
+
disposition: "stop",
|
|
123
|
+
responsible_party: "conduit",
|
|
124
|
+
message: `The agent turn reached Conduit's ${minutes}-minute package budget.`,
|
|
125
|
+
next_action: "Conduit must revise the mechanically derived execution budget or continue from a bounded checkpoint. The product owner does not need to author implementation constraints.",
|
|
126
|
+
diagnostic_detail: `agent_turn_timeout: ${resolution.timeoutMs}ms; source=${resolution.source}`,
|
|
127
|
+
},
|
|
128
|
+
error: `agent_turn_timeout: agent turn exceeded ${resolution.timeoutMs}ms`,
|
|
129
|
+
retryable: false,
|
|
130
|
+
idempotency_key: `bridge:agent-timeout:${attemptId}`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
class AgentTurnTimeoutError extends Error {
|
|
134
|
+
}
|
|
99
135
|
/**
|
|
100
136
|
* When the window refills, if the vendor said so.
|
|
101
137
|
*
|
|
@@ -302,6 +338,7 @@ const workPackageSchema = z.object({
|
|
|
302
338
|
failure_output: z.string().min(1).max(12_000),
|
|
303
339
|
}).optional(),
|
|
304
340
|
working_language: z.enum(["en", "zh"]).optional(),
|
|
341
|
+
max_duration_ms: z.number().int().min(60_000).max(4 * 60 * 60_000).optional(),
|
|
305
342
|
}).nullable().optional();
|
|
306
343
|
const diagnosticRepairBriefSchema = z.object({
|
|
307
344
|
root_cause: z.string().trim().min(1).max(2_000),
|
|
@@ -728,6 +765,10 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
728
765
|
const task = taskDetailSchema.parse(detail.task);
|
|
729
766
|
const executionContract = executionContractSchema.parse(detail.execution_contract ?? {});
|
|
730
767
|
const workPackage = workPackageSchema.parse(detail.work_package) ?? null;
|
|
768
|
+
const agentTimeout = resolveAgentTimeout({
|
|
769
|
+
machineCeilingMs: timeoutMs,
|
|
770
|
+
packageBudgetMs: workPackage?.max_duration_ms,
|
|
771
|
+
});
|
|
731
772
|
const executionKind = task.execution_kind;
|
|
732
773
|
const diagnosis = executionKind === "diagnosis";
|
|
733
774
|
const sourceAttemptId = task.source_attempt_id ?? executionContract.source_attempt_id ?? active.sourceAttemptId ?? null;
|
|
@@ -992,6 +1033,8 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
992
1033
|
executionClass,
|
|
993
1034
|
claimedHead: executionContract.claimed_head ?? startCommit,
|
|
994
1035
|
bootstrapResult: await bootstrapResultForWorkspace(workspace),
|
|
1036
|
+
agentTimeoutMs: agentTimeout.timeoutMs,
|
|
1037
|
+
agentTimeoutSource: agentTimeout.source,
|
|
995
1038
|
});
|
|
996
1039
|
await client.updateAttempt(taskId, { executionFacts });
|
|
997
1040
|
await client.attemptRequest(taskId, "progress", {
|
|
@@ -1067,6 +1110,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
1067
1110
|
executionClass,
|
|
1068
1111
|
resumeSessionId,
|
|
1069
1112
|
timeoutMs,
|
|
1113
|
+
maxDurationMs: workPackage?.max_duration_ms,
|
|
1070
1114
|
model: selection.model,
|
|
1071
1115
|
fuel,
|
|
1072
1116
|
fuelSource,
|
|
@@ -1153,6 +1197,16 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
1153
1197
|
}
|
|
1154
1198
|
if (result.status === "failed") {
|
|
1155
1199
|
const agentMessage = result.error ?? "Agent execution failed";
|
|
1200
|
+
if (agentTurnTimedOut(agentMessage)) {
|
|
1201
|
+
retainAttemptWorktree = true;
|
|
1202
|
+
const response = await queueTerminal(client, taskId, {
|
|
1203
|
+
action: "fail",
|
|
1204
|
+
body: timeoutFailureBody(agentTimeout, active.attemptId),
|
|
1205
|
+
});
|
|
1206
|
+
retainAttemptWorktree = response.retain_worktree === true;
|
|
1207
|
+
console.error(`Assignment ${taskId} timed out: ${redactSecrets(agentMessage)}`);
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1156
1210
|
// The agent verifies inside its own tool loop, so its compiler/test output never reaches
|
|
1157
1211
|
// result.error — production saw a bare git SHA and echoed source arrive as the "failure
|
|
1158
1212
|
// reason", leaving Conductor nothing to diagnose. Re-run the project's bounded verification
|
|
@@ -1221,6 +1275,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
1221
1275
|
fuel,
|
|
1222
1276
|
fuelSource,
|
|
1223
1277
|
timeoutMs,
|
|
1278
|
+
maxDurationMs: workPackage?.max_duration_ms,
|
|
1224
1279
|
resumeSessionId: agentSessionId,
|
|
1225
1280
|
reason: "Agent claimed repository work but git shows none on the attempt branch; starting one land-only continuation turn instead of report-only repair.",
|
|
1226
1281
|
idempotencyKey: `bridge:progress:${active.attemptId}:claims-git-gate`,
|
|
@@ -1257,6 +1312,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
1257
1312
|
fuel,
|
|
1258
1313
|
fuelSource,
|
|
1259
1314
|
timeoutMs,
|
|
1315
|
+
maxDurationMs: workPackage?.max_duration_ms,
|
|
1260
1316
|
resumeSessionId: agentSessionId,
|
|
1261
1317
|
reason: "Agent claimed repository work but git shows none; starting land-only continuation instead of read-only report repair.",
|
|
1262
1318
|
idempotencyKey: `bridge:progress:${active.attemptId}:claims-git-gate-parse`,
|
|
@@ -1331,6 +1387,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
1331
1387
|
executionClass: "observe",
|
|
1332
1388
|
resumeSessionId,
|
|
1333
1389
|
timeoutMs,
|
|
1390
|
+
maxDurationMs: workPackage?.max_duration_ms,
|
|
1334
1391
|
model: selection.model,
|
|
1335
1392
|
fuel,
|
|
1336
1393
|
fuelSource,
|
|
@@ -1419,6 +1476,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
1419
1476
|
fuel,
|
|
1420
1477
|
fuelSource,
|
|
1421
1478
|
timeoutMs,
|
|
1479
|
+
maxDurationMs: workPackage?.max_duration_ms,
|
|
1422
1480
|
resumeSessionId: agentSessionId,
|
|
1423
1481
|
reason: "Parsed Delivery claims repository work but git shows none; starting land-only continuation.",
|
|
1424
1482
|
idempotencyKey: `bridge:progress:${active.attemptId}:claims-git-gate-report`,
|
|
@@ -1466,6 +1524,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
1466
1524
|
fuel,
|
|
1467
1525
|
fuelSource,
|
|
1468
1526
|
timeoutMs,
|
|
1527
|
+
maxDurationMs: workPackage?.max_duration_ms,
|
|
1469
1528
|
resumeSessionId: agentSessionId,
|
|
1470
1529
|
landContinuationUsed,
|
|
1471
1530
|
});
|
|
@@ -1552,6 +1611,17 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
1552
1611
|
else
|
|
1553
1612
|
console.error(`Assignment ${taskId} Delivery was rejected and routed to Conductor repair.`);
|
|
1554
1613
|
}
|
|
1614
|
+
catch (error) {
|
|
1615
|
+
if (!(error instanceof AgentTurnTimeoutError))
|
|
1616
|
+
throw error;
|
|
1617
|
+
retainAttemptWorktree = true;
|
|
1618
|
+
const response = await queueTerminal(client, taskId, {
|
|
1619
|
+
action: "fail",
|
|
1620
|
+
body: timeoutFailureBody(agentTimeout, active.attemptId),
|
|
1621
|
+
});
|
|
1622
|
+
retainAttemptWorktree = response.retain_worktree === true;
|
|
1623
|
+
console.error(`Assignment ${taskId} timed out: ${redactSecrets(error.message)}`);
|
|
1624
|
+
}
|
|
1555
1625
|
finally {
|
|
1556
1626
|
releaseIdleSleep();
|
|
1557
1627
|
clearInterval(renewTimer);
|
|
@@ -1619,12 +1689,15 @@ async function runLandContinuationTurn(input) {
|
|
|
1619
1689
|
executionClass: "mutate_repo",
|
|
1620
1690
|
resumeSessionId: input.resumeSessionId ?? undefined,
|
|
1621
1691
|
timeoutMs: input.timeoutMs,
|
|
1692
|
+
maxDurationMs: input.maxDurationMs,
|
|
1622
1693
|
model: input.selection.model ?? undefined,
|
|
1623
1694
|
fuel: input.fuel,
|
|
1624
1695
|
fuelSource: input.fuelSource,
|
|
1625
1696
|
});
|
|
1626
1697
|
await learnDriverFuel(input.config, input.driver.name, input.fuelSource ?? "conduit", continuation);
|
|
1627
1698
|
if (continuation.status === "failed") {
|
|
1699
|
+
if (agentTurnTimedOut(continuation.error ?? ""))
|
|
1700
|
+
throw new AgentTurnTimeoutError(continuation.error);
|
|
1628
1701
|
throw new AgentNoLandCommitError(input.spec.repository?.base_commit ?? "unknown");
|
|
1629
1702
|
}
|
|
1630
1703
|
return {
|
|
@@ -1679,6 +1752,7 @@ async function finalizeRepositoryLand(input) {
|
|
|
1679
1752
|
fuel: input.fuel,
|
|
1680
1753
|
fuelSource: input.fuelSource,
|
|
1681
1754
|
timeoutMs: input.timeoutMs,
|
|
1755
|
+
maxDurationMs: input.maxDurationMs,
|
|
1682
1756
|
resumeSessionId: input.resumeSessionId,
|
|
1683
1757
|
reason: "Agent finished without landing repository changes; starting one land-only continuation turn.",
|
|
1684
1758
|
idempotencyKey: `bridge:progress:${input.attemptId}:land-continuation`,
|
package/dist/ops.js
CHANGED
|
@@ -13,7 +13,7 @@ import { ConduitClient } from "./client.js";
|
|
|
13
13
|
import { loadConfig } from "./config.js";
|
|
14
14
|
import { ensureCheckout } from "./checkout.js";
|
|
15
15
|
import { detectInstalledClients, probeAgentHealth } from "./detect.js";
|
|
16
|
-
import { driverIdsFromDetectedLabels } from "./drivers.js";
|
|
16
|
+
import { driverIdsFromDetectedLabels, laneDispatchBlock, laneStatuses } from "./drivers.js";
|
|
17
17
|
import { BRIDGE_PROTOCOL_VERSION, describePreflightIssue, runBridgePreflight } from "./preflight.js";
|
|
18
18
|
import { applyRunnerToolPath, readActiveRunnerServiceWorkspace, readStoredRunnerServiceOptions, runnerServiceWorkspaceWarnings } from "./service.js";
|
|
19
19
|
import { bridgeVersion } from "./version.js";
|
|
@@ -245,6 +245,24 @@ export async function runOps(verb, argv = [], deps = {}) {
|
|
|
245
245
|
if (verb === "status") {
|
|
246
246
|
const packageVersion = bridgeVersion();
|
|
247
247
|
console.log(`Bridge: v${packageVersion} (protocol ${BRIDGE_PROTOCOL_VERSION})`);
|
|
248
|
+
// Print lanes through the same projection `drivers list` uses. These two commands disagreeing
|
|
249
|
+
// about whether a lane was usable is what sent an operator looking for a product fault when the
|
|
250
|
+
// real answer was a spent provider allowance.
|
|
251
|
+
try {
|
|
252
|
+
const laneConfig = await (deps.loadBridgeConfig ?? loadConfig)();
|
|
253
|
+
const statuses = laneStatuses(laneConfig);
|
|
254
|
+
for (const lane of statuses) {
|
|
255
|
+
const when = lane.observed_at ? `, observed ${lane.observed_at}` : "";
|
|
256
|
+
const reset = lane.resets_at ? `, resets ${lane.resets_at}` : lane.allowance === "unavailable" ? ", reset unknown" : "";
|
|
257
|
+
console.log(`Lane ${lane.id}: ${lane.state}, fuel ${lane.fuel}, allowance ${lane.allowance}${when}${reset}`);
|
|
258
|
+
}
|
|
259
|
+
const blocked = laneDispatchBlock(statuses);
|
|
260
|
+
// Never say READY on the strength of a login alone; dispatch is what the operator is asking about.
|
|
261
|
+
console.log(blocked ? `Machine dispatch: BLOCKED — ${blocked}` : "Machine dispatch: ready");
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
console.log("Lanes: unavailable (no local Bridge configuration)");
|
|
265
|
+
}
|
|
248
266
|
try {
|
|
249
267
|
const config = await (deps.loadBridgeConfig ?? loadConfig)();
|
|
250
268
|
const controller = new AbortController();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miraland-labs/conduit-bridge",
|
|
3
|
-
"version": "0.16.
|
|
4
|
-
"description": "Conduit Bridge CLI
|
|
3
|
+
"version": "0.16.28",
|
|
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": {
|
|
7
7
|
"conduit": "dist/cli.js"
|