@miraland-labs/conduit-bridge 0.16.104 → 0.16.106

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
  }
@@ -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
@@ -13,7 +13,7 @@ import { execFile } from "node:child_process";
13
13
  import { promisify } from "node:util";
14
14
  import { buildWorkspaceBrief, ensureCommitAvailable, fetchOrigin, normalizeRepositoryUrl, resolveAttemptStartCommit } from "./brief.js";
15
15
  import { buildSovereignExecutionFacts, bootstrapResultForWorkspace, changedExecutionFact, resumeExecutionFacts, workspaceIsClean } from "./execution-facts.js";
16
- import { executionFactChangedSignal, verificationRedBaseSignal, verificationScopeConflictSignal } from "./failure-signal.js";
16
+ import { bridgeCause, executionFactChangedSignal, verificationRedBaseSignal, verificationScopeConflictSignal } from "./failure-signal.js";
17
17
  import { forgeTransportFailure } from "./transport-fault.js";
18
18
  import { managedWorkspacePath, switchManagedWorkspace } from "./on-shift-apply.js";
19
19
  import { formatResetMinute, parseQuotaResetAt } from "./quota-reset.js";
@@ -22,7 +22,7 @@ import { BRIDGE_PROTOCOL_VERSION } from "./preflight.js";
22
22
  import { bridgeVersion } from "./version.js";
23
23
  import { PULL_REQUEST_OPEN_FAILURE_PREFIX, ensureDeliveryPullRequest } from "./ensure-pull-request.js";
24
24
  import { ensureLandCommit } from "./ensure-land-commit.js";
25
- import { AgentNoLandCommitError, agentNoLandCommitMessage, requiresLandCommit } from "./land-contract.js";
25
+ import { AGENT_NO_LAND_COMMIT_PREFIX, AgentNoLandCommitError, agentNoLandCommitMessage, requiresLandCommit } from "./land-contract.js";
26
26
  import { agentClaimsRepositoryWork, claimsVsGitMismatch, isLandTreeEmpty, reportClaimsRepositoryWork, } from "./land-git-reality.js";
27
27
  const execFileAsync = promisify(execFile);
28
28
  import { captureVerificationFailure, detectGateRanNothing, ensureTestEvidence, needsTestEvidence, runBoundedVerificationCommand, verificationEvidenceCommands, VerificationGateRanNothingError } from "./ensure-test-evidence.js";
