@miraland-labs/conduit-bridge 0.16.103 → 0.16.105

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -14,7 +14,7 @@ import { detectInstalledClients, localFuelOnlyClients, suggestFuelSource } from
14
14
  import { DRIVERS, MODEL_TIERS } from "./driver.js";
15
15
  import { driversHeartbeatReport, driverLabel, heartbeatStatusForDrivers, isSupportedDriverId, listDriverLanes, onlineDriverIds, seedDriversFromDetection, seedDriverLanes, setDriverFuel, setDriverModels, setDriversOnline, formatLaneTiers, laneDispatchBlock, laneStatuses, } from "./drivers.js";
16
16
  import { buildWorkspaceBrief } from "./brief.js";
17
- import { parseAgentTimeoutMinutes } from "./execution-budget.js";
17
+ import { parseAgentTimeoutMinutes, parseMaxTurns, resolveMaxTurns } from "./execution-budget.js";
18
18
  import { BOOTSTRAP_RESULTS, buildSovereignExecutionFacts } from "./execution-facts.js";
19
19
  import { ensureCheckout } from "./checkout.js";
20
20
  import { buildOnShiftIntentProof, maybeApplyOnShiftIntent } from "./on-shift-apply.js";
@@ -534,10 +534,26 @@ async function runner() {
534
534
  applyRunnerToolPath();
535
535
  const { values } = parseArgs({ args: process.argv.slice(3), options: {
536
536
  workspace: { type: "string" }, interval: { type: "string" }, once: { type: "boolean" },
537
- "agent-timeout-minutes": { type: "string" }, fuel: { type: "string" }, "ensure-checkout": { type: "string" },
537
+ "agent-timeout-minutes": { type: "string" }, "max-turns": { type: "string" }, fuel: { type: "string" }, "ensure-checkout": { type: "string" },
538
538
  "bootstrap-result": { type: "string" },
539
539
  } });
540
540
  let config = await loadConfig();
541
+ // The cap is a machine setting, so it is written to the config the way `--fuel` is: the resident
542
+ // service, `ops status` and the readiness report then all read one number instead of each being
543
+ // told separately, and a runner started without the flag keeps the operator's last choice.
544
+ let maxTurnsOverride;
545
+ try {
546
+ maxTurnsOverride = parseMaxTurns(values["max-turns"]);
547
+ }
548
+ catch (error) {
549
+ console.error(error instanceof Error ? error.message : String(error));
550
+ process.exitCode = 1;
551
+ return;
552
+ }
553
+ if (maxTurnsOverride !== undefined) {
554
+ config.maxTurns = maxTurnsOverride;
555
+ await saveConfigPrefs(config);
556
+ }
541
557
  const fuelOverride = parseFuelSource(values.fuel);
542
558
  const bootstrapResult = values["bootstrap-result"]
543
559
  ? parseBootstrapResult(values["bootstrap-result"])
@@ -581,7 +597,7 @@ async function runner() {
581
597
  console.warn(`WARNING: no online driver lanes — ${bridgeUsage("drivers", "online", AGENT_PLACEHOLDER)}`);
582
598
  }
583
599
  const onlineLabel = onlineDriverIds(config).join(", ") || "(none)";
584
- console.log(`Conduit runner (bridge v${bridgeVersion()}) connected to ${config.baseUrl}${workspace ? ` — workspace ${workspace}` : " — heartbeat only"} (lanes: ${onlineLabel}; machine fuel: ${config.fuelSource === "local" ? "local" : "conduit"}; slots: ${config.leaseCapacity})`);
600
+ console.log(`Conduit runner (bridge v${bridgeVersion()}) connected to ${config.baseUrl}${workspace ? ` — workspace ${workspace}` : " — heartbeat only"} (lanes: ${onlineLabel}; machine fuel: ${config.fuelSource === "local" ? "local" : "conduit"}; slots: ${config.leaseCapacity}; turn cap: ${resolveMaxTurns(config.maxTurns)})`);
585
601
  const running = new Map();
586
602
  for (;;) {
587
603
  let progressed = false;
@@ -591,6 +607,7 @@ async function runner() {
591
607
  config.drivers = latest.drivers;
592
608
  config.fuelSource = latest.fuelSource;
593
609
  config.leaseCapacity = latest.leaseCapacity;
610
+ config.maxTurns = latest.maxTurns;
594
611
  // Refresh before discovery: the brief and the readiness report must describe origin's code,
595
612
  // not whatever this checkout was left on. Cadence is the readiness report's own, not the beat.
596
613
  const refresh = workspace ? await reportedRefresh(workspace) : null;
package/dist/driver.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { boundedTail } from "./ensure-test-evidence.js";
3
- import { resolveAgentTimeout } from "./execution-budget.js";
4
- import { agentExitSignal, vendorKindForAntigravity } from "./failure-signal.js";
3
+ import { resolveAgentTimeout, resolveMaxTurns } from "./execution-budget.js";
4
+ import { agentExitSignal, turnCapSignal, vendorKindForAntigravity } from "./failure-signal.js";
5
5
  import { existsSync } from "node:fs";
6
6
  import { mkdir, readFile, rm, rmdir, writeFile } from "node:fs/promises";
7
7
  import { join, resolve } from "node:path";
@@ -721,7 +721,8 @@ export const claudeCodeDriver = {
721
721
  error: "No Bridge-mapped tools for active grants; refusing to start agent without an allow-list",
722
722
  };
723
723
  }
724
- const args = ["-p", input.prompt, "--output-format", "json"];
724
+ const maxTurns = resolveMaxTurns(input.maxTurns);
725
+ const args = ["-p", input.prompt, "--output-format", "json", "--max-turns", String(maxTurns)];
725
726
  args.push(...modelArgs(claudeCodeDriver, input.model));
726
727
  if (input.resumeSessionId)
727
728
  args.push("--resume", input.resumeSessionId);
@@ -739,6 +740,18 @@ export const claudeCodeDriver = {
739
740
  }
740
741
  const sessionId = message?.session_id ?? null;
741
742
  const usage = claudeUsage(message);
743
+ // The CLI names this stop itself (`subtype: "error_max_turns"`, verified against claude 2.1.258),
744
+ // so the cap is read from the vendor's envelope rather than guessed from an exit code.
745
+ if (message?.subtype === "error_max_turns") {
746
+ return {
747
+ status: "failed",
748
+ resultText: message.result ?? null,
749
+ sessionId,
750
+ usage,
751
+ signal: turnCapSignal({ maxTurns, turns: usage.turns, model: input.model }),
752
+ error: `agent_turn_cap: the run reached the computer's cap of ${maxTurns} turns`,
753
+ };
754
+ }
742
755
  if (code !== 0 || !message || message.is_error) {
743
756
  return { status: "failed", resultText: message?.result ?? null, sessionId, usage, signal: agentExitSignal({ exit_code: code, stderr, stdout, model: input.model }), error: boundedTail(message?.result || stderr || `agent exited with code ${code}`, 20_000) };
744
757
  }
@@ -45,14 +45,24 @@ export function pickVerificationCommand(commands) {
45
45
  * a declared gate neither dry-run nor witnessed unless an acceptance criterion happened to name it.
46
46
  * So the declared gates run beside the aggregate, in declared order, de-duplicated.
47
47
  */
48
- export function verificationEvidenceCommands(commands, declared = []) {
48
+ export function verificationEvidenceCommands(commands, declared = [], acceptance = null) {
49
49
  const bounded = [...new Set(commands
50
50
  .map((command) => command.trim())
51
51
  .filter((command) => command && isRunnableVerificationCommand(command)))];
52
+ const declaredGates = bounded.filter((command) => declared.some((line) => line.trim() === command));
53
+ // T191: the gate is what the project declared for itself plus what the contract will be judged
54
+ // on — the commands its acceptance criteria name. Every other discovered script (this repository's
55
+ // ten-minute `npm run verify` for a docs change judged on `npm run typecheck`) is a candidate the
56
+ // planner may name, not a gate the Bridge runs unasked. When the criteria name no command the
57
+ // discovered rule below decides, as before (T31: a declared gate runs beside the aggregate).
58
+ if (acceptance !== null) {
59
+ const named = acceptanceVerificationCommands([...acceptance], commands).commands;
60
+ if (named.length)
61
+ return [...new Set([...declaredGates, ...named])];
62
+ }
52
63
  const aggregate = bounded.find((command) => /^(?:npm run|pnpm|yarn) verify$|^make check$/.test(command));
53
64
  if (!aggregate)
54
65
  return bounded;
55
- const declaredGates = bounded.filter((command) => declared.some((line) => line.trim() === command));
56
66
  return [...new Set([...declaredGates, aggregate])];
57
67
  }
58
68
  /**
@@ -475,7 +485,7 @@ export async function ensureTestEvidence(input) {
475
485
  // The project's gate first, then every bounded command the criteria name that the workspace
476
486
  // authorizes. Each is witnessed on its own, so the reviewer sees the criterion's own proof.
477
487
  const commands = [...new Set([
478
- ...verificationEvidenceCommands(input.verificationCommands, input.declaredVerificationCommands ?? []),
488
+ ...verificationEvidenceCommands(input.verificationCommands, input.declaredVerificationCommands ?? [], acceptance),
479
489
  ...named.commands,
480
490
  ])];
481
491
  if (commands.length === 0) {
@@ -75,3 +75,48 @@ function usableMs(value) {
75
75
  return undefined;
76
76
  return Math.round(value);
77
77
  }
78
+ /**
79
+ * How many turns one attempt may spend on a lane whose CLI can be told.
80
+ *
81
+ * A turn is not a minute. The agent CLI re-sends the whole conversation every turn, so the tokens
82
+ * one attempt bills grow with the square of its turn count: a one-paragraph documentation edit on
83
+ * 2026-09-15 ran 33 turns for 1,029,028 input tokens and $5.27, and the timer never came close.
84
+ * Forty turns is what ordinary delivery work has needed; beyond it a run is looping, not working.
85
+ */
86
+ export const DEFAULT_MAX_TURNS = 40;
87
+ /**
88
+ * The widest cap a configuration may ask for. Past this the number is far more likely to be a
89
+ * mistake than an intention — and an attempt that needs a thousand turns is the defect itself.
90
+ */
91
+ export const MAX_MAX_TURNS = 500;
92
+ export class InvalidMaxTurnsError extends Error {
93
+ }
94
+ /**
95
+ * A turn cap from a command line, validated.
96
+ *
97
+ * Validated at startup rather than at use, for the reason `parseAgentTimeoutMinutes` records:
98
+ * `Number("abc")` is `NaN`, `NaN` is not nullish, and an unvalidated value flows past every `??`
99
+ * default and becomes the bound itself.
100
+ */
101
+ export function parseMaxTurns(value) {
102
+ if (value === undefined)
103
+ return undefined;
104
+ const turns = Number(value);
105
+ if (!Number.isInteger(turns) || turns < 1 || turns > MAX_MAX_TURNS) {
106
+ throw new InvalidMaxTurnsError(`--max-turns must be a whole number of turns between 1 and ${MAX_MAX_TURNS}, got ${JSON.stringify(value)}`);
107
+ }
108
+ return turns;
109
+ }
110
+ /**
111
+ * The effective turn cap for one run.
112
+ *
113
+ * The default applies here, at the point the flag is written, so a call site that knows nothing
114
+ * about the machine's configuration still runs capped. An uncapped run is the incident.
115
+ */
116
+ export function resolveMaxTurns(configured) {
117
+ if (typeof configured !== "number" || !Number.isInteger(configured))
118
+ return DEFAULT_MAX_TURNS;
119
+ if (configured < 1 || configured > MAX_MAX_TURNS)
120
+ return DEFAULT_MAX_TURNS;
121
+ return configured;
122
+ }
package/dist/execution.js CHANGED
@@ -1285,7 +1285,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
1285
1285
  await removeReleasedDiagnosticWorktree(response);
1286
1286
  return;
1287
1287
  }
1288
- const evidenceCommands = verificationEvidenceCommands(attemptBrief?.verification ?? [], attemptBrief?.declared_verification ?? []);
1288
+ const evidenceCommands = verificationEvidenceCommands(attemptBrief?.verification ?? [], attemptBrief?.declared_verification ?? [], spec.acceptance ?? []);
1289
1289
  // Ahead of the dry run and of the model: the contract requires test evidence and this worktree
1290
1290
  // offers no command that can witness it. Every fact is known here — the required evidence, the
1291
1291
  // test_run grant, the attempt worktree's own gates — and the same question was asked only after
@@ -1323,6 +1323,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
1323
1323
  workspace: attemptWorkspace,
1324
1324
  verificationCommands: attemptBrief?.verification ?? [],
1325
1325
  declaredVerificationCommands: attemptBrief?.declared_verification ?? [],
1326
+ acceptance: spec.acceptance ?? [],
1326
1327
  changeScope: spec.change_scope ?? [],
1327
1328
  });
1328
1329
  if (dryRun.ranNothing) {
@@ -1564,6 +1565,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
1564
1565
  resumeSessionId,
1565
1566
  timeoutMs,
1566
1567
  maxDurationMs: workPackage?.max_duration_ms,
1568
+ maxTurns: config.maxTurns,
1567
1569
  model: selection.model,
1568
1570
  fuel,
1569
1571
  fuelSource,
@@ -1706,9 +1708,14 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
1706
1708
  // A vendor allowance refusal is already a precise environment diagnosis. Running repository
1707
1709
  // verification after it can surface an unrelated pre-existing failure and overwrite the lane
1708
1710
  // Hold with a false source-repair brief.
1709
- if (grants.includes("test_run") && !subscriptionExhausted(agentMessage))
1711
+ // T197: the machine's turn cap is such a diagnosis too. Recovering a verification failure
1712
+ // after it would retype the signal as a Bridge verification witness, which classifies as a
1713
+ // transient retry — the one verdict that would run the capped work again at the same price.
1714
+ const turnCapReached = result.signal?.kind === "turn_cap";
1715
+ const recoverVerification = grants.includes("test_run") && !subscriptionExhausted(agentMessage) && !turnCapReached;
1716
+ if (recoverVerification)
1710
1717
  watchdog.phase("verification");
1711
- const verificationDetail = grants.includes("test_run") && !subscriptionExhausted(agentMessage)
1718
+ const verificationDetail = recoverVerification
1712
1719
  ? await captureVerificationFailure({
1713
1720
  workspace: attemptWorkspace,
1714
1721
  verificationCommands: attemptBrief?.verification ?? [],
@@ -1952,6 +1959,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
1952
1959
  resumeSessionId,
1953
1960
  timeoutMs,
1954
1961
  maxDurationMs: workPackage?.max_duration_ms,
1962
+ maxTurns: config.maxTurns,
1955
1963
  model: selection.model,
1956
1964
  fuel,
1957
1965
  fuelSource,
@@ -2304,6 +2312,7 @@ async function runLandContinuationTurn(input) {
2304
2312
  resumeSessionId: input.resumeSessionId ?? undefined,
2305
2313
  timeoutMs: input.timeoutMs,
2306
2314
  maxDurationMs: input.maxDurationMs,
2315
+ maxTurns: input.config.maxTurns,
2307
2316
  model: input.selection.model ?? undefined,
2308
2317
  fuel: input.fuel,
2309
2318
  fuelSource: input.fuelSource,
@@ -2791,7 +2800,7 @@ export function verificationScopeConflictDetail(paths, changeScope) {
2791
2800
  * discarded before the agent starts, so the agent always begins in the tree of the base commit.
2792
2801
  */
2793
2802
  export async function verificationScopeDryRun(input) {
2794
- const commands = verificationEvidenceCommands(input.verificationCommands, input.declaredVerificationCommands ?? []);
2803
+ const commands = verificationEvidenceCommands(input.verificationCommands, input.declaredVerificationCommands ?? [], input.acceptance ?? null);
2795
2804
  if (!commands.length)
2796
2805
  return { ranNothing: null, redBase: null, outsideScope: [] };
2797
2806
  const run = input.runCommand ?? runBoundedVerificationCommand;
@@ -17,7 +17,7 @@ export const failureSignalSchema = z.object({
17
17
  phase: z.enum(["claim", "checkout", "bootstrap", "agent", "verification", "delivery_report", "land"]),
18
18
  /** Who produced the evidence for this signal: Bridge itself, the agent CLI, or the vendor. */
19
19
  witness: z.enum(["bridge", "agent", "vendor"]),
20
- kind: z.enum(["exit_code", "timeout", "denied_tool", "quota", "usage_error", "no_output", "contract", "verification_failed", "model_unbound", "stalled", "vendor_transient", "gateway_refusal", "unknown"]),
20
+ kind: z.enum(["exit_code", "timeout", "turn_cap", "denied_tool", "quota", "usage_error", "no_output", "contract", "verification_failed", "model_unbound", "stalled", "vendor_transient", "gateway_refusal", "unknown"]),
21
21
  /** The exact command when phase is verification or bootstrap. */
22
22
  command: z.string().max(2_000).optional(),
23
23
  /** The repository paths a Bridge check names as the cause, bounded so one signal stays readable. */
@@ -49,6 +49,17 @@ export const failureSignalSchema = z.object({
49
49
  pinned: z.string().max(200).nullable(),
50
50
  observed: z.string().max(200).nullable(),
51
51
  }).optional(),
52
+ /**
53
+ * The per-attempt turn cap the run reached, and the turns the vendor counted against it.
54
+ *
55
+ * Both numbers are the operator's own: the limit is this computer's setting, and the count is what
56
+ * the CLI said it spent. A card that only said "the run was stopped" would leave the operator
57
+ * unable to tell a cap that is too tight from work that is looping.
58
+ */
59
+ turn_cap: z.object({
60
+ limit: z.number().int().min(1),
61
+ turns: z.number().int().min(0),
62
+ }).optional(),
52
63
  /** The intelligence tier and driver named by a `model_unbound` signal: no configured candidate
53
64
  * the retry could bind, so the run must not repeat the abort on the same or an unconfirmed name. */
54
65
  model_binding: z.object({
@@ -110,20 +121,45 @@ export function agentExitSignal(input) {
110
121
  const output = `${input.stderr ?? ""}\n${input.stdout ?? ""}`;
111
122
  const decisiveOutput = output.replace(CLAUDE_UNRECOGNIZED_MODEL_WARNING, "");
112
123
  const rejectedModel = Boolean(input.model) && MODEL_REJECTION.test(decisiveOutput);
124
+ const kind = TOOL_DENIAL.test(decisiveOutput) ? "denied_tool"
125
+ : GATEWAY_REFUSAL.test(decisiveOutput) ? "gateway_refusal"
126
+ : rejectedModel ? "usage_error"
127
+ : VENDOR_TRANSIENT.test(decisiveOutput) ? "vendor_transient"
128
+ : "unknown";
129
+ // T196: the gateway's own words travel as the vendor message, whichever stream carried them.
130
+ // Claude Code prints "API Error: 400 Unknown model alias" on stdout, and the owner's card read
131
+ // "Conduit's gateway refused the agent's request:" followed by nothing (2026-09-15).
132
+ const refusalLine = kind === "gateway_refusal"
133
+ ? decisiveOutput.split("\n").map((line) => line.trim()).find((line) => GATEWAY_REFUSAL.test(line))
134
+ : undefined;
113
135
  return {
114
136
  phase: "agent",
115
137
  witness: "agent",
116
- kind: TOOL_DENIAL.test(decisiveOutput) ? "denied_tool"
117
- : GATEWAY_REFUSAL.test(decisiveOutput) ? "gateway_refusal"
118
- : rejectedModel ? "usage_error"
119
- : VENDOR_TRANSIENT.test(decisiveOutput) ? "vendor_transient"
120
- : "unknown",
138
+ kind,
121
139
  exit_code: input.exit_code ?? null,
122
140
  ...(input.model ? { model: input.model } : {}),
141
+ ...(refusalLine ? { vendor_message: refusalLine.slice(0, 400) } : {}),
123
142
  ...(input.stderr?.trim() ? { stderr_tail: bounded2k(input.stderr) } : {}),
124
143
  ...(input.stdout?.trim() ? { stdout_tail: bounded2k(input.stdout) } : {}),
125
144
  };
126
145
  }
146
+ /**
147
+ * The signal a driver emits when the machine's turn cap, not the work, ended the run.
148
+ *
149
+ * T197: the agent CLI re-sends the whole conversation every turn, so an attempt's token bill grows
150
+ * with the square of its turn count — 33 turns for one paragraph of documentation cost $5.27 on
151
+ * 2026-09-15, and nothing stopped it. The cap stops it; this signal makes the stop a decided
152
+ * environment Hold rather than a failure some later rule retries blind at the same price.
153
+ */
154
+ export function turnCapSignal(input) {
155
+ return {
156
+ phase: "agent",
157
+ witness: "vendor",
158
+ kind: "turn_cap",
159
+ turn_cap: { limit: input.maxTurns, turns: input.turns },
160
+ ...(input.model ? { model: input.model } : {}),
161
+ };
162
+ }
127
163
  /**
128
164
  * The signal Bridge emits when a pinned execution fact no longer matches the workspace.
129
165
  *
@@ -173,6 +209,24 @@ export function verificationScopeConflictSignal(paths) {
173
209
  * Bridge writes it, the classifier reads it, so the two sides agree on one string and not on prose.
174
210
  */
175
211
  export const VERIFICATION_FAILS_ON_BASE = "verification fails on the base commit";
212
+ /**
213
+ * A shell reporting that a command the gate needs does not exist on this computer. Both the prose
214
+ * classifier and the base-gate rule read it: a gate that cannot start is a missing tool, never a
215
+ * red commit.
216
+ */
217
+ export const WORKSPACE_DEPENDENCIES_MISSING_PATTERN = /(?:^|\n)\s*(?:\/(?:usr\/)?bin\/)?(?:ba|z|da)?sh:\s*(?:(?:\d+:\s*)?[^:\n]{1,120}:\s*(?:command\s+)?not found|command not found:\s*[^\n]+)\s*(?=\n|$)|(?:^|\n)\s*'[^'\n]+' is not recognized as an internal or external command/im;
218
+ /** The one envelope for a gate whose tool is absent from the checkout, wherever it was noticed. */
219
+ export function workspaceDependenciesMissingFailure(detail) {
220
+ return {
221
+ code: "workspace_dependencies_missing",
222
+ class: "environment",
223
+ disposition: "hold",
224
+ responsible_party: "conduit",
225
+ message: "The checkout is missing a locked tool required by its verification command.",
226
+ next_action: "Bridge must complete deterministic workspace bootstrap before authoring resumes. No plan or compiler constraints are needed from the owner.",
227
+ diagnostic_detail: detail,
228
+ };
229
+ }
176
230
  /**
177
231
  * The signal Bridge emits when a gate exits non-zero on the base commit, before the agent starts.
178
232
  *
@@ -225,6 +279,8 @@ function quotedVendorText(signal) {
225
279
  * 8. verification run by Bridge, any other cause → the verify class: diagnose and brief a repair.
226
280
  * 9. contract, or a delivery_report phase → the delivery contract codes: rework.
227
281
  * 10. timeout → the turn reached its execution limit: stop.
282
+ * 11. turn_cap → the run reached the computer's per-attempt turn cap: Hold, on the operator, who
283
+ * decides whether the work was looping or the cap is too tight. Never a silent retry.
228
284
  */
229
285
  export function classifyFailureSignal(signal) {
230
286
  // T90: ahead of every other rule, because a stalled attempt has no vendor evidence at all — the
@@ -335,6 +391,14 @@ export function classifyFailureSignal(signal) {
335
391
  && signal.vendor_message === VERIFICATION_FAILS_ON_BASE) {
336
392
  const command = signal.command ?? "the declared gate";
337
393
  const base = signal.base_commit ?? "the base commit";
394
+ const output = [signal.stderr_tail, signal.stdout_tail].filter(Boolean).join("\n");
395
+ // T198: a gate that could not start (`sh: tsc: not found`, exit 127) says nothing about the
396
+ // base commit. It is the computer's checkout that lacks a tool — an environment Hold that
397
+ // clears when the workspace is bootstrapped, not a contract Hold on the author that never
398
+ // clears. runner-2's first claim (2026-09-15) sat on the author for a missing `node_modules`.
399
+ if (signal.exit_code === 127 || WORKSPACE_DEPENDENCIES_MISSING_PATTERN.test(output)) {
400
+ return workspaceDependenciesMissingFailure([`$ ${command}`, `exit ${signal.exit_code ?? "unknown"}`, output].filter(Boolean).join("\n"));
401
+ }
338
402
  return {
339
403
  code: "verification_red_base",
340
404
  class: "contract",
@@ -425,6 +489,22 @@ export function classifyFailureSignal(signal) {
425
489
  diagnostic_detail: signal.stderr_tail,
426
490
  };
427
491
  }
492
+ // T197: the run was stopped by this computer's own turn cap. Retrying it spends the same money to
493
+ // reach the same cap, so the operator decides: raise the cap for work that genuinely needs the
494
+ // turns, or read the run and find why it was looping.
495
+ if (signal.kind === "turn_cap") {
496
+ const limit = signal.turn_cap?.limit ?? 0;
497
+ const spent = signal.turn_cap?.turns ?? 0;
498
+ return {
499
+ code: "agent_turn_cap_reached",
500
+ class: "environment",
501
+ disposition: "hold",
502
+ responsible_party: "computer_operator",
503
+ message: `The run hit this computer's turn cap of ${limit} turns after ${spent}, so Conduit stopped it before it spent more.`,
504
+ next_action: `Read the run: work that needs more turns gets a larger cap with \`npx @miraland-labs/conduit-bridge@latest runner --max-turns <n>\` on this computer, and a run that was looping needs the package narrowed. Then Recheck.`,
505
+ diagnostic_detail: `turn cap ${limit}; turns ${spent}`,
506
+ };
507
+ }
428
508
  return null;
429
509
  }
430
510
  /**
@@ -325,6 +325,7 @@ export async function executeNextInvestigation(client, config, workspace, brief,
325
325
  fuelSource: investigationFuel,
326
326
  fuel,
327
327
  timeoutMs: Math.min(timeoutMs ?? assignment.budget.max_duration_ms, assignment.budget.max_duration_ms),
328
+ maxTurns: config.maxTurns,
328
329
  });
329
330
  await learnDriverFuel(config, driver.name, investigationFuel, result);
330
331
  if (result.status === "failed") {
package/dist/preflight.js CHANGED
@@ -2,6 +2,7 @@ import { execFile } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { promisify } from "node:util";
4
4
  import { buildWorkspaceBrief, normalizeRepositoryUrl } from "./brief.js";
5
+ import { resolveMaxTurns } from "./execution-budget.js";
5
6
  import { hasAntigravityLogin, hasClaudeLogin, hasGrokLogin, hasOpenAiLogin, hasOpenCodeLogin, driverExecutable, } from "./driver.js";
6
7
  import { laneQuota, laneTierBindings, localFuelOnlyDriver, onlineDriverIds, resolveDriverFuel, supportsReadOnlyDiagnosis } from "./drivers.js";
7
8
  /** Protocol 3: read-only diagnosis can reuse a retained failed-attempt worktree. */
@@ -228,7 +229,8 @@ export async function runBridgePreflight(input, deps = {}) {
228
229
  issues.push({ code: "driver_quota_exhausted" });
229
230
  }
230
231
  return { ready: issues.length === 0, checked_at: new Date().toISOString(), workspace_clean: workspaceClean,
231
- drivers: driverChecks.map((check) => check.snapshot), models_fingerprint: modelsFingerprint(input.config), issues,
232
+ drivers: driverChecks.map((check) => check.snapshot), models_fingerprint: modelsFingerprint(input.config),
233
+ max_turns: resolveMaxTurns(input.config.maxTurns), issues,
232
234
  ...(input.managedRoot?.trim() ? { managed_root: input.managedRoot.trim() } : {}) };
233
235
  }
234
236
  let cached = null;
@@ -240,7 +242,7 @@ export const PREFLIGHT_CACHE_TTL_MS = 5 * 60_000;
240
242
  /** Keep auth probes off the 15-second heartbeat hot path while still expiring readiness promptly. */
241
243
  export async function cachedBridgePreflight(input, options = {}) {
242
244
  const online = input.processOnlineIds?.length ? input.processOnlineIds : onlineDriverIds(input.config);
243
- const key = JSON.stringify([input.workspace, input.expectedRepository ?? "", online, input.config.fuelSource, input.config.drivers,
245
+ const key = JSON.stringify([input.workspace, input.expectedRepository ?? "", online, input.config.fuelSource, input.config.drivers, input.config.maxTurns ?? 0,
244
246
  input.refresh?.state ?? "", input.refresh?.local_commits ?? 0, input.managedRoot ?? ""]);
245
247
  if (!options.force && cached?.key === key && Date.now() - cached.at < PREFLIGHT_CACHE_TTL_MS)
246
248
  return cached.report;
@@ -262,6 +264,7 @@ export function unavailableWorkspacePreflight(input) {
262
264
  drivers: online.map((id) => ({ id, version: null, ready: false, diagnosis_read_only: supportsReadOnlyDiagnosis(id), compatibility_fingerprint: null,
263
265
  tiers: laneTierBindings(input.config, id) })),
264
266
  models_fingerprint: modelsFingerprint(input.config),
267
+ max_turns: resolveMaxTurns(input.config.maxTurns),
265
268
  issues,
266
269
  ...(input.managedRoot?.trim() ? { managed_root: input.managedRoot.trim() } : {}),
267
270
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miraland-labs/conduit-bridge",
3
- "version": "0.16.103",
3
+ "version": "0.16.105",
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": {