@miraland-labs/conduit-bridge 0.16.159 → 0.16.161

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/driver.js CHANGED
@@ -1884,14 +1884,14 @@ 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;
@@ -1903,6 +1903,17 @@ export 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,22 +37,22 @@ 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 ?? ((workspace) => uncommittedPaths(workspace, { untracked: "all" }));
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
- if (ahead !== null && ahead > 0) {
44
- return { kind: "already_landed", headCommit: await readHead(input.workspace) };
45
- }
46
43
  const pending = filterPathsInScope(await listPending(input.workspace), input.spec.change_scope ?? []);
47
- if (!pending.length) {
48
- throw new AgentNoLandCommitError(base);
44
+ if (pending.length) {
45
+ await commitPaths(input.workspace, pending);
46
+ const afterAhead = await countAhead(input.workspace, base);
47
+ if (afterAhead === 0) {
48
+ throw new AgentNoLandCommitError(base);
49
+ }
50
+ return { kind: "committed", headCommit: await readHead(input.workspace), paths: pending };
49
51
  }
50
- await commitPaths(input.workspace, pending);
51
- const afterAhead = await countAhead(input.workspace, base);
52
- if (afterAhead === 0) {
53
- throw new AgentNoLandCommitError(base);
52
+ if (ahead !== null && ahead > 0) {
53
+ return { kind: "already_landed", headCommit: await readHead(input.workspace) };
54
54
  }
55
- return { kind: "committed", headCommit: await readHead(input.workspace), paths: pending };
55
+ throw new AgentNoLandCommitError(base);
56
56
  }
57
57
  async function currentBranch(workspace) {
58
58
  const { stdout } = await execFileAsync("git", ["-C", workspace, "branch", "--show-current"], {
@@ -69,7 +69,7 @@ async function currentBranch(workspace) {
69
69
  export async function checkpointWorkInProgress(input) {
70
70
  if (!requiresLandCommit({ grants: input.grants, spec: input.spec }))
71
71
  return null;
72
- const pending = filterPathsInScope(await uncommittedPaths(input.workspace, { untracked: "all" }), input.spec.change_scope ?? []);
72
+ const pending = filterPathsInScope(await uncommittedPaths(input.workspace), input.spec.change_scope ?? []);
73
73
  if (pending.length) {
74
74
  await commitScopedPaths(input.workspace, pending, "Conduit: work in progress at the package budget");
75
75
  }
@@ -4,7 +4,7 @@
4
4
  * it never substitutes for it.
5
5
  */
6
6
  import { spawn } from "node:child_process";
7
- import { boundedEnvironment, criterionKey, resolveCriterionReference, signalGroup, STDIO_DRAIN_MS } from "./driver.js";
7
+ import { boundedEnvironment, criterionKey, resolveCriterionReference, signalGroup, STDIO_DRAIN_MS, CHILD_STOP_GRACE_MS, OWN_PROCESS_GROUP, trackProcessGroup, untrackProcessGroup } from "./driver.js";
8
8
  import { isBoundedVerificationCommand, isRunnableVerificationCommand } from "./execution-class.js";
9
9
  const TEST_EVIDENCE_DETAILS_MIN = 32;
10
10
  /** Cold `cargo test` in an attempt worktree routinely exceeds two minutes of compile. */
@@ -193,14 +193,10 @@ export async function captureVerificationFailure(input) {
193
193
  const run = input.runCommand ?? runBoundedVerificationCommand;
194
194
  // Try each bounded command until one fails. First non-zero wins (typecheck before test when both exist).
195
195
  for (const command of commands) {
196
- let result;
197
- try {
198
- result = await run(command, input.workspace);
199
- }
200
- catch {
201
- // 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)
202
199
  continue;
203
- }
204
200
  if (result.timedOut) {
205
201
  return { command, detail: boundedTail(outputLines(result).join("\n"), DIAGNOSIS_DETAIL_MAX_CHARS),
206
202
  timedOut: true, stdout: result.stdout, stderr: result.stderr };
@@ -429,12 +425,14 @@ export async function runBoundedVerificationCommand(command, workspace, timeoutM
429
425
  };
430
426
  const output = () => [lines.text().replace(/\n$/, ""), partial.stdout.text(), partial.stderr.text()].filter(Boolean).join("\n");
431
427
  return new Promise((resolve, reject) => {
432
- const ownGroup = process.platform !== "win32";
433
428
  const child = spawn(bin, argv.slice(1), { cwd: workspace, env: boundedEnvironment(),
434
- stdio: ["ignore", "pipe", "pipe"], detached: ownGroup });
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);
435
433
  const stop = (signal) => {
436
- if (ownGroup && child.pid !== undefined)
437
- signalGroup(child.pid, signal);
434
+ if (group !== undefined)
435
+ signalGroup(group, signal);
438
436
  else
439
437
  child.kill(signal);
440
438
  };
@@ -442,10 +440,11 @@ export async function runBoundedVerificationCommand(command, workspace, timeoutM
442
440
  const stderr = boundedOutput(VERIFICATION_MAX_BUFFER_BYTES);
443
441
  let timedOut = false;
444
442
  let settled = false;
445
- let exited = false;
446
443
  let drainTimer = null;
447
444
  let graceTimer = null;
448
445
  const cleanup = () => {
446
+ if (group !== undefined)
447
+ untrackProcessGroup(group);
449
448
  clearTimeout(timer);
450
449
  if (drainTimer)
451
450
  clearTimeout(drainTimer);
@@ -467,12 +466,10 @@ export async function runBoundedVerificationCommand(command, workspace, timeoutM
467
466
  : { stdout: stdout.text(), stderr: stderr.text(), output: output(), code: code ?? 1 });
468
467
  };
469
468
  const timer = setTimeout(() => {
470
- if (exited)
471
- return;
472
469
  timedOut = true;
473
470
  stop("SIGTERM");
474
471
  setTimeout(() => stop("SIGKILL"), 10_000).unref?.();
475
- graceTimer = setTimeout(() => settle(null), 20_000);
472
+ graceTimer = setTimeout(() => settle(null), CHILD_STOP_GRACE_MS);
476
473
  }, timeoutMs);
477
474
  child.stdout?.setEncoding("utf8");
478
475
  child.stderr?.setEncoding("utf8");
@@ -494,7 +491,6 @@ export async function runBoundedVerificationCommand(command, workspace, timeoutM
494
491
  });
495
492
  child.on("close", (code) => settle(code));
496
493
  child.on("exit", (code) => {
497
- exited = true;
498
494
  clearTimeout(timer);
499
495
  drainTimer = setTimeout(() => settle(code), STDIO_DRAIN_MS);
500
496
  });
package/dist/execution.js CHANGED
@@ -1090,6 +1090,17 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
1090
1090
  // A diagnostic terminal defaults to retention. If its lease disappears, deleting the only copy
1091
1091
  // of the failed code would make the durable retry mechanically impossible.
1092
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
+ };
1093
1104
  if (diagnosis) {
1094
1105
  if (!sourceAttemptId) {
1095
1106
  await queueTerminal(client, taskId, {
@@ -1804,15 +1815,8 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
1804
1815
  : null;
1805
1816
  if (capturedVerification?.timedOut) {
1806
1817
  retainAttemptWorktree = true;
1807
- const response = await queueTerminal(client, taskId, { action: "fail", body: {
1808
- error: capturedVerification.detail,
1809
- retryable: false,
1810
- signal: verificationGateTimeoutSignal({ command: capturedVerification.command,
1811
- stderr: capturedVerification.stderr ?? "", stdout: capturedVerification.stdout ?? "" }),
1812
- idempotency_key: `bridge:verification-gate-timeout:${active.attemptId}`,
1813
- } });
1814
- retainAttemptWorktree = response.retain_worktree === true;
1815
- console.error(`Assignment ${taskId} verification gate did not finish: ${redactSecrets(capturedVerification.detail)}`);
1818
+ await reportVerificationGateTimeout({ command: capturedVerification.command, detail: capturedVerification.detail,
1819
+ stderr: capturedVerification.stderr ?? "", stdout: capturedVerification.stdout ?? "" });
1816
1820
  return;
1817
1821
  }
1818
1822
  const verificationDetail = capturedVerification?.detail ?? null;
@@ -2274,18 +2278,8 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
2274
2278
  // timeout signal the dry run emits, held on the computer operator — never a diagnosis or a
2275
2279
  // repair brief. Task ce673ef1 (2026-10-10) ran two diagnoses on a CPU-starved `npm run test`.
2276
2280
  if (error instanceof VerificationGateTimedOutError) {
2277
- const response = await queueTerminal(client, taskId, {
2278
- action: "fail",
2279
- body: {
2280
- error: error.message,
2281
- retryable: false,
2282
- signal: verificationGateTimeoutSignal({ command: error.command, stderr: error.stderr, stdout: error.stdout }),
2283
- ...(report.pull_request_url ? { pull_request_url: report.pull_request_url } : {}),
2284
- idempotency_key: `bridge:verification-gate-timeout:${active.attemptId}`,
2285
- },
2286
- });
2287
- retainAttemptWorktree = response.retain_worktree === true;
2288
- 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 });
2289
2283
  return;
2290
2284
  }
2291
2285
  // A gate that ran no tests is a misconfigured gate, not a failed verification: the delivery
@@ -2924,10 +2918,10 @@ export function verificationScopeConflictDetail(paths, changeScope) {
2924
2918
  return `Verification writes outside the approved scope: ${paths.join(", ")}\napproved change scope: ${changeScope.join(", ")}`;
2925
2919
  }
2926
2920
  /**
2927
- * 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
2928
2922
  * questions before the first model call: did a gate start but test nothing, did a gate already fail
2929
- * on the untouched base commit, and which paths did a gate write that the approved change scope
2930
- * 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?
2931
2925
  *
2932
2926
  * R5 (2026-09-03) and R10 (2026-09-05) failed the same way: the gates write files under the
2933
2927
  * repository root, the change scope did not contain them, and `validateChangeScope` rejected the
@@ -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. */
@@ -11,6 +11,7 @@ import { boundedTail, runBoundedVerificationCommand } from "./ensure-test-eviden
11
11
  import { createAttemptWorktree, removeAttemptWorktree } from "./attempt-worktree.js";
12
12
  import { buildWorkspaceBrief, ensureCommitAvailable, normalizeRepositoryUrl } from "./brief.js";
13
13
  import { headCommitOrNull } from "./git.js";
14
+ import { ConduitRequestError } from "./client.js";
14
15
  import { learnDriverFuel, resolveAssignmentModel, changedPathsSince, runDeliveryVerificationGates, discardWorktreeWrites } from "./execution.js";
15
16
  import { DRIVERS, execute, extractAgentReportJsonText, parseJsonObjectCandidate } from "./driver.js";
16
17
  import { pickDriverForClaim, resolveDriverFuel, supportsReadOnlyDiagnosis } from "./drivers.js";
@@ -773,13 +774,24 @@ export async function executeNextInvestigation(client, config, workspace, brief,
773
774
  const fingerprintBefore = await workspaceFingerprint(workspace);
774
775
  let attemptWorkspace = null;
775
776
  const runId = investigationRunId(assignment.id);
776
- // Renew inside the lease for as long as the driver runs; a renewal the control plane refuses
777
- // means the run is already over for it, and the settle below reports that honestly.
777
+ // Renew inside the lease for as long as the driver runs. A terminal refusal (`invalid_lease`)
778
+ // means the control plane already took the run back — abort the driver and do not settle.
779
+ const runAbort = new AbortController();
780
+ let leaseTakenBack = false;
778
781
  const renewTimer = setInterval(() => {
779
782
  void client.request(`/runner/v1/investigations/${assignment.id}/renew`, {
780
783
  method: "POST",
781
784
  body: JSON.stringify({ lease_token: leaseToken }),
782
- }).catch((error) => console.error(`Investigation ${assignment.id} lease renewal failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`));
785
+ }).catch((error) => {
786
+ if (error instanceof ConduitRequestError && error.code === "invalid_lease") {
787
+ leaseTakenBack = true;
788
+ clearInterval(renewTimer);
789
+ runAbort.abort();
790
+ console.error(`Investigation ${assignment.id} stopped: lease is no longer valid`);
791
+ return;
792
+ }
793
+ console.error(`Investigation ${assignment.id} lease renewal failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`);
794
+ });
783
795
  }, options.renewIntervalMs ?? INVESTIGATION_RENEW_INTERVAL_MS);
784
796
  renewTimer.unref?.();
785
797
  let heartbeatRunning = false;
@@ -853,7 +865,10 @@ export async function executeNextInvestigation(client, config, workspace, brief,
853
865
  fuel,
854
866
  timeoutMs: Math.min(timeoutMs ?? assignment.budget.max_duration_ms, assignment.budget.max_duration_ms),
855
867
  maxTurns: config.maxTurns,
868
+ signal: runAbort.signal,
856
869
  });
870
+ if (leaseTakenBack || runAbort.signal.aborted)
871
+ return true;
857
872
  await learnDriverFuel(config, driver.name, investigationFuel, result);
858
873
  if (result.status === "failed") {
859
874
  const message = result.error ?? "Investigation run failed";
@@ -927,6 +942,9 @@ export async function executeNextInvestigation(client, config, workspace, brief,
927
942
  console.log(`Investigation ${assignment.id} settled with a bounded brief.`);
928
943
  }
929
944
  catch (error) {
945
+ if (leaseTakenBack || runAbort.signal.aborted) {
946
+ return true;
947
+ }
930
948
  const message = error instanceof Error ? error.message : "investigation_failed";
931
949
  await settle(client, assignment.id, {
932
950
  lease_token: leaseToken,
@@ -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 ?? ((workspace) => listUncommittedPaths(workspace, { untracked: "all" })))(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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miraland-labs/conduit-bridge",
3
- "version": "0.16.159",
3
+ "version": "0.16.161",
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": {