@@ -185,6 +185,9 @@ class AgentTurnTimeoutError extends Error {
185
185
  /** Environment faults that must Hold — mirror CP ENVIRONMENT_FAILURES message patterns. */
186
186
  const FINALIZE_ENVIRONMENT_PATTERN = /base[_ ]not[_ ]ancestor|required base commit is not available|source[_ ]workspace[_ ]dirty|uncommitted changes|dirty workspace|workspace[_ ]head[_ ]changed|workspace[_ ]repository|workspace[_ ]unavailable|driver[_ ]not[_ ]authenticated|not logged in|no login|not authenticated|login required|no[_ ]online[_ ]driver|bridge[_ ]preflight|stale bridge|bridge version/i;
187
187
  /** Delivery-report / grant / evidence contract defects (non-retryable rework). */
188
+ /** Two contract messages the control plane reads with builders of its own; they carry no code from here. */
189
+ const CONTRACT_SCOPE_CONFLICT_MESSAGE = /Agent reported changes outside the approved scope:/i;
190
+ const VERIFICATION_UNWITNESSABLE_MESSAGE = /verification produced (?:no|insufficient) output/i;
188
191
  const FINALIZE_CONTRACT_PATTERN = /missing required evidence|without the repo_write grant|pull_request_url|Artifact delivery requires|outside the approved scope|head commit|test evidence must include|Met acceptance criteria|Agent report|Agent reported|Agent did not land repository changes|Delivery report|Integration obligation not met|Normative reference unavailable|Normative marker not found/i;
189
192
  /**
190
193
  * Classify throws from ensureDeliveryPullRequest + validateDeliveryReport.
@@ -192,7 +195,7 @@ const FINALIZE_CONTRACT_PATTERN = /missing required evidence|without the repo_wr
192
195
  */
193
196
  export function classifyFinalizeFailure(message) {
194
197
  if (forgeTransportFailure(message))
195
- return { retryable: true, error: message };
198
+ return { retryable: true, error: message, failure: bridgeCause("forge_unreachable", message) };
196
199
  // The control plane refused to open the change request — no forge connection resolves for this
197
200
  // organization or project, or the task's repository is not one that connection can open a change
198
201
  // request on. No retry and no rework can supply a connection, and the owner must read the control
@@ -201,8 +204,19 @@ export function classifyFinalizeFailure(message) {
201
204
  return { retryable: false, error: message };
202
205
  if (FINALIZE_ENVIRONMENT_PATTERN.test(message))
203
206
  return { retryable: false, error: message };
204
- if (FINALIZE_CONTRACT_PATTERN.test(message))
205
- return { retryable: false, error: message };
207
+ // Bridge names the contract defect it found (K3). A scope conflict keeps its own builder on the
208
+ // control plane, which quotes the paths; an unknown throw below stays prose until its own site
209
+ // names a code.
210
+ if (FINALIZE_CONTRACT_PATTERN.test(message)) {
211
+ const failure = CONTRACT_SCOPE_CONFLICT_MESSAGE.test(message)
212
+ ? undefined
213
+ : VERIFICATION_UNWITNESSABLE_MESSAGE.test(message)
214
+ ? bridgeCause("verification_unwitnessable", message)
215
+ : message.startsWith(AGENT_NO_LAND_COMMIT_PREFIX)
216
+ ? bridgeCause("agent_no_land_commit", message)
217
+ : bridgeCause("execution_contract_failed", message);
218
+ return { retryable: false, error: message, ...(failure ? { failure } : {}) };
219
+ }
206
220
  // Unknown finalize faults fail closed once — retrying burns the attempt budget (Invariant 22).
207
221
  // Distinct from execution_contract_failed so owners see a Bridge fault, not a contract lie.
208
222
  return { retryable: false, error: `Bridge finalize interrupted: ${message}` };
@@ -1060,7 +1074,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
1060
1074
  const liveBrief = await buildWorkspaceBrief(workspace).catch(() => brief);
1061
1075
  const liveRepository = liveBrief?.repository ? normalizeRepositoryUrl(liveBrief.repository) : null;
1062
1076
  if (executionContract.repository_fingerprint && liveRepository !== executionContract.repository_fingerprint) {
1063
- await queueTerminal(client, taskId, { action: "fail", body: { error: "Workspace repository changed after dispatch", retryable: true, idempotency_key: `bridge:workspace-mismatch:${active.attemptId}` } });
1077
+ await queueTerminal(client, taskId, { action: "fail", body: { error: "Workspace repository changed after dispatch", retryable: true, failure: bridgeCause("workspace_repository_mismatch", "Workspace repository changed after dispatch"), idempotency_key: `bridge:workspace-mismatch:${active.attemptId}` } });
1064
1078
  console.error(`Assignment ${taskId} preflight failed: Workspace repository changed after dispatch`);
1065
1079
  return;
1066
1080
  }
@@ -1122,13 +1136,13 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
1122
1136
  }
1123
1137
  else {
1124
1138
  if (!startCommit) {
1125
- await queueTerminal(client, taskId, { action: "fail", body: { error: "No start commit for attempt worktree", retryable: true, idempotency_key: `bridge:no-start-commit:${active.attemptId}` } });
1139
+ await queueTerminal(client, taskId, { action: "fail", body: { error: "No start commit for attempt worktree", retryable: true, failure: bridgeCause("agent_execution_failed", "No start commit for attempt worktree"), idempotency_key: `bridge:no-start-commit:${active.attemptId}` } });
1126
1140
  return;
1127
1141
  }
1128
1142
  if (executionContract.requested_base_commit) {
1129
1143
  const resolved = await resolveAttemptStartCommit(workspace, executionContract.requested_base_commit, startCommit);
1130
1144
  if (!resolved) {
1131
- await queueTerminal(client, taskId, { action: "fail", body: { error: "Required base commit is not available in this workspace", retryable: true, idempotency_key: `bridge:base-not-ancestor:${active.attemptId}` } });
1145
+ await queueTerminal(client, taskId, { action: "fail", body: { error: "Required base commit is not available in this workspace", retryable: true, failure: bridgeCause("base_not_ancestor", "Required base commit is not available in this workspace"), idempotency_key: `bridge:base-not-ancestor:${active.attemptId}` } });
1132
1146
  return;
1133
1147
  }
1134
1148
  worktreeStart = resolved;
@@ -1136,7 +1150,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
1136
1150
  else if (liveBrief?.base_commit && liveBrief.base_commit !== startCommit) {
1137
1151
  const reachable = await ensureCommitAvailable(workspace, startCommit).catch(() => false);
1138
1152
  if (!reachable) {
1139
- await queueTerminal(client, taskId, { action: "fail", body: { error: "Delivered head is not available in this workspace", retryable: true, idempotency_key: `bridge:workspace-mismatch:${active.attemptId}` } });
1153
+ await queueTerminal(client, taskId, { action: "fail", body: { error: "Delivered head is not available in this workspace", retryable: true, failure: bridgeCause("agent_execution_failed", "Delivered head is not available in this workspace"), idempotency_key: `bridge:workspace-mismatch:${active.attemptId}` } });
1140
1154
  console.error(`Assignment ${taskId} preflight failed: Delivered head is not available in this workspace`);
1141
1155
  return;
1142
1156
  }
@@ -1153,7 +1167,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
1153
1167
  const message = code === "source_workspace_dirty"
1154
1168
  ? "Source workspace has uncommitted changes; refuse to start an isolated attempt"
1155
1169
  : `Unable to create attempt worktree: ${code}`;
1156
- await queueTerminal(client, taskId, { action: "fail", body: { error: message, retryable: true, idempotency_key: `bridge:worktree:${active.attemptId}:${code}` } });
1170
+ await queueTerminal(client, taskId, { action: "fail", body: { error: message, retryable: true, ...(code === "source_workspace_dirty" ? { failure: bridgeCause("workspace_dirty", message) } : {}), idempotency_key: `bridge:worktree:${active.attemptId}:${code}` } });
1157
1171
  console.error(`Assignment ${taskId} worktree create failed: ${redactSecrets(message)}`);
1158
1172
  return;
1159
1173
  }
@@ -1190,6 +1204,9 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
1190
1204
  body: {
1191
1205
  error: message,
1192
1206
  retryable,
1207
+ failure: retryable
1208
+ ? bridgeCause("agent_execution_failed", message)
1209
+ : bridgeCause(message.startsWith(NORMATIVE_REF_PREFIX) ? "execution_contract_failed" : "execution_unclassified", message),
1193
1210
  idempotency_key: `bridge:normative-ref:${active.attemptId}`,
1194
1211
  },
1195
1212
  });
@@ -1221,7 +1238,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
1221
1238
  // attempt budget when a pre-inversion task row still lacked the stamp.
1222
1239
  const response = await queueTerminal(client, taskId, {
1223
1240
  action: "fail",
1224
- body: { error: detail, retryable: false, idempotency_key: `bridge:fail:${active.attemptId}:missing-class` },
1241
+ body: { error: detail, retryable: false, failure: bridgeCause("execution_unclassified", detail), idempotency_key: `bridge:fail:${active.attemptId}:missing-class` },
1225
1242
  });
1226
1243
  await removeReleasedDiagnosticWorktree(response);
1227
1244
  return;
@@ -1232,7 +1249,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
1232
1249
  console.error(`Assignment ${taskId}: ${detail}`);
1233
1250
  await queueTerminal(client, taskId, {
1234
1251
  action: "fail",
1235
- body: { error: detail, retryable: false, idempotency_key: `bridge:fail:${active.attemptId}:artifact-class` },
1252
+ body: { error: detail, retryable: false, failure: bridgeCause("execution_unclassified", detail), idempotency_key: `bridge:fail:${active.attemptId}:artifact-class` },
1236
1253
  });
1237
1254
  return;
1238
1255
  }
@@ -1243,7 +1260,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
1243
1260
  console.error(`Assignment ${taskId}: ${detail}`);
1244
1261
  const response = await queueTerminal(client, taskId, {
1245
1262
  action: "fail",
1246
- body: { error: detail, retryable: false, idempotency_key: `bridge:fail:${active.attemptId}:class-floor` },
1263
+ body: { error: detail, retryable: false, failure: bridgeCause("execution_unclassified", detail), idempotency_key: `bridge:fail:${active.attemptId}:class-floor` },
1247
1264
  });
1248
1265
  await removeReleasedDiagnosticWorktree(response);
1249
1266
  return;
@@ -1279,6 +1296,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
1279
1296
  body: {
1280
1297
  error: "Diagnostic assignment is missing witnessed failure output",
1281
1298
  retryable: true,
1299
+ failure: bridgeCause("agent_execution_failed", "Diagnostic assignment is missing witnessed failure output"),
1282
1300
  idempotency_key: `bridge:diagnosis-context-missing:${active.attemptId}`,
1283
1301
  },
1284
1302
  });
