@miraland-labs/conduit-bridge 0.16.149 → 0.16.151

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.
@@ -2,7 +2,7 @@
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, rm } from "node:fs/promises";
5
+ import { access, appendFile, mkdir, readdir, readFile, rm, stat } from "node:fs/promises";
6
6
  import { constants } from "node:fs";
7
7
  import { basename, join, resolve } from "node:path";
8
8
  import { execFile } from "node:child_process";
@@ -118,6 +118,45 @@ export async function removeAttemptWorktree(sourceWorkspace, worktreePath) {
118
118
  }
119
119
  return true;
120
120
  }
121
+ /** A retained worktree the control plane never released is swept after this long. */
122
+ export const STALE_ATTEMPT_WORKTREE_MS = 7 * 24 * 60 * 60_000;
123
+ /**
124
+ * Removes every retained attempt worktree older than seven days that no attempt of this Bridge is
125
+ * running in. The control plane releases a retained tree only when its repair settles; a repair
126
+ * that never settles left the tree on disk for ever (four September trees deleted by hand,
127
+ * 2026-10-03). A diagnosis runs inside its source attempt's tree, so that tree counts as in use.
128
+ * Answers the paths it removed.
129
+ */
130
+ export async function sweepStaleAttemptWorktrees(sourceWorkspace, activeAttempts, now = Date.now()) {
131
+ const root = join(sourceWorkspace, ".conduit", "attempts");
132
+ let entries;
133
+ try {
134
+ entries = await readdir(root);
135
+ }
136
+ catch {
137
+ return [];
138
+ }
139
+ const inUse = new Set();
140
+ for (const attempt of Object.values(activeAttempts)) {
141
+ inUse.add(resolve(root, attempt.attemptId));
142
+ if (attempt.sourceAttemptId)
143
+ inUse.add(resolve(root, attempt.sourceAttemptId));
144
+ if (attempt.worktreePath)
145
+ inUse.add(resolve(attempt.worktreePath));
146
+ }
147
+ const removed = [];
148
+ for (const entry of entries) {
149
+ const path = join(root, entry);
150
+ if (inUse.has(resolve(path)))
151
+ continue;
152
+ const modified = await stat(path).then((info) => info.isDirectory() ? info.mtimeMs : null, () => null);
153
+ if (modified === null || now - modified < STALE_ATTEMPT_WORKTREE_MS)
154
+ continue;
155
+ if (await removeAttemptWorktree(sourceWorkspace, path))
156
+ removed.push(path);
157
+ }
158
+ return removed;
159
+ }
121
160
  /**
122
161
  * F-07 safe resume: prove the interrupted attempt still owns this worktree path.
123
162
  * Does not check session identity — caller requires config.sessions[taskId] separately.
package/dist/cli.js CHANGED
@@ -22,7 +22,7 @@ import { buildOnShiftIntentProof, managedWorkspacePath, maybeApplyOnShiftIntent
22
22
  import { expandOpsValue, loadOpsEnv, OPS_VERBS, runOps } from "./ops.js";
23
23
  import { discardWorktreeWrites, pumpExecutionSlots, renewLeases } from "./execution.js";
24
24
  import { applyRunnerToolPath, installRunnerService, uninstallRunnerService } from "./service.js";
25
- import { BRIDGE_PROTOCOL_VERSION, cachedBridgePreflight, planGateRehearsals, PREFLIGHT_CACHE_TTL_MS, runGateRehearsal, unavailableWorkspacePreflight } from "./preflight.js";
25
+ import { BRIDGE_PROTOCOL_VERSION, cachedBridgePreflight, gateRehearsalLockHeld, planGateRehearsals, PREFLIGHT_CACHE_TTL_MS, runGateRehearsal, unavailableWorkspacePreflight } from "./preflight.js";
26
26
  import { refreshSourceWorkspace } from "./workspace-refresh.js";
27
27
  import { executeNextInvestigation } from "./investigation.js";
28
28
  import { bridgeVersion } from "./version.js";
@@ -666,8 +666,9 @@ async function runner() {
666
666
  await heartbeat(client, config, workspace, currentBrief, preflight, intentProof, bootstrapResult);
667
667
  }
668
668
  await renewLeases(client, config);
669
+ // Another runner on this computer rehearsing its gate: claim as usual, rehearse on a later cycle.
669
670
  if (workspace && onlineDriverIds(config).length && !gateRehearsalRunning && rehearsalPlan?.due
670
- && running.size === 0 && Object.keys(config.activeAttempts).length === 0) {
671
+ && running.size === 0 && Object.keys(config.activeAttempts).length === 0 && !gateRehearsalLockHeld()) {
671
672
  startGateRehearsal(rehearsalPlan.due.workspace, rehearsalPlan.due.plan);
672
673
  }
673
674
  // No claim while the gate rehearses: the rehearsal measures the gate alone, never beside an attempt.
@@ -767,6 +768,8 @@ function startGateRehearsal(workspace, plan) {
767
768
  gateRehearsalRunning = true;
768
769
  console.log(`Rehearsing the gate in ${workspace}: ${plan.commands.join(", ")}`);
769
770
  void runGateRehearsal(workspace, plan)
771
+ .then((rehearsal) => { if (rehearsal === "busy")
772
+ console.log("Gate rehearsal deferred: another runner on this computer is rehearsing"); })
770
773
  .catch((error) => console.error(`Gate rehearsal failed: ${redactSecrets(error instanceof Error ? error.message : "unknown error")}`))
771
774
  .finally(async () => {
772
775
  if (!await workspaceIsClean(workspace))
@@ -569,6 +569,23 @@ export class VerificationGateRanNothingError extends Error {
569
569
  this.vendorMessage = vendorMessage;
570
570
  }
571
571
  }
572
+ /**
573
+ * A declared gate the verification timeout stopped after the agent run: it never exited, so it says
574
+ * nothing about the change. Carries the command and the output tails the timeout signal needs — the
575
+ * classifier holds this on the computer operator and never diagnoses or repairs it.
576
+ */
577
+ export class VerificationGateTimedOutError extends Error {
578
+ command;
579
+ stdout;
580
+ stderr;
581
+ constructor(command, stdout, stderr, detail) {
582
+ super(`Agent report: Verification gate did not finish (${command}): ${detail}`);
583
+ this.name = "VerificationGateTimedOutError";
584
+ this.command = command;
585
+ this.stdout = stdout;
586
+ this.stderr = stderr;
587
+ }
588
+ }
572
589
  /**
573
590
  * If test evidence is required, run the bounded verification commands in the attempt workspace
574
591
  * and attach verbatim stdout/stderr with Bridge witness markers.
@@ -593,6 +610,13 @@ export async function ensureTestEvidence(input) {
593
610
  const result = await run(command, input.workspace);
594
611
  const stdoutLines = result.stdout.trim() ? result.stdout.trim().split("\n") : [];
595
612
  const stderrLines = result.stderr.trim() ? result.stderr.trim().split("\n") : [];
613
+ // First, as in the dry run: a gate the timeout stopped never exited, so its partial output and
614
+ // exit code say nothing about the change. Read as a failed test, it sent a slow computer's
615
+ // timeout to diagnosis and repair (2026-10-10).
616
+ if (result.timedOut) {
617
+ const detail = boundedFailureDetail(command, outputLines(result), result.code);
618
+ throw new VerificationGateTimedOutError(command, result.stdout, result.stderr, detail);
619
+ }
596
620
  // Checked ahead of both the exit-code and the pass path: `go test` on an empty package exits 0
597
621
  // and would otherwise be witnessed as a passing test run that tested nothing.
598
622
  const ranNothing = detectGateRanNothing(command, result.stdout, result.stderr, result.code);
package/dist/execution.js CHANGED
@@ -11,7 +11,7 @@ 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, removeAttemptWorktree, } from "./attempt-worktree.js";
14
+ import { attemptWorktreePath, createAttemptWorktree, proveResumeWorktree, removeAttemptWorktree, sweepStaleAttemptWorktrees, } 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";
@@ -28,7 +28,7 @@ import { checkpointWorkInProgress, ensureLandCommit } from "./ensure-land-commit
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, DeliveryReportDefectError, detectGateRanNothing, ensureTestEvidence, needsTestEvidence, runBoundedVerificationCommand, verificationEvidenceCommands, VerificationFailedError, VerificationGateRanNothingError, VERIFICATION_TIMEOUT_MS } from "./ensure-test-evidence.js";
31
+ import { boundedTail, captureVerificationFailure, DeliveryReportDefectError, detectGateRanNothing, ensureTestEvidence, needsTestEvidence, runBoundedVerificationCommand, verificationEvidenceCommands, VerificationFailedError, VerificationGateRanNothingError, VerificationGateTimedOutError, 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";
@@ -856,6 +856,14 @@ export async function claimNextAssignment(client, config, workspace, brief, driv
856
856
  console.error(`Diagnostic worktree release failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`);
857
857
  }
858
858
  }
859
+ try {
860
+ for (const swept of await sweepStaleAttemptWorktrees(workspace, config.activeAttempts)) {
861
+ console.log(`Released stale diagnostic worktree ${swept}`);
862
+ }
863
+ }
864
+ catch (error) {
865
+ console.error(`Stale worktree sweep failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`);
866
+ }
859
867
  const assignment = assignments[0];
860
868
  if (!assignment)
861
869
  return null;
@@ -2244,6 +2252,24 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
2244
2252
  validateDeliveryReport(report, spec, grants, actualPaths ?? undefined, deliveryBaseCommit);
2245
2253
  }
2246
2254
  catch (error) {
2255
+ // A gate the timeout stopped never exited, so nothing says the change is at fault: the same
2256
+ // timeout signal the dry run emits, held on the computer operator — never a diagnosis or a
2257
+ // repair brief. Task ce673ef1 (2026-10-10) ran two diagnoses on a CPU-starved `npm run test`.
2258
+ if (error instanceof VerificationGateTimedOutError) {
2259
+ const response = await queueTerminal(client, taskId, {
2260
+ action: "fail",
2261
+ body: {
2262
+ error: error.message,
2263
+ retryable: false,
2264
+ signal: verificationGateTimeoutSignal({ command: error.command, stderr: error.stderr, stdout: error.stdout }),
2265
+ ...(report.pull_request_url ? { pull_request_url: report.pull_request_url } : {}),
2266
+ idempotency_key: `bridge:verification-gate-timeout:${active.attemptId}`,
2267
+ },
2268
+ });
2269
+ retainAttemptWorktree = response.retain_worktree === true;
2270
+ console.error(`Assignment ${taskId} verification gate did not finish: ${redactSecrets(error.message)}`);
2271
+ return;
2272
+ }
2247
2273
  // A gate that ran no tests is a misconfigured gate, not a failed verification: the delivery
2248
2274
  // pack may be entirely correct. Emit the T1 typed signal and Hold on the computer operator —
2249
2275
  // never a repair attempt or a blind retry, which is what burned six deliveries on 2026-09-04
@@ -403,6 +403,9 @@ export function verificationRedBaseSignal(input) {
403
403
  * The signal Bridge emits when the verification timeout stops a gate on the base commit, before the
404
404
  * agent starts. The gate never exited, so it says nothing about the base: read as exit 1, a gate
405
405
  * that hung (waiting for input or the network, or on a slow computer) was a red base on the author.
406
+ *
407
+ * The delivery gate after the agent run emits it without a base commit: the gate ran on the change,
408
+ * and a gate that never exited says nothing about the change either.
406
409
  */
