@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 +20 -3
- package/dist/driver.js +16 -3
- package/dist/execution-budget.js +45 -0
- package/dist/execution.js +49 -15
- package/dist/failure-signal.js +118 -6
- package/dist/investigation.js +1 -0
- package/dist/preflight.js +5 -2
- package/package.json +1 -1
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
|
|
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
|
}
|
package/dist/execution-budget.js
CHANGED
|
@@ -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
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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 =
|
|
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,
|
package/dist/failure-signal.js
CHANGED
|
@@ -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
|
|
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
|
/**
|
package/dist/investigation.js
CHANGED
|
@@ -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),
|
|
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.
|
|
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": {
|