@@ -1298,6 +1316,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
1298
1316
  body: {
1299
1317
  error: detail,
1300
1318
  retryable: false,
1319
+ failure: bridgeCause("missing_verification", detail),
1301
1320
  idempotency_key: `bridge:missing-verification:${active.attemptId}`,
1302
1321
  },
1303
1322
  });
@@ -1565,6 +1584,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
1565
1584
  resumeSessionId,
1566
1585
  timeoutMs,
1567
1586
  maxDurationMs: workPackage?.max_duration_ms,
1587
+ maxTurns: config.maxTurns,
1568
1588
  model: selection.model,
1569
1589
  fuel,
1570
1590
  fuelSource,
@@ -1707,9 +1727,14 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
1707
1727
  // A vendor allowance refusal is already a precise environment diagnosis. Running repository
1708
1728
  // verification after it can surface an unrelated pre-existing failure and overwrite the lane
1709
1729
  // Hold with a false source-repair brief.
1710
- if (grants.includes("test_run") && !subscriptionExhausted(agentMessage))
1730
+ // T197: the machine's turn cap is such a diagnosis too. Recovering a verification failure
1731
+ // after it would retype the signal as a Bridge verification witness, which classifies as a
1732
+ // transient retry — the one verdict that would run the capped work again at the same price.
1733
+ const turnCapReached = result.signal?.kind === "turn_cap";
1734
+ const recoverVerification = grants.includes("test_run") && !subscriptionExhausted(agentMessage) && !turnCapReached;
1735
+ if (recoverVerification)
1711
1736
  watchdog.phase("verification");