407
410
  export function verificationGateTimeoutSignal(input) {
408
411
  return {
@@ -410,7 +413,7 @@ export function verificationGateTimeoutSignal(input) {
410
413
  witness: "bridge",
411
414
  kind: "timeout",
412
415
  command: input.command,
413
- base_commit: input.baseCommit,
416
+ ...(input.baseCommit ? { base_commit: input.baseCommit } : {}),
414
417
  exit_code: null,
415
418
  ...(input.stderr?.trim() ? { stderr_tail: bounded2k(input.stderr) } : {}),
416
419
  ...(input.stdout?.trim() ? { stdout_tail: bounded2k(input.stdout) } : {}),
@@ -562,13 +565,16 @@ export function classifyFailureSignal(signal) {
562
565
  // base commit nor a change is known to be at fault. The generic rule read it as a failed test.
563
566
  if (signal.phase === "verification" && signal.kind === "timeout") {
564
567
  const command = signal.command ?? "the declared gate";
565
- const base = signal.base_commit ? shortCommit(signal.base_commit) : "the base commit";
568
+ // No base commit: the delivery gate after the agent run, on the agent's change.
569
+ const message = signal.base_commit
570
+ ? `The verification gate \`${command}\` did not finish on the base commit ${shortCommit(signal.base_commit)} within Bridge's 10-minute limit, so Bridge stopped it before any change was made.`
571
+ : `The verification gate \`${command}\` did not finish on the agent's change within Bridge's 10-minute limit, so Bridge stopped it before delivery.`;
566
572
  return {
567
573
  code: "verification_gate_timeout",
568
574
  class: "environment",
569
575
  disposition: "hold",
570
576
  responsible_party: "computer_operator",
571
- 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.`,
577
+ message,
572
578
  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.`,
573
579
  diagnostic_detail: [`$ ${command}`, "stopped after 10 minutes", signal.stderr_tail, signal.stdout_tail].filter(Boolean).join("\n"),
574
580
  };
package/dist/preflight.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { execFile } from "node:child_process";
2
- import { createHash } from "node:crypto";
3
- import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { readFileSync } from "node:fs";
4
+ import { link, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
5
+ import { homedir } from "node:os";
4
6
  import { dirname, join } from "node:path";
5
7
  import { promisify } from "node:util";
6
8
  import { buildWorkspaceBrief, normalizeRepositoryUrl } from "./brief.js";
@@ -352,8 +354,78 @@ export async function planGateRehearsals(checkouts, options = {}) {
352
354
  }
353
355
  return { reports, due };
354
356
  }
355
- /** Run the planned gate once and remember it for the day. Null when no command could start. */
357
+ /**
358
+ * One gate rehearsal at a time on a computer. The VPS runs two runner services (conduit-runner and
359
+ * forge-runner); on 2026-10-03 both rehearsed together, each gate slowed the other, and the times
360
+ * recorded were wrong. Each installation has its own config directory, so the lock lives in the
361
+ * user's cache directory, which every runner of that user shares. The lock names its holder's
362
+ * process; a lock whose process is gone is taken over.
363
+ */
364
+ export function gateRehearsalLockPath() {
365
+ return join(homedir(), ".cache", "conduit", "gate-rehearsal.lock");
366
+ }
367
+ /** A live process holds the gate rehearsal lock. */
368
+ export function gateRehearsalLockHeld(lockPath = gateRehearsalLockPath()) {
369
+ let pid;
370
+ try {
371
+ pid = Number.parseInt(readFileSync(lockPath, "utf8"), 10);
372
+ }
373
+ catch {
374
+ return false;
375
+ }
376
+ if (!Number.isInteger(pid) || pid <= 0)
377
+ return false;
378
+ try {
379
+ process.kill(pid, 0);
380
+ return true;
381
+ }
382
+ catch (error) {
383
+ return error.code === "EPERM";
384
+ }
385
+ }
386
+ /** Take the lock, or answer false when a live process holds it. */
387
+ async function acquireGateRehearsalLock(lockPath) {
388
+ await mkdir(dirname(lockPath), { recursive: true });
389
+ // Linked into place whole, so the lock never exists without its holder's pid in it.
390
+ const claim = `${lockPath}.${randomUUID()}`;
391
+ await writeFile(claim, `${process.pid}\n`);
392
+ try {
393
+ for (let tries = 0; tries < 2; tries += 1) {
394
+ try {
395
+ await link(claim, lockPath);
396
+ return true;
397
+ }
398
+ catch (error) {
399
+ if (error.code !== "EEXIST")
400
+ throw error;
401
+ }
402
+ if (gateRehearsalLockHeld(lockPath))
403
+ return false;
404
+ await unlink(lockPath).catch(() => undefined);
405
+ }
406
+ return false;
407
+ }
408
+ finally {
409
+ await unlink(claim).catch(() => undefined);
410
+ }
411
+ }
412
+ /**
413
+ * Run the planned gate once and remember it for the day. Null when no command could start.
414
+ * "busy" when another rehearsal on this computer holds the lock: nothing is run or remembered, so
415
+ * the gate is still due and the next cycle tries again.
416
+ */
356
417
  export async function runGateRehearsal(workspace, plan, options = {}) {
418
+ const lockPath = options.lockPath ?? gateRehearsalLockPath();
419
+ if (!await acquireGateRehearsalLock(lockPath))
420
+ return "busy";
421
+ try {
422
+ return await rehearseGate(workspace, plan, options);
423
+ }
424
+ finally {
425
+ await unlink(lockPath).catch(() => undefined);
426
+ }
427
+ }
428
+ async function rehearseGate(workspace, plan, options) {
357
429
  const now = options.now ?? Date.now;
358
430
  const run = options.runCommand ?? runBoundedVerificationCommand;
359
431
  const commands = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miraland-labs/conduit-bridge",
3
- "version": "0.16.149",
3
+ "version": "0.16.151",
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": {