@miraland-labs/conduit-bridge 0.16.190 → 0.16.193

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/cli.js CHANGED
@@ -23,7 +23,7 @@ import { expandOpsValue, loadOpsEnv, OPS_VERBS, runOps } from "./ops.js";
23
23
  import { assignmentsPending, pumpExecutionSlots, renewLeases } from "./execution.js";
24
24
  import { createRehearsalWorktree, rehearsalWorktreePath, removeAttemptWorktree } from "./attempt-worktree.js";
25
25
  import { applyRunnerToolPath, installRunnerService, uninstallRunnerService } from "./service.js";
26
- import { BRIDGE_PROTOCOL_VERSION, cachedBridgePreflight, gateRehearsalLockHeld, planGateRehearsals, PREFLIGHT_CACHE_TTL_MS, runGateRehearsal, unavailableWorkspacePreflight } from "./preflight.js";
26
+ import { attemptsActiveOnComputer, BRIDGE_PROTOCOL_VERSION, cachedBridgePreflight, gateRehearsalLockHeld, planGateRehearsals, PREFLIGHT_CACHE_TTL_MS, runGateRehearsal, unavailableWorkspacePreflight } from "./preflight.js";
27
27
  import { refreshSourceWorkspace } from "./workspace-refresh.js";
28
28
  import { executeNextInvestigation } from "./investigation.js";
29
29
  import { bridgeVersion } from "./version.js";
@@ -667,16 +667,19 @@ async function runner() {
667
667
  await heartbeat(client, config, workspace, currentBrief, preflight, intentProof, bootstrapResult);
668
668
  }
669
669
  await renewLeases(client, config);