1712
- const verificationDetail = grants.includes("test_run") && !subscriptionExhausted(agentMessage)
1737
+ const verificationDetail = recoverVerification
1713
1738
  ? await captureVerificationFailure({
1714
1739
  workspace: attemptWorkspace,
1715
1740
  verificationCommands: attemptBrief?.verification ?? [],
@@ -1756,6 +1781,9 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
1756
1781
  const response = await queueTerminal(client, taskId, { action: "fail", body: {
1757
1782
  error: message,
1758
1783
  retryable: bindingRequired ? false : retryableAgentFailure(agentMessage),
1784
+ // The abort is Bridge's own reading of the vendor's words, so Bridge names the cause; a spent
1785
+ // allowance, read the same way, wins over it as it always has.
1786
+ ...(modelAborted && !bindingRequired && !verificationDetail ? { failure: bridgeCause("agent_model_abort", message) } : {}),
1759
1787
  ...(providerQuotaRefusal(agentMessage) ? { failure: providerQuotaFailure(agentMessage) } : {}),
1760
1788
  ...(signal ? { signal } : {}),
1761
1789
  idempotency_key: `bridge:fail:${active.attemptId}`,
@@ -1865,6 +1893,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
1865
1893
  body: {
1866
1894
  error: classified.error,
1867
1895
  retryable: classified.retryable,
1896
+ ...(classified.failure ? { failure: classified.failure } : {}),
1868
1897
  idempotency_key: `bridge:claims-git-gate:${active.attemptId}`,
1869
1898
  },
1870
1899
  });
@@ -1881,6 +1910,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
1881
1910
  body: {
1882
1911
  error: classified.error,
1883
1912
  retryable: classified.retryable,
1913
+ ...(classified.failure ? { failure: classified.failure } : {}),
1884
1914
  idempotency_key: `bridge:claims-git-gate:${active.attemptId}`,
1885
1915
  },
1886
1916
  });
@@ -1953,6 +1983,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
1953
1983
  resumeSessionId,
1954
1984
  timeoutMs,
1955
1985
  maxDurationMs: workPackage?.max_duration_ms,
1986
+ maxTurns: config.maxTurns,
1956
1987
  model: selection.model,
1957
1988
  fuel,
1958
1989
  fuelSource,
@@ -2059,6 +2090,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
2059
2090
  body: {
2060
2091
  error: classified.error,
2061
2092
  retryable: classified.retryable,
2093
+ ...(classified.failure ? { failure: classified.failure } : {}),
2062
2094
  idempotency_key: `bridge:claims-git-gate-report:${active.attemptId}`,
2063
2095
  },
2064
2096
  });
