@miraland-labs/conduit-bridge 0.16.103 → 0.16.105
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +20 -3
- package/dist/driver.js +16 -3
- package/dist/ensure-test-evidence.js +13 -3
- package/dist/execution-budget.js +45 -0
- package/dist/execution.js +13 -4
- package/dist/failure-signal.js +86 -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
|
}
|
|
@@ -45,14 +45,24 @@ export function pickVerificationCommand(commands) {
|
|
|
45
45
|
* a declared gate neither dry-run nor witnessed unless an acceptance criterion happened to name it.
|
|
46
46
|
* So the declared gates run beside the aggregate, in declared order, de-duplicated.
|
|
47
47
|
*/
|
|
48
|
-
export function verificationEvidenceCommands(commands, declared = []) {
|
|
48
|
+
export function verificationEvidenceCommands(commands, declared = [], acceptance = null) {
|
|
49
49
|
const bounded = [...new Set(commands
|
|
50
50
|
.map((command) => command.trim())
|
|
51
51
|
.filter((command) => command && isRunnableVerificationCommand(command)))];
|
|
52
|
+
const declaredGates = bounded.filter((command) => declared.some((line) => line.trim() === command));
|
|
53
|
+
// T191: the gate is what the project declared for itself plus what the contract will be judged
|
|
54
|
+
// on — the commands its acceptance criteria name. Every other discovered script (this repository's
|
|
55
|
+
// ten-minute `npm run verify` for a docs change judged on `npm run typecheck`) is a candidate the
|
|
56
|
+
// planner may name, not a gate the Bridge runs unasked. When the criteria name no command the
|
|
57
|
+
// discovered rule below decides, as before (T31: a declared gate runs beside the aggregate).
|
|
58
|
+
if (acceptance !== null) {
|
|
59
|
+
const named = acceptanceVerificationCommands([...acceptance], commands).commands;
|
|
60
|
+
if (named.length)
|
|
61
|
+
return [...new Set([...declaredGates, ...named])];
|
|
62
|
+
}
|
|
52
63
|
const aggregate = bounded.find((command) => /^(?:npm run|pnpm|yarn) verify$|^make check$/.test(command));
|
|
53
64
|
if (!aggregate)
|
|
54
65
|
return bounded;
|
|
55
|
-
const declaredGates = bounded.filter((command) => declared.some((line) => line.trim() === command));
|
|
56
66
|
return [...new Set([...declaredGates, aggregate])];
|
|
57
67
|
}
|
|
58
68
|
/**
|
|
@@ -475,7 +485,7 @@ export async function ensureTestEvidence(input) {
|
|
|
475
485
|
// The project's gate first, then every bounded command the criteria name that the workspace
|
|
476
486
|
// authorizes. Each is witnessed on its own, so the reviewer sees the criterion's own proof.
|
|
477
487
|
const commands = [...new Set([
|
|
478
|
-
...verificationEvidenceCommands(input.verificationCommands, input.declaredVerificationCommands ?? []),
|
|
488
|
+
...verificationEvidenceCommands(input.verificationCommands, input.declaredVerificationCommands ?? [], acceptance),
|
|
479
489
|
...named.commands,
|
|
480
490
|
])];
|
|
481
491
|
if (commands.length === 0) {
|
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
|
@@ -1285,7 +1285,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1285
1285
|
await removeReleasedDiagnosticWorktree(response);
|
|
1286
1286
|
return;
|
|
1287
1287
|
}
|
|
1288
|
-
const evidenceCommands = verificationEvidenceCommands(attemptBrief?.verification ?? [], attemptBrief?.declared_verification ?? []);
|
|
1288
|
+
const evidenceCommands = verificationEvidenceCommands(attemptBrief?.verification ?? [], attemptBrief?.declared_verification ?? [], spec.acceptance ?? []);
|
|
1289
1289
|
// Ahead of the dry run and of the model: the contract requires test evidence and this worktree
|
|
1290
1290
|
// offers no command that can witness it. Every fact is known here — the required evidence, the
|
|
1291
1291
|
// test_run grant, the attempt worktree's own gates — and the same question was asked only after
|
|
@@ -1323,6 +1323,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1323
1323
|
workspace: attemptWorkspace,
|
|
1324
1324
|
verificationCommands: attemptBrief?.verification ?? [],
|
|
1325
1325
|
declaredVerificationCommands: attemptBrief?.declared_verification ?? [],
|
|
1326
|
+
acceptance: spec.acceptance ?? [],
|
|
1326
1327
|
changeScope: spec.change_scope ?? [],
|
|
1327
1328
|
});
|
|
1328
1329
|
if (dryRun.ranNothing) {
|
|
@@ -1564,6 +1565,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1564
1565
|
resumeSessionId,
|
|
1565
1566
|
timeoutMs,
|
|
1566
1567
|
maxDurationMs: workPackage?.max_duration_ms,
|
|
1568
|
+
maxTurns: config.maxTurns,
|
|
1567
1569
|
model: selection.model,
|
|
1568
1570
|
fuel,
|
|
1569
1571
|
fuelSource,
|
|
@@ -1706,9 +1708,14 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1706
1708
|
// A vendor allowance refusal is already a precise environment diagnosis. Running repository
|
|
1707
1709
|
// verification after it can surface an unrelated pre-existing failure and overwrite the lane
|
|
1708
1710
|
// Hold with a false source-repair brief.
|
|
1709
|
-
|
|
1711
|
+
// T197: the machine's turn cap is such a diagnosis too. Recovering a verification failure
|
|
1712
|
+
// after it would retype the signal as a Bridge verification witness, which classifies as a
|
|
1713
|
+
// transient retry — the one verdict that would run the capped work again at the same price.
|
|
1714
|
+
const turnCapReached = result.signal?.kind === "turn_cap";
|
|
1715
|
+
const recoverVerification = grants.includes("test_run") && !subscriptionExhausted(agentMessage) && !turnCapReached;
|
|
1716
|
+
if (recoverVerification)
|
|
1710
1717
|
watchdog.phase("verification");
|
|
1711
|
-
const verificationDetail =
|
|
1718
|
+
const verificationDetail = recoverVerification
|
|
1712
1719
|
? await captureVerificationFailure({
|
|
1713
1720
|
workspace: attemptWorkspace,
|
|
1714
1721
|
verificationCommands: attemptBrief?.verification ?? [],
|
|
@@ -1952,6 +1959,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1952
1959
|
resumeSessionId,
|
|
1953
1960
|
timeoutMs,
|
|
1954
1961
|
maxDurationMs: workPackage?.max_duration_ms,
|
|
1962
|
+
maxTurns: config.maxTurns,
|
|
1955
1963
|
model: selection.model,
|
|
1956
1964
|
fuel,
|
|
1957
1965
|
fuelSource,
|
|
@@ -2304,6 +2312,7 @@ async function runLandContinuationTurn(input) {
|
|
|
2304
2312
|
resumeSessionId: input.resumeSessionId ?? undefined,
|
|
2305
2313
|
timeoutMs: input.timeoutMs,
|
|
2306
2314
|
maxDurationMs: input.maxDurationMs,
|
|
2315
|
+
maxTurns: input.config.maxTurns,
|
|
2307
2316
|
model: input.selection.model ?? undefined,
|
|
2308
2317
|
fuel: input.fuel,
|
|
2309
2318
|
fuelSource: input.fuelSource,
|
|
@@ -2791,7 +2800,7 @@ export function verificationScopeConflictDetail(paths, changeScope) {
|
|
|
2791
2800
|
* discarded before the agent starts, so the agent always begins in the tree of the base commit.
|
|
2792
2801
|
*/
|
|
2793
2802
|
export async function verificationScopeDryRun(input) {
|
|
2794
|
-
const commands = verificationEvidenceCommands(input.verificationCommands, input.declaredVerificationCommands ?? []);
|
|
2803
|
+
const commands = verificationEvidenceCommands(input.verificationCommands, input.declaredVerificationCommands ?? [], input.acceptance ?? null);
|
|
2795
2804
|
if (!commands.length)
|
|
2796
2805
|
return { ranNothing: null, redBase: null, outsideScope: [] };
|
|
2797
2806
|
const run = input.runCommand ?? runBoundedVerificationCommand;
|
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({
|
|
@@ -110,20 +121,45 @@ export function agentExitSignal(input) {
|
|
|
110
121
|
const output = `${input.stderr ?? ""}\n${input.stdout ?? ""}`;
|
|
111
122
|
const decisiveOutput = output.replace(CLAUDE_UNRECOGNIZED_MODEL_WARNING, "");
|
|
112
123
|
const rejectedModel = Boolean(input.model) && MODEL_REJECTION.test(decisiveOutput);
|
|
124
|
+
const kind = TOOL_DENIAL.test(decisiveOutput) ? "denied_tool"
|
|
125
|
+
: GATEWAY_REFUSAL.test(decisiveOutput) ? "gateway_refusal"
|
|
126
|
+
: rejectedModel ? "usage_error"
|
|
127
|
+
: VENDOR_TRANSIENT.test(decisiveOutput) ? "vendor_transient"
|
|
128
|
+
: "unknown";
|
|
129
|
+
// T196: the gateway's own words travel as the vendor message, whichever stream carried them.
|
|
130
|
+
// Claude Code prints "API Error: 400 Unknown model alias" on stdout, and the owner's card read
|
|
131
|
+
// "Conduit's gateway refused the agent's request:" followed by nothing (2026-09-15).
|
|
132
|
+
const refusalLine = kind === "gateway_refusal"
|
|
133
|
+
? decisiveOutput.split("\n").map((line) => line.trim()).find((line) => GATEWAY_REFUSAL.test(line))
|
|
134
|
+
: undefined;
|
|
113
135
|
return {
|
|
114
136
|
phase: "agent",
|
|
115
137
|
witness: "agent",
|
|
116
|
-
kind
|
|
117
|
-
: GATEWAY_REFUSAL.test(decisiveOutput) ? "gateway_refusal"
|
|
118
|
-
: rejectedModel ? "usage_error"
|
|
119
|
-
: VENDOR_TRANSIENT.test(decisiveOutput) ? "vendor_transient"
|
|
120
|
-
: "unknown",
|
|
138
|
+
kind,
|
|
121
139
|
exit_code: input.exit_code ?? null,
|
|
122
140
|
...(input.model ? { model: input.model } : {}),
|
|
141
|
+
...(refusalLine ? { vendor_message: refusalLine.slice(0, 400) } : {}),
|
|
123
142
|
...(input.stderr?.trim() ? { stderr_tail: bounded2k(input.stderr) } : {}),
|
|
124
143
|
...(input.stdout?.trim() ? { stdout_tail: bounded2k(input.stdout) } : {}),
|
|
125
144
|
};
|
|
126
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* The signal a driver emits when the machine's turn cap, not the work, ended the run.
|
|
148
|
+
*
|
|
149
|
+
* T197: the agent CLI re-sends the whole conversation every turn, so an attempt's token bill grows
|
|
150
|
+
* with the square of its turn count — 33 turns for one paragraph of documentation cost $5.27 on
|
|
151
|
+
* 2026-09-15, and nothing stopped it. The cap stops it; this signal makes the stop a decided
|
|
152
|
+
* environment Hold rather than a failure some later rule retries blind at the same price.
|
|
153
|
+
*/
|
|
154
|
+
export function turnCapSignal(input) {
|
|
155
|
+
return {
|
|
156
|
+
phase: "agent",
|
|
157
|
+
witness: "vendor",
|
|
158
|
+
kind: "turn_cap",
|
|
159
|
+
turn_cap: { limit: input.maxTurns, turns: input.turns },
|
|
160
|
+
...(input.model ? { model: input.model } : {}),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
127
163
|
/**
|
|
128
164
|
* The signal Bridge emits when a pinned execution fact no longer matches the workspace.
|
|
129
165
|
*
|
|
@@ -173,6 +209,24 @@ export function verificationScopeConflictSignal(paths) {
|
|
|
173
209
|
* Bridge writes it, the classifier reads it, so the two sides agree on one string and not on prose.
|
|
174
210
|
*/
|
|
175
211
|
export const VERIFICATION_FAILS_ON_BASE = "verification fails on the base commit";
|
|
212
|
+
/**
|
|
213
|
+
* A shell reporting that a command the gate needs does not exist on this computer. Both the prose
|
|
214
|
+
* classifier and the base-gate rule read it: a gate that cannot start is a missing tool, never a
|
|
215
|
+
* red commit.
|
|
216
|
+
*/
|
|
217
|
+
export const WORKSPACE_DEPENDENCIES_MISSING_PATTERN = /(?:^|\n)\s*(?:\/(?:usr\/)?bin\/)?(?:ba|z|da)?sh:\s*(?:(?:\d+:\s*)?[^:\n]{1,120}:\s*(?:command\s+)?not found|command not found:\s*[^\n]+)\s*(?=\n|$)|(?:^|\n)\s*'[^'\n]+' is not recognized as an internal or external command/im;
|
|
218
|
+
/** The one envelope for a gate whose tool is absent from the checkout, wherever it was noticed. */
|
|
219
|
+
export function workspaceDependenciesMissingFailure(detail) {
|
|
220
|
+
return {
|
|
221
|
+
code: "workspace_dependencies_missing",
|
|
222
|
+
class: "environment",
|
|
223
|
+
disposition: "hold",
|
|
224
|
+
responsible_party: "conduit",
|
|
225
|
+
message: "The checkout is missing a locked tool required by its verification command.",
|
|
226
|
+
next_action: "Bridge must complete deterministic workspace bootstrap before authoring resumes. No plan or compiler constraints are needed from the owner.",
|
|
227
|
+
diagnostic_detail: detail,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
176
230
|
/**
|
|
177
231
|
* The signal Bridge emits when a gate exits non-zero on the base commit, before the agent starts.
|
|
178
232
|
*
|
|
@@ -225,6 +279,8 @@ function quotedVendorText(signal) {
|
|
|
225
279
|
* 8. verification run by Bridge, any other cause → the verify class: diagnose and brief a repair.
|
|
226
280
|
* 9. contract, or a delivery_report phase → the delivery contract codes: rework.
|
|
227
281
|
* 10. timeout → the turn reached its execution limit: stop.
|
|
282
|
+
* 11. turn_cap → the run reached the computer's per-attempt turn cap: Hold, on the operator, who
|
|
283
|
+
* decides whether the work was looping or the cap is too tight. Never a silent retry.
|
|
228
284
|
*/
|
|
229
285
|
export function classifyFailureSignal(signal) {
|
|
230
286
|
// T90: ahead of every other rule, because a stalled attempt has no vendor evidence at all — the
|
|
@@ -335,6 +391,14 @@ export function classifyFailureSignal(signal) {
|
|
|
335
391
|
&& signal.vendor_message === VERIFICATION_FAILS_ON_BASE) {
|
|
336
392
|
const command = signal.command ?? "the declared gate";
|
|
337
393
|
const base = signal.base_commit ?? "the base commit";
|
|
394
|
+
const output = [signal.stderr_tail, signal.stdout_tail].filter(Boolean).join("\n");
|
|
395
|
+
// T198: a gate that could not start (`sh: tsc: not found`, exit 127) says nothing about the
|
|
396
|
+
// base commit. It is the computer's checkout that lacks a tool — an environment Hold that
|
|
397
|
+
// clears when the workspace is bootstrapped, not a contract Hold on the author that never
|
|
398
|
+
// clears. runner-2's first claim (2026-09-15) sat on the author for a missing `node_modules`.
|
|
399
|
+
if (signal.exit_code === 127 || WORKSPACE_DEPENDENCIES_MISSING_PATTERN.test(output)) {
|
|
400
|
+
return workspaceDependenciesMissingFailure([`$ ${command}`, `exit ${signal.exit_code ?? "unknown"}`, output].filter(Boolean).join("\n"));
|
|
401
|
+
}
|
|
338
402
|
return {
|
|
339
403
|
code: "verification_red_base",
|
|
340
404
|
class: "contract",
|
|
@@ -425,6 +489,22 @@ export function classifyFailureSignal(signal) {
|
|
|
425
489
|
diagnostic_detail: signal.stderr_tail,
|
|
426
490
|
};
|
|
427
491
|
}
|
|
492
|
+
// T197: the run was stopped by this computer's own turn cap. Retrying it spends the same money to
|
|
493
|
+
// reach the same cap, so the operator decides: raise the cap for work that genuinely needs the
|
|
494
|
+
// turns, or read the run and find why it was looping.
|
|
495
|
+
if (signal.kind === "turn_cap") {
|
|
496
|
+
const limit = signal.turn_cap?.limit ?? 0;
|
|
497
|
+
const spent = signal.turn_cap?.turns ?? 0;
|
|
498
|
+
return {
|
|
499
|
+
code: "agent_turn_cap_reached",
|
|
500
|
+
class: "environment",
|
|
501
|
+
disposition: "hold",
|
|
502
|
+
responsible_party: "computer_operator",
|
|
503
|
+
message: `The run hit this computer's turn cap of ${limit} turns after ${spent}, so Conduit stopped it before it spent more.`,
|
|
504
|
+
next_action: `Read the run: work that needs more turns gets a larger cap with \`npx @miraland-labs/conduit-bridge@latest runner --max-turns <n>\` on this computer, and a run that was looping needs the package narrowed. Then Recheck.`,
|
|
505
|
+
diagnostic_detail: `turn cap ${limit}; turns ${spent}`,
|
|
506
|
+
};
|
|
507
|
+
}
|
|
428
508
|
return null;
|
|
429
509
|
}
|
|
430
510
|
/**
|
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.105",
|
|
4
4
|
"description": "Conduit Bridge CLI \u2014 join, connect, disconnect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Pi / Kiro / Antigravity / Grok Build agents for a Conduit organization",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|