@miraland-labs/conduit-bridge 0.16.141 → 0.16.143
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 +1 -1
- package/dist/attempt-worktree.js +12 -27
- package/dist/config.js +0 -2
- package/dist/driver.js +19 -37
- package/dist/drivers.js +0 -9
- package/dist/ensure-demonstration-evidence.js +1 -1
- package/dist/ensure-test-evidence.js +26 -3
- package/dist/execution.js +73 -62
- package/dist/failure-signal.js +50 -13
- package/dist/investigation.js +9 -31
- package/dist/ops.js +0 -8
- package/dist/service.js +0 -1
- 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.125` — the workspace brief reports `declared_demonstration` so the planner knows whether the repository can show itself (
|
|
5
|
+
**Package version:** `0.16.125` — the workspace brief reports `declared_demonstration` so the planner knows whether the repository can show itself (docs/historical/KERNEL_HISTORY_2026-09.md §51). Earlier (0.16.124) — when a project declares `.conduit/demonstrate`, Bridge runs that bounded command at the delivered head and attaches a stamped `preview` row (docs/historical/KERNEL_HISTORY_2026-09.md §47). Earlier (0.16.95) — Conduit-fuel lanes bind organization aliases (T144), the Anthropic base URL is the origin (T148), the investigation lane resolves its model (T154); a hosted container image lives under `hosted/` (docs/HOSTED_RUNNER.md). Earlier (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
|
|
package/dist/attempt-worktree.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* F-07 slice 1: one Git worktree per attempt. The install-service source checkout is never the
|
|
3
3
|
* agent cwd; retries must not inherit dirty files from a failed run.
|
|
4
4
|
*/
|
|
5
|
-
import { access, appendFile, mkdir, readFile,
|
|
5
|
+
import { access, appendFile, mkdir, readFile, rm } from "node:fs/promises";
|
|
6
6
|
import { constants } from "node:fs";
|
|
7
|
-
import { join, resolve } from "node:path";
|
|
7
|
+
import { basename, join, resolve } from "node:path";
|
|
8
8
|
import { execFile } from "node:child_process";
|
|
9
9
|
import { promisify } from "node:util";
|
|
10
10
|
import { headCommitOrNull } from "./git.js";
|
|
@@ -106,6 +106,16 @@ export async function removeAttemptWorktree(sourceWorkspace, worktreePath) {
|
|
|
106
106
|
maxBuffer: 1_000_000,
|
|
107
107
|
}).catch(() => undefined);
|
|
108
108
|
}
|
|
109
|
+
// The attempt branch goes with its worktree: left behind, every attempt added one local branch to
|
|
110
|
+
// the source checkout for ever. Only the branch Bridge created for this path, and only the local
|
|
111
|
+
// ref: a delivered branch was pushed before the release and lives on in the forge.
|
|
112
|
+
const attemptId = basename(worktreePath);
|
|
113
|
+
if (resolve(worktreePath) === resolve(attemptWorktreePath(sourceWorkspace, attemptId))) {
|
|
114
|
+
await execFileAsync("git", ["-C", sourceWorkspace, "branch", "-D", attemptBranchName(attemptId)], {
|
|
115
|
+
timeout: 30_000,
|
|
116
|
+
maxBuffer: 1_000_000,
|
|
117
|
+
}).catch(() => undefined);
|
|
118
|
+
}
|
|
109
119
|
return true;
|
|
110
120
|
}
|
|
111
121
|
/**
|
|
@@ -126,28 +136,3 @@ export async function proveResumeWorktree(sourceWorkspace, attemptId, worktreePa
|
|
|
126
136
|
return false;
|
|
127
137
|
}
|
|
128
138
|
}
|
|
129
|
-
/** Hold a crashed attempt tree for local diagnosis; never reused as agent cwd. */
|
|
130
|
-
export async function quarantineAttemptWorktree(sourceWorkspace, worktreePath, attemptId) {
|
|
131
|
-
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
132
|
-
const dest = join(sourceWorkspace, ".conduit", "quarantine", `${attemptId}-${stamp}`);
|
|
133
|
-
await mkdir(join(sourceWorkspace, ".conduit", "quarantine"), { recursive: true });
|
|
134
|
-
try {
|
|
135
|
-
await execFileAsync("git", ["-C", sourceWorkspace, "worktree", "remove", "--force", worktreePath], { timeout: 60_000, maxBuffer: 2_000_000 });
|
|
136
|
-
return null;
|
|
137
|
-
}
|
|
138
|
-
catch {
|
|
139
|
-
try {
|
|
140
|
-
await rename(worktreePath, dest);
|
|
141
|
-
await execFileAsync("git", ["-C", sourceWorkspace, "worktree", "prune"], {
|
|
142
|
-
timeout: 30_000,
|
|
143
|
-
maxBuffer: 1_000_000,
|
|
144
|
-
}).catch(() => undefined);
|
|
145
|
-
await writeFile(join(dest, ".conduit-quarantine.json"), `${JSON.stringify({ attemptId, at: stamp })}\n`, "utf8");
|
|
146
|
-
return dest;
|
|
147
|
-
}
|
|
148
|
-
catch {
|
|
149
|
-
await removeAttemptWorktree(sourceWorkspace, worktreePath);
|
|
150
|
-
return null;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
}
|
package/dist/config.js
CHANGED
|
@@ -58,8 +58,6 @@ export async function loadConfigIfPresent() {
|
|
|
58
58
|
// Claims live only in runtime.json; a fresh install has none.
|
|
59
59
|
config.activeAttempts = runtime?.activeAttempts ?? {};
|
|
60
60
|
config.sessions = runtime?.sessions;
|
|
61
|
-
for (const active of Object.values(config.activeAttempts))
|
|
62
|
-
active.phase ??= "agent_running";
|
|
63
61
|
if (config.fuelSource !== "local" && config.fuelSource !== "conduit")
|
|
64
62
|
config.fuelSource = "conduit";
|
|
65
63
|
// Clamp to what concurrent slot workers can run (1..BRIDGE_MAX_LEASE_CAPACITY).
|
package/dist/driver.js
CHANGED
|
@@ -152,11 +152,6 @@ export function tierForRisk(risk) {
|
|
|
152
152
|
export function isSafeModelName(value) {
|
|
153
153
|
return /^[A-Za-z0-9][A-Za-z0-9._/-]{0,99}$/.test(value);
|
|
154
154
|
}
|
|
155
|
-
/** Grants → Claude Code tool allow-list (mutate_repo mapping). Privileged grants are never mapped. */
|
|
156
|
-
export function claudeToolsForGrants(grants, verificationCommands = [], capabilities = []) {
|
|
157
|
-
return projectClaude("mutate_repo", { grants, verificationCommands, capabilities }).allowedTools;
|
|
158
|
-
}
|
|
159
|
-
export const claudeDeniedTools = deniedCommands.map((command) => `Bash(${command}:*)`);
|
|
160
155
|
function resolveRunClass(input) {
|
|
161
156
|
if (input.executionClass)
|
|
162
157
|
return input.executionClass;
|
|
@@ -739,7 +734,9 @@ export const claudeCodeDriver = {
|
|
|
739
734
|
};
|
|
740
735
|
}
|
|
741
736
|
const maxTurns = resolveMaxTurns(input.maxTurns);
|
|
742
|
-
|
|
737
|
+
// The prompt goes on stdin (`claude -p` reads it there when no prompt argument is given): Linux
|
|
738
|
+
// caps one argument at 128 KiB (MAX_ARG_STRLEN) and an assignment contract exceeds that.
|
|
739
|
+
const args = ["-p", "--output-format", "json", "--max-turns", String(maxTurns)];
|
|
743
740
|
args.push(...modelArgs(claudeCodeDriver, input.model));
|
|
744
741
|
if (input.resumeSessionId)
|
|
745
742
|
args.push("--resume", input.resumeSessionId);
|
|
@@ -747,7 +744,7 @@ export const claudeCodeDriver = {
|
|
|
747
744
|
args.push("--disallowedTools", projected.disallowedTools.join(","));
|
|
748
745
|
if (projected.acceptEdits)
|
|
749
746
|
args.push("--permission-mode", "acceptEdits");
|
|
750
|
-
const { code, stdout, stderr } = await execute(driverExecutable(claudeCodeDriver.name, input.executable), args, input.workspace, agentTurnTimeoutMs(input), fuelSource === "conduit" ? input.fuel : undefined, fuelSource,
|
|
747
|
+
const { code, stdout, stderr } = await execute(driverExecutable(claudeCodeDriver.name, input.executable), args, input.workspace, agentTurnTimeoutMs(input), fuelSource === "conduit" ? input.fuel : undefined, fuelSource, input.prompt, input.signal, claudeCodeFuelEnv(fuelSource, input.model));
|
|
751
748
|
let message = null;
|
|
752
749
|
try {
|
|
753
750
|
message = JSON.parse(stdout);
|
|
@@ -914,21 +911,6 @@ export const codexDriver = {
|
|
|
914
911
|
return { status: "completed", resultText, sessionId: parsed.sessionId, usage: parsed.usage };
|
|
915
912
|
},
|
|
916
913
|
};
|
|
917
|
-
/**
|
|
918
|
-
* Grants + capabilities → Cursor CLI permissions via ExecutionClass.
|
|
919
|
-
* Current Cursor Agent schema accepts only `permissions.{allow,deny}` (no
|
|
920
|
-
* `version` / `approvalMode`). Outside the allow list is denied without --force.
|
|
921
|
-
*
|
|
922
|
-
* `observe_network` / networked mutate / publish_artifact use `--force` (see
|
|
923
|
-
* cursorRunArgs). Field evidence: allow-listing WebFetch alone still yields
|
|
924
|
-
* "User Rejected" under headless `-p`; `--force` is required for fetch and for
|
|
925
|
-
* publish shell under open class policy. Research-only networked assignments
|
|
926
|
-
* deny Shell(*) and Write(**).
|
|
927
|
-
*/
|
|
928
|
-
export function cursorPermissionsForGrants(grants, verificationCommands, capabilities, executionClass) {
|
|
929
|
-
const { allow, deny } = projectCursor(executionClass, { grants, verificationCommands, capabilities });
|
|
930
|
-
return { allow, deny };
|
|
931
|
-
}
|
|
932
914
|
/**
|
|
933
915
|
* Cursor CLI arguments. Deliberately no `--mode plan` for read-only assignments: plan mode is not an
|
|
934
916
|
* enforcement boundary (in headless `-p` mode the agent can self-escalate out of it and edit anyway)
|
|
@@ -1229,9 +1211,9 @@ export const openCodeDriver = {
|
|
|
1229
1211
|
if (!agent) {
|
|
1230
1212
|
return { status: "failed", resultText: null, sessionId: null, error: "No Bridge-mapped OpenCode agent for active grants; refusing to start agent" };
|
|
1231
1213
|
}
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
const { code, stdout, stderr } = await withOpenCodePermissions(input.workspace, () => execute(driverExecutable(openCodeDriver.name, input.executable),
|
|
1214
|
+
// The prompt goes on stdin (`opencode run` reads a piped stdin as the message), as it does for
|
|
1215
|
+
// codex: Linux caps one argument at 128 KiB (MAX_ARG_STRLEN).
|
|
1216
|
+
const { code, stdout, stderr } = await withOpenCodePermissions(input.workspace, () => execute(driverExecutable(openCodeDriver.name, input.executable), openCodeRunArgs(input, agent), input.workspace, agentTurnTimeoutMs(input), fuelSource === "conduit" ? input.fuel : undefined, fuelSource, input.prompt, input.signal));
|
|
1235
1217
|
const parsed = parseOpenCodeOutput(stdout);
|
|
1236
1218
|
if (code !== 0 || parsed.isError) {
|
|
1237
1219
|
return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, signal: agentExitSignal({ exit_code: code, stderr, stdout, model: input.model }), error: boundedTail(stderr || parsed.resultText || `opencode exited with code ${code}`, 20_000) };
|
|
@@ -1313,9 +1295,9 @@ export const kiroDriver = {
|
|
|
1313
1295
|
return { status: "failed", resultText: null, sessionId: null, error: "kiro has no login (set KIRO_API_KEY or run `kiro-cli login`)" };
|
|
1314
1296
|
}
|
|
1315
1297
|
}
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
const { code, stdout, stderr } = await execute(executable,
|
|
1298
|
+
// The prompt goes on stdin (`kiro-cli chat` takes a piped stdin as its input), as it does for
|
|
1299
|
+
// codex: Linux caps one argument at 128 KiB (MAX_ARG_STRLEN).
|
|
1300
|
+
const { code, stdout, stderr } = await execute(executable, kiroChatArgs(input, trusted), input.workspace, agentTurnTimeoutMs(input), undefined, "local", input.prompt, input.signal);
|
|
1319
1301
|
if (code !== 0) {
|
|
1320
1302
|
return { status: "failed", resultText: stdout || null, sessionId: null, signal: agentExitSignal({ exit_code: code, stderr, stdout, model: input.model }), error: boundedTail(stderr || stdout || `kiro-cli exited with code ${code}`, 20_000) };
|
|
1321
1303
|
}
|
|
@@ -1638,7 +1620,8 @@ export const antigravityDriver = {
|
|
|
1638
1620
|
export function piRunArgs(input) {
|
|
1639
1621
|
const args = ["--mode", "json", "-p", "--no-session", "--tools", input.tools];
|
|
1640
1622
|
args.push(...modelArgs(piDriver, input.model));
|
|
1641
|
-
|
|
1623
|
+
// The prompt goes on stdin (pi reads a piped stdin as its initial message), as it does for codex:
|
|
1624
|
+
// Linux caps one argument at 128 KiB (MAX_ARG_STRLEN).
|
|
1642
1625
|
return args;
|
|
1643
1626
|
}
|
|
1644
1627
|
/**
|
|
@@ -1703,7 +1686,7 @@ export const piDriver = {
|
|
|
1703
1686
|
if (version.code !== 0 || !(version.stdout || version.stderr).trim()) {
|
|
1704
1687
|
return { status: "failed", resultText: null, sessionId: null, error: "pi preflight could not verify the installed CLI version" };
|
|
1705
1688
|
}
|
|
1706
|
-
const { code, stdout, stderr } = await execute(executable, piRunArgs({
|
|
1689
|
+
const { code, stdout, stderr } = await execute(executable, piRunArgs({ tools: projected.tools, model: input.model }), input.workspace, agentTurnTimeoutMs(input), undefined, "local", input.prompt, input.signal);
|
|
1707
1690
|
const parsed = parsePiJsonl(stdout);
|
|
1708
1691
|
if (code !== 0) {
|
|
1709
1692
|
return {
|
|
@@ -1894,12 +1877,14 @@ const MAX_AGENT_OUTPUT = 8 * 1024 * 1024;
|
|
|
1894
1877
|
export const STDIO_DRAIN_MS = 2_000;
|
|
1895
1878
|
/** How long a stopped child may take to die before the run reports what it already has. */
|
|
1896
1879
|
const CHILD_STOP_GRACE_MS = 20_000;
|
|
1897
|
-
export function execute(executable, args, cwd, timeoutMs, fuel, fuelSource = "conduit", stdinText, signal, extraEnv
|
|
1880
|
+
export function execute(executable, args, cwd, timeoutMs, fuel, fuelSource = "conduit", stdinText, signal, extraEnv,
|
|
1881
|
+
/** The whole environment, for a run that must see less than an agent does (a probe instrument). */
|
|
1882
|
+
env) {
|
|
1898
1883
|
return new Promise((resolve, reject) => {
|
|
1899
1884
|
const child = spawn(executable, args, {
|
|
1900
1885
|
cwd,
|
|
1901
1886
|
stdio: [stdinText !== undefined ? "pipe" : "ignore", "pipe", "pipe"],
|
|
1902
|
-
env: boundedEnvironment(fuel, fuelSource, extraEnv),
|
|
1887
|
+
env: env ?? boundedEnvironment(fuel, fuelSource, extraEnv),
|
|
1903
1888
|
});
|
|
1904
1889
|
let stdout = "";
|
|
1905
1890
|
let stderr = "";
|
|
@@ -1991,7 +1976,8 @@ const LOCAL_VENDOR_ENV = [
|
|
|
1991
1976
|
"CURSOR_API_KEY", "KIRO_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY",
|
|
1992
1977
|
"XAI_API_KEY",
|
|
1993
1978
|
];
|
|
1994
|
-
|
|
1979
|
+
/** The stripped process environment an agent CLI runs with, carrying Conduit fuel when given. */
|
|
1980
|
+
export function boundedEnvironment(fuel, fuelSource = "conduit", extraEnv) {
|
|
1995
1981
|
// Proxy and TLS variables stay: fueled agents must reach Conduit's /v1 on
|
|
1996
1982
|
// machines that only have network access through a proxy.
|
|
1997
1983
|
const allowed = ["PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL", "TERM", "NO_COLOR", "FORCE_COLOR",
|
|
@@ -2066,7 +2052,3 @@ export function hasGrokLogin(env = process.env) {
|
|
|
2066
2052
|
// `~/.grok` exists after install; auth.json is the `grok login` / cached_token file.
|
|
2067
2053
|
return Boolean(home && existsSync(join(home, ".grok", "auth.json")));
|
|
2068
2054
|
}
|
|
2069
|
-
/** Exported for tests — builds the stripped process env with optional Conduit fuel. */
|
|
2070
|
-
export function agentProcessEnv(fuel, fuelSource = "conduit", extraEnv) {
|
|
2071
|
-
return boundedEnvironment(fuel, fuelSource, extraEnv);
|
|
2072
|
-
}
|
package/dist/drivers.js
CHANGED
|
@@ -400,15 +400,6 @@ export function setDriverFuel(config, driverId, fuel) {
|
|
|
400
400
|
export function heartbeatStatusForDrivers(onlineIds) {
|
|
401
401
|
return onlineIds.length > 0 ? "online" : "standby";
|
|
402
402
|
}
|
|
403
|
-
/**
|
|
404
|
-
* Effective capacity advertised on heartbeat: full approved pool when any lane is online;
|
|
405
|
-
* 0 (via standby + no claims) when none are. Caller still sends lease_capacity = approved when online.
|
|
406
|
-
*/
|
|
407
|
-
export function advertiseLeaseCapacity(approved, onlineIds) {
|
|
408
|
-
if (onlineIds.length === 0)
|
|
409
|
-
return 1; // schema/heartbeat min; status standby prevents matching
|
|
410
|
-
return approved;
|
|
411
|
-
}
|
|
412
403
|
export function driversHeartbeatReport(config, activeAttempts, processOnlineIds, env = process.env) {
|
|
413
404
|
const processOnline = new Set(processOnlineIds ?? []);
|
|
414
405
|
const busyByDriver = new Map();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* When the project declares how it shows itself, Bridge runs that command at the delivered head and
|
|
3
|
-
* attaches what it printed. The agent never authors this entry (
|
|
3
|
+
* attaches what it printed. The agent never authors this entry (docs/historical/KERNEL_HISTORY_2026-09.md §47, "See it").
|
|
4
4
|
*
|
|
5
5
|
* `.conduit/demonstrate` is one bounded command, `#` comments allowed, absent by default. It runs
|
|
6
6
|
* with the same shape check, runner and bound as a verification command, and it is never a gate:
|
|
@@ -319,8 +319,11 @@ export function boundedFailureDetail(command, stdoutLines, stderrLines, code, ma
|
|
|
319
319
|
/**
|
|
320
320
|
* The bounded runner every Bridge-run verification uses: the declared command, in the given
|
|
321
321
|
* workspace, with the verification timeout and buffer. A non-zero exit is a result, not a throw.
|
|
322
|
+
*
|
|
323
|
+
* A gate the timeout stopped did not exit: `timedOut` says so, and stderr ends with the limit it
|
|
324
|
+
* reached. Read as exit 1, a gate that hung on the untouched base was a red base on the author.
|
|
322
325
|
*/
|
|
323
|
-
export async function runBoundedVerificationCommand(command, workspace) {
|
|
326
|
+
export async function runBoundedVerificationCommand(command, workspace, timeoutMs = VERIFICATION_TIMEOUT_MS) {
|
|
324
327
|
const argv = argvForBoundedCommand(command);
|
|
325
328
|
const bin = argv[0];
|
|
326
329
|
if (!bin)
|
|
@@ -328,7 +331,7 @@ export async function runBoundedVerificationCommand(command, workspace) {
|
|
|
328
331
|
try {
|
|
329
332
|
const { stdout, stderr } = await execFileAsync(bin, argv.slice(1), {
|
|
330
333
|
cwd: workspace,
|
|
331
|
-
timeout:
|
|
334
|
+
timeout: timeoutMs,
|
|
332
335
|
maxBuffer: VERIFICATION_MAX_BUFFER_BYTES,
|
|
333
336
|
env: process.env,
|
|
334
337
|
});
|
|
@@ -343,6 +346,14 @@ export async function runBoundedVerificationCommand(command, workspace) {
|
|
|
343
346
|
// message reaches the `enoent` rule that Holds it on the computer operator.
|
|
344
347
|
if (typeof err.code === "string")
|
|
345
348
|
throw error;
|
|
349
|
+
if (err.killed === true) {
|
|
350
|
+
return {
|
|
351
|
+
stdout: String(err.stdout ?? ""),
|
|
352
|
+
stderr: `${String(err.stderr ?? "")}\nBridge stopped the gate: it did not exit within ${Math.round(timeoutMs / 1_000)} seconds.`.trimStart(),
|
|
353
|
+
code: 1,
|
|
354
|
+
timedOut: true,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
346
357
|
const stderr = String(err.stderr || err.message || "verification failed");
|
|
347
358
|
return {
|
|
348
359
|
stdout: String(err.stdout ?? ""),
|
|
@@ -456,6 +467,18 @@ export function detectGateRanNothing(command, stdout, stderr, code) {
|
|
|
456
467
|
}
|
|
457
468
|
return null;
|
|
458
469
|
}
|
|
470
|
+
/**
|
|
471
|
+
* A delivery report that falls short of the delivery contract while the work itself may be
|
|
472
|
+
* complete: a required evidence kind is missing, a met criterion cites no evidence, test evidence is
|
|
473
|
+
* a summary, or the pull request or published artifact is missing. The type, not the sentence, is
|
|
474
|
+
* what tells the control plane this is a report defect for repair.
|
|
475
|
+
*/
|
|
476
|
+
export class DeliveryReportDefectError extends Error {
|
|
477
|
+
constructor(message) {
|
|
478
|
+
super(message);
|
|
479
|
+
this.name = "DeliveryReportDefectError";
|
|
480
|
+
}
|
|
481
|
+
}
|
|
459
482
|
/**
|
|
460
483
|
* A declared gate that ran and failed. Carries the command as a field so the verification signal
|
|
461
484
|
* can name the gate without reading it back out of this error's text.
|
|
@@ -501,7 +524,7 @@ export async function ensureTestEvidence(input) {
|
|
|
501
524
|
...named.commands,
|
|
502
525
|
])];
|
|
503
526
|
if (commands.length === 0) {
|
|
504
|
-
throw new
|
|
527
|
+
throw new DeliveryReportDefectError("Agent report is missing required evidence: test (no bounded verification command available for Bridge to run)");
|
|
505
528
|
}
|
|
506
529
|
const run = input.runCommand ?? runBoundedVerificationCommand;
|
|
507
530
|
const witnessed = [];
|
package/dist/execution.js
CHANGED
|
@@ -11,12 +11,12 @@ import { saveDriverOutcome, saveDriverQuota, tierCandidates } from "./config.js"
|
|
|
11
11
|
import { DRIVERS, acceptanceCriteriaLines, acceptanceReportSkeleton, agentReportTemplate, buildAssignmentPrompt, evidenceKinds, extractAgentReportJsonText, fuelEndpoint, normalizeEvidenceDigest, parseAgentReport, parseJsonObjectCandidate, pickModelCandidate, isSafeModelName, driverExecutable, requireStampedExecutionClass, tierForRisk } from "./driver.js";
|
|
12
12
|
import { assertClassFloor } from "./execution-class.js";
|
|
13
13
|
import { pickDriverForClaim, recordDriverQuota, resolveDriverFuel, resolveDriverFuelProvenance, supportsReadOnlyDiagnosis, clearDriverOutcome, recordDriverTimeout } from "./drivers.js";
|
|
14
|
-
import { attemptWorktreePath, createAttemptWorktree, proveResumeWorktree,
|
|
14
|
+
import { attemptWorktreePath, createAttemptWorktree, proveResumeWorktree, removeAttemptWorktree, } from "./attempt-worktree.js";
|
|
15
15
|
import { execFile } from "node:child_process";
|
|
16
16
|
import { promisify } from "node:util";
|
|
17
17
|
import { buildWorkspaceBrief, ensureCommitAvailable, fetchOrigin, normalizeRepositoryUrl, resolveAttemptStartCommit } from "./brief.js";
|
|
18
18
|
import { buildSovereignExecutionFacts, bootstrapResultForWorkspace, changedExecutionFact, resumeExecutionFacts, workspaceIsClean } from "./execution-facts.js";
|
|
19
|
-
import { agentOutputCause, boundedDetail, bridgeCause, contractScopeConflictFailure, executionFactChangedSignal, verificationRedBaseSignal, verificationScopeConflictSignal } from "./failure-signal.js";
|
|
19
|
+
import { agentOutputCause, boundedDetail, bridgeCause, contractScopeConflictFailure, executionFactChangedSignal, verificationGateTimeoutSignal, verificationRedBaseSignal, verificationScopeConflictSignal } from "./failure-signal.js";
|
|
20
20
|
import { forgeTransportFailure } from "./transport-fault.js";
|
|
21
21
|
import { managedWorkspacePath, switchManagedWorkspace } from "./on-shift-apply.js";
|
|
22
22
|
import { formatResetMinute, parseQuotaResetAt } from "./quota-reset.js";
|
|
@@ -28,7 +28,7 @@ import { ensureLandCommit } from "./ensure-land-commit.js";
|
|
|
28
28
|
import { AGENT_NO_LAND_COMMIT_PREFIX, AgentNoLandCommitError, agentNoLandCommitMessage, requiresLandCommit } from "./land-contract.js";
|
|
29
29
|
import { agentClaimsRepositoryWork, claimsVsGitMismatch, isLandTreeEmpty, reportClaimsRepositoryWork, } from "./land-git-reality.js";
|
|
30
30
|
const execFileAsync = promisify(execFile);
|
|
31
|
-
import { boundedTail, captureVerificationFailure, detectGateRanNothing, ensureTestEvidence, needsTestEvidence, runBoundedVerificationCommand, verificationEvidenceCommands, VerificationFailedError, VerificationGateRanNothingError } from "./ensure-test-evidence.js";
|
|
31
|
+
import { boundedTail, captureVerificationFailure, DeliveryReportDefectError, detectGateRanNothing, ensureTestEvidence, needsTestEvidence, runBoundedVerificationCommand, verificationEvidenceCommands, VerificationFailedError, VerificationGateRanNothingError, VERIFICATION_TIMEOUT_MS } from "./ensure-test-evidence.js";
|
|
32
32
|
import { ensureChangeEvidence } from "./ensure-change-evidence.js";
|
|
33
33
|
import { ensureDemonstrationEvidence } from "./ensure-demonstration-evidence.js";
|
|
34
34
|
import { ensureNormativeEvidence } from "./ensure-normative-evidence.js";
|
|
@@ -534,7 +534,17 @@ export function buildDiagnosticPrompt(input) {
|
|
|
534
534
|
* control plane has ended.
|
|
535
535
|
*/
|
|
536
536
|
const slotAborts = new Map();
|
|
537
|
-
|
|
537
|
+
/**
|
|
538
|
+
* The renewal pass in flight. Each running slot and the runner loop all ask for a pass; one pass
|
|
539
|
+
* covers every attempt, so a caller that arrives while one runs waits for it instead of renewing
|
|
540
|
+
* the same leases and dropping the same attempts a second time.
|
|
541
|
+
*/
|
|
542
|
+
let renewalPass = null;
|
|
543
|
+
export function renewLeases(client, config) {
|
|
544
|
+
renewalPass ??= renewEachLease(client, config).finally(() => { renewalPass = null; });
|
|
545
|
+
return renewalPass;
|
|
546
|
+
}
|
|
547
|
+
async function renewEachLease(client, config) {
|
|
538
548
|
for (const active of Object.values(config.activeAttempts)) {
|
|
539
549
|
if (Date.parse(active.leaseExpiresAt) - Date.now() < 120_000) {
|
|
540
550
|
try {
|
|
@@ -633,14 +643,8 @@ function checkpointInstructionPrompt(instructions) {
|
|
|
633
643
|
agentReportTemplate,
|
|
634
644
|
].join("\n");
|
|
635
645
|
}
|
|
636
|
-
function resolveAttemptDriver(
|
|
637
|
-
|
|
638
|
-
return DRIVERS[active.driverId];
|
|
639
|
-
if (fallback)
|
|
640
|
-
return fallback;
|
|
641
|
-
// Crash between claim and driverId persist: pick any free online lane so recovery is not stranded.
|
|
642
|
-
const picked = pickDriverForClaim(config);
|
|
643
|
-
return picked ? DRIVERS[picked] ?? null : null;
|
|
646
|
+
function resolveAttemptDriver(active, fallback) {
|
|
647
|
+
return (active.driverId ? DRIVERS[active.driverId] : undefined) ?? fallback ?? null;
|
|
644
648
|
}
|
|
645
649
|
export async function recoverActiveAttempt(client, config, driver, sourceWorkspace, brief, timeoutMs, supervision, taskId) {
|
|
646
650
|
const active = taskId ? config.activeAttempts[taskId] : Object.values(config.activeAttempts)[0];
|
|
@@ -660,25 +664,15 @@ export async function recoverActiveAttempt(client, config, driver, sourceWorkspa
|
|
|
660
664
|
await removeAttemptWorktree(workspace, worktree).catch(() => undefined);
|
|
661
665
|
}
|
|
662
666
|
else if (!diagnosis && response.retain_worktree !== true && worktree) {
|
|
663
|
-
|
|
664
|
-
await quarantineAttemptWorktree(workspace, worktree, active.attemptId).catch(() => removeAttemptWorktree(workspace, worktree).catch(() => undefined));
|
|
665
|
-
}
|
|
666
|
-
else {
|
|
667
|
-
await removeAttemptWorktree(workspace, worktree).catch(() => undefined);
|
|
668
|
-
}
|
|
667
|
+
await removeAttemptWorktree(workspace, worktree).catch(() => undefined);
|
|
669
668
|
}
|
|
670
669
|
return true;
|
|
671
670
|
}
|
|
672
|
-
const laneDriver = resolveAttemptDriver(
|
|
671
|
+
const laneDriver = resolveAttemptDriver(active, driver);
|
|
673
672
|
if (!laneDriver) {
|
|
674
673
|
console.error(`No driver for attempt ${active.attemptId} (driverId=${active.driverId ?? "unset"})`);
|
|
675
674
|
return false;
|
|
676
675
|
}
|
|
677
|
-
if (!active.driverId) {
|
|
678
|
-
const assigned = Object.entries(DRIVERS).find(([, d]) => d === laneDriver)?.[0];
|
|
679
|
-
if (assigned)
|
|
680
|
-
await client.updateAttempt(active.taskId, { driverId: assigned });
|
|
681
|
-
}
|
|
682
676
|
if (active.phase === "agent_running") {
|
|
683
677
|
const diagnosis = active.executionKind === "diagnosis";
|
|
684
678
|
const worktreeOwner = diagnosis ? active.sourceAttemptId : active.attemptId;
|
|
@@ -706,9 +700,9 @@ export async function recoverActiveAttempt(client, config, driver, sourceWorkspa
|
|
|
706
700
|
await removeAttemptWorktree(workspace, worktree).catch(() => undefined);
|
|
707
701
|
}
|
|
708
702
|
else if (!diagnosis && response.retain_worktree !== true && worktree) {
|
|
709
|
-
// Do not
|
|
703
|
+
// Do not remove before the terminal response: Conduit may need this exact tree for a
|
|
710
704
|
// pinned diagnosis even when Bridge restarted before it could preserve the agent session.
|
|
711
|
-
await
|
|
705
|
+
await removeAttemptWorktree(workspace, worktree).catch(() => undefined);
|
|
712
706
|
}
|
|
713
707
|
return true;
|
|
714
708
|
}
|
|
@@ -752,7 +746,7 @@ export async function pumpExecutionSlots(client, config, workspace, brief, timeo
|
|
|
752
746
|
const active = config.activeAttempts[id];
|
|
753
747
|
const laneDriver = active.phase === "terminal_pending"
|
|
754
748
|
? fallbackDriver
|
|
755
|
-
: resolveAttemptDriver(
|
|
749
|
+
: resolveAttemptDriver(active, fallbackDriver);
|
|
756
750
|
if (!laneDriver && active.phase !== "terminal_pending") {
|
|
757
751
|
console.error(`Slot recovery skipped for ${id}: no driver lane`);
|
|
758
752
|
continue;
|
|
@@ -799,7 +793,7 @@ export async function pumpExecutionSlots(client, config, workspace, brief, timeo
|
|
|
799
793
|
break;
|
|
800
794
|
const taskId = claimed.taskId;
|
|
801
795
|
driverId = claimed.driverId ?? driverId;
|
|
802
|
-
laneDriver =
|
|
796
|
+
laneDriver = fallbackDriver ?? DRIVERS[driverId] ?? laneDriver;
|
|
803
797
|
progressed = true;
|
|
804
798
|
console.log(`Executing ${taskId} via ${laneDriver.name}`);
|
|
805
799
|
// T86: the claim answers with the checkout it proved, which is this assignment's repository.
|
|
@@ -945,27 +939,6 @@ export async function claimNextAssignment(client, config, workspace, brief, driv
|
|
|
945
939
|
});
|
|
946
940
|
return { taskId: assignment.id, driverId: selectedDriverId, workspace: claimWorkspace };
|
|
947
941
|
}
|
|
948
|
-
export function claimedAssignmentDriver(fallback, driverId) {
|
|
949
|
-
if (!driverId)
|
|
950
|
-
return fallback;
|
|
951
|
-
const selected = DRIVERS[driverId];
|
|
952
|
-
if (!selected)
|
|
953
|
-
throw new Error(`Claimed assignment selected unavailable driver ${driverId}`);
|
|
954
|
-
return selected;
|
|
955
|
-
}
|
|
956
|
-
export async function executeNextAssignment(client, config, driver, workspace, brief, timeoutMs, supervision) {
|
|
957
|
-
if (Object.keys(config.activeAttempts).length)
|
|
958
|
-
return recoverActiveAttempt(client, config, driver, workspace, brief, timeoutMs, supervision);
|
|
959
|
-
const preferredDriverId = Object.entries(DRIVERS).find(([, candidate]) => candidate === driver)?.[0] ?? null;
|
|
960
|
-
const claimed = await claimNextAssignment(client, config, workspace, brief, preferredDriverId, null, {
|
|
961
|
-
beforeClaim: supervision?.beforeClaim,
|
|
962
|
-
});
|
|
963
|
-
if (!claimed)
|
|
964
|
-
return false;
|
|
965
|
-
const selectedDriver = claimedAssignmentDriver(driver, claimed.driverId);
|
|
966
|
-
await runClaimedAssignment(client, config, selectedDriver, claimed.workspace, brief, claimed.taskId, timeoutMs, supervision);
|
|
967
|
-
return true;
|
|
968
|
-
}
|
|
969
942
|
/**
|
|
970
943
|
* T90: attempts whose stall report already ended them, by attempt id.
|
|
971
944
|
*
|
|
@@ -1342,9 +1315,9 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1342
1315
|
return;
|
|
1343
1316
|
}
|
|
1344
1317
|
// T22/T24/T28: the gates run once here, before the first model call. A gate that started but
|
|
1345
|
-
// tested nothing is the earliest fact and refuses first; then a gate
|
|
1346
|
-
// base; only a green base still runs the scope check, which refuses
|
|
1347
|
-
// approved change scope.
|
|
1318
|
+
// tested nothing is the earliest fact and refuses first; then a gate that never exited; then a gate
|
|
1319
|
+
// already red on the untouched base; only a green base still runs the scope check, which refuses
|
|
1320
|
+
// when a gate wrote outside the approved change scope.
|
|
1348
1321
|
if (dryRunsVerification({
|
|
1349
1322
|
executionKind,
|
|
1350
1323
|
executionClass,
|
|
@@ -1388,6 +1361,25 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1388
1361
|
console.error(`Assignment ${taskId} refused before the agent started: ${redactSecrets(detail)}`);
|
|
1389
1362
|
return;
|
|
1390
1363
|
}
|
|
1364
|
+
if (dryRun.timedOut) {
|
|
1365
|
+
const { command, stderrTail, stdoutTail } = dryRun.timedOut;
|
|
1366
|
+
const detail = `Verification gate did not finish on the base commit ${worktreeStart}: ${command} (stopped after ${Math.round(VERIFICATION_TIMEOUT_MS / 60_000)} minutes)`;
|
|
1367
|
+
const response = await queueTerminal(client, taskId, {
|
|
1368
|
+
action: "fail",
|
|
1369
|
+
body: {
|
|
1370
|
+
error: detail,
|
|
1371
|
+
retryable: false,
|
|
1372
|
+
signal: verificationGateTimeoutSignal({ command, baseCommit: worktreeStart, stderr: stderrTail, stdout: stdoutTail }),
|
|
1373
|
+
idempotency_key: `bridge:verification-gate-timeout:${active.attemptId}`,
|
|
1374
|
+
},
|
|
1375
|
+
});
|
|
1376
|
+
// No agent ran and the tree stands at the base commit, so it holds no diagnostic value.
|
|
1377
|
+
if (response.retain_worktree !== true) {
|
|
1378
|
+
await removeAttemptWorktree(workspace, attemptWorkspace).catch(() => undefined);
|
|
1379
|
+
}
|
|
1380
|
+
console.error(`Assignment ${taskId} refused before the agent started: ${redactSecrets(detail)}`);
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1391
1383
|
if (dryRun.redBase) {
|
|
1392
1384
|
const { command, exitCode, stderrTail, stdoutTail } = dryRun.redBase;
|
|
1393
1385
|
const detail = `Verification fails on the base commit ${worktreeStart}: ${command} (exit ${exitCode ?? "unknown"})`;
|
|
@@ -2264,11 +2256,16 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
2264
2256
|
.then((stat) => stat?.slice(0, 8_000) ?? null)
|
|
2265
2257
|
.catch(() => null)
|
|
2266
2258
|
: null;
|
|
2259
|
+
// A report that fell short of the delivery contract is named by its type and travels as the
|
|
2260
|
+
// delivery_report signal, so repair routing never reads this message (K3, debt 18 item 3c).
|
|
2261
|
+
const reportDefect = error instanceof DeliveryReportDefectError;
|
|
2267
2262
|
const classified = verificationFailed
|
|
2268
2263
|
? { retryable: true, error: `Verification failed after the agent run.\n${message}${diffStat ? `\n\nChanged files:\n${diffStat}` : ""}` }
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2264
|
+
: reportDefect
|
|
2265
|
+
? { retryable: false, error: message }
|
|
2266
|
+
// classifyFinalizeFailure: forge transport → retryable; contract/environment/unknown → fail
|
|
2267
|
+
// closed (unknown prefixed as "Bridge finalize interrupted", not laundered as contract).
|
|
2268
|
+
: classifyFinalizeFailure(message);
|
|
2272
2269
|
if (verificationFailed)
|
|
2273
2270
|
retainAttemptWorktree = true;
|
|
2274
2271
|
const response = await queueTerminal(client, taskId, {
|
|
@@ -2284,6 +2281,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
2284
2281
|
command: error.command,
|
|
2285
2282
|
...(diffStat ? { diff_stat: diffStat } : {}),
|
|
2286
2283
|
} } : {}),
|
|
2284
|
+
...(reportDefect ? { signal: { phase: "delivery_report", witness: "bridge", kind: "contract" } } : {}),
|
|
2287
2285
|
// A rework's own PR may already be open (ensureDeliveryPullRequest ran before this
|
|
2288
2286
|
// gate). Carry the URL so the control plane can close it instead of littering.
|
|
2289
2287
|
...(report.pull_request_url ? { pull_request_url: report.pull_request_url } : {}),
|
|
@@ -2759,7 +2757,7 @@ export function validateDeliveryReport(report, spec, grants = [], actualPaths, b
|
|
|
2759
2757
|
}
|
|
2760
2758
|
});
|
|
2761
2759
|
if (!published) {
|
|
2762
|
-
throw new
|
|
2760
|
+
throw new DeliveryReportDefectError("Artifact delivery requires a published HTTP(S) URL and sha256 digest");
|
|
2763
2761
|
}
|
|
2764
2762
|
}
|
|
2765
2763
|
// Invariant 9: grants bound what an attempt may do. Cursor/plan-mode are soft hints the headless
|
|
@@ -2773,7 +2771,7 @@ export function validateDeliveryReport(report, spec, grants = [], actualPaths, b
|
|
|
2773
2771
|
if (grants.includes("pr_create")
|
|
2774
2772
|
&& report.head_commit
|
|
2775
2773
|
&& !report.pull_request_url) {
|
|
2776
|
-
throw new
|
|
2774
|
+
throw new DeliveryReportDefectError("Repository changes require a pull_request_url when pr_create is granted");
|
|
2777
2775
|
}
|
|
2778
2776
|
const requiredEvidence = spec.required_evidence ?? [];
|
|
2779
2777
|
const supplied = new Set(report.evidence.map((item) => item.kind));
|
|
@@ -2782,20 +2780,20 @@ export function validateDeliveryReport(report, spec, grants = [], actualPaths, b
|
|
|
2782
2780
|
// Name the base Bridge diffed against: an empty path list is a fact about that base, not proof
|
|
2783
2781
|
// that the agent did no work. Without the base the repair brief can only guess the symptom.
|
|
2784
2782
|
const since = missing.includes("change") && baseCommit ? ` (no paths changed since ${baseCommit})` : "";
|
|
2785
|
-
throw new
|
|
2783
|
+
throw new DeliveryReportDefectError(`Agent report is missing required evidence: ${missing.join(", ")}${since}`);
|
|
2786
2784
|
}
|
|
2787
2785
|
const unsupported = report.acceptance_results
|
|
2788
2786
|
.filter((result) => result.status === "met" && !report.evidence.some((item) => item.acceptance_criteria.includes(result.criterion)))
|
|
2789
2787
|
.map((result) => result.criterion);
|
|
2790
2788
|
if (unsupported.length)
|
|
2791
|
-
throw new
|
|
2789
|
+
throw new DeliveryReportDefectError(`Met acceptance criteria require mapped evidence: ${unsupported.join("; ")}`);
|
|
2792
2790
|
// Plan-time normalizeRequiredEvidence puts "test" on verify packages; reject summary-only details.
|
|
2793
2791
|
if (requiredEvidence.includes("test")) {
|
|
2794
2792
|
const thin = report.evidence
|
|
2795
2793
|
.filter((item) => item.kind === "test")
|
|
2796
2794
|
.filter((item) => item.details.join("\n").trim().length < TEST_EVIDENCE_DETAILS_MIN);
|
|
2797
2795
|
if (thin.length) {
|
|
2798
|
-
throw new
|
|
2796
|
+
throw new DeliveryReportDefectError("test evidence must include verbatim command output in details (not a summary)");
|
|
2799
2797
|
}
|
|
2800
2798
|
}
|
|
2801
2799
|
}
|
|
@@ -2882,15 +2880,28 @@ export function verificationScopeConflictDetail(paths, changeScope) {
|
|
|
2882
2880
|
export async function verificationScopeDryRun(input) {
|
|
2883
2881
|
const commands = verificationEvidenceCommands(input.verificationCommands, input.declaredVerificationCommands ?? [], input.acceptance ?? null);
|
|
2884
2882
|
if (!commands.length)
|
|
2885
|
-
return { ranNothing: null, redBase: null, outsideScope: [] };
|
|
2883
|
+
return { ranNothing: null, timedOut: null, redBase: null, outsideScope: [] };
|
|
2886
2884
|
const run = input.runCommand ?? runBoundedVerificationCommand;
|
|
2887
2885
|
let ranNothing = null;
|
|
2886
|
+
let timedOut = null;
|
|
2888
2887
|
let redBase = null;
|
|
2889
2888
|
for (const command of commands) {
|
|
2890
2889
|
// A gate that cannot start says nothing about the scope; the next one still can.
|
|
2891
2890
|
const result = await run(command, input.workspace).catch(() => null);
|
|
2892
2891
|
if (!result)
|
|
2893
2892
|
continue;
|
|
2893
|
+
// A gate that never exited says nothing about the base commit: it is not a red base, and it
|
|
2894
|
+
// does not go to the initiative author.
|
|
2895
|
+
if (result.timedOut) {
|
|
2896
|
+
if (!timedOut) {
|
|
2897
|
+
timedOut = {
|
|
2898
|
+
command,
|
|
2899
|
+
...(result.stderr.trim() ? { stderrTail: result.stderr } : {}),
|
|
2900
|
+
...(result.stdout.trim() ? { stdoutTail: result.stdout } : {}),
|
|
2901
|
+
};
|
|
2902
|
+
}
|
|
2903
|
+
continue;
|
|
2904
|
+
}
|
|
2894
2905
|
// Ahead of the exit code: a gate that discovered no test exits 5, and a no-op recipe exits 0
|
|
2895
2906
|
// with no output. Neither is a red base, so neither goes to the initiative author.
|
|
2896
2907
|
const reason = detectGateRanNothing(command, result.stdout, result.stderr, result.code)
|
|
@@ -2915,7 +2926,7 @@ export async function verificationScopeDryRun(input) {
|
|
|
2915
2926
|
const outsideScope = written === null
|
|
2916
2927
|
? []
|
|
2917
2928
|
: written.filter((path) => !input.changeScope.some((scope) => pathMatchesScope(path, scope)));
|
|
2918
|
-
return { ranNothing, redBase, outsideScope };
|
|
2929
|
+
return { ranNothing, timedOut, redBase, outsideScope };
|
|
2919
2930
|
}
|
|
2920
2931
|
/** Prompt and failure lines keep a short tail; the investigation brief caps each failure at 1,000. */
|
|
2921
2932
|
const DELIVERY_VERIFICATION_GATE_TAIL_CHARS = 800;
|
package/dist/failure-signal.js
CHANGED
|
@@ -228,9 +228,10 @@ export function contractScopeConflictFailure(detail) {
|
|
|
228
228
|
function resetClock(resetsAt) {
|
|
229
229
|
return `${resetsAt.slice(0, 16).replace("T", " ")} UTC`;
|
|
230
230
|
}
|
|
231
|
-
/** Signal tails are bounded: one runaway log cannot fill the wire or the card.
|
|
231
|
+
/** Signal tails are bounded: one runaway log cannot fill the wire or the card. A tail keeps the
|
|
232
|
+
* end of the output, because the decisive error is printed last. */
|
|
232
233
|
function bounded2k(text) {
|
|
233
|
-
return text.length > 2_000 ?
|
|
234
|
+
return text.length > 2_000 ? `…${text.slice(-1_999)}` : text;
|
|
234
235
|
}
|
|
235
236
|
/**
|
|
236
237
|
* Vendor text that names the model as the reason the CLI refused the run. The model name is the one
|
|
@@ -398,14 +399,33 @@ export function verificationRedBaseSignal(input) {
|
|
|
398
399
|
...(input.stdout?.trim() ? { stdout_tail: bounded2k(input.stdout) } : {}),
|
|
399
400
|
};
|
|
400
401
|
}
|
|
402
|
+
/**
|
|
403
|
+
* The signal Bridge emits when the verification timeout stops a gate on the base commit, before the
|
|
404
|
+
* agent starts. The gate never exited, so it says nothing about the base: read as exit 1, a gate
|
|
405
|
+
* that hung (waiting for input or the network, or on a slow computer) was a red base on the author.
|
|
406
|
+
*/
|
|
407
|
+
export function verificationGateTimeoutSignal(input) {
|
|
408
|
+
return {
|
|
409
|
+
phase: "verification",
|
|
410
|
+
witness: "bridge",
|
|
411
|
+
kind: "timeout",
|
|
412
|
+
command: input.command,
|
|
413
|
+
base_commit: input.baseCommit,
|
|
414
|
+
exit_code: null,
|
|
415
|
+
...(input.stderr?.trim() ? { stderr_tail: bounded2k(input.stderr) } : {}),
|
|
416
|
+
...(input.stdout?.trim() ? { stdout_tail: bounded2k(input.stdout) } : {}),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
401
419
|
/** A fact value as a person reads it on the card; an absent value says so in words. */
|
|
402
420
|
function factValue(value) {
|
|
403
421
|
return value === null ? "none" : value;
|
|
404
422
|
}
|
|
405
|
-
/** The vendor's own reason, kept inside the 2,000-char message bound. */
|
|
423
|
+
/** The vendor's own reason, kept inside the 2,000-char message bound; a stderr tail keeps its end. */
|
|
406
424
|
function quotedVendorText(signal) {
|
|
407
|
-
|
|
408
|
-
|
|
425
|
+
if (signal.vendor_message)
|
|
426
|
+
return signal.vendor_message.length > 400 ? `${signal.vendor_message.slice(0, 399)}…` : signal.vendor_message;
|
|
427
|
+
const tail = signal.stderr_tail ?? "";
|
|
428
|
+
return tail.length > 400 ? `…${tail.slice(-399)}` : tail;
|
|
409
429
|
}
|
|
410
430
|
/**
|
|
411
431
|
* Classify one typed signal. Null when the signal carries no typed verdict (`kind: "unknown"`):
|
|
@@ -418,17 +438,19 @@ function quotedVendorText(signal) {
|
|
|
418
438
|
* 2. model_unbound → the tier has no other model to retry into: Hold, naming the tier and driver.
|
|
419
439
|
* 3. denied_tool / usage_error → the driver and vendor cannot run this assignment: Hold.
|
|
420
440
|
* 4. a changed pinned execution fact → the computer moved under the run: Hold, then Retry re-pins.
|
|
421
|
-
* 5. a gate
|
|
441
|
+
* 5. a gate the verification timeout stopped on the base commit → it never exited, so it says
|
|
442
|
+
* nothing about the base: Hold, on the computer operator, naming the gate.
|
|
443
|
+
* 6. a gate that already fails on the untouched base commit → no agent ran and none can repair a
|
|
422
444
|
* gate broken before it started: Hold, on the initiative author, naming the gate and the base.
|
|
423
|
-
*
|
|
445
|
+
* 7. verification whose gates wrote outside the approved change scope → the work package is the
|
|
424
446
|
* defect: Hold, on the initiative author, who opens a revision.
|
|
425
|
-
*
|
|
447
|
+
* 8. verification run by Bridge whose gate ran nothing → the gate is misconfigured, not failed:
|
|
426
448
|
* Hold, on the computer operator, naming the command and the file that declares it. Never a
|
|
427
449
|
* repair attempt: the delivery pack may be entirely correct.
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
450
|
+
* 9. verification run by Bridge, any other cause → the verify class: diagnose and brief a repair.
|
|
451
|
+
* 10. contract, or a delivery_report phase → the delivery contract codes: rework.
|
|
452
|
+
* 11. timeout → the turn reached its execution limit: stop.
|
|
453
|
+
* 12. turn_cap → the run reached the computer's per-attempt turn cap: Hold, on the operator, who
|
|
432
454
|
* decides whether the work was looping or the cap is too tight. Never a silent retry.
|
|
433
455
|
*/
|
|
434
456
|
export function classifyFailureSignal(signal) {
|
|
@@ -532,6 +554,21 @@ export function classifyFailureSignal(signal) {
|
|
|
532
554
|
diagnostic_detail: `${fact.name}: pinned ${factValue(fact.pinned)}, found ${factValue(fact.observed)}`,
|
|
533
555
|
};
|
|
534
556
|
}
|
|
557
|
+
// Ahead of the red-base and generic verification rules: the gate never exited, so neither the
|
|
558
|
+
// base commit nor a change is known to be at fault. The generic rule read it as a failed test.
|
|
559
|
+
if (signal.phase === "verification" && signal.kind === "timeout") {
|
|
560
|
+
const command = signal.command ?? "the declared gate";
|
|
561
|
+
const base = signal.base_commit ?? "the base commit";
|
|
562
|
+
return {
|
|
563
|
+
code: "verification_gate_timeout",
|
|
564
|
+
class: "environment",
|
|
565
|
+
disposition: "hold",
|
|
566
|
+
responsible_party: "computer_operator",
|
|
567
|
+
message: `The verification gate \`${command}\` did not finish on the base commit ${base} within Bridge's 10-minute limit, so Bridge stopped it before any change was made.`,
|
|
568
|
+
next_action: `Run \`${command}\` on this computer and find where it hangs — a test that waits for input or the network, or a computer too slow for the gate — then fix it and Recheck. Conduit does not retry or repair a gate that does not finish.`,
|
|
569
|
+
diagnostic_detail: [`$ ${command}`, "stopped after 10 minutes", signal.stderr_tail, signal.stdout_tail].filter(Boolean).join("\n"),
|
|
570
|
+
};
|
|
571
|
+
}
|
|
535
572
|
// Ahead of the scope and generic verification rules: the base commit itself does not pass the
|
|
536
573
|
// gates, so no agent ran and no agent can repair a gate that was already broken before it
|
|
537
574
|
// started. This is the earlier fact — checked in the dry run before the scope conflict — so it
|
|
@@ -614,7 +651,7 @@ export function classifyFailureSignal(signal) {
|
|
|
614
651
|
disposition: "rework",
|
|
615
652
|
responsible_party: "conduit",
|
|
616
653
|
message: "The delivery report did not satisfy the delivery contract.",
|
|
617
|
-
next_action: "
|
|
654
|
+
next_action: "Conductor will prepare bounded recovery guidance. You do not need to edit technical constraints.",
|
|
618
655
|
diagnostic_detail: signal.vendor_message ?? signal.stderr_tail,
|
|
619
656
|
}
|
|
620
657
|
: {
|
package/dist/investigation.js
CHANGED
|
@@ -12,11 +12,11 @@ import { createAttemptWorktree, removeAttemptWorktree } from "./attempt-worktree
|
|
|
12
12
|
import { buildWorkspaceBrief, ensureCommitAvailable, normalizeRepositoryUrl } from "./brief.js";
|
|
13
13
|
import { headCommitOrNull } from "./git.js";
|
|
14
14
|
import { learnDriverFuel, resolveAssignmentModel, changedPathsSince, runDeliveryVerificationGates, discardWorktreeWrites } from "./execution.js";
|
|
15
|
-
import { DRIVERS, extractAgentReportJsonText, parseJsonObjectCandidate } from "./driver.js";
|
|
15
|
+
import { DRIVERS, execute, extractAgentReportJsonText, parseJsonObjectCandidate } from "./driver.js";
|
|
16
16
|
import { pickDriverForClaim, resolveDriverFuel, supportsReadOnlyDiagnosis } from "./drivers.js";
|
|
17
17
|
import { diffStatSince } from "./git-witness.js";
|
|
18
18
|
import { switchManagedWorkspace } from "./on-shift-apply.js";
|
|
19
|
-
import { execFile
|
|
19
|
+
import { execFile } from "node:child_process";
|
|
20
20
|
import { rm, writeFile } from "node:fs/promises";
|
|
21
21
|
import { createRequire } from "node:module";
|
|
22
22
|
import { join } from "node:path";
|
|
@@ -634,34 +634,17 @@ function toStepWitness(witness) {
|
|
|
634
634
|
instrument: witness.instrument === "delivered" ? "delivered" : "running",
|
|
635
635
|
};
|
|
636
636
|
}
|
|
637
|
+
/**
|
|
638
|
+
* The delivered instrument runs through the agent runs' own process helper (T90): bounded output,
|
|
639
|
+
* an answer on the child's exit even when a process it left behind holds the pipes, and a stop
|
|
640
|
+
* that escalates to SIGKILL. Its environment stays the probe's own, PATH and HOME only.
|
|
641
|
+
*/
|
|
637
642
|
async function execDeliveredProbeRun(argv, stdin, workspace, timeoutMs) {
|
|
638
643
|
const [file, ...args] = argv;
|
|
639
644
|
if (!file)
|
|
640
645
|
throw new Error("delivered probe command is empty");
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
cwd: workspace,
|
|
644
|
-
env: probeEnv(),
|
|
645
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
646
|
-
});
|
|
647
|
-
let stdout = "";
|
|
648
|
-
let stderr = "";
|
|
649
|
-
const timer = setTimeout(() => {
|
|
650
|
-
child.kill("SIGKILL");
|
|
651
|
-
}, timeoutMs);
|
|
652
|
-
child.stdout?.on("data", (chunk) => { stdout += String(chunk); });
|
|
653
|
-
child.stderr?.on("data", (chunk) => { stderr += String(chunk); });
|
|
654
|
-
child.on("error", (error) => {
|
|
655
|
-
clearTimeout(timer);
|
|
656
|
-
reject(error);
|
|
657
|
-
});
|
|
658
|
-
child.on("close", (code) => {
|
|
659
|
-
clearTimeout(timer);
|
|
660
|
-
resolve({ code: code ?? -1, stdout, stderr });
|
|
661
|
-
});
|
|
662
|
-
child.stdin?.on("error", () => undefined);
|
|
663
|
-
child.stdin?.end(stdin);
|
|
664
|
-
});
|
|
646
|
+
const result = await execute(file, args, workspace, timeoutMs, undefined, "conduit", stdin, undefined, undefined, probeEnv());
|
|
647
|
+
return { code: result.code ?? -1, stdout: result.stdout, stderr: result.stderr };
|
|
665
648
|
}
|
|
666
649
|
export async function runDeliveredProbeInstrument(input) {
|
|
667
650
|
const entry = join(input.workspace, PROBE_RUN_ENTRY);
|
|
@@ -724,10 +707,6 @@ export const INVESTIGATION_RENEW_INTERVAL_MS = 2 * 60_000;
|
|
|
724
707
|
export function investigationRunId(investigationId) {
|
|
725
708
|
return `${investigationId}-${crypto.randomUUID().slice(0, 8)}`;
|
|
726
709
|
}
|
|
727
|
-
async function deleteRunBranch(workspace, runId) {
|
|
728
|
-
await execFileAsync("git", ["-C", workspace, "branch", "-D", `conduit/attempt/${runId}`], { timeout: 30_000, maxBuffer: 1_000_000 })
|
|
729
|
-
.catch(() => undefined);
|
|
730
|
-
}
|
|
731
710
|
export async function executeNextInvestigation(client, config, workspace, brief, timeoutMs, options = {}) {
|
|
732
711
|
const data = await client.request("/runner/v1/investigations");
|
|
733
712
|
const assignments = z.array(investigationAssignmentSchema).parse(data.investigations ?? []);
|
|
@@ -959,7 +938,6 @@ export async function executeNextInvestigation(client, config, workspace, brief,
|
|
|
959
938
|
if (attemptWorkspace) {
|
|
960
939
|
await removeAttemptWorktree(workspace, attemptWorkspace)
|
|
961
940
|
.catch((error) => console.error(`Investigation worktree release failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`));
|
|
962
|
-
await deleteRunBranch(workspace, runId);
|
|
963
941
|
}
|
|
964
942
|
}
|
|
965
943
|
return true;
|
package/dist/ops.js
CHANGED
|
@@ -191,14 +191,6 @@ export function resolveInstallDrivers(env, argv, detect = detectInstalledClients
|
|
|
191
191
|
return [preferred];
|
|
192
192
|
});
|
|
193
193
|
}
|
|
194
|
-
/** Quote args for a copy-pasteable shell/cmd line (paths with spaces). */
|
|
195
|
-
export function shellQuoteArgs(args) {
|
|
196
|
-
return args.map((arg) => {
|
|
197
|
-
if (!/[^\w@%+=:,./\\-]/i.test(arg))
|
|
198
|
-
return arg;
|
|
199
|
-
return `"${arg.replaceAll('"', '\\"')}"`;
|
|
200
|
-
}).join(" ");
|
|
201
|
-
}
|
|
202
194
|
/** Ask the operator to approve a step that cannot be undone. Non-interactive shells must pass --yes. */
|
|
203
195
|
async function confirmOps(question) {
|
|
204
196
|
if (!input.isTTY || !output.isTTY) {
|
package/dist/service.js
CHANGED
|
@@ -262,7 +262,6 @@ export function runnerServiceWorkspaceWarnings(declaredWorkspace, home = homedir
|
|
|
262
262
|
}
|
|
263
263
|
return warnings;
|
|
264
264
|
}
|
|
265
|
-
export const WINDOWS_TASK_NAME = "ConduitBridgeRunner";
|
|
266
265
|
/** Quote one argv token for a Windows Task Scheduler /TR command line. */
|
|
267
266
|
export function quoteWindowsTaskArg(value) {
|
|
268
267
|
if (!/[\s&<>|^()"]/.test(value))
|
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.143",
|
|
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": {
|