@@ -2195,6 +2227,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
2195
2227
  body: {
2196
2228
  error: classified.error,
2197
2229
  retryable: classified.retryable,
2230
+ ...(!verificationFailed && classified.failure ? { failure: classified.failure } : {}),
2198
2231
  ...(verificationFailed ? { signal: {
2199
2232
  phase: "verification",
2200
2233
  witness: "bridge",
@@ -2305,6 +2338,7 @@ async function runLandContinuationTurn(input) {
2305
2338
  resumeSessionId: input.resumeSessionId ?? undefined,
2306
2339
  timeoutMs: input.timeoutMs,
2307
2340
  maxDurationMs: input.maxDurationMs,
2341
+ maxTurns: input.config.maxTurns,
2308
2342
  model: input.selection.model ?? undefined,
2309
2343
  fuel: input.fuel,
2310
2344
  fuelSource: input.fuelSource,
@@ -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({
@@ -64,6 +75,38 @@ export const failureSignalSchema = z.object({
64
75
  */
65
76
  model: z.string().max(200).optional(),
66
77
  });
78
+ /**
79
+ * The causes Bridge names itself — before the agent starts, or while finishing its report — as one
80
+ * table keyed by code, so Bridge sends the envelope and the control plane validates it (K3).
81
+ *
82
+ * Until 2026-09-15 Bridge wrote these causes as sentences chosen so the control plane's regex
83
+ * tables would recognise them; every wording change on either side was a silent reclassification.
84
+ * The texts here are those tables' texts, moved: one definition, read by both twins.
85
+ */
86
+ export const BRIDGE_CAUSES = {
87
+ workspace_repository_mismatch: { class: "environment", disposition: "hold", responsible_party: "computer_operator", message: "This checkout does not match the work package repository.", next_action: "Run Bridge from the matching repository checkout, then recheck readiness." },
88
+ workspace_unavailable: { class: "environment", disposition: "hold", responsible_party: "computer_operator", message: "Bridge cannot read the configured workspace.", next_action: "Restore access to the workspace, then recheck readiness." },
89
+ workspace_head_changed: { class: "environment", disposition: "hold", responsible_party: "computer_operator", message: "The checkout changed after Conduit prepared the assignment.", next_action: "Let the checkout settle, then recheck readiness." },
90
+ base_not_ancestor: { class: "environment", disposition: "hold", responsible_party: "computer_operator", message: "Conduit could not prepare the required project version on this computer.", next_action: "No Git work is required from the product owner. Keep the computer and Bridge online, then try again so Bridge can refresh the project. If the hold returns, the computer operator should check Bridge's repository access." },
91
+ workspace_dirty: { class: "environment", disposition: "hold", responsible_party: "computer_operator", message: "The source workspace has uncommitted changes.", next_action: "Commit, stash, or discard the local changes, then recheck readiness." },
92
+ missing_verification: { class: "environment", disposition: "hold", responsible_party: "computer_operator", message: "This checkout has no verification commands Conduit can run for this package.", next_action: "Conductor will adjust how this package is verified. Open Connect only if the computer itself is unhealthy — you do not need to author test commands." },
93
+ verification_unwitnessable: { class: "environment", disposition: "hold", responsible_party: "computer_operator", message: "The project's verification command runs but prints nothing, so test evidence cannot be witnessed.", next_action: "Fix the verification recipe in the checkout so it prints real output, then recheck readiness. You do not need to author test commands for the agent." },
94
+ execution_contract_failed: { class: "contract", disposition: "rework", responsible_party: "conduit", message: "The agent could not satisfy the approved execution contract.", next_action: "Review the last run and rework the same contract; change the plan only if its authority or outcome is wrong." },
95
+ agent_no_land_commit: { class: "contract", disposition: "rework", responsible_party: "conduit", message: "The agent finished without committing the repository changes this package requires.", next_action: "Retry implement. Conductor will brief a land-focused recovery attempt — you do not need to edit technical constraints." },
96
+ execution_unclassified: { class: "platform", disposition: "stop", responsible_party: "conduit", message: "Conduit could not classify this failure.", next_action: "Ask Conductor to diagnose the stopped run, then retry the same plan or open a revision. You do not need to translate the technical detail into agent constraints." },
97
+ forge_unreachable: { class: "transient", disposition: "retry", responsible_party: "conduit", message: "The connection to the code host failed mid-run.", next_action: "Nothing to repair here — the run stopped on a network fault, not on the work. Retry the same plan." },
98
+ agent_model_abort: { class: "transient", disposition: "retry", responsible_party: "conduit", message: "The agent CLI stopped its own run: it detected a loop, asked for a different model, or the prompt did not fit the context window.", next_action: "Conduit retries on another model of the same intelligence tier. The product owner does not need to author technical constraints." },
99
+ agent_execution_failed: { class: "transient", disposition: "retry", responsible_party: "conduit", message: "The agent run failed before delivery.", next_action: "Conduit will retry with bounded backoff while the authoring budget remains. No technical constraints are required from the owner." },
100
+ };
101
+ /** Is this string a code Bridge may name as a cause? Narrows a caller-supplied reason. */
102
+ export function isBridgeCauseCode(value) {
103
+ return Object.prototype.hasOwnProperty.call(BRIDGE_CAUSES, value);
104
+ }
105
+ /** The envelope for a cause Bridge names by code, with the run's own words as the detail. */
106
+ export function bridgeCause(code, detail) {
107
+ const cause = BRIDGE_CAUSES[code];
108
+ return { code, ...cause, ...(detail?.trim() ? { diagnostic_detail: detail.trim() } : {}) };
109
+ }
67
110
  /** A reset time as a person reads it: "2026-09-10 09:36 UTC". Every reset is stored ISO in UTC. */
68
111
  function resetClock(resetsAt) {
69
112
  return `${resetsAt.slice(0, 16).replace("T", " ")} UTC`;
@@ -110,20 +153,45 @@ export function agentExitSignal(input) {
110
153
  const output = `${input.stderr ?? ""}\n${input.stdout ?? ""}`;
111
154
  const decisiveOutput = output.replace(CLAUDE_UNRECOGNIZED_MODEL_WARNING, "");
112
155
  const rejectedModel = Boolean(input.model) && MODEL_REJECTION.test(decisiveOutput);
156
+ const kind = TOOL_DENIAL.test(decisiveOutput) ? "denied_tool"
157
+ : GATEWAY_REFUSAL.test(decisiveOutput) ? "gateway_refusal"
158
+ : rejectedModel ? "usage_error"
159
+ : VENDOR_TRANSIENT.test(decisiveOutput) ? "vendor_transient"
160
+ : "unknown";
161
+ // T196: the gateway's own words travel as the vendor message, whichever stream carried them.
162
+ // Claude Code prints "API Error: 400 Unknown model alias" on stdout, and the owner's card read
163
+ // "Conduit's gateway refused the agent's request:" followed by nothing (2026-09-15).
164
+ const refusalLine = kind === "gateway_refusal"
165
+ ? decisiveOutput.split("\n").map((line) => line.trim()).find((line) => GATEWAY_REFUSAL.test(line))
166
+ : undefined;
113
167
  return {
114
168
  phase: "agent",
115
169
  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",
170
+ kind,
121
171
  exit_code: input.exit_code ?? null,
122
172
  ...(input.model ? { model: input.model } : {}),
173
+ ...(refusalLine ? { vendor_message: refusalLine.slice(0, 400) } : {}),
123
174
  ...(input.stderr?.trim() ? { stderr_tail: bounded2k(input.stderr) } : {}),
124
175
  ...(input.stdout?.trim() ? { stdout_tail: bounded2k(input.stdout) } : {}),
125
176
  };
126
177
  }
178
+ /**
179
+ * The signal a driver emits when the machine's turn cap, not the work, ended the run.
180
+ *
181
+ * T197: the agent CLI re-sends the whole conversation every turn, so an attempt's token bill grows
182
+ * with the square of its turn count — 33 turns for one paragraph of documentation cost $5.27 on
183
+ * 2026-09-15, and nothing stopped it. The cap stops it; this signal makes the stop a decided
184
+ * environment Hold rather than a failure some later rule retries blind at the same price.
185
+ */
186
+ export function turnCapSignal(input) {
187
+ return {
188
+ phase: "agent",
189
+ witness: "vendor",
190
+ kind: "turn_cap",
191
+ turn_cap: { limit: input.maxTurns, turns: input.turns },
192
+ ...(input.model ? { model: input.model } : {}),
193
+ };
194
+ }
127
195
  /**
128
196
  * The signal Bridge emits when a pinned execution fact no longer matches the workspace.
129
197
  *
@@ -173,6 +241,24 @@ export function verificationScopeConflictSignal(paths) {
173
241
  * Bridge writes it, the classifier reads it, so the two sides agree on one string and not on prose.
174
242
  */
175
243
  export const VERIFICATION_FAILS_ON_BASE = "verification fails on the base commit";
244
+ /**
245
+ * A shell reporting that a command the gate needs does not exist on this computer. Both the prose
246
+ * classifier and the base-gate rule read it: a gate that cannot start is a missing tool, never a
247
+ * red commit.
248
+ */
249
+ 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;
250
+ /** The one envelope for a gate whose tool is absent from the checkout, wherever it was noticed. */
251
+ export function workspaceDependenciesMissingFailure(detail) {
252
+ return {
253
+ code: "workspace_dependencies_missing",
254
+ class: "environment",
255
+ disposition: "hold",
256
+ responsible_party: "conduit",
257
+ message: "The checkout is missing a locked tool required by its verification command.",
258
+ next_action: "Bridge must complete deterministic workspace bootstrap before authoring resumes. No plan or compiler constraints are needed from the owner.",
259
+ diagnostic_detail: detail,
260
+ };
261
+ }
176
262
  /**
177
263
  * The signal Bridge emits when a gate exits non-zero on the base commit, before the agent starts.
178
264
  *
@@ -225,6 +311,8 @@ function quotedVendorText(signal) {
225
311
  * 8. verification run by Bridge, any other cause → the verify class: diagnose and brief a repair.
226
312
  * 9. contract, or a delivery_report phase → the delivery contract codes: rework.
227
313
  * 10. timeout → the turn reached its execution limit: stop.
314
+ * 11. turn_cap → the run reached the computer's per-attempt turn cap: Hold, on the operator, who
315
+ * decides whether the work was looping or the cap is too tight. Never a silent retry.
228
316
  */
229
317
  export function classifyFailureSignal(signal) {
230
318
  // T90: ahead of every other rule, because a stalled attempt has no vendor evidence at all — the
@@ -335,6 +423,14 @@ export function classifyFailureSignal(signal) {
335
423
  && signal.vendor_message === VERIFICATION_FAILS_ON_BASE) {
336
424
  const command = signal.command ?? "the declared gate";
337
425
  const base = signal.base_commit ?? "the base commit";
426
+ const output = [signal.stderr_tail, signal.stdout_tail].filter(Boolean).join("\n");
427
+ // T198: a gate that could not start (`sh: tsc: not found`, exit 127) says nothing about the
428
+ // base commit. It is the computer's checkout that lacks a tool — an environment Hold that
429
+ // clears when the workspace is bootstrapped, not a contract Hold on the author that never
430
+ // clears. runner-2's first claim (2026-09-15) sat on the author for a missing `node_modules`.
431
+ if (signal.exit_code === 127 || WORKSPACE_DEPENDENCIES_MISSING_PATTERN.test(output)) {
432
+ return workspaceDependenciesMissingFailure([`$ ${command}`, `exit ${signal.exit_code ?? "unknown"}`, output].filter(Boolean).join("\n"));
433
+ }
338
434
  return {
339
435
  code: "verification_red_base",
340
436
  class: "contract",
@@ -425,6 +521,22 @@ export function classifyFailureSignal(signal) {
425
521
  diagnostic_detail: signal.stderr_tail,
426
522
  };
427
523
  }
524
+ // T197: the run was stopped by this computer's own turn cap. Retrying it spends the same money to
525
+ // reach the same cap, so the operator decides: raise the cap for work that genuinely needs the
526
+ // turns, or read the run and find why it was looping.
527
+ if (signal.kind === "turn_cap") {
528
+ const limit = signal.turn_cap?.limit ?? 0;
529
+ const spent = signal.turn_cap?.turns ?? 0;
530
+ return {
531
+ code: "agent_turn_cap_reached",
532
+ class: "environment",
533
+ disposition: "hold",
534
+ responsible_party: "computer_operator",
535
+ message: `The run hit this computer's turn cap of ${limit} turns after ${spent}, so Conduit stopped it before it spent more.`,
536
+ 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.`,
537
+ diagnostic_detail: `turn cap ${limit}; turns ${spent}`,
538
+ };
539
+ }
428
540
  return null;
429
541
  }
430
542
  /**
@@ -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.104",
3
+ "version": "0.16.106",
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": {