@miraland-labs/conduit-bridge 0.16.157 → 0.16.160

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -69,10 +69,16 @@ export class ConduitClient {
69
69
  signal: init.signal ?? AbortSignal.timeout(CONTROL_PLANE_TIMEOUT_MS),
70
70
  headers: { authorization: `Bearer ${this.config.runnerKey}`, "content-type": "application/json", ...init.headers },
71
71
  });
72
- const data = await response.json();
73
- if (!response.ok)
74
- throw new ConduitRequestError(data.error?.message ?? `Conduit request failed (${response.status})`, response.status, data.error?.code, data.error?.details);
75
- return data;
72
+ if (!response.ok) {
73
+ const text = await response.text();
74
+ let data = null;
75
+ try {
76
+ data = JSON.parse(text);
77
+ }
78
+ catch { /* A proxy may answer with a non-JSON error page. */ }
79
+ throw new ConduitRequestError(data?.error?.message ?? `Conduit request failed (${response.status})`, response.status, data?.error?.code, data?.error?.details);
80
+ }
81
+ return await response.json();
76
82
  }
77
83
  async claim(taskId, attemptId, extras) {
78
84
  // The lease token is this machine's bearer credential for the attempt. The server stores only
package/dist/driver.js CHANGED
@@ -1884,18 +1884,18 @@ const MAX_AGENT_OUTPUT = 8 * 1024 * 1024;
1884
1884
  */
1885
1885
  export const STDIO_DRAIN_MS = 2_000;
1886
1886
  /** How long a stopped child may take to die before the run reports what it already has. */
1887
- const CHILD_STOP_GRACE_MS = 20_000;
1887
+ export const CHILD_STOP_GRACE_MS = 20_000;
1888
1888
  /**
1889
1889
  * An agent runs as the leader of its own process group, so what it starts in the background (a dev
1890
1890
  * server, a test watcher, a database) is stopped with it. Before, only the agent itself was
1891
1891
  * stopped: its background processes outlived the run and held files and ports on the computer
1892
1892
  * (acme-sim SV1b, 2026-10-10). Windows has no process groups; there only the agent is stopped.
1893
1893
  */
1894
- const OWN_PROCESS_GROUP = process.platform !== "win32";
1894
+ export const OWN_PROCESS_GROUP = process.platform !== "win32";
1895
1895
  /** Process groups of the agents running now, stopped when the runner itself is stopped. */
1896
1896
  const runningGroups = new Set();
1897
1897
  let shutdownHookInstalled = false;
1898
- function signalGroup(pid, signal) {
1898
+ export function signalGroup(pid, signal) {
1899
1899
  try {
1900
1900
  process.kill(-pid, signal);
1901
1901
  }
@@ -1903,6 +1903,17 @@ function signalGroup(pid, signal) {
1903
1903
  // ESRCH: every process of the group is already gone.
1904
1904
  }
1905
1905
  }
1906
+ export function trackProcessGroup(pid) {
1907
+ installShutdownHook();
1908
+ runningGroups.add(pid);
1909
+ }
1910
+ export function untrackProcessGroup(pid) {
1911
+ runningGroups.delete(pid);
1912
+ }
1913
+ export function stopTrackedProcessGroups() {
1914
+ for (const pid of runningGroups)
1915
+ signalGroup(pid, "SIGKILL");
1916
+ }
1906
1917
  /**
1907
1918
  * systemd stops a whole cgroup, but launchd stops only the runner's own process group, which an
1908
1919
  * agent no longer shares. Stop the agents' groups first, then let the signal end the runner as it
@@ -1914,8 +1925,7 @@ function installShutdownHook() {
1914
1925
  shutdownHookInstalled = true;
1915
1926
  for (const signal of ["SIGTERM", "SIGINT"]) {
1916
1927
  process.once(signal, () => {
1917
- for (const pid of runningGroups)
1918
- signalGroup(pid, "SIGKILL");
1928
+ stopTrackedProcessGroups();
1919
1929
  process.kill(process.pid, signal);
1920
1930
  });
1921
1931
  }
@@ -1931,10 +1941,8 @@ env) {
1931
1941
  detached: OWN_PROCESS_GROUP,
1932
1942
  });
1933
1943
  const group = OWN_PROCESS_GROUP ? child.pid : undefined;
1934
- if (group !== undefined) {
1935
- installShutdownHook();
1936
- runningGroups.add(group);
1937
- }
1944
+ if (group !== undefined)
1945
+ trackProcessGroup(group);
1938
1946
  const stop = (signal) => {
1939
1947
  if (group !== undefined)
1940
1948
  signalGroup(group, signal);
@@ -1966,7 +1974,7 @@ env) {
1966
1974
  child.stderr?.destroy();
1967
1975
  // The agent has answered; whatever it left running in its group stops now.
1968
1976
  if (group !== undefined) {
1969
- runningGroups.delete(group);
1977
+ untrackProcessGroup(group);
1970
1978
  signalGroup(group, "SIGTERM");
1971
1979
  setTimeout(() => signalGroup(group, "SIGKILL"), 5_000).unref?.();
1972
1980
  }
@@ -2012,7 +2020,7 @@ env) {
2012
2020
  return;
2013
2021
  settled = true;
2014
2022
  if (group !== undefined)
2015
- runningGroups.delete(group);
2023
+ untrackProcessGroup(group);
2016
2024
  clearTimeout(timer);
2017
2025
  signal?.removeEventListener("abort", stopChild);
2018
2026
  reject(error);
@@ -37,7 +37,7 @@ export async function ensureLandCommit(input) {
37
37
  return { kind: "skipped" };
38
38
  const countAhead = input.countCommitsAhead ?? commitsAheadOfBase;
39
39
  const readHead = input.readHeadCommit ?? readHeadCommit;
40
- const listPending = input.listUncommittedPaths ?? uncommittedPaths;
40
+ const listPending = input.listUncommittedPaths ?? ((workspace) => uncommittedPaths(workspace));
41
41
  const commitPaths = input.commitPaths ?? commitScopedPaths;
42
42
  const ahead = await countAhead(input.workspace, base);
43
43
  if (ahead !== null && ahead > 0) {
@@ -8,10 +8,13 @@ import { promisify } from "node:util";
8
8
  import { normalizeRepositoryUrl } from "./brief.js";
9
9
  import { readHeadCommit } from "./git.js";
10
10
  import { forgeTransportFailure } from "./transport-fault.js";
11
+ import { ConduitRequestError } from "./client.js";
12
+ import { redactSecrets } from "./secret-redaction.js";
11
13
  import { agentNoLandCommitMessage } from "./land-contract.js";
12
14
  const execFileAsync = promisify(execFile);
13
15
  /** Keep the pull request body inside forge limits, even for a very large delivery packet. */
14
16
  const PULL_REQUEST_BODY_MAX_BYTES = 60_000;
17
+ export const CONTROL_PLANE_TRANSIENT_PREFIX = "Control plane did not answer the change request open: ";
15
18
  /**
16
19
  * What the packet says when the control plane refused to open the change request.
17
20
  *
@@ -289,14 +292,16 @@ export async function ensureDeliveryPullRequest(input) {
289
292
  head_branch: headBranch,
290
293
  head_commit: report.head_commit,
291
294
  title: (input.title ?? "Conduit delivery").slice(0, 200),
292
- body: composePullRequestBody(report),
295
+ body: redactSecrets(composePullRequestBody(report)),
293
296
  idempotency_key: `bridge:open-pr:${input.attemptId}`,
294
297
  });
295
298
  }
296
299
  catch (error) {
297
300
  const message = error instanceof Error ? error.message : String(error);
298
- const code = typeof error.code === "string" ? error.code : "";
299
- throw new Error(`${PULL_REQUEST_OPEN_FAILURE_PREFIX}: ${message}${code ? ` (${code})` : ""}`);
301
+ if (error instanceof ConduitRequestError && error.status < 500) {
302
+ throw new Error(`${PULL_REQUEST_OPEN_FAILURE_PREFIX}: ${message}${error.code ? ` (${error.code})` : ""}`);
303
+ }
304
+ throw new Error(`${CONTROL_PLANE_TRANSIENT_PREFIX}${message}`);
300
305
  }
301
306
  const pullRequestUrl = typeof response.pull_request_url === "string" ? response.pull_request_url : "";
302
307
  // Any https URL: the control plane resolved which forge this project uses and parsed the response
@@ -304,7 +309,7 @@ export async function ensureDeliveryPullRequest(input) {
304
309
  // GitLab merge request — after the branch was already pushed. The head-commit cross-check below is
305
310
  // what actually proves the returned change request is this delivery's.
306
311
  if (!isAbsoluteHttpsUrl(pullRequestUrl)) {
307
- throw new Error("Control plane did not return a pull_request_url");
312
+ throw new Error("Control plane answered without a change request link");
308
313
  }
309
314
  const openedHead = typeof response.head_commit === "string" ? response.head_commit : "";
310
315
  const deliveredHead = report.head_commit ?? "";
@@ -3,15 +3,13 @@
3
3
  * commands and attaches their verbatim results. Agent prose may supplement those runs;
4
4
  * it never substitutes for it.
5
5
  */
6
- import { execFile } from "node:child_process";
7
- import { promisify } from "node:util";
8
- import { boundedEnvironment, criterionKey, resolveCriterionReference } from "./driver.js";
6
+ import { spawn } from "node:child_process";
7
+ import { boundedEnvironment, criterionKey, resolveCriterionReference, signalGroup, STDIO_DRAIN_MS, CHILD_STOP_GRACE_MS, OWN_PROCESS_GROUP, trackProcessGroup, untrackProcessGroup } from "./driver.js";
9
8
  import { isBoundedVerificationCommand, isRunnableVerificationCommand } from "./execution-class.js";
10
- const execFileAsync = promisify(execFile);
11
9
  const TEST_EVIDENCE_DETAILS_MIN = 32;
12
10
  /** Cold `cargo test` in an attempt worktree routinely exceeds two minutes of compile. */
13
11
  export const VERIFICATION_TIMEOUT_MS = 600_000;
14
- /** rustc + cargo logs routinely exceed 2 MiB; Node kills the child when maxBuffer is hit. */
12
+ /** rustc + cargo logs routinely exceed 2 MiB; keep a bounded tail per stream. */
15
13
  export const VERIFICATION_MAX_BUFFER_BYTES = 32_000_000;
16
14
  /**
17
15
  * Contract requires test + test_run — Bridge always runs; agent paste is not a substitute.
@@ -195,13 +193,13 @@ export async function captureVerificationFailure(input) {
195
193
  const run = input.runCommand ?? runBoundedVerificationCommand;
196
194
  // Try each bounded command until one fails. First non-zero wins (typecheck before test when both exist).
197
195
  for (const command of commands) {
198
- let result;
199
- try {
200
- result = await run(command, input.workspace);
201
- }
202
- catch {
203
- // Diagnosis is best-effort: try the next command rather than failing the attempt again.
196
+ // Diagnosis is best-effort: try the next command rather than failing the attempt again.
197
+ const result = await run(command, input.workspace).catch(() => null);
198
+ if (!result)
204
199
  continue;
200
+ if (result.timedOut) {
201
+ return { command, detail: boundedTail(outputLines(result).join("\n"), DIAGNOSIS_DETAIL_MAX_CHARS),
202
+ timedOut: true, stdout: result.stdout, stderr: result.stderr };
205
203
  }
206
204
  if (result.code === 0)
207
205
  continue;
@@ -375,55 +373,128 @@ export async function runBoundedVerificationCommand(command, workspace, timeoutM
375
373
  throw new Error("Verification command is empty");
376
374
  // Chunks are not whole lines. Each stream keeps its partial last line until the line ends, so a
377
375
  // stderr write between two stdout chunks cannot split a stdout line.
378
- const lines = [];
379
- const partial = { stdout: "", stderr: "" };
376
+ const boundedOutput = (limit) => {
377
+ const chunks = [];
378
+ let bytes = 0;
379
+ return {
380
+ append(text) {
381
+ const chunk = Buffer.from(text);
382
+ if (!chunk.length)
383
+ return;
384
+ const last = chunks.at(-1);
385
+ if (last && last.length + chunk.length <= 64 * 1024)
386
+ chunks[chunks.length - 1] = Buffer.concat([last, chunk]);
387
+ else
388
+ chunks.push(chunk);
389
+ bytes += chunk.length;
390
+ while (bytes > limit) {
391
+ const first = chunks[0];
392
+ const excess = bytes - limit;
393
+ if (first.length <= excess) {
394
+ bytes -= first.length;
395
+ chunks.shift();
396
+ }
397
+ else {
398
+ chunks[0] = first.subarray(excess);
399
+ bytes = limit;
400
+ }
401
+ }
402
+ },
403
+ text() {
404
+ const buffer = Buffer.concat(chunks, bytes);
405
+ let start = 0;
406
+ while (start < buffer.length && (buffer[start] & 0xc0) === 0x80)
407
+ start += 1;
408
+ return buffer.subarray(start).toString("utf8");
409
+ },
410
+ clear() { chunks.length = 0; bytes = 0; },
411
+ };
412
+ };
413
+ const lines = boundedOutput(2 * VERIFICATION_MAX_BUFFER_BYTES);
414
+ const partial = { stdout: boundedOutput(VERIFICATION_MAX_BUFFER_BYTES), stderr: boundedOutput(VERIFICATION_MAX_BUFFER_BYTES) };
380
415
  const collect = (stream) => (chunk) => {
381
- const parts = `${partial[stream]}${String(chunk)}`.split("\n");
382
- partial[stream] = parts.pop() ?? "";
383
- lines.push(...parts);
416
+ const parts = String(chunk).split("\n");
417
+ partial[stream].append(parts[0]);
418
+ if (parts.length === 1)
419
+ return;
420
+ lines.append(`${partial[stream].text()}\n`);
421
+ partial[stream].clear();
422
+ if (parts.length > 2)
423
+ lines.append(`${parts.slice(1, -1).join("\n")}\n`);
424
+ partial[stream].append(parts.at(-1));
384
425
  };
385
- const output = () => [...lines, ...[partial.stdout, partial.stderr].filter(Boolean)].join("\n");
386
- try {
387
- const pending = execFileAsync(bin, argv.slice(1), {
388
- cwd: workspace,
389
- timeout: timeoutMs,
390
- maxBuffer: VERIFICATION_MAX_BUFFER_BYTES,
391
- // The agent's environment, not the runner's: a gate that passes only with a variable the
392
- // agent never sees ties the project to this computer (acme-sim DATABASE_URL, 2026-10-10).
393
- env: boundedEnvironment(),
394
- });
395
- pending.child.stdout?.on("data", collect("stdout"));
396
- pending.child.stderr?.on("data", collect("stderr"));
397
- const { stdout, stderr } = await pending;
398
- return { stdout: String(stdout), stderr: String(stderr), output: output(), code: 0 };
399
- }
400
- catch (error) {
401
- const err = error;
402
- // A spawn failure carries a string code (`ENOENT` when the tool is not on PATH); a gate that
403
- // ran carries its numeric exit code. Reporting a missing tool as exit 1 made the dry run read
404
- // it as a red base and put the computer's missing tool on the initiative author. A command that
405
- // never started says nothing, so it is thrown: the dry run skips it, and the delivery gate's
406
- // message reaches the `enoent` rule that Holds it on the computer operator.
407
- if (typeof err.code === "string")
408
- throw error;
409
- if (err.killed === true) {
426
+ const output = () => [lines.text().replace(/\n$/, ""), partial.stdout.text(), partial.stderr.text()].filter(Boolean).join("\n");
427
+ return new Promise((resolve, reject) => {
428
+ const child = spawn(bin, argv.slice(1), { cwd: workspace, env: boundedEnvironment(),
429
+ stdio: ["ignore", "pipe", "pipe"], detached: OWN_PROCESS_GROUP });
430
+ const group = OWN_PROCESS_GROUP ? child.pid : undefined;
431
+ if (group !== undefined)
432
+ trackProcessGroup(group);
433
+ const stop = (signal) => {
434
+ if (group !== undefined)
435
+ signalGroup(group, signal);
436
+ else
437
+ child.kill(signal);
438
+ };
439
+ const stdout = boundedOutput(VERIFICATION_MAX_BUFFER_BYTES);
440
+ const stderr = boundedOutput(VERIFICATION_MAX_BUFFER_BYTES);
441
+ let timedOut = false;
442
+ let settled = false;
443
+ let drainTimer = null;
444
+ let graceTimer = null;
445
+ const cleanup = () => {
446
+ if (group !== undefined)
447
+ untrackProcessGroup(group);
448
+ clearTimeout(timer);
449
+ if (drainTimer)
450
+ clearTimeout(drainTimer);
451
+ if (graceTimer)
452
+ clearTimeout(graceTimer);
453
+ child.stdout?.destroy();
454
+ child.stderr?.destroy();
455
+ stop("SIGTERM");
456
+ setTimeout(() => stop("SIGKILL"), 5_000).unref?.();
457
+ };
458
+ const settle = (code) => {
459
+ if (settled)
460
+ return;
461
+ settled = true;
462
+ cleanup();
410
463
  const stopped = `Bridge stopped the gate: it did not exit within ${Math.round(timeoutMs / 1_000)} seconds.`;
411
- return {
412
- stdout: String(err.stdout ?? ""),
413
- stderr: `${String(err.stderr ?? "")}\n${stopped}`.trimStart(),
414
- output: `${output()}\n${stopped}`.trimStart(),
415
- code: 1,
416
- timedOut: true,
417
- };
418
- }
419
- const stderr = String(err.stderr || err.message || "verification failed");
420
- return {
421
- stdout: String(err.stdout ?? ""),
422
- stderr,
423
- output: output() || stderr,
424
- code: typeof err.code === "number" ? err.code : 1,
464
+ resolve(timedOut ? { stdout: stdout.text(), stderr: `${stderr.text()}\n${stopped}`.trimStart(),
465
+ output: `${output()}\n${stopped}`.trimStart(), code: 1, timedOut: true }
466
+ : { stdout: stdout.text(), stderr: stderr.text(), output: output(), code: code ?? 1 });
425
467
  };
426
- }
468
+ const timer = setTimeout(() => {
469
+ timedOut = true;
470
+ stop("SIGTERM");
471
+ setTimeout(() => stop("SIGKILL"), 10_000).unref?.();
472
+ graceTimer = setTimeout(() => settle(null), CHILD_STOP_GRACE_MS);
473
+ }, timeoutMs);
474
+ child.stdout?.setEncoding("utf8");
475
+ child.stderr?.setEncoding("utf8");
476
+ child.stdout?.on("data", (chunk) => { stdout.append(chunk); collect("stdout")(chunk); });
477
+ child.stderr?.on("data", (chunk) => { stderr.append(chunk); collect("stderr")(chunk); });
478
+ child.on("error", (error) => {
479
+ if (settled)
480
+ return;
481
+ if (typeof error.code === "string") {
482
+ settled = true;
483
+ cleanup();
484
+ reject(error);
485
+ }
486
+ else {
487
+ if (!stderr.text())
488
+ stderr.append(error.message);
489
+ settle(1);
490
+ }
491
+ });
492
+ child.on("close", (code) => settle(code));
493
+ child.on("exit", (code) => {
494
+ clearTimeout(timer);
495
+ drainTimer = setTimeout(() => settle(code), STDIO_DRAIN_MS);
496
+ });
497
+ });
427
498
  }
428
499
  /**
429
500
  * Bridge may attach genuine command output; it must not invent which criteria that output proves.
package/dist/execution.js CHANGED
@@ -23,7 +23,7 @@ import { formatResetMinute, parseQuotaResetAt } from "./quota-reset.js";
23
23
  import { headCommitOrNull } from "./git.js";
24
24
  import { BRIDGE_PROTOCOL_VERSION } from "./preflight.js";
25
25
  import { bridgeVersion } from "./version.js";
26
- import { PULL_REQUEST_OPEN_FAILURE_PREFIX, commitsMatch, ensureDeliveryPullRequest } from "./ensure-pull-request.js";
26
+ import { CONTROL_PLANE_TRANSIENT_PREFIX, PULL_REQUEST_OPEN_FAILURE_PREFIX, commitsMatch, ensureDeliveryPullRequest } from "./ensure-pull-request.js";
27
27
  import { checkpointWorkInProgress, 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";
@@ -219,6 +219,8 @@ const FINALIZE_CONTRACT_PATTERN = /missing required evidence|without the repo_wr
219
219
  export function classifyFinalizeFailure(message) {
220
220
  if (forgeTransportFailure(message))
221
221
  return { retryable: true, error: message, failure: bridgeCause("forge_unreachable", message) };
222
+ if (message.startsWith(CONTROL_PLANE_TRANSIENT_PREFIX))
223
+ return { retryable: true, error: message };
222
224
  // The control plane refused to open the change request — no forge connection resolves for this
223
225
  // organization or project, or the task's repository is not one that connection can open a change
224
226
  // request on. No retry and no rework can supply a connection. The cause travels as an envelope so
@@ -1088,6 +1090,17 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
1088
1090
  // A diagnostic terminal defaults to retention. If its lease disappears, deleting the only copy
1089
1091
  // of the failed code would make the durable retry mechanically impossible.
1090
1092
  let retainAttemptWorktree = diagnosis;
1093
+ const reportVerificationGateTimeout = async (input) => {
1094
+ const response = await queueTerminal(client, taskId, { action: "fail", body: {
1095
+ error: input.detail,
1096
+ retryable: false,
1097
+ signal: verificationGateTimeoutSignal({ command: input.command, stderr: input.stderr, stdout: input.stdout }),
1098
+ ...(input.pullRequestUrl ? { pull_request_url: input.pullRequestUrl } : {}),
1099
+ idempotency_key: `bridge:verification-gate-timeout:${active.attemptId}`,
1100
+ } });
1101
+ retainAttemptWorktree = response.retain_worktree === true;
1102
+ console.error(`Assignment ${taskId} verification gate did not finish: ${redactSecrets(input.detail)}`);
1103
+ };
1091
1104
  if (diagnosis) {
1092
1105
  if (!sourceAttemptId) {
1093
1106
  await queueTerminal(client, taskId, {
@@ -1800,6 +1813,12 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
1800
1813
  acceptance: spec.acceptance ?? [],
1801
1814
  })
1802
1815
  : null;
1816
+ if (capturedVerification?.timedOut) {
1817
+ retainAttemptWorktree = true;
1818
+ await reportVerificationGateTimeout({ command: capturedVerification.command, detail: capturedVerification.detail,
1819
+ stderr: capturedVerification.stderr ?? "", stdout: capturedVerification.stdout ?? "" });
1820
+ return;
1821
+ }
1803
1822
  const verificationDetail = capturedVerification?.detail ?? null;
1804
1823
  // The vendor named the model as the cause, so the model is what the retry must change. The
1805
1824
  // model rides in the prefix: the control plane cannot read Bridge's selection any other way.
@@ -2259,18 +2278,8 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
2259
2278
  // timeout signal the dry run emits, held on the computer operator — never a diagnosis or a
2260
2279
  // repair brief. Task ce673ef1 (2026-10-10) ran two diagnoses on a CPU-starved `npm run test`.
2261
2280
  if (error instanceof VerificationGateTimedOutError) {
2262
- const response = await queueTerminal(client, taskId, {
2263
- action: "fail",
2264
- body: {
2265
- error: error.message,
2266
- retryable: false,
2267
- signal: verificationGateTimeoutSignal({ command: error.command, stderr: error.stderr, stdout: error.stdout }),
2268
- ...(report.pull_request_url ? { pull_request_url: report.pull_request_url } : {}),
2269
- idempotency_key: `bridge:verification-gate-timeout:${active.attemptId}`,
2270
- },
2271
- });
2272
- retainAttemptWorktree = response.retain_worktree === true;
2273
- console.error(`Assignment ${taskId} verification gate did not finish: ${redactSecrets(error.message)}`);
2281
+ await reportVerificationGateTimeout({ command: error.command, detail: error.message,
2282
+ stderr: error.stderr, stdout: error.stdout, pullRequestUrl: report.pull_request_url });
2274
2283
  return;
2275
2284
  }
2276
2285
  // A gate that ran no tests is a misconfigured gate, not a failed verification: the delivery
@@ -2750,7 +2759,8 @@ async function prepareDelivery(client, attemptId, taskId, report) {
2750
2759
  const fuelMode = client.attempt(taskId).fuelMode;
2751
2760
  const evidence = [];
2752
2761
  const evidenceByCriterion = new Map();
2753
- for (const [index, item] of report.evidence.entries()) {
2762
+ for (const [index, evidenceItem] of report.evidence.entries()) {
2763
+ const item = redactSecretsDeep(evidenceItem);
2754
2764
  const uri = item.uri ?? `urn:conduit:attempt:${attemptId}:evidence:${index + 1}`;
2755
2765
  // Publication receipts require a digest on every acceptance artifact. Textual agent
2756
2766
  // evidence has no file to hash, so default to a canonical content digest of the
@@ -2908,10 +2918,10 @@ export function verificationScopeConflictDetail(paths, changeScope) {
2908
2918
  return `Verification writes outside the approved scope: ${paths.join(", ")}\napproved change scope: ${changeScope.join(", ")}`;
2909
2919
  }
2910
2920
  /**
2911
- * Run the project's verification commands once in the fresh attempt worktree, then answer three
2921
+ * Run the project's verification commands once in the fresh attempt worktree, then answer four
2912
2922
  * questions before the first model call: did a gate start but test nothing, did a gate already fail
2913
- * on the untouched base commit, and which paths did a gate write that the approved change scope
2914
- * does not contain?
2923
+ * on the untouched base commit, which paths did a gate write that the approved change scope
2924
+ * does not contain, and did a gate fail to finish?
2915
2925
  *
2916
2926
  * R5 (2026-09-03) and R10 (2026-09-05) failed the same way: the gates write files under the
2917
2927
  * repository root, the change scope did not contain them, and `validateChangeScope` rejected the
@@ -3001,6 +3011,7 @@ export async function runDeliveryVerificationGates(input) {
3001
3011
  results.push({
3002
3012
  command,
3003
3013
  exitCode: result.code,
3014
+ timedOut: result.timedOut === true,
3004
3015
  stdoutTail: boundedTail(result.stdout, DELIVERY_VERIFICATION_GATE_TAIL_CHARS),
3005
3016
  stderrTail: boundedTail(result.stderr, DELIVERY_VERIFICATION_GATE_TAIL_CHARS),
3006
3017
  });
@@ -3011,6 +3022,7 @@ export async function runDeliveryVerificationGates(input) {
3011
3022
  results.push({
3012
3023
  command,
3013
3024
  exitCode: -1,
3025
+ timedOut: false,
3014
3026
  stdoutTail: "",
3015
3027
  stderrTail: boundedTail(message, DELIVERY_VERIFICATION_GATE_TAIL_CHARS),
3016
3028
  });
@@ -46,7 +46,7 @@ export async function changedPathsSince(worktree, startCommit) {
46
46
  */
47
47
  export async function worktreeWrittenPaths(worktree) {
48
48
  try {
49
- return [...new Set(await uncommittedPaths(worktree, { untracked: "all" }))];
49
+ return [...new Set(await uncommittedPaths(worktree))];
50
50
  }
51
51
  catch {
52
52
  return null;
package/dist/git.js CHANGED
@@ -12,8 +12,8 @@ const execFileAsync = promisify(execFile);
12
12
  * and escape (`中文.txt` as `"\344\270\255\346\226\207.txt"`) comes back verbatim and can be
13
13
  * handed back to git (review 2026-10-03, finding 3).
14
14
  */
15
- export async function uncommittedPaths(workspace, options = {}) {
16
- const { stdout } = await execFileAsync("git", ["-C", workspace, "status", "--porcelain=v1", "-z", `--untracked-files=${options.untracked ?? "normal"}`], { timeout: 30_000, maxBuffer: 8_000_000 });
15
+ export async function uncommittedPaths(workspace) {
16
+ const { stdout } = await execFileAsync("git", ["-C", workspace, "status", "--porcelain=v1", "-z", "--untracked-files=all"], { timeout: 30_000, maxBuffer: 8_000_000 });
17
17
  return porcelainPaths(stdout);
18
18
  }
19
19
  /** Porcelain v1 with `-z`: `XY path`, and a rename or copy adds its source as the next field. */
@@ -41,7 +41,7 @@ export function parseIntegrationObligations(value) {
41
41
  async function symbolReferencedFrom(workspace, symbol, globs) {
42
42
  const pathspecs = globs.map((glob) => `:(glob)${glob}`);
43
43
  try {
44
- const { stdout } = await execFileAsync("git", ["-C", workspace, "grep", "-l", "-F", "--untracked", symbol, "--", ...pathspecs], { timeout: 30_000, maxBuffer: 4_000_000 });
44
+ const { stdout } = await execFileAsync("git", ["-C", workspace, "grep", "-l", "-F", "--untracked", "-e", symbol, "--", ...pathspecs], { timeout: 30_000, maxBuffer: 4_000_000 });
45
45
  return stdout.trim().length > 0;
46
46
  }
47
47
  catch (error) {
@@ -5,7 +5,7 @@
5
5
  * held to repo_read + test_run by the same driver projection that enforces read-only diagnosis, and
6
6
  * the worktree it reads is deleted whichever way the run ends.
7
7
  */
8
- import { redactSecrets } from "./secret-redaction.js";
8
+ import { redactSecrets, redactSecretsDeep } from "./secret-redaction.js";
9
9
  import { z } from "zod";
10
10
  import { boundedTail, runBoundedVerificationCommand } from "./ensure-test-evidence.js";
11
11
  import { createAttemptWorktree, removeAttemptWorktree } from "./attempt-worktree.js";
@@ -191,9 +191,10 @@ function formatGatesRunBlock(gates) {
191
191
  return `${header}\n- none`;
192
192
  return `${header}\n${gates.map((gate) => {
193
193
  const tail = gate.stderrTail.trim() || gate.stdoutTail.trim();
194
+ const verdict = gate.timedOut ? "did not finish (stopped by Bridge)" : `exit ${gate.exitCode}`;
194
195
  return tail
195
- ? `- ${gate.command} — exit ${gate.exitCode}\n ${tail.replace(/\n/g, "\n ")}`
196
- : `- ${gate.command} — exit ${gate.exitCode}`;
196
+ ? `- ${gate.command} — ${verdict}\n ${tail.replace(/\n/g, "\n ")}`
197
+ : `- ${gate.command} — ${verdict}`;
197
198
  }).join("\n")}`;
198
199
  }
199
200
  function gateFailureLine(tail) {
@@ -444,8 +445,8 @@ function sourceProbeVerdict(exitCode, stdout) {
444
445
  return "fails";
445
446
  return "broken";
446
447
  }
447
- function testProbeVerdict(ran, exitCode) {
448
- if (!ran)
448
+ function testProbeVerdict(ran, exitCode, timedOut) {
449
+ if (!ran || timedOut)
449
450
  return "broken";
450
451
  return exitCode === 0 ? "holds" : "fails";
451
452
  }
@@ -553,12 +554,14 @@ export async function runDeliveryVerificationProbes(input) {
553
554
  let stdout = "";
554
555
  let stderr = "";
555
556
  let ran = false;
557
+ let timedOut = false;
556
558
  try {
557
559
  const result = await (input.runCommand ?? runBoundedVerificationCommand)(named, input.workspace);
558
560
  exitCode = result.code;
559
561
  stdout = result.stdout;
560
562
  stderr = result.stderr;
561
563
  ran = true;
564
+ timedOut = result.timedOut === true;
562
565
  }
563
566
  catch (error) {
564
567
  stderr = error instanceof Error ? error.message : String(error);
@@ -566,7 +569,7 @@ export async function runDeliveryVerificationProbes(input) {
566
569
  }
567
570
  witnesses.push(witnessRecord({
568
571
  key: probe.key, kind: "test", command: named, exitCode, stdout, stderr, checks,
569
- verdict: testProbeVerdict(ran, exitCode),
572
+ verdict: testProbeVerdict(ran, exitCode, timedOut),
570
573
  }));
571
574
  continue;
572
575
  }
@@ -688,7 +691,10 @@ export async function runDeliveryVerificationProbesForAttempt(input) {
688
691
  async function settle(client, investigationId, body) {
689
692
  await client.request(`/runner/v1/investigations/${investigationId}/settle`, {
690
693
  method: "POST",
691
- body: JSON.stringify(body),
694
+ body: JSON.stringify({ ...body,
695
+ ...(body.result !== undefined ? { result: redactSecretsDeep(body.result) } : {}),
696
+ ...(body.error !== undefined ? { error: redactSecrets(body.error) } : {}),
697
+ }),
692
698
  });
693
699
  }
694
700
  /**
@@ -901,7 +907,7 @@ export async function executeNextInvestigation(client, config, workspace, brief,
901
907
  limitations: observed.limitations,
902
908
  criteria: observed.criteria,
903
909
  command_failures: deliveryVerification
904
- ? gatesRun.filter((gate) => gate.exitCode !== 0).map(gateFailureFact).slice(0, 10)
910
+ ? gatesRun.filter((gate) => gate.exitCode !== 0 && !gate.timedOut).map(gateFailureFact).slice(0, 10)
905
911
  : observed.command_failures,
906
912
  step_witness: stepWitness,
907
913
  no_behaviour: deliveryVerification ? observed.no_behaviour : [],
@@ -18,7 +18,7 @@ export async function isLandTreeEmpty(input) {
18
18
  const ahead = await countAhead(input.workspace, base);
19
19
  if (ahead !== null && ahead > 0)
20
20
  return false;
21
- const pending = filterPathsInScope(await (input.listUncommittedPaths ?? listUncommittedPaths)(input.workspace), input.spec.change_scope ?? []);
21
+ const pending = filterPathsInScope(await (input.listUncommittedPaths ?? ((workspace) => listUncommittedPaths(workspace)))(input.workspace), input.spec.change_scope ?? []);
22
22
  return pending.length === 0;
23
23
  }
24
24
  function deliveryObjectClaimsWork(raw) {
@@ -197,7 +197,7 @@ function isConduitPath(path) {
197
197
  return normalized === ".conduit" || normalized.startsWith(".conduit/");
198
198
  }
199
199
  async function markerPresent(workspace, marker, paths) {
200
- const args = ["-C", workspace, "grep", "-l", "-F", "--untracked", marker, "--", ...paths];
200
+ const args = ["-C", workspace, "grep", "-l", "-F", "--untracked", "-e", marker, "--", ...paths];
201
201
  try {
202
202
  const { stdout } = await execFileAsync("git", args, {
203
203
  timeout: 30_000,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miraland-labs/conduit-bridge",
3
- "version": "0.16.157",
3
+ "version": "0.16.160",
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": {