670
- // Dispatched work outranks a rehearsal already running. Stop it with nothing recorded, release
671
- // the lock, and claim on this same cycle. The rehearsal stays due.
672
- if (gateRehearsalRunning && await assignmentsPending(client)) {
670
+ // Dispatched work outranks a rehearsal already running, and so does an attempt on this
671
+ // computer (this runner or another). Stop it with nothing recorded, release the lock, and
672
+ // claim on this same cycle. The rehearsal stays due.
673
+ if (gateRehearsalRunning && (attemptsActiveOnComputer() || await assignmentsPending(client))) {
673
674
  gateRehearsalAbort?.abort();
674
675
  await gateRehearsalDone;
675
676
  }
676
- // Another runner on this computer rehearsing its gate: claim as usual, rehearse on a later cycle.
677
- // An assignment already waiting is claimed below; this cycle does not start a rehearsal.
677
+ // Another runner on this computer rehearsing its gate, or any runner with an attempt in
678
+ // flight: claim as usual, rehearse on a later cycle. An assignment already waiting is
679
+ // claimed below; this cycle does not start a rehearsal.
678
680
  if (workspace && onlineDriverIds(config).length && !gateRehearsalRunning && rehearsalPlan?.due
679
681
  && running.size === 0 && Object.keys(config.activeAttempts).length === 0 && !gateRehearsalLockHeld()
682
+ && !attemptsActiveOnComputer()
680
683
  && !await assignmentsPending(client)) {
681
684
  startGateRehearsal(rehearsalPlan.due.workspace, rehearsalPlan.due.plan);
682
685
  }
@@ -802,6 +805,8 @@ function startGateRehearsal(workspace, plan) {
802
805
  .then((rehearsal) => {
803
806
  if (rehearsal === "busy")
804
807
  console.log("Gate rehearsal deferred: another runner on this computer is rehearsing");
808
+ else if (abort.signal.aborted && attemptsActiveOnComputer())
809
+ console.log("Gate rehearsal stopped: an attempt is in flight on this computer");
805
810
  else if (abort.signal.aborted)
806
811
  console.log("Gate rehearsal stopped: an assignment is waiting");
807
812
  })
package/dist/execution.js CHANGED
@@ -9,7 +9,7 @@ import { AttemptStalledError, createAttemptWatchdog, withTimeout } from "./attem
9
9
  import { workPackageSchema } from "./work-package-schema.js";
10
10
  import { ConduitRequestError, reportAssignmentPollShapeMismatch } from "./client.js";
11
11
  import { saveDriverOutcome, saveDriverQuota, tierCandidates } from "./config.js";
12
- import { DRIVERS, acceptanceCriteriaLines, acceptanceReportSkeleton, agentReportTemplate, buildAssignmentPrompt, evidenceKinds, fuelEndpoint, normalizeEvidenceDigest, parseAgentReport, parseReplyObject, pickModelCandidate, ReplyInvalid, isSafeModelName, driverExecutable, requireStampedExecutionClass, tierForRisk } from "./driver.js";
12
+ import { DRIVERS, acceptanceCriteriaLines, acceptanceReportSkeleton, agentReportTemplate, buildAssignmentPrompt, evidenceKinds, fuelEndpoint, normalizeEvidenceDigest, parseAgentReport, parseReplyObject, pickModelCandidate, ReplyInvalid, isSafeModelName, driverExecutable, requireStampedExecutionClass, stopTrackedProcessGroups, tierForRisk } from "./driver.js";
13
13
  import { assertClassFloor } from "./execution-class.js";
14
14
  import { pickDriverForClaim, recordDriverQuota, resolveDriverFuel, resolveDriverFuelProvenance, supportsReadOnlyDiagnosis, clearDriverOutcome, recordDriverTimeout } from "./drivers.js";
15
15
  import { attemptWorktreePath, createAttemptWorktree, proveResumeWorktree, removeAttemptWorktree, sweepStaleAttemptWorktrees, } from "./attempt-worktree.js";
@@ -22,7 +22,7 @@ import { forgeTransportFailure } from "./transport-fault.js";
22
22
  import { managedWorkspacePath, switchManagedWorkspace } from "./on-shift-apply.js";
23
23
  import { formatResetMinute, parseQuotaResetAt } from "./quota-reset.js";
24
24
  import { headCommitOrNull } from "./git.js";
25
- import { BRIDGE_PROTOCOL_VERSION } from "./preflight.js";
25
+ import { BRIDGE_PROTOCOL_VERSION, markAttemptActive } from "./preflight.js";
26
26
  import { bridgeVersion } from "./version.js";
27
27
  import { CONTROL_PLANE_TRANSIENT_PREFIX, PULL_REQUEST_OPEN_FAILURE_PREFIX, commitsMatch, ensureDeliveryPullRequest } from "./ensure-pull-request.js";
28
28
  import { checkpointWorkInProgress, ensureLandCommit } from "./ensure-land-commit.js";
@@ -571,7 +571,9 @@ export function renewLeases(client, config) {
571
571
  }
572
572
  async function renewEachLease(client, config) {
573
573
  for (const active of Object.values(config.activeAttempts)) {
574
- if (Date.parse(active.leaseExpiresAt) - Date.now() < 120_000) {
574
+ // Every pass asks, not only when the lease is about to lapse: the answer is also how this
575
+ // computer learns within a minute that the control plane ended the attempt (CR28).
576
+ {
575
577
  try {
576
578
  const response = await client.attemptRequest(active.taskId, "lease", { idempotency_key: `bridge:lease:${active.attemptId}:${Math.floor(Date.now() / 60_000)}` });
577
579
  await client.updateAttempt(active.taskId, { leaseExpiresAt: String(response.lease_expires_at) });
@@ -764,6 +766,17 @@ export async function pumpExecutionSlots(client, config, workspace, brief, timeo
764
766
  ?? (fallbackDriver
765
767
  ? [Object.entries(DRIVERS).find(([, d]) => d === fallbackDriver)?.[0]].filter((id) => Boolean(id))
766
768
  : null);
769
+ // The marker is the computer-wide record that this slot is in flight. Release it when the
770
+ // promise settles, success or failure, so a gate rehearsal can use the CPU again.
771
+ const holdAttempt = (id, work) => {
772
+ const releaseMarker = markAttemptActive();
773
+ const slot = work.finally(() => {
774
+ releaseMarker();
775
+ if (running.get(id) === slot)
776
+ running.delete(id);
777
+ });
778
+ running.set(id, slot);
779
+ };
767
780
  let progressed = running.size > 0;
768
781
  for (const id of Object.keys(config.activeAttempts)) {
769
782
  if (running.has(id))
@@ -777,7 +790,7 @@ export async function pumpExecutionSlots(client, config, workspace, brief, timeo
777
790
  continue;
778
791
  }
779
792
  progressed = true;
780
- const slot = recoverActiveAttempt(client, config, laneDriver, active.sourceWorkspace ?? workspace, brief, timeoutMs, supervision, id)
793
+ const recovery = recoverActiveAttempt(client, config, laneDriver, active.sourceWorkspace ?? workspace, brief, timeoutMs, supervision, id)
781
794
  .catch(async (error) => {
782
795
  // One changed execution fact is terminal for this attempt. Recovery must not run again on
783
796
  // the same slot: the workspace will not go back, so every later pass fails identically.
@@ -790,10 +803,8 @@ export async function pumpExecutionSlots(client, config, workspace, brief, timeo
790
803
  }
791
804
  console.error(`Slot recovery failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`);
792
805
  })
793
- .then(() => undefined)
794
- .finally(() => { if (running.get(id) === slot)
795
- running.delete(id); });
796
- running.set(id, slot);
806
+ .then(() => undefined);
807
+ holdAttempt(id, recovery);
797
808
  }
798
809
  while (running.size < config.leaseCapacity) {
799
810
  let laneDriver = null;
@@ -822,14 +833,12 @@ export async function pumpExecutionSlots(client, config, workspace, brief, timeo
822
833
  progressed = true;
823
834
  console.log(`Executing ${taskId} via ${laneDriver.name}`);
824
835
  // T86: the claim answers with the checkout it proved, which is this assignment's repository.
825
- const slot = runClaimedAssignment(client, config, laneDriver, claimed.workspace, brief, taskId, timeoutMs, supervision)
836
+ const claimedRun = runClaimedAssignment(client, config, laneDriver, claimed.workspace, brief, taskId, timeoutMs, supervision)
826
837
  .catch((error) => {
827
838
  console.error(`Slot execution failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`);
828
839
  })
829
- .then(() => undefined)
830
- .finally(() => { if (running.get(taskId) === slot)
831
- running.delete(taskId); });
832
- running.set(taskId, slot);
840
+ .then(() => undefined);
841
+ holdAttempt(taskId, claimedRun);
833
842
  }
834
843
  return progressed;
835
844
  }
@@ -1246,7 +1255,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
1246
1255
  priorHeadCommit: executionContract.prior_head_commit,
1247
1256
  resumeFromAttemptId: executionContract.resume_from_attempt_id,
1248
1257
  });
1249
- const deliveryBaseCommit = await resolveDeliveryBaseCommit({
1258
+ let deliveryBaseCommit = await resolveDeliveryBaseCommit({
1250
1259
  worktree: attemptWorkspace,
1251
1260
  sourceWorkspace: workspace,
1252
1261
  reworkOrResume,
@@ -1424,129 +1433,152 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
1424
1433
  // when a gate wrote outside the approved change scope.
1425
1434
  // A resumed tree stands at the timed-out run's unfinished work, where red gates are expected; the
1426
1435
  // timed-out run already passed these checks on the base.
1427
- if (dryRunsVerification({
1428
- executionKind,
1429
- executionClass,
1430
- freshWorktree: (options.freshWorktree ?? !options.existingWorktree) && !resumeHead,
1431
- })) {
1432
- watchdog.phase("verification");
1433
- const dryRun = await verificationScopeDryRun({
1434
- workspace: attemptWorkspace,
1435
- verificationCommands: attemptBrief?.verification ?? [],
1436
- declaredVerificationCommands: attemptBrief?.declared_verification ?? [],
1437
- acceptance: spec.acceptance ?? [],
1438
- changeScope: spec.change_scope ?? [],
1439
- }).catch(async (error) => {
1440
- if (!(error instanceof VerificationCommandUnavailableError))
1441
- throw error;
1442
- retainAttemptWorktree = true;
1443
- const response = await queueTerminal(client, taskId, { action: "fail", body: {
1444
- error: error.message, retryable: false, failure: error.failure,
1445
- idempotency_key: `bridge:verification-command-unavailable:${active.attemptId}`,
1446
- } });
1447
- retainAttemptWorktree = retainDiagnosticWorktree(response);
1448
- if (!retainAttemptWorktree)
1449
- await removeAttemptWorktree(workspace, attemptWorkspace).catch(() => undefined);
1450
- console.error(`Assignment ${taskId} refused before the agent started: ${redactSecrets(error.message)}`);
1451
- return null;
1452
- });
1453
- if (!dryRun)
1454
- return;
1455
- if (dryRun.ranNothing) {
1456
- const { command, exitCode, reason } = dryRun.ranNothing;
1457
- const detail = `Verification gate ran nothing on the base commit ${worktreeStart}: ${command} (${reason}, exit ${exitCode})`;
1458
- // The same T13 signal the delivery gate emits: a Hold on the computer operator, never a
1459
- // repair attempt and never a retry, because no change can make a misconfigured gate prove
1460
- // anything. Emitted here it costs no model turn.
1461
- const signal = {
1462
- phase: "verification",
1463
- witness: "bridge",
1464
- kind: "verification_failed",
1465
- command,
1466
- exit_code: exitCode,
1467
- vendor_message: reason,
1468
- };
1469
- retainAttemptWorktree = true;
1470
- const response = await queueTerminal(client, taskId, {
1471
- action: "fail",
1472
- body: {
1473
- error: detail,
1474
- retryable: false,
1475
- signal,
1476
- idempotency_key: `bridge:verification-gate-ran-nothing:${active.attemptId}`,
1477
- },
1436
+ // The control plane can end this attempt while the gates run on the base (an owner cancel), so
1437
+ // the lease loop and the abort start here, before the dry run, not after it (CR28, 2026-10-24:
1438
+ // a cancelled attempt's `cargo test` ran to its own end and the next gate started after it).
1439
+ const abort = new AbortController();
1440
+ slotAborts.set(taskId, abort);
1441
+ const renewTimer = setInterval(() => { void renewLeases(client, config).catch((error) => console.error(`Lease renewal failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`)); }, 60_000);
1442
+ let dryRunPassed = false;
1443
+ try {
1444
+ if (dryRunsVerification({
1445
+ executionKind,
1446
+ executionClass,
1447
+ freshWorktree: (options.freshWorktree ?? !options.existingWorktree) && !resumeHead,
1448
+ })) {
1449
+ watchdog.phase("verification");
1450
+ const dryRun = await verificationScopeDryRun({
1451
+ workspace: attemptWorkspace,
1452
+ verificationCommands: attemptBrief?.verification ?? [],
1453
+ declaredVerificationCommands: attemptBrief?.declared_verification ?? [],
1454
+ acceptance: spec.acceptance ?? [],
1455
+ changeScope: spec.change_scope ?? [],
1456
+ signal: abort.signal,
1457
+ }).catch(async (error) => {
1458
+ if (!(error instanceof VerificationCommandUnavailableError))
1459
+ throw error;
1460
+ retainAttemptWorktree = true;
1461
+ const response = await queueTerminal(client, taskId, { action: "fail", body: {
1462
+ error: error.message, retryable: false, failure: error.failure,
1463
+ idempotency_key: `bridge:verification-command-unavailable:${active.attemptId}`,
1464
+ } });
1465
+ retainAttemptWorktree = retainDiagnosticWorktree(response);
1466
+ if (!retainAttemptWorktree)
1467
+ await removeAttemptWorktree(workspace, attemptWorkspace).catch(() => undefined);
1468
+ console.error(`Assignment ${taskId} refused before the agent started: ${redactSecrets(error.message)}`);
1469
+ return null;
1478
1470
  });
1479
- retainAttemptWorktree = retainDiagnosticWorktree(response);
1480
- // No agent ran and the tree stands at the base commit, so it holds no diagnostic value.
1481
- if (!retainAttemptWorktree) {
1482
- await removeAttemptWorktree(workspace, attemptWorkspace).catch(() => undefined);
1471
+ if (abort.signal.aborted) {
1472
+ // `renewLeases` cleared the local state: the control plane ended this attempt during the dry run.
1473
+ console.log(`Assignment ${taskId} stopped during the verification dry run: the control plane ended this attempt.`);
1474
+ if (!retainAttemptWorktree)
1475
+ await removeAttemptWorktree(workspace, attemptWorkspace).catch(() => undefined);
1476
+ return;
1483
1477
  }
1484
- console.error(`Assignment ${taskId} refused before the agent started: ${redactSecrets(detail)}`);
1485
- return;
1486
- }
1487
- if (dryRun.timedOut) {
1488
- const { command, stderrTail, stdoutTail } = dryRun.timedOut;
1489
- const detail = `Verification gate did not finish on the base commit ${worktreeStart}: ${command} (stopped after ${Math.round(VERIFICATION_TIMEOUT_MS / 60_000)} minutes)`;
1490
- retainAttemptWorktree = true;
1491
- const response = await queueTerminal(client, taskId, {
1492
- action: "fail",
1493
- body: {
1494
- error: detail,
1495
- retryable: false,
1496
- signal: verificationGateTimeoutSignal({ command, baseCommit: worktreeStart, stderr: stderrTail, stdout: stdoutTail }),
1497
- idempotency_key: `bridge:verification-gate-timeout:${active.attemptId}`,
1498
- },
1499
- });
1500
- retainAttemptWorktree = retainDiagnosticWorktree(response);
1501
- // No agent ran and the tree stands at the base commit, so it holds no diagnostic value.
1502
- if (!retainAttemptWorktree) {
1503
- await removeAttemptWorktree(workspace, attemptWorkspace).catch(() => undefined);
1478
+ if (!dryRun)
1479
+ return;
1480
+ if (dryRun.ranNothing) {
1481
+ const { command, exitCode, reason } = dryRun.ranNothing;
1482
+ const detail = `Verification gate ran nothing on the base commit ${worktreeStart}: ${command} (${reason}, exit ${exitCode})`;
1483
+ // The same T13 signal the delivery gate emits: a Hold on the computer operator, never a
1484
+ // repair attempt and never a retry, because no change can make a misconfigured gate prove
1485
+ // anything. Emitted here it costs no model turn.
1486
+ const signal = {
1487
+ phase: "verification",
1488
+ witness: "bridge",
1489
+ kind: "verification_failed",
1490
+ command,
1491
+ exit_code: exitCode,
1492
+ vendor_message: reason,
1493
+ };
1494
+ retainAttemptWorktree = true;
1495
+ const response = await queueTerminal(client, taskId, {
1496
+ action: "fail",
1497
+ body: {
1498
+ error: detail,
1499
+ retryable: false,
1500
+ signal,
1501
+ idempotency_key: `bridge:verification-gate-ran-nothing:${active.attemptId}`,
1502
+ },
1503
+ });
1504
+ retainAttemptWorktree = retainDiagnosticWorktree(response);
1505
+ // No agent ran and the tree stands at the base commit, so it holds no diagnostic value.
1506
+ if (!retainAttemptWorktree) {
1507
+ await removeAttemptWorktree(workspace, attemptWorkspace).catch(() => undefined);
1508
+ }
1509
+ console.error(`Assignment ${taskId} refused before the agent started: ${redactSecrets(detail)}`);
1510
+ return;
1504
1511
  }
1505
- console.error(`Assignment ${taskId} refused before the agent started: ${redactSecrets(detail)}`);
1506
- return;
1507
- }
1508
- if (dryRun.redBase) {
1509
- const { command, exitCode, stderrTail, stdoutTail } = dryRun.redBase;
1510
- const detail = `Verification fails on the base commit ${worktreeStart}: ${command} (exit ${exitCode ?? "unknown"})`;
1511
- retainAttemptWorktree = true;
1512
- const response = await queueTerminal(client, taskId, {
1513
- action: "fail",
1514
- body: {
1515
- error: detail,
1516
- retryable: false,
1517
- signal: verificationRedBaseSignal({ command, baseCommit: worktreeStart, exitCode, stderr: stderrTail, stdout: stdoutTail }),
1518
- idempotency_key: `bridge:verification-red-base:${active.attemptId}`,
1519
- },
1520
- });
1521
- retainAttemptWorktree = retainDiagnosticWorktree(response);
1522
- // No agent ran and the tree stands at the base commit, so it holds no diagnostic value.
1523
- if (!retainAttemptWorktree) {
1524
- await removeAttemptWorktree(workspace, attemptWorkspace).catch(() => undefined);
1512
+ if (dryRun.timedOut) {
1513
+ const { command, stderrTail, stdoutTail } = dryRun.timedOut;
1514
+ const detail = `Verification gate did not finish on the base commit ${worktreeStart}: ${command} (stopped after ${Math.round(VERIFICATION_TIMEOUT_MS / 60_000)} minutes)`;
1515
+ retainAttemptWorktree = true;
1516
+ const response = await queueTerminal(client, taskId, {
1517
+ action: "fail",
1518
+ body: {
1519
+ error: detail,
1520
+ retryable: false,
1521
+ signal: verificationGateTimeoutSignal({ command, baseCommit: worktreeStart, stderr: stderrTail, stdout: stdoutTail }),
1522
+ idempotency_key: `bridge:verification-gate-timeout:${active.attemptId}`,
1523
+ },
1524
+ });
1525
+ retainAttemptWorktree = retainDiagnosticWorktree(response);
1526
+ // No agent ran and the tree stands at the base commit, so it holds no diagnostic value.
1527
+ if (!retainAttemptWorktree) {
1528
+ await removeAttemptWorktree(workspace, attemptWorkspace).catch(() => undefined);
1529
+ }
1530
+ console.error(`Assignment ${taskId} refused before the agent started: ${redactSecrets(detail)}`);
1531
+ return;
1525
1532
  }
1526
- console.error(`Assignment ${taskId} refused before the agent started: ${redactSecrets(detail)}`);
1527
- return;
1528
- }
1529
- if (dryRun.outsideScope.length) {
1530
- const detail = verificationScopeConflictDetail(dryRun.outsideScope, spec.change_scope ?? []);
1531
- retainAttemptWorktree = true;
1532
- const response = await queueTerminal(client, taskId, {
1533
- action: "fail",
1534
- body: {
1535
- error: detail,
1536
- retryable: false,
1537
- signal: verificationScopeConflictSignal(dryRun.outsideScope),
1538
- idempotency_key: `bridge:verification-scope-conflict:${active.attemptId}`,
1539
- },
1540
- });
1541
- retainAttemptWorktree = retainDiagnosticWorktree(response);
1542
- // No agent ran and the tree stands at the base commit, so it holds no diagnostic value.
1543
- // Keep it only when the control plane says it still needs it.
1544
- if (!retainAttemptWorktree) {
1545
- await removeAttemptWorktree(workspace, attemptWorkspace).catch(() => undefined);
1533
+ if (dryRun.redBase) {
1534
+ const { command, exitCode, stderrTail, stdoutTail } = dryRun.redBase;
1535
+ const detail = `Verification fails on the base commit ${worktreeStart}: ${command} (exit ${exitCode ?? "unknown"})`;
1536
+ retainAttemptWorktree = true;
1537
+ const response = await queueTerminal(client, taskId, {
1538
+ action: "fail",
1539
+ body: {
1540
+ error: detail,
1541
+ retryable: false,
1542
+ signal: verificationRedBaseSignal({ command, baseCommit: worktreeStart, exitCode, stderr: stderrTail, stdout: stdoutTail }),
1543
+ idempotency_key: `bridge:verification-red-base:${active.attemptId}`,
1544
+ },
1545
+ });
1546
+ retainAttemptWorktree = retainDiagnosticWorktree(response);
1547
+ // No agent ran and the tree stands at the base commit, so it holds no diagnostic value.
1548
+ if (!retainAttemptWorktree) {
1549
+ await removeAttemptWorktree(workspace, attemptWorkspace).catch(() => undefined);
1550
+ }
1551
+ console.error(`Assignment ${taskId} refused before the agent started: ${redactSecrets(detail)}`);
1552
+ return;
1553
+ }
1554
+ if (dryRun.outsideScope.length) {
1555
+ const detail = verificationScopeConflictDetail(dryRun.outsideScope, spec.change_scope ?? []);
1556
+ retainAttemptWorktree = true;
1557
+ const response = await queueTerminal(client, taskId, {
1558
+ action: "fail",
1559
+ body: {
1560
+ error: detail,
1561
+ retryable: false,
1562
+ signal: verificationScopeConflictSignal(dryRun.outsideScope),
1563
+ idempotency_key: `bridge:verification-scope-conflict:${active.attemptId}`,
1564
+ },
1565
+ });
1566
+ retainAttemptWorktree = retainDiagnosticWorktree(response);
1567
+ // No agent ran and the tree stands at the base commit, so it holds no diagnostic value.
1568
+ // Keep it only when the control plane says it still needs it.
1569
+ if (!retainAttemptWorktree) {
1570
+ await removeAttemptWorktree(workspace, attemptWorkspace).catch(() => undefined);
1571
+ }
1572
+ console.error(`Assignment ${taskId} refused before the agent started: ${redactSecrets(detail)}`);
1573
+ return;
1546
1574
  }
1547
- console.error(`Assignment ${taskId} refused before the agent started: ${redactSecrets(detail)}`);
1548
- return;
1549
1575
  }
1576
+ dryRunPassed = true;
1577
+ }
1578
+ finally {
1579
+ // An attempt refused before the agent leaves here; the agent phase below owns the loop otherwise.
1580
+ if (!dryRunPassed)
1581
+ clearInterval(renewTimer);
1550
1582
  }
1551
1583
  const prompt = diagnosis
1552
1584
  ? buildDiagnosticPrompt({
@@ -1683,10 +1715,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
1683
1715
  sourceAttemptId: sourceAttemptId ?? undefined,
1684
1716
  });
1685
1717
  const releaseIdleSleep = startIdleSleepGuard();
1686
- const abort = new AbortController();
1687
- slotAborts.set(taskId, abort);
1688
1718
  watchdog.phase("agent");
1689
- const renewTimer = setInterval(() => { void renewLeases(client, config).catch((error) => console.error(`Lease renewal failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`)); }, 60_000);
1690
1719
  let heartbeatRunning = false;
1691
1720
  const heartbeatTimer = supervision ? setInterval(() => {
1692
1721
  if (heartbeatRunning)
@@ -2002,6 +2031,21 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
2002
2031
  retainAttemptWorktree = await failAttemptFromDriverResult(result);
2003
2032
  return;
2004
2033
  }
2034
+ // The agent may have rebased or merged the default branch during its turn (CR25 rework,
2035
+ // 2026-10-24: the base fixed at attempt start counted the default branch's own commits as the
2036
+ // agent's writes). Measure a rework from where the tree stands now.
2037
+ if (reworkOrResume) {
2038
+ deliveryBaseCommit = await resolveDeliveryBaseCommit({
2039
+ worktree: attemptWorkspace,
2040
+ sourceWorkspace: workspace,
2041
+ reworkOrResume,
2042
+ worktreeStart,
2043
+ taskId,
2044
+ deliveryBaseCommit: executionContract.delivery_base_commit,
2045
+ requestedBaseCommit: executionContract.requested_base_commit,
2046
+ repositoryBaseCommit: spec.repository?.base_commit,
2047
+ });
2048
+ }
2005
2049
  watchdog.phase("land");
2006
2050
  if (requiresLandCommit({ grants, spec })) {
2007
2051
  try {
@@ -3120,46 +3164,55 @@ export async function verificationScopeDryRun(input) {
3120
3164
  let ranNothing = null;
3121
3165
  let timedOut = null;
3122
3166
  let redBase = null;
3123
- for (const command of commands) {
3124
- // A gate that cannot start says nothing about the scope; the next one still can.
3125
- const result = await run(command, input.workspace).catch((error) => {
3126
- if (error instanceof VerificationCommandUnavailableError)
3127
- throw error;
3128
- return null;
3129
- });
3130
- if (!result)
3131
- continue;
3132
- // A gate that never exited says nothing about the base commit: it is not a red base, and it
3133
- // does not go to the initiative author.
3134
- if (result.timedOut) {
3135
- if (!timedOut) {
3136
- timedOut = {
3167
+ const stopOnAbort = () => stopTrackedProcessGroups();
3168
+ input.signal?.addEventListener("abort", stopOnAbort, { once: true });
3169
+ try {
3170
+ for (const command of commands) {
3171
+ if (input.signal?.aborted)
3172
+ break;
3173
+ // A gate that cannot start says nothing about the scope; the next one still can.
3174
+ const result = await run(command, input.workspace).catch((error) => {
3175
+ if (error instanceof VerificationCommandUnavailableError)
3176
+ throw error;
3177
+ return null;
3178
+ });
3179
+ if (!result || input.signal?.aborted)
3180
+ continue;
3181
+ // A gate that never exited says nothing about the base commit: it is not a red base, and it
3182
+ // does not go to the initiative author.
3183
+ if (result.timedOut) {
3184
+ if (!timedOut) {
3185
+ timedOut = {
3186
+ command,
3187
+ ...(result.stderr.trim() ? { stderrTail: result.stderr } : {}),
3188
+ ...(result.stdout.trim() ? { stdoutTail: result.stdout } : {}),
3189
+ };
3190
+ }
3191
+ continue;
3192
+ }
3193
+ // Ahead of the exit code: a gate that discovered no test exits 5, and a no-op recipe exits 0
3194
+ // with no output. Neither is a red base, so neither goes to the initiative author.
3195
+ const reason = detectGateRanNothing(command, result.stdout, result.stderr, result.code)
3196
+ ?? (result.code === 0 && !result.stdout.trim() && !result.stderr.trim() ? "gate ran no tests" : null);
3197
+ if (reason) {
3198
+ if (!ranNothing)
3199
+ ranNothing = { command, exitCode: result.code, reason };
3200
+ continue;
3201
+ }
3202
+ // First non-zero wins: the earliest gate to fail on the base is the one the operator fixes first.
3203
+ if (result.code !== 0 && !redBase) {
3204
+ redBase = {
3137
3205
  command,
3206
+ exitCode: result.code,
3138
3207
  ...(result.stderr.trim() ? { stderrTail: result.stderr } : {}),
3139
3208
  ...(result.stdout.trim() ? { stdoutTail: result.stdout } : {}),
3140
3209
  };
3141
3210
  }
3142
- continue;
3143
- }
3144
- // Ahead of the exit code: a gate that discovered no test exits 5, and a no-op recipe exits 0
3145
- // with no output. Neither is a red base, so neither goes to the initiative author.
3146
- const reason = detectGateRanNothing(command, result.stdout, result.stderr, result.code)
3147
- ?? (result.code === 0 && !result.stdout.trim() && !result.stderr.trim() ? "gate ran no tests" : null);
3148
- if (reason) {
3149
- if (!ranNothing)
3150
- ranNothing = { command, exitCode: result.code, reason };
3151
- continue;
3152
- }
3153
- // First non-zero wins: the earliest gate to fail on the base is the one the operator fixes first.
3154
- if (result.code !== 0 && !redBase) {
3155
- redBase = {
3156
- command,
3157
- exitCode: result.code,
3158
- ...(result.stderr.trim() ? { stderrTail: result.stderr } : {}),
3159
- ...(result.stdout.trim() ? { stdoutTail: result.stdout } : {}),
3160
- };
3161
3211
  }
3162
3212
  }
3213
+ finally {
3214
+ input.signal?.removeEventListener("abort", stopOnAbort);
3215
+ }
3163
3216
  const written = input.changeScope.length ? await worktreeWrittenPaths(input.workspace) : null;
3164
3217
  await discardWorktreeWrites(input.workspace);
3165
3218
  const outsideScope = written === null
package/dist/preflight.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
- import { readFileSync } from "node:fs";
3
+ import { linkSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { link, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
5
5
  import { homedir } from "node:os";
6
6
  import { dirname, join } from "node:path";
@@ -385,6 +385,10 @@ export function gateRehearsalLockHeld(lockPath = gateRehearsalLockPath()) {
385
385
  catch {
386
386
  return false;
387
387
  }
388
+ return processAlive(pid);
389
+ }
390
+ /** True when `pid` is a running process. EPERM counts: the process exists and we cannot signal it. */
391
+ function processAlive(pid) {
388
392
  if (!Number.isInteger(pid) || pid <= 0)
389
393
  return false;
390
394
  try {
@@ -421,6 +425,102 @@ async function acquireGateRehearsalLock(lockPath) {
421
425
  await unlink(claim).catch(() => undefined);
422
426
  }
423
427
  }
428
+ /**
429
+ * In-flight attempts on this computer. Each runner has its own config directory, so the markers
430
+ * live in the user cache beside the gate-rehearsal lock. One file per attempt, stamped with the
431
+ * holder's pid. A marker whose process is gone is not an attempt.
432
+ */
433
+ /** The runner loop sleeps for the heartbeat. A gate already inside a command cannot wait that long. */
434
+ const ATTEMPT_ACTIVE_POLL_MS = 20;
435
+ export function attemptActiveDirectory() {
436
+ const override = process.env.CONDUIT_ATTEMPT_ACTIVE_DIR?.trim();
437
+ return override ? override : join(homedir(), ".cache", "conduit", "attempts-active");
438
+ }
439
+ /** Record one attempt in flight. The returned function removes that marker and no other. */
440
+ export function markAttemptActive(directory = attemptActiveDirectory()) {
441
+ mkdirSync(directory, { recursive: true });
442
+ const marker = join(directory, `${process.pid}.${randomUUID()}`);
443
+ const claim = `${marker}.claim`;
444
+ writeFileSync(claim, `${process.pid}\n`);
445
+ try {
446
+ linkSync(claim, marker);
447
+ }
448
+ finally {
449
+ try {
450
+ unlinkSync(claim);
451
+ }
452
+ catch { /* the claim is already gone */ }
453
+ }
454
+ let released = false;
455
+ return () => {
456
+ if (released)
457
+ return;
458
+ released = true;
459
+ try {
460
+ unlinkSync(marker);
461
+ }
462
+ catch { /* a crash already removed it */ }
463
+ };
464
+ }
465
+ /**
466
+ * True when any live attempt marker is in `directory`. A marker whose pid is not alive is removed
467
+ * and does not count. An unreadable directory counts as active: a rehearsal must not start when
468
+ * this computer cannot see whether an attempt is in flight.
469
+ */
470
+ export function attemptsActiveOnComputer(directory = attemptActiveDirectory()) {
471
+ let names;
472
+ try {
473
+ names = readdirSync(directory);
474
+ }
475
+ catch (error) {
476
+ return error.code !== "ENOENT";
477
+ }
478
+ let active = false;
479
+ for (const name of names) {
480
+ if (name.endsWith(".claim"))
481
+ continue;
482
+ const path = join(directory, name);
483
+ let pid = Number.NaN;
484
+ try {
485
+ pid = Number.parseInt(readFileSync(path, "utf8"), 10);
486
+ }
487
+ catch {
488
+ continue;
489
+ }
490
+ if (!processAlive(pid)) {
491
+ try {
492
+ unlinkSync(path);
493
+ }
494
+ catch { /* removed by the holder, or not a file */ }
495
+ continue;
496
+ }
497
+ active = true;
498
+ }
499
+ return active;
500
+ }
501
+ /** Abort `abort` while an attempt marker is present. The caller stops the watch. */
502
+ function watchAttemptMarkers(directory, abort) {
503
+ const stop = () => {
504
+ if (attemptsActiveOnComputer(directory))
505
+ abort();
506
+ };
507
+ stop();
508
+ const timer = setInterval(stop, ATTEMPT_ACTIVE_POLL_MS);
509
+ timer.unref();
510
+ return () => clearInterval(timer);
511
+ }
512
+ /** Abort `to` when `from` aborts. An absent signal does nothing. */
513
+ function forwardAbort(from, to) {
514
+ if (!from)
515
+ return () => undefined;
516
+ if (from.aborted) {
517
+ to.abort();
518
+ return () => undefined;
519
+ }
520
+ const abort = () => to.abort();
521
+ from.addEventListener("abort", abort, { once: true });
522
+ return () => from.removeEventListener("abort", abort);
523
+ }
424
524
  /** Resolve when `work` settles, or with "aborted" when `signal` aborts first. */
425
525
  function untilAbort(work, signal) {
426
526
  if (!signal)
@@ -459,24 +559,33 @@ async function runRehearsalCommand(run, command, cwd, timeoutMs, signal) {
459
559
  return result;
460
560
  }
461
561
  /**
462
- * Run the planned gate once and remember it for the day. Null when no command could start, or when
463
- * `signal` aborts: the command is stopped, nothing is remembered, and the lock is released, so the
464
- * gate stays due. "busy" when another rehearsal on this computer holds the lock: nothing is run or
465
- * remembered, so the gate is still due and the next cycle tries again.
562
+ * Run the planned gate once and remember it for the day. Null when no command could start, when
563
+ * `signal` aborts, or when an attempt marker is present: the command is stopped, nothing is
564
+ * remembered, and the lock is released, so the gate stays due. "busy" when another rehearsal on
565
+ * this computer holds the lock: nothing is run or remembered, so the gate is still due and the
566
+ * next cycle tries again.
466
567
  */
467
568
  export async function runGateRehearsal(workspace, plan, options = {}) {
468
569
  if (options.signal?.aborted)
469
570
  return null;
571
+ const attemptDirectory = options.attemptDirectory ?? attemptActiveDirectory();
572
+ if (attemptsActiveOnComputer(attemptDirectory))
573
+ return null;
470
574
  const lockPath = options.lockPath ?? gateRehearsalLockPath();
471
575
  if (!await acquireGateRehearsalLock(lockPath))
472
576
  return "busy";
473
- const releaseAbort = stopWhenAborted(options.signal, stopTrackedProcessGroups);
577
+ const abort = new AbortController();
578
+ const stopForward = forwardAbort(options.signal, abort);
579
+ const stopWatch = watchAttemptMarkers(attemptDirectory, () => abort.abort());
580
+ const releaseAbort = stopWhenAborted(abort.signal, stopTrackedProcessGroups);
474
581
  try {
475
- if (options.signal?.aborted)
582
+ if (abort.signal.aborted)
476
583
  return null;
477
- return await rehearseGate(workspace, plan, options);
584
+ return await rehearseGate(workspace, plan, { ...options, signal: abort.signal });
478
585
  }
479
586
  finally {
587
+ stopWatch();
588
+ stopForward();
480
589
  releaseAbort();
481
590
  await unlink(lockPath).catch(() => undefined);
482
591
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miraland-labs/conduit-bridge",
3
- "version": "0.16.190",
3
+ "version": "0.16.193",
4
4
  "description": "Conduit Bridge CLI — join, connect, disconnect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Pi / Kiro / Antigravity / Grok Build agents for a Conduit organization",
5
5
  "type": "module",
6
6
  "bin": {