@miraland-labs/conduit-bridge 0.16.25 → 0.16.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -3
- package/dist/brief.js +36 -1
- package/dist/cli.js +47 -10
- package/dist/driver.js +32 -10
- package/dist/drivers.js +28 -0
- package/dist/execution-budget.js +77 -0
- package/dist/execution-class.js +9 -2
- package/dist/execution-facts.js +2 -0
- package/dist/execution.js +79 -2
- package/dist/ops.js +27 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Local Bridge CLI for [Conduit](https://github.com/miralandlabs/conduit). Connects a computer to one organization, claims work, and drives a local agent.
|
|
4
4
|
|
|
5
|
-
**Package version:** `0.16.
|
|
5
|
+
**Package version:** `0.16.27` — work-package execution budgets reach every agent turn, are bounded by the machine ceiling, and expose their effective source for operations. Local-fuel provider refusals make the affected lane unavailable instead of repeatedly spending attempts; an operator who changes the lane's account or plan can invalidate that observation with `ops quota-clear <driver>`. Bridge discovers bounded repository verification commands, including Make targets `test`, `check`, `verify`, `replay`, `typecheck`, `lint`, and `build`. `ops disconnect` asks before it removes the runner service and clears credentials; pass `--yes` to skip the question. `ops.env` is enrollment/bootstrap defaults only: an explicit workspace does not inherit an old repository, and project switches do not rewrite global project state. `ops enroll` exchanges a single-use token, registers the machine, binds project affinity, provisions fuel keys, brings detected drivers online, and starts the runner in one command. On Linux, `ops install` refuses any effective systemd drop-in. A switch intent remains queued until the target runner heartbeat proves Bound. After every Bridge publish, operators must re-run `ops install` (LaunchAgent pins an absolute `cli.js`).
|
|
6
6
|
|
|
7
7
|
## Prerequisites
|
|
8
8
|
|
|
@@ -48,6 +48,7 @@ Later:
|
|
|
48
48
|
```bash
|
|
49
49
|
npx @miraland-labs/conduit-bridge@latest ops online cursor
|
|
50
50
|
npx @miraland-labs/conduit-bridge@latest ops offline cursor
|
|
51
|
+
npx @miraland-labs/conduit-bridge@latest ops quota-clear cursor # after changing its account or plan
|
|
51
52
|
npx @miraland-labs/conduit-bridge@latest ops disconnect # then join again to change organizations
|
|
52
53
|
```
|
|
53
54
|
|
|
@@ -78,8 +79,12 @@ npx @miraland-labs/conduit-bridge disconnect --yes
|
|
|
78
79
|
Before agent spend, Bridge checks that the workspace is readable, matches the expected repository,
|
|
79
80
|
and is clean; each online driver must be installed, compatible with its configured fuel, and signed
|
|
80
81
|
in when local fuel is used. The same bounded report is sent on heartbeat so Conduit can place an
|
|
81
|
-
environment failure on Hold without consuming an execution attempt.
|
|
82
|
-
|
|
82
|
+
environment failure on Hold without consuming an execution attempt. A local provider refusal is
|
|
83
|
+
persisted and excludes only that lane; `online`/`offline` does not erase it. `ops doctor` proves CLI
|
|
84
|
+
installation and authentication, not remaining vendor allowance. After deliberately changing an
|
|
85
|
+
account or plan, run `ops quota-clear <driver>` to clear the old observation; this does not probe the
|
|
86
|
+
provider, and the next real claim records another refusal if the new allowance is still unavailable.
|
|
87
|
+
Fix the named issue and let a fresh heartbeat land before choosing **Recheck** in Activity.
|
|
83
88
|
|
|
84
89
|
Conduit labels an exact driver/CLI version **Certified** only after two consecutive real canaries:
|
|
85
90
|
repository delivery with PR/evidence and a successful rework cycle. All other lanes are
|
package/dist/brief.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readdir, readFile, stat } from "node:fs/promises";
|
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
|
+
import { isBoundedVerificationCommand } from "./execution-class.js";
|
|
5
6
|
const execFileAsync = promisify(execFile);
|
|
6
7
|
const MANIFESTS = [
|
|
7
8
|
"package.json", "wrangler.jsonc", "wrangler.toml", "tsconfig.json",
|
|
@@ -10,6 +11,7 @@ const MANIFESTS = [
|
|
|
10
11
|
];
|
|
11
12
|
/** Prefer test before typecheck/lint so discovery order matches what pickVerificationCommand wants. */
|
|
12
13
|
const VERIFICATION_SCRIPTS = ["test", "verify", "typecheck", "lint", "build"];
|
|
14
|
+
const MAKE_VERIFICATION_TARGETS = ["test", "check", "verify", "replay", "typecheck", "lint", "build"];
|
|
13
15
|
const EXCLUDED_DIRECTORIES = new Set(["node_modules", "dist", "build", "target", "coverage", ".venv", "venv"]);
|
|
14
16
|
export async function buildWorkspaceBrief(workspace) {
|
|
15
17
|
const entries = await readdir(workspace, { withFileTypes: true });
|
|
@@ -197,8 +199,40 @@ async function gitDirectories(workspace) {
|
|
|
197
199
|
}
|
|
198
200
|
}
|
|
199
201
|
/** Exported for tests — discovers bounded verification commands from workspace manifests. */
|
|
202
|
+
/**
|
|
203
|
+
* Commands a project declares for itself, in `.conduit/verification` — one per line, `#` comments
|
|
204
|
+
* ignored.
|
|
205
|
+
*
|
|
206
|
+
* Discovery below recognises the conventional names (`test`, `check`, `npm run verify`, …). A project
|
|
207
|
+
* whose gate is named anything else had no way to say so, and an acceptance criterion naming that
|
|
208
|
+
* gate was unrunnable and therefore unverifiable — which is how four agent attempts died on a target
|
|
209
|
+
* that was declared in the very Makefile Bridge had already read.
|
|
210
|
+
*
|
|
211
|
+
* The file is repository content and therefore UNTRUSTED. Every line must pass the same shape check
|
|
212
|
+
* every discovered command passes, so a declaration can name a gate but can never smuggle a command:
|
|
213
|
+
* one tool, one identifier, no shell metacharacters. That check is the boundary, not the name — a
|
|
214
|
+
* recipe body was always repository-authored.
|
|
215
|
+
*/
|
|
216
|
+
const MAX_DECLARED_VERIFICATION = 8;
|
|
217
|
+
export function parseDeclaredVerification(text) {
|
|
218
|
+
const declared = [];
|
|
219
|
+
for (const raw of text.split("\n")) {
|
|
220
|
+
const line = raw.split("#")[0].trim();
|
|
221
|
+
if (!line || !isBoundedVerificationCommand(line))
|
|
222
|
+
continue;
|
|
223
|
+
if (!declared.includes(line))
|
|
224
|
+
declared.push(line);
|
|
225
|
+
if (declared.length >= MAX_DECLARED_VERIFICATION)
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
return declared;
|
|
229
|
+
}
|
|
200
230
|
export async function discoverVerificationCommands(workspace, files) {
|
|
201
231
|
const commands = [];
|
|
232
|
+
try {
|
|
233
|
+
commands.push(...parseDeclaredVerification(await readFile(join(workspace, ".conduit", "verification"), "utf8")));
|
|
234
|
+
}
|
|
235
|
+
catch { /* no declaration, or unreadable: discovery below still applies */ }
|
|
202
236
|
if (files.has("package.json")) {
|
|
203
237
|
try {
|
|
204
238
|
const manifest = JSON.parse(await readFile(join(workspace, "package.json"), "utf8"));
|
|
@@ -209,9 +243,10 @@ export async function discoverVerificationCommands(workspace, files) {
|
|
|
209
243
|
if (files.has("Makefile")) {
|
|
210
244
|
try {
|
|
211
245
|
const makefile = await readFile(join(workspace, "Makefile"), "utf8");
|
|
212
|
-
for (const target of
|
|
246
|
+
for (const target of MAKE_VERIFICATION_TARGETS) {
|
|
213
247
|
if (new RegExp(`^${target}\\s*:`, "m").test(makefile))
|
|
214
248
|
commands.push(`make ${target}`);
|
|
249
|
+
}
|
|
215
250
|
}
|
|
216
251
|
catch { /* unreadable Makefiles do not broaden execution */ }
|
|
217
252
|
}
|
package/dist/cli.js
CHANGED
|
@@ -8,12 +8,13 @@ import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
|
|
8
8
|
import { stdin as input, stdout as output } from "node:process";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
import { ConduitClient, ConduitRequestError } from "./client.js";
|
|
11
|
-
import { BRIDGE_LEASE_CAPACITY, BRIDGE_MAX_LEASE_CAPACITY, clampLeaseCapacity, clearLocalConnection, clearPendingConnection, loadConfig, loadConfigIfPresent, loadOrCreateInstallationId, loadPendingConnection, redactSecrets, saveConfig, saveConfigPrefs, savePendingConnection, suggestMachineName, } from "./config.js";
|
|
11
|
+
import { BRIDGE_LEASE_CAPACITY, BRIDGE_MAX_LEASE_CAPACITY, clampLeaseCapacity, clearLocalConnection, clearPendingConnection, loadConfig, loadConfigIfPresent, loadOrCreateInstallationId, loadPendingConnection, redactSecrets, saveConfig, saveConfigPrefs, saveDriverQuota, savePendingConnection, suggestMachineName, } from "./config.js";
|
|
12
12
|
import { runMcp } from "./mcp.js";
|
|
13
13
|
import { detectInstalledClients, localFuelOnlyClients, suggestFuelSource } from "./detect.js";
|
|
14
14
|
import { DRIVERS } from "./driver.js";
|
|
15
|
-
import { driversHeartbeatReport, driverLabel, heartbeatStatusForDrivers, isSupportedDriverId, listDriverLanes, onlineDriverIds, seedDriversFromDetection, seedDriverLanes, setDriverFuel, setDriversOnline, } from "./drivers.js";
|
|
15
|
+
import { driversHeartbeatReport, driverLabel, heartbeatStatusForDrivers, isSupportedDriverId, listDriverLanes, onlineDriverIds, seedDriversFromDetection, seedDriverLanes, setDriverFuel, setDriversOnline, laneDispatchBlock, laneStatuses, } from "./drivers.js";
|
|
16
16
|
import { buildWorkspaceBrief } from "./brief.js";
|
|
17
|
+
import { parseAgentTimeoutMinutes } from "./execution-budget.js";
|
|
17
18
|
import { BOOTSTRAP_RESULTS, buildSovereignExecutionFacts } from "./execution-facts.js";
|
|
18
19
|
import { ensureCheckout } from "./checkout.js";
|
|
19
20
|
import { buildOnShiftIntentProof, maybeApplyOnShiftIntent } from "./on-shift-apply.js";
|
|
@@ -382,7 +383,7 @@ async function initOps() {
|
|
|
382
383
|
console.log(`Config file (separate): ${envPath}`);
|
|
383
384
|
console.log("");
|
|
384
385
|
console.log("Works the same on macOS, Linux, and Windows via:");
|
|
385
|
-
console.log(` ${bridgeUsage("ops", "<connect|install|switch|online|offline|status|doctor|disconnect|uninstall>")}`);
|
|
386
|
+
console.log(` ${bridgeUsage("ops", "<connect|install|switch|online|offline|quota-clear|status|doctor|disconnect|uninstall>")}`);
|
|
386
387
|
console.log("");
|
|
387
388
|
console.log("1. Set enrollment/bootstrap defaults once:");
|
|
388
389
|
console.log(` Create folder: ${configDir}`);
|
|
@@ -401,12 +402,12 @@ async function initOps() {
|
|
|
401
402
|
console.log("3. Install runner:");
|
|
402
403
|
console.log(` ${pathJoin(target, "install.sh")}`);
|
|
403
404
|
}
|
|
404
|
-
console.log("Later: status · online · offline · disconnect (same names .sh / .cmd)");
|
|
405
|
+
console.log("Later: status · online · offline · quota-clear · disconnect (same names .sh / .cmd)");
|
|
405
406
|
}
|
|
406
407
|
async function opsCommand() {
|
|
407
408
|
const verb = process.argv[3];
|
|
408
409
|
if (!verb || !OPS_VERBS.includes(verb)) {
|
|
409
|
-
throw new Error(`Usage: ${bridgeUsage("ops", "<connect|install|switch|online|offline|status|doctor|disconnect|uninstall>", "[driver…]")} (install also takes --workspace <path> [--repo <url>])`);
|
|
410
|
+
throw new Error(`Usage: ${bridgeUsage("ops", "<connect|install|switch|online|offline|quota-clear|status|doctor|disconnect|uninstall>", "[driver…]")} (install also takes --workspace <path> [--repo <url>])`);
|
|
410
411
|
}
|
|
411
412
|
await runOps(verb, process.argv.slice(4));
|
|
412
413
|
}
|
|
@@ -433,12 +434,21 @@ async function driversCommand() {
|
|
|
433
434
|
return;
|
|
434
435
|
}
|
|
435
436
|
console.log(`Computer ${config.machineId} — shared capacity ${config.leaseCapacity} (sum across online lanes, not per IDE)`);
|
|
436
|
-
|
|
437
|
-
|
|
437
|
+
// Allowance is printed beside state because the two disagree exactly when it matters: a lane can
|
|
438
|
+
// be installed, logged in and online while the provider is refusing it, and reporting only the
|
|
439
|
+
// first three reads as "Ready" for a lane dispatch is skipping.
|
|
440
|
+
for (const lane of laneStatuses(config)) {
|
|
441
|
+
const when = lane.observed_at ? ` observed=${lane.observed_at}` : "";
|
|
442
|
+
const reset = lane.resets_at ? ` resets=${lane.resets_at}` : lane.allowance === "unavailable" ? " resets=unknown" : "";
|
|
443
|
+
console.log(` ${lane.id.padEnd(14)} ${lane.state.padEnd(8)} fuel=${lane.fuel} allowance=${lane.allowance}${when}${reset} (${lane.label})`);
|
|
438
444
|
}
|
|
445
|
+
const blocked = laneDispatchBlock(laneStatuses(config));
|
|
446
|
+
console.log(blocked
|
|
447
|
+
? `Dispatch: BLOCKED — ${blocked}.`
|
|
448
|
+
: `Dispatch: ready — eligible ${laneStatuses(config).filter((lane) => lane.eligible).map((lane) => lane.id).join(", ")}.`);
|
|
439
449
|
const online = onlineDriverIds(config);
|
|
440
450
|
console.log(online.length
|
|
441
|
-
? `
|
|
451
|
+
? `Toggle a lane: ${bridgeUsage("drivers", "online|offline", "<id…>")}`
|
|
442
452
|
: `All offline — no new claims. Bring one online: ${bridgeUsage("drivers", "online", AGENT_PLACEHOLDER)}`);
|
|
443
453
|
return;
|
|
444
454
|
}
|
|
@@ -458,6 +468,23 @@ async function driversCommand() {
|
|
|
458
468
|
console.log(`Now online: ${onlineDriverIds(config).join(", ") || "(none)"}`);
|
|
459
469
|
return;
|
|
460
470
|
}
|
|
471
|
+
if (sub === "quota") {
|
|
472
|
+
const action = process.argv[4]?.trim();
|
|
473
|
+
const id = process.argv[5]?.trim();
|
|
474
|
+
if (action !== "clear" || !id || process.argv[6]) {
|
|
475
|
+
throw new Error(`Usage: ${bridgeUsage("drivers", "quota", "clear", AGENT_PLACEHOLDER)}`);
|
|
476
|
+
}
|
|
477
|
+
if (!isSupportedDriverId(id))
|
|
478
|
+
throw new Error(`Unknown driver: ${id}. Available: ${Object.keys(DRIVERS).join(", ")}`);
|
|
479
|
+
if (!listDriverLanes(config).some((lane) => lane.id === id))
|
|
480
|
+
throw new Error(`Driver lane is not registered: ${id}`);
|
|
481
|
+
// A login or plan change happens outside Bridge, so only the operator can invalidate the last
|
|
482
|
+
// observed refusal. This intentionally does not toggle the lane or synthesize a successful probe:
|
|
483
|
+
// the next real claim is the proof, and will record another refusal if allowance is still spent.
|
|
484
|
+
await saveDriverQuota(id, undefined);
|
|
485
|
+
console.log(`Cleared recorded provider allowance refusal for ${id}. The next claim will recheck this lane.`);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
461
488
|
if (sub === "fuel") {
|
|
462
489
|
const id = process.argv[4]?.trim();
|
|
463
490
|
const mode = process.argv[5];
|
|
@@ -470,7 +497,7 @@ async function driversCommand() {
|
|
|
470
497
|
console.log(`${driverLabel(id)} lane fuel set to ${mode}`);
|
|
471
498
|
return;
|
|
472
499
|
}
|
|
473
|
-
throw new Error(`Usage: ${bridgeUsage("drivers", "[list|online|offline|fuel]", "…")}`);
|
|
500
|
+
throw new Error(`Usage: ${bridgeUsage("drivers", "[list|online|offline|fuel|quota]", "…")}`);
|
|
474
501
|
}
|
|
475
502
|
async function runner() {
|
|
476
503
|
applyRunnerToolPath();
|
|
@@ -503,7 +530,17 @@ async function runner() {
|
|
|
503
530
|
}
|
|
504
531
|
const brief = workspace ? await buildWorkspaceBrief(workspace) : null;
|
|
505
532
|
const intervalMs = values.interval ? Math.max(5_000, Number(values.interval)) : 15_000;
|
|
506
|
-
|
|
533
|
+
// Validated at startup rather than at use. Number("abc") is NaN, NaN is not nullish, and the
|
|
534
|
+
// unvalidated value flowed straight past `input.timeoutMs ?? DEFAULT` to become the timer itself.
|
|
535
|
+
let timeoutMs;
|
|
536
|
+
try {
|
|
537
|
+
timeoutMs = parseAgentTimeoutMinutes(values["agent-timeout-minutes"]);
|
|
538
|
+
}
|
|
539
|
+
catch (error) {
|
|
540
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
541
|
+
process.exitCode = 1;
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
507
544
|
const canExecute = Boolean(workspace) && onlineDriverIds(config).length > 0;
|
|
508
545
|
if (!workspace) {
|
|
509
546
|
console.warn(`WARNING: heartbeat only — pass --workspace <repo>. Lanes: ${bridgeUsage("drivers", "online", AGENT_PLACEHOLDER)}`);
|
package/dist/driver.js
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { resolveAgentTimeout } from "./execution-budget.js";
|
|
2
3
|
import { existsSync } from "node:fs";
|
|
3
4
|
import { mkdir, readFile, rm, rmdir, writeFile } from "node:fs/promises";
|
|
4
5
|
import { join } from "node:path";
|
|
5
6
|
import { z } from "zod";
|
|
6
7
|
import { deniedCommands, executionClassPromptRules, parsePiJsonl, projectClaude, projectCoarseMode, projectCodex, projectCursor, projectGrok, projectKiroTools, projectPi, requireStampedExecutionClass, } from "./execution-class.js";
|
|
8
|
+
/**
|
|
9
|
+
* The bound for this turn.
|
|
10
|
+
*
|
|
11
|
+
* One place, so the eight call sites cannot drift apart, and so a package budget has a single seam to
|
|
12
|
+
* arrive through when the contract carries one. `input.timeoutMs` is the machine ceiling the operator
|
|
13
|
+
* configured; `input.maxDurationMs` is what the package asked for and is treated as payload.
|
|
14
|
+
*/
|
|
15
|
+
function agentTurnTimeoutMs(input) {
|
|
16
|
+
return resolveAgentTimeout({ machineCeilingMs: input.timeoutMs, packageBudgetMs: input.maxDurationMs }).timeoutMs;
|
|
17
|
+
}
|
|
7
18
|
export { branchCreateCommands, deniedCommands, isBoundedVerificationCommand, prCreateCommands, requireStampedExecutionClass, } from "./execution-class.js";
|
|
8
19
|
function deliveryLanguageRule(language) {
|
|
9
20
|
if (language === "zh") {
|
|
@@ -502,7 +513,7 @@ export const claudeCodeDriver = {
|
|
|
502
513
|
args.push("--disallowedTools", projected.disallowedTools.join(","));
|
|
503
514
|
if (projected.acceptEdits)
|
|
504
515
|
args.push("--permission-mode", "acceptEdits");
|
|
505
|
-
const { code, stdout, stderr } = await execute(input.executable ?? "claude", args, input.workspace, input
|
|
516
|
+
const { code, stdout, stderr } = await execute(input.executable ?? "claude", args, input.workspace, agentTurnTimeoutMs(input), fuelSource === "conduit" ? input.fuel : undefined, fuelSource);
|
|
506
517
|
let message = null;
|
|
507
518
|
try {
|
|
508
519
|
message = JSON.parse(stdout);
|
|
@@ -609,7 +620,7 @@ export const codexDriver = {
|
|
|
609
620
|
diagnosis: input.workRole === "diagnose",
|
|
610
621
|
});
|
|
611
622
|
// Prompt via stdin avoids ARG_MAX limits on large assignment contracts.
|
|
612
|
-
const { code, stdout, stderr } = await execute(input.executable ?? resolveCodexExecutable(), codexExecArgs({ ...input, networkAccess: projected.networkAccess }, projected.sandbox), input.workspace, input
|
|
623
|
+
const { code, stdout, stderr } = await execute(input.executable ?? resolveCodexExecutable(), codexExecArgs({ ...input, networkAccess: projected.networkAccess }, projected.sandbox), input.workspace, agentTurnTimeoutMs(input), fuelSource === "conduit" ? input.fuel : undefined, fuelSource, input.prompt);
|
|
613
624
|
const parsed = parseCodexJsonl(stdout);
|
|
614
625
|
const resultText = parsed.resultText ?? (stdout || null);
|
|
615
626
|
if (code !== 0) {
|
|
@@ -741,7 +752,7 @@ export const cursorDriver = {
|
|
|
741
752
|
capabilities: input.capabilities ?? [],
|
|
742
753
|
diagnosis: input.workRole === "diagnose",
|
|
743
754
|
});
|
|
744
|
-
const configured = await withCursorPermissions(input.workspace, { allow: projected.allow, deny: projected.deny }, () => execute(executable, cursorRunArgs({ ...input, trustWorkspace, executionClass, force: projected.force }), input.workspace, input
|
|
755
|
+
const configured = await withCursorPermissions(input.workspace, { allow: projected.allow, deny: projected.deny }, () => execute(executable, cursorRunArgs({ ...input, trustWorkspace, executionClass, force: projected.force }), input.workspace, agentTurnTimeoutMs(input), undefined, "local"));
|
|
745
756
|
const { code, stdout, stderr } = configured;
|
|
746
757
|
const parsed = parseCursorOutput(stdout);
|
|
747
758
|
if (code !== 0 || parsed.isError) {
|
|
@@ -909,7 +920,7 @@ export const openCodeDriver = {
|
|
|
909
920
|
}
|
|
910
921
|
const args = openCodeRunArgs(input, agent);
|
|
911
922
|
args.push(input.prompt);
|
|
912
|
-
const { code, stdout, stderr } = await withOpenCodePermissions(input.workspace, () => execute(input.executable ?? "opencode", args, input.workspace, input
|
|
923
|
+
const { code, stdout, stderr } = await withOpenCodePermissions(input.workspace, () => execute(input.executable ?? "opencode", args, input.workspace, agentTurnTimeoutMs(input), fuelSource === "conduit" ? input.fuel : undefined, fuelSource));
|
|
913
924
|
const parsed = parseOpenCodeOutput(stdout);
|
|
914
925
|
if (code !== 0 || parsed.isError) {
|
|
915
926
|
return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, error: (stderr || parsed.resultText || `opencode exited with code ${code}`).slice(0, 20_000) };
|
|
@@ -975,7 +986,7 @@ export const kiroDriver = {
|
|
|
975
986
|
}
|
|
976
987
|
const args = kiroChatArgs(input, trusted);
|
|
977
988
|
args.push(input.prompt);
|
|
978
|
-
const { code, stdout, stderr } = await execute(executable, args, input.workspace, input
|
|
989
|
+
const { code, stdout, stderr } = await execute(executable, args, input.workspace, agentTurnTimeoutMs(input), undefined, "local");
|
|
979
990
|
if (code !== 0) {
|
|
980
991
|
return { status: "failed", resultText: stdout || null, sessionId: null, error: (stderr || stdout || `kiro-cli exited with code ${code}`).slice(0, 20_000) };
|
|
981
992
|
}
|
|
@@ -1023,7 +1034,7 @@ export const antigravityDriver = {
|
|
|
1023
1034
|
return { status: "failed", resultText: null, sessionId: null, error: "No Bridge-mapped Antigravity mode for active grants; refusing to start agent" };
|
|
1024
1035
|
}
|
|
1025
1036
|
// agy print mode emits plain text and does not surface a resumable id, so rework resume is not wired.
|
|
1026
|
-
const { code, stdout, stderr } = await execute(input.executable ?? "agy", antigravityRunArgs({ prompt: input.prompt, grants: input.grants }, mode), input.workspace, input
|
|
1037
|
+
const { code, stdout, stderr } = await execute(input.executable ?? "agy", antigravityRunArgs({ prompt: input.prompt, grants: input.grants }, mode), input.workspace, agentTurnTimeoutMs(input), undefined, "local");
|
|
1027
1038
|
if (code !== 0) {
|
|
1028
1039
|
return { status: "failed", resultText: stdout || null, sessionId: null, error: (stderr || stdout || `agy exited with code ${code}`).slice(0, 20_000) };
|
|
1029
1040
|
}
|
|
@@ -1071,7 +1082,7 @@ export const piDriver = {
|
|
|
1071
1082
|
if (version.code !== 0 || !(version.stdout || version.stderr).trim()) {
|
|
1072
1083
|
return { status: "failed", resultText: null, sessionId: null, error: "pi preflight could not verify the installed CLI version" };
|
|
1073
1084
|
}
|
|
1074
|
-
const { code, stdout, stderr } = await execute(executable, piRunArgs({ prompt: input.prompt, tools: projected.tools, model: input.model }), input.workspace, input
|
|
1085
|
+
const { code, stdout, stderr } = await execute(executable, piRunArgs({ prompt: input.prompt, tools: projected.tools, model: input.model }), input.workspace, agentTurnTimeoutMs(input), undefined, "local");
|
|
1075
1086
|
const parsed = parsePiJsonl(stdout);
|
|
1076
1087
|
if (code !== 0) {
|
|
1077
1088
|
return {
|
|
@@ -1207,7 +1218,7 @@ export const grokDriver = {
|
|
|
1207
1218
|
if (input.grants.includes("test_run") && !(input.verificationCommands?.length)) {
|
|
1208
1219
|
return { status: "failed", resultText: null, sessionId: null, error: "grok preflight found no bounded verification command for the test_run grant" };
|
|
1209
1220
|
}
|
|
1210
|
-
const { code, stdout, stderr } = await execute(executable, grokRunArgs(input), input.workspace, input
|
|
1221
|
+
const { code, stdout, stderr } = await execute(executable, grokRunArgs(input), input.workspace, agentTurnTimeoutMs(input), undefined, "local");
|
|
1211
1222
|
const parsed = parseGrokOutput(stdout);
|
|
1212
1223
|
if (code !== 0 || parsed.isError) {
|
|
1213
1224
|
return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, error: (stderr || parsed.resultText || `grok exited with code ${code}`).slice(0, 20_000) };
|
|
@@ -1247,7 +1258,12 @@ function execute(executable, args, cwd, timeoutMs, fuel, fuelSource = "conduit",
|
|
|
1247
1258
|
});
|
|
1248
1259
|
let stdout = "";
|
|
1249
1260
|
let stderr = "";
|
|
1250
|
-
|
|
1261
|
+
let timedOut = false;
|
|
1262
|
+
const timer = setTimeout(() => {
|
|
1263
|
+
timedOut = true;
|
|
1264
|
+
child.kill("SIGTERM");
|
|
1265
|
+
setTimeout(() => child.kill("SIGKILL"), 10_000).unref();
|
|
1266
|
+
}, timeoutMs);
|
|
1251
1267
|
// Bounded: a noisy or hostile local agent could otherwise grow these strings until the runner
|
|
1252
1268
|
// dies. The report is the last fenced JSON block, so keeping the tail preserves what is parsed
|
|
1253
1269
|
// while discarding the transcript ahead of it. Truncation is recorded, never silent.
|
|
@@ -1269,7 +1285,13 @@ function execute(executable, args, cwd, timeoutMs, fuel, fuelSource = "conduit",
|
|
|
1269
1285
|
clearTimeout(timer);
|
|
1270
1286
|
if (stdoutTruncated)
|
|
1271
1287
|
console.error(`Agent output exceeded ${MAX_AGENT_OUTPUT} bytes; kept the tail for report parsing.`);
|
|
1272
|
-
resolve({
|
|
1288
|
+
resolve({
|
|
1289
|
+
code,
|
|
1290
|
+
stdout,
|
|
1291
|
+
// Never let vendor stderr appended before SIGTERM turn a local timer into a quota refusal.
|
|
1292
|
+
// This stable prefix is classified by Bridge and the control plane as execution timeout.
|
|
1293
|
+
stderr: timedOut ? `agent_turn_timeout: agent turn exceeded ${timeoutMs}ms` : stderr.slice(0, 20_000),
|
|
1294
|
+
});
|
|
1273
1295
|
});
|
|
1274
1296
|
if (stdinText !== undefined && child.stdin) {
|
|
1275
1297
|
// An agent that exits before reading its prompt closes this pipe, and the write then raises
|
package/dist/drivers.js
CHANGED
|
@@ -150,6 +150,34 @@ export function listDriverLanes(config) {
|
|
|
150
150
|
};
|
|
151
151
|
});
|
|
152
152
|
}
|
|
153
|
+
export function laneStatuses(config, now = Date.now()) {
|
|
154
|
+
return listDriverLanes(config).map((lane) => {
|
|
155
|
+
const quota = laneQuota(config, lane.id, now);
|
|
156
|
+
// A conduit-fuelled lane bills through the control plane, so a local refusal record says nothing
|
|
157
|
+
// about whether the next claim can run.
|
|
158
|
+
const local = lane.fuel === "local";
|
|
159
|
+
const allowance = !local || !quota
|
|
160
|
+
? "unknown"
|
|
161
|
+
: quota.exhausted ? "unavailable" : "available";
|
|
162
|
+
return {
|
|
163
|
+
...lane,
|
|
164
|
+
allowance,
|
|
165
|
+
observed_at: quota?.observed_at,
|
|
166
|
+
resets_at: quota?.resets_at ?? undefined,
|
|
167
|
+
eligible: lane.state === "online" && allowance !== "unavailable",
|
|
168
|
+
};
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
/** Why this machine cannot take a claim, or null when at least one lane can. */
|
|
172
|
+
export function laneDispatchBlock(statuses) {
|
|
173
|
+
if (statuses.some((lane) => lane.eligible))
|
|
174
|
+
return null;
|
|
175
|
+
if (!statuses.length)
|
|
176
|
+
return "no driver lane is registered";
|
|
177
|
+
if (!statuses.some((lane) => lane.state === "online"))
|
|
178
|
+
return "every lane is offline";
|
|
179
|
+
return "every online lane has an allowance the provider refused";
|
|
180
|
+
}
|
|
153
181
|
export function onlineDriverIds(config) {
|
|
154
182
|
const drivers = normalizeDrivers(config.drivers);
|
|
155
183
|
return SUPPORTED_AGENTS.map((agent) => agent.id).filter((id) => drivers[id]?.state === "online");
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long one agent turn may run.
|
|
3
|
+
*
|
|
4
|
+
* Twenty minutes is a safety default for ordinary work, not a statement that longer work is
|
|
5
|
+
* ill-formed. A package should be split when its deliverables or authority boundaries are
|
|
6
|
+
* independent — never merely to fit a timer — so a cohesive task that genuinely needs thirty or
|
|
7
|
+
* sixty minutes must be able to say so.
|
|
8
|
+
*
|
|
9
|
+
* Two sources, and the smaller wins: the package asks for what the work needs, the machine caps what
|
|
10
|
+
* this host will tolerate. The operator's ceiling is the one that cannot be argued with by a payload.
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_AGENT_TIMEOUT_MS = 20 * 60_000;
|
|
13
|
+
/**
|
|
14
|
+
* The longest turn any configuration may request.
|
|
15
|
+
*
|
|
16
|
+
* A bound this generous is not a scheduling opinion; it is the point past which a value is far more
|
|
17
|
+
* likely to be a mistake — a millisecond figure pasted where minutes were wanted — than an intention.
|
|
18
|
+
*/
|
|
19
|
+
export const MAX_AGENT_TIMEOUT_MS = 4 * 60 * 60_000;
|
|
20
|
+
export const MIN_AGENT_TIMEOUT_MS = 60_000;
|
|
21
|
+
export class InvalidAgentTimeoutError extends Error {
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Minutes from a command line, validated.
|
|
25
|
+
*
|
|
26
|
+
* `Number("abc")` is `NaN`, and `NaN` is not nullish — so an unvalidated value flowed straight past
|
|
27
|
+
* `input.timeoutMs ?? DEFAULT` and became the timer itself. A typo must fail loudly at startup, not
|
|
28
|
+
* silently produce a turn that never times out or one that fires at once.
|
|
29
|
+
*/
|
|
30
|
+
export function parseAgentTimeoutMinutes(value) {
|
|
31
|
+
if (value === undefined)
|
|
32
|
+
return undefined;
|
|
33
|
+
const minutes = Number(value);
|
|
34
|
+
if (!Number.isFinite(minutes) || minutes <= 0) {
|
|
35
|
+
throw new InvalidAgentTimeoutError(`--agent-timeout-minutes must be a positive number of minutes, got ${JSON.stringify(value)}`);
|
|
36
|
+
}
|
|
37
|
+
const ms = Math.round(minutes * 60_000);
|
|
38
|
+
if (ms < MIN_AGENT_TIMEOUT_MS) {
|
|
39
|
+
throw new InvalidAgentTimeoutError(`--agent-timeout-minutes must be at least ${MIN_AGENT_TIMEOUT_MS / 60_000} minute`);
|
|
40
|
+
}
|
|
41
|
+
if (ms > MAX_AGENT_TIMEOUT_MS) {
|
|
42
|
+
throw new InvalidAgentTimeoutError(`--agent-timeout-minutes must not exceed ${MAX_AGENT_TIMEOUT_MS / 60_000} minutes`);
|
|
43
|
+
}
|
|
44
|
+
return ms;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The effective turn budget, and where it came from.
|
|
48
|
+
*
|
|
49
|
+
* The source is returned rather than inferred later because an operator reading a timed-out attempt
|
|
50
|
+
* needs to know which side set the bound; "it timed out" without that is not actionable.
|
|
51
|
+
*
|
|
52
|
+
* A package budget is payload, so it is bounded on both ends before use. A package asking for more
|
|
53
|
+
* than the machine allows is not an error — the machine simply wins, and the decision records that
|
|
54
|
+
* it was clamped so the difference is visible rather than mysterious.
|
|
55
|
+
*/
|
|
56
|
+
export function resolveAgentTimeout(inputs = {}) {
|
|
57
|
+
const ceiling = usableMs(inputs.machineCeilingMs);
|
|
58
|
+
const requested = usableMs(inputs.packageBudgetMs);
|
|
59
|
+
if (requested === undefined) {
|
|
60
|
+
return ceiling === undefined
|
|
61
|
+
? { timeoutMs: DEFAULT_AGENT_TIMEOUT_MS, source: "default" }
|
|
62
|
+
: { timeoutMs: ceiling, source: "machine" };
|
|
63
|
+
}
|
|
64
|
+
// With no operator ceiling the default is the ceiling: a payload must not be able to lengthen a
|
|
65
|
+
// turn on a host whose operator never opted into longer runs.
|
|
66
|
+
const limit = ceiling ?? DEFAULT_AGENT_TIMEOUT_MS;
|
|
67
|
+
if (requested > limit)
|
|
68
|
+
return { timeoutMs: limit, source: "clamped_to_machine" };
|
|
69
|
+
return { timeoutMs: requested, source: "package" };
|
|
70
|
+
}
|
|
71
|
+
function usableMs(value) {
|
|
72
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
73
|
+
return undefined;
|
|
74
|
+
if (value < MIN_AGENT_TIMEOUT_MS || value > MAX_AGENT_TIMEOUT_MS)
|
|
75
|
+
return undefined;
|
|
76
|
+
return Math.round(value);
|
|
77
|
+
}
|
package/dist/execution-class.js
CHANGED
|
@@ -5,7 +5,12 @@
|
|
|
5
5
|
/** Bounded verification commands a `test_run` grant may execute. */
|
|
6
6
|
export function isBoundedVerificationCommand(command) {
|
|
7
7
|
// Exact forms only — a trailing `.*` on pytest would admit `pytest ; curl … | sh`.
|
|
8
|
-
|
|
8
|
+
// A target or script NAME is not a trust boundary: `make check` already runs whatever recipe the
|
|
9
|
+
// repository authored, so restricting the name to check/test grants no safety — it only stops a
|
|
10
|
+
// project from naming its own gate, which is what forced four attempts to die on a command they
|
|
11
|
+
// could not run. The boundary that matters is the SHAPE: one tool, one identifier, no shell
|
|
12
|
+
// metacharacters, so nothing can be smuggled in through the name.
|
|
13
|
+
return /^(npm run [A-Za-z0-9][A-Za-z0-9._:-]*|pnpm [A-Za-z0-9][A-Za-z0-9._:-]*|yarn [A-Za-z0-9][A-Za-z0-9._:-]*|cargo (test|check)|go test(?: \.\/\.\.\.)?|make [A-Za-z0-9][A-Za-z0-9._-]*|python -m pytest|pytest(?: [\w./=-]+)?|\.\/gradlew test)$/.test(command);
|
|
9
14
|
}
|
|
10
15
|
/** Shell commands each grant authorizes — mapped per driver so they cannot drift. */
|
|
11
16
|
export const branchCreateCommands = [
|
|
@@ -76,7 +81,9 @@ export function executionClassPromptRules(input) {
|
|
|
76
81
|
const artifact = executionClass === "publish_artifact";
|
|
77
82
|
const rules = [];
|
|
78
83
|
const grantShell = [
|
|
79
|
-
...(grants.includes("test_run")
|
|
84
|
+
...(grants.includes("test_run")
|
|
85
|
+
? verificationCommands.filter((command) => isBoundedVerificationCommand(command) && !deniedCommands.includes(command))
|
|
86
|
+
: []),
|
|
80
87
|
...(grants.includes("branch_create") ? branchCreateCommands : []),
|
|
81
88
|
...(grants.includes("pr_create") ? prCreateCommands : []),
|
|
82
89
|
];
|
package/dist/execution-facts.js
CHANGED
|
@@ -123,5 +123,7 @@ export async function buildSovereignExecutionFacts(input) {
|
|
|
123
123
|
verification_commands_digest: verificationCommandsDigest(input.brief?.verification ?? []),
|
|
124
124
|
workspace_clean: input.workspaceClean,
|
|
125
125
|
final_commit: commitOrNull(input.finalCommit),
|
|
126
|
+
...(input.agentTimeoutMs !== undefined ? { agent_timeout_ms: input.agentTimeoutMs } : {}),
|
|
127
|
+
...(input.agentTimeoutSource ? { agent_timeout_source: input.agentTimeoutSource } : {}),
|
|
126
128
|
};
|
|
127
129
|
}
|
package/dist/execution.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { resolveAgentTimeout } from "./execution-budget.js";
|
|
4
5
|
import { ConduitRequestError } from "./client.js";
|
|
5
6
|
import { redactSecrets, saveDriverQuota } from "./config.js";
|
|
6
7
|
import { DRIVERS, agentReportTemplate, buildAssignmentPrompt, evidenceKinds, extractAgentReportJsonText, fuelEndpoint, normalizeEvidenceDigest, parseAgentReport, parseJsonObjectCandidate, pickModelCandidate, requireStampedExecutionClass, tierForRisk } from "./driver.js";
|
|
@@ -79,7 +80,7 @@ export function forgeTransportFailure(message) {
|
|
|
79
80
|
* costs an evening. Deliberately narrow — an unmatched refusal degrades to an ordinary agent
|
|
80
81
|
* failure, which is exactly today's behaviour.
|
|
81
82
|
*/
|
|
82
|
-
export const SUBSCRIPTION_EXHAUSTED_PATTERN = /\b(?:usage|rate)[ _-]?limit(?:ed|s)?\b|\bquota (?:exceeded|exhausted|reached)\b|\b(?:http|status|code)\W{0,3}429\b|\b429\b(?=\W{0,3}too many)|too many requests|out of (?:credits|usage)/i;
|
|
83
|
+
export const SUBSCRIPTION_EXHAUSTED_PATTERN = /\b(?:usage|rate|session)[ _-]?limit(?:ed|s)?\b|\b(?:daily|weekly|monthly) limit\b|\bquota (?:exceeded|exhausted|reached)\b|\b(?:http|status|code)\W{0,3}429\b|\b429\b(?=\W{0,3}too many)|too many requests|out of (?:credits|usage)/i;
|
|
83
84
|
/**
|
|
84
85
|
* Does this failure mean the plan is spent, rather than the prompt being wrong?
|
|
85
86
|
*
|
|
@@ -94,8 +95,43 @@ export const SUBSCRIPTION_EXHAUSTED_PATTERN = /\b(?:usage|rate)[ _-]?limit(?:ed|
|
|
|
94
95
|
* sentence in the same string.
|
|
95
96
|
*/
|
|
96
97
|
export function subscriptionExhausted(message) {
|
|
98
|
+
if (agentTurnTimedOut(message))
|
|
99
|
+
return false;
|
|
97
100
|
return SUBSCRIPTION_EXHAUSTED_PATTERN.test(message);
|
|
98
101
|
}
|
|
102
|
+
export function agentTurnTimedOut(message) {
|
|
103
|
+
return /^agent_turn_timeout:/i.test(message.trim());
|
|
104
|
+
}
|
|
105
|
+
function timeoutFailureBody(resolution, attemptId) {
|
|
106
|
+
const machineBound = resolution.source === "default"
|
|
107
|
+
|| resolution.source === "machine"
|
|
108
|
+
|| resolution.source === "clamped_to_machine";
|
|
109
|
+
const minutes = Math.round(resolution.timeoutMs / 60_000);
|
|
110
|
+
return {
|
|
111
|
+
failure: machineBound ? {
|
|
112
|
+
code: "agent_turn_timeout",
|
|
113
|
+
class: "environment",
|
|
114
|
+
disposition: "hold",
|
|
115
|
+
responsible_party: "computer_operator",
|
|
116
|
+
message: `The agent turn reached this computer's ${minutes}-minute execution limit.`,
|
|
117
|
+
next_action: "The computer operator can raise the Bridge agent timeout when this cohesive package needs longer. The product owner does not need to write technical constraints or split the package merely to fit the timer.",
|
|
118
|
+
diagnostic_detail: `agent_turn_timeout: ${resolution.timeoutMs}ms; source=${resolution.source}`,
|
|
119
|
+
} : {
|
|
120
|
+
code: "agent_turn_timeout",
|
|
121
|
+
class: "platform",
|
|
122
|
+
disposition: "stop",
|
|
123
|
+
responsible_party: "conduit",
|
|
124
|
+
message: `The agent turn reached Conduit's ${minutes}-minute package budget.`,
|
|
125
|
+
next_action: "Conduit must revise the mechanically derived execution budget or continue from a bounded checkpoint. The product owner does not need to author implementation constraints.",
|
|
126
|
+
diagnostic_detail: `agent_turn_timeout: ${resolution.timeoutMs}ms; source=${resolution.source}`,
|
|
127
|
+
},
|
|
128
|
+
error: `agent_turn_timeout: agent turn exceeded ${resolution.timeoutMs}ms`,
|
|
129
|
+
retryable: false,
|
|
130
|
+
idempotency_key: `bridge:agent-timeout:${attemptId}`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
class AgentTurnTimeoutError extends Error {
|
|
134
|
+
}
|
|
99
135
|
/**
|
|
100
136
|
* When the window refills, if the vendor said so.
|
|
101
137
|
*
|
|
@@ -302,6 +338,7 @@ const workPackageSchema = z.object({
|
|
|
302
338
|
failure_output: z.string().min(1).max(12_000),
|
|
303
339
|
}).optional(),
|
|
304
340
|
working_language: z.enum(["en", "zh"]).optional(),
|
|
341
|
+
max_duration_ms: z.number().int().min(60_000).max(4 * 60 * 60_000).optional(),
|
|
305
342
|
}).nullable().optional();
|
|
306
343
|
const diagnosticRepairBriefSchema = z.object({
|
|
307
344
|
root_cause: z.string().trim().min(1).max(2_000),
|
|
@@ -728,6 +765,10 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
728
765
|
const task = taskDetailSchema.parse(detail.task);
|
|
729
766
|
const executionContract = executionContractSchema.parse(detail.execution_contract ?? {});
|
|
730
767
|
const workPackage = workPackageSchema.parse(detail.work_package) ?? null;
|
|
768
|
+
const agentTimeout = resolveAgentTimeout({
|
|
769
|
+
machineCeilingMs: timeoutMs,
|
|
770
|
+
packageBudgetMs: workPackage?.max_duration_ms,
|
|
771
|
+
});
|
|
731
772
|
const executionKind = task.execution_kind;
|
|
732
773
|
const diagnosis = executionKind === "diagnosis";
|
|
733
774
|
const sourceAttemptId = task.source_attempt_id ?? executionContract.source_attempt_id ?? active.sourceAttemptId ?? null;
|
|
@@ -992,6 +1033,8 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
992
1033
|
executionClass,
|
|
993
1034
|
claimedHead: executionContract.claimed_head ?? startCommit,
|
|
994
1035
|
bootstrapResult: await bootstrapResultForWorkspace(workspace),
|
|
1036
|
+
agentTimeoutMs: agentTimeout.timeoutMs,
|
|
1037
|
+
agentTimeoutSource: agentTimeout.source,
|
|
995
1038
|
});
|
|
996
1039
|
await client.updateAttempt(taskId, { executionFacts });
|
|
997
1040
|
await client.attemptRequest(taskId, "progress", {
|
|
@@ -1067,6 +1110,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
1067
1110
|
executionClass,
|
|
1068
1111
|
resumeSessionId,
|
|
1069
1112
|
timeoutMs,
|
|
1113
|
+
maxDurationMs: workPackage?.max_duration_ms,
|
|
1070
1114
|
model: selection.model,
|
|
1071
1115
|
fuel,
|
|
1072
1116
|
fuelSource,
|
|
@@ -1153,12 +1197,25 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
1153
1197
|
}
|
|
1154
1198
|
if (result.status === "failed") {
|
|
1155
1199
|
const agentMessage = result.error ?? "Agent execution failed";
|
|
1200
|
+
if (agentTurnTimedOut(agentMessage)) {
|
|
1201
|
+
retainAttemptWorktree = true;
|
|
1202
|
+
const response = await queueTerminal(client, taskId, {
|
|
1203
|
+
action: "fail",
|
|
1204
|
+
body: timeoutFailureBody(agentTimeout, active.attemptId),
|
|
1205
|
+
});
|
|
1206
|
+
retainAttemptWorktree = response.retain_worktree === true;
|
|
1207
|
+
console.error(`Assignment ${taskId} timed out: ${redactSecrets(agentMessage)}`);
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1156
1210
|
// The agent verifies inside its own tool loop, so its compiler/test output never reaches
|
|
1157
1211
|
// result.error — production saw a bare git SHA and echoed source arrive as the "failure
|
|
1158
1212
|
// reason", leaving Conductor nothing to diagnose. Re-run the project's bounded verification
|
|
1159
1213
|
// in the attempt worktree to recover the real errors. Best-effort: keep the agent's own
|
|
1160
1214
|
// message when there is no command, the command cannot run, or the tree actually verifies.
|
|
1161
|
-
|
|
1215
|
+
// A vendor allowance refusal is already a precise environment diagnosis. Running repository
|
|
1216
|
+
// verification after it can surface an unrelated pre-existing failure and overwrite the lane
|
|
1217
|
+
// Hold with a false source-repair brief.
|
|
1218
|
+
const verificationDetail = grants.includes("test_run") && !subscriptionExhausted(agentMessage)
|
|
1162
1219
|
? await captureVerificationFailure({
|
|
1163
1220
|
workspace: attemptWorkspace,
|
|
1164
1221
|
verificationCommands: attemptBrief?.verification ?? [],
|
|
@@ -1218,6 +1275,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
1218
1275
|
fuel,
|
|
1219
1276
|
fuelSource,
|
|
1220
1277
|
timeoutMs,
|
|
1278
|
+
maxDurationMs: workPackage?.max_duration_ms,
|
|
1221
1279
|
resumeSessionId: agentSessionId,
|
|
1222
1280
|
reason: "Agent claimed repository work but git shows none on the attempt branch; starting one land-only continuation turn instead of report-only repair.",
|
|
1223
1281
|
idempotencyKey: `bridge:progress:${active.attemptId}:claims-git-gate`,
|
|
@@ -1254,6 +1312,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
1254
1312
|
fuel,
|
|
1255
1313
|
fuelSource,
|
|
1256
1314
|
timeoutMs,
|
|
1315
|
+
maxDurationMs: workPackage?.max_duration_ms,
|
|
1257
1316
|
resumeSessionId: agentSessionId,
|
|
1258
1317
|
reason: "Agent claimed repository work but git shows none; starting land-only continuation instead of read-only report repair.",
|
|
1259
1318
|
idempotencyKey: `bridge:progress:${active.attemptId}:claims-git-gate-parse`,
|
|
@@ -1328,6 +1387,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
1328
1387
|
executionClass: "observe",
|
|
1329
1388
|
resumeSessionId,
|
|
1330
1389
|
timeoutMs,
|
|
1390
|
+
maxDurationMs: workPackage?.max_duration_ms,
|
|
1331
1391
|
model: selection.model,
|
|
1332
1392
|
fuel,
|
|
1333
1393
|
fuelSource,
|
|
@@ -1416,6 +1476,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
1416
1476
|
fuel,
|
|
1417
1477
|
fuelSource,
|
|
1418
1478
|
timeoutMs,
|
|
1479
|
+
maxDurationMs: workPackage?.max_duration_ms,
|
|
1419
1480
|
resumeSessionId: agentSessionId,
|
|
1420
1481
|
reason: "Parsed Delivery claims repository work but git shows none; starting land-only continuation.",
|
|
1421
1482
|
idempotencyKey: `bridge:progress:${active.attemptId}:claims-git-gate-report`,
|
|
@@ -1463,6 +1524,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
1463
1524
|
fuel,
|
|
1464
1525
|
fuelSource,
|
|
1465
1526
|
timeoutMs,
|
|
1527
|
+
maxDurationMs: workPackage?.max_duration_ms,
|
|
1466
1528
|
resumeSessionId: agentSessionId,
|
|
1467
1529
|
landContinuationUsed,
|
|
1468
1530
|
});
|
|
@@ -1549,6 +1611,17 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
1549
1611
|
else
|
|
1550
1612
|
console.error(`Assignment ${taskId} Delivery was rejected and routed to Conductor repair.`);
|
|
1551
1613
|
}
|
|
1614
|
+
catch (error) {
|
|
1615
|
+
if (!(error instanceof AgentTurnTimeoutError))
|
|
1616
|
+
throw error;
|
|
1617
|
+
retainAttemptWorktree = true;
|
|
1618
|
+
const response = await queueTerminal(client, taskId, {
|
|
1619
|
+
action: "fail",
|
|
1620
|
+
body: timeoutFailureBody(agentTimeout, active.attemptId),
|
|
1621
|
+
});
|
|
1622
|
+
retainAttemptWorktree = response.retain_worktree === true;
|
|
1623
|
+
console.error(`Assignment ${taskId} timed out: ${redactSecrets(error.message)}`);
|
|
1624
|
+
}
|
|
1552
1625
|
finally {
|
|
1553
1626
|
releaseIdleSleep();
|
|
1554
1627
|
clearInterval(renewTimer);
|
|
@@ -1616,12 +1689,15 @@ async function runLandContinuationTurn(input) {
|
|
|
1616
1689
|
executionClass: "mutate_repo",
|
|
1617
1690
|
resumeSessionId: input.resumeSessionId ?? undefined,
|
|
1618
1691
|
timeoutMs: input.timeoutMs,
|
|
1692
|
+
maxDurationMs: input.maxDurationMs,
|
|
1619
1693
|
model: input.selection.model ?? undefined,
|
|
1620
1694
|
fuel: input.fuel,
|
|
1621
1695
|
fuelSource: input.fuelSource,
|
|
1622
1696
|
});
|
|
1623
1697
|
await learnDriverFuel(input.config, input.driver.name, input.fuelSource ?? "conduit", continuation);
|
|
1624
1698
|
if (continuation.status === "failed") {
|
|
1699
|
+
if (agentTurnTimedOut(continuation.error ?? ""))
|
|
1700
|
+
throw new AgentTurnTimeoutError(continuation.error);
|
|
1625
1701
|
throw new AgentNoLandCommitError(input.spec.repository?.base_commit ?? "unknown");
|
|
1626
1702
|
}
|
|
1627
1703
|
return {
|
|
@@ -1676,6 +1752,7 @@ async function finalizeRepositoryLand(input) {
|
|
|
1676
1752
|
fuel: input.fuel,
|
|
1677
1753
|
fuelSource: input.fuelSource,
|
|
1678
1754
|
timeoutMs: input.timeoutMs,
|
|
1755
|
+
maxDurationMs: input.maxDurationMs,
|
|
1679
1756
|
resumeSessionId: input.resumeSessionId,
|
|
1680
1757
|
reason: "Agent finished without landing repository changes; starting one land-only continuation turn.",
|
|
1681
1758
|
idempotencyKey: `bridge:progress:${input.attemptId}:land-continuation`,
|
package/dist/ops.js
CHANGED
|
@@ -13,13 +13,13 @@ import { ConduitClient } from "./client.js";
|
|
|
13
13
|
import { loadConfig } from "./config.js";
|
|
14
14
|
import { ensureCheckout } from "./checkout.js";
|
|
15
15
|
import { detectInstalledClients, probeAgentHealth } from "./detect.js";
|
|
16
|
-
import { driverIdsFromDetectedLabels } from "./drivers.js";
|
|
16
|
+
import { driverIdsFromDetectedLabels, laneDispatchBlock, laneStatuses } from "./drivers.js";
|
|
17
17
|
import { BRIDGE_PROTOCOL_VERSION, describePreflightIssue, runBridgePreflight } from "./preflight.js";
|
|
18
18
|
import { applyRunnerToolPath, readActiveRunnerServiceWorkspace, readStoredRunnerServiceOptions, runnerServiceWorkspaceWarnings } from "./service.js";
|
|
19
19
|
import { bridgeVersion } from "./version.js";
|
|
20
20
|
import { bootstrapManagedWorkspace } from "./workspace-bootstrap.js";
|
|
21
21
|
export const OPS_VERBS = [
|
|
22
|
-
"connect", "enroll", "install", "switch", "online", "offline", "status", "doctor", "disconnect", "uninstall",
|
|
22
|
+
"connect", "enroll", "install", "switch", "online", "offline", "quota-clear", "status", "doctor", "disconnect", "uninstall",
|
|
23
23
|
];
|
|
24
24
|
const LOCAL_FUEL_DRIVERS = new Set(["cursor", "pi", "kiro", "antigravity", "grok"]);
|
|
25
25
|
/**
|
|
@@ -245,6 +245,24 @@ export async function runOps(verb, argv = [], deps = {}) {
|
|
|
245
245
|
if (verb === "status") {
|
|
246
246
|
const packageVersion = bridgeVersion();
|
|
247
247
|
console.log(`Bridge: v${packageVersion} (protocol ${BRIDGE_PROTOCOL_VERSION})`);
|
|
248
|
+
// Print lanes through the same projection `drivers list` uses. These two commands disagreeing
|
|
249
|
+
// about whether a lane was usable is what sent an operator looking for a product fault when the
|
|
250
|
+
// real answer was a spent provider allowance.
|
|
251
|
+
try {
|
|
252
|
+
const laneConfig = await (deps.loadBridgeConfig ?? loadConfig)();
|
|
253
|
+
const statuses = laneStatuses(laneConfig);
|
|
254
|
+
for (const lane of statuses) {
|
|
255
|
+
const when = lane.observed_at ? `, observed ${lane.observed_at}` : "";
|
|
256
|
+
const reset = lane.resets_at ? `, resets ${lane.resets_at}` : lane.allowance === "unavailable" ? ", reset unknown" : "";
|
|
257
|
+
console.log(`Lane ${lane.id}: ${lane.state}, fuel ${lane.fuel}, allowance ${lane.allowance}${when}${reset}`);
|
|
258
|
+
}
|
|
259
|
+
const blocked = laneDispatchBlock(statuses);
|
|
260
|
+
// Never say READY on the strength of a login alone; dispatch is what the operator is asking about.
|
|
261
|
+
console.log(blocked ? `Machine dispatch: BLOCKED — ${blocked}` : "Machine dispatch: ready");
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
console.log("Lanes: unavailable (no local Bridge configuration)");
|
|
265
|
+
}
|
|
248
266
|
try {
|
|
249
267
|
const config = await (deps.loadBridgeConfig ?? loadConfig)();
|
|
250
268
|
const controller = new AbortController();
|
|
@@ -343,12 +361,18 @@ export async function runOps(verb, argv = [], deps = {}) {
|
|
|
343
361
|
console.log(`- ${describePreflightIssue(issue)}`);
|
|
344
362
|
throw new Error("Fix the preflight issues before retrying Conduit work");
|
|
345
363
|
}
|
|
346
|
-
// Lane
|
|
364
|
+
// Lane controls only need Bridge config + optional driver ids — not a full ops.env.
|
|
347
365
|
if (verb === "online" || verb === "offline") {
|
|
348
366
|
const drivers = await resolveDrivers(env, argv);
|
|
349
367
|
runBridge(["drivers", verb, ...drivers]);
|
|
350
368
|
return;
|
|
351
369
|
}
|
|
370
|
+
if (verb === "quota-clear") {
|
|
371
|
+
if (argv.length !== 1)
|
|
372
|
+
throw new Error("Usage: ops quota-clear <driver>");
|
|
373
|
+
runBridge(["drivers", "quota", "clear", argv[0]]);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
352
376
|
// Explicit install arguments are active-run inputs, not machine-wide defaults. Enrollment may
|
|
353
377
|
// seed ops.env once; later project changes must not mutate it.
|
|
354
378
|
let installEnv = env;
|
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.27",
|
|
4
4
|
"description": "Conduit Bridge CLI — join, connect, disconnect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Pi / Kiro / Antigravity / Grok Build agents for a Conduit organization",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|