@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 +11 -6
- package/dist/execution.js +220 -167
- package/dist/preflight.js +117 -8
- package/package.json +1 -1
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
|
|
671
|
-
//
|
|
672
|
-
|
|
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
|
|
677
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
})
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
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
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
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
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
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
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
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
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
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
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
if (!
|
|
3136
|
-
|
|
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,
|
|
463
|
-
* `signal` aborts
|
|
464
|
-
* gate stays due. "busy" when another rehearsal on
|
|
465
|
-
* remembered, so the gate is still due and the
|
|
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
|
|
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 (
|
|
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.
|
|
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": {
|