@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 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.26` — 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`).
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
- for (const lane of lanes) {
437
- console.log(` ${lane.id.padEnd(14)} ${lane.state.padEnd(8)} fuel=${lane.fuel} (${lane.label})`);
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
- ? `Online: ${online.join(", ")}. Toggle: ${bridgeUsage("drivers", "online|offline", "<id…>")}`
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
- const timeoutMs = values["agent-timeout-minutes"] ? Number(values["agent-timeout-minutes"]) * 60_000 : undefined;
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.timeoutMs ?? 20 * 60_000, fuelSource === "conduit" ? input.fuel : undefined, fuelSource);
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.timeoutMs ?? 20 * 60_000, fuelSource === "conduit" ? input.fuel : undefined, fuelSource, input.prompt);
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.timeoutMs ?? 20 * 60_000, undefined, "local"));
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.timeoutMs ?? 20 * 60_000, fuelSource === "conduit" ? input.fuel : undefined, fuelSource));
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.timeoutMs ?? 20 * 60_000, undefined, "local");
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.timeoutMs ?? 20 * 60_000, undefined, "local");
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.timeoutMs ?? 20 * 60_000, undefined, "local");
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.timeoutMs ?? 20 * 60_000, undefined, "local");
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
- const timer = setTimeout(() => { child.kill("SIGTERM"); setTimeout(() => child.kill("SIGKILL"), 10_000).unref(); }, timeoutMs);
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({ code, stdout, stderr: stderr.slice(0, 20_000) });
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
- const failedDetails = [
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
+ }
@@ -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.26",
4
- "description": "Conduit Bridge CLI join, connect, disconnect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Pi / Kiro / Antigravity / Grok Build agents for a Conduit organization",
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"