@miraland-labs/conduit-bridge 0.16.148 → 0.16.150
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/attempt-worktree.js +40 -1
- package/dist/cli.js +5 -2
- package/dist/ensure-test-evidence.js +35 -5
- package/dist/execution.js +9 -1
- package/dist/preflight.js +75 -3
- package/package.json +1 -1
package/dist/attempt-worktree.js
CHANGED
|
@@ -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))
|
|
@@ -232,18 +232,49 @@ const TAIL_HEAD_CHARS = 200;
|
|
|
232
232
|
*
|
|
233
233
|
* Returns the input unchanged when it already fits, so nothing short is ever decorated.
|
|
234
234
|
*/
|
|
235
|
-
export function boundedDetailLines(lines, maxLines) {
|
|
235
|
+
export function boundedDetailLines(lines, maxLines, identifiers = []) {
|
|
236
236
|
if (lines.length <= maxLines)
|
|
237
237
|
return lines;
|
|
238
238
|
const head = Math.min(3, Math.max(0, maxLines - 2));
|
|
239
239
|
const tail = maxLines - head - 1;
|
|
240
|
-
const
|
|
240
|
+
const middle = lines.slice(head, lines.length - tail);
|
|
241
|
+
// A criterion such as "its output shows privileged_wallet_holds_the_plan_without_payment as ok"
|
|
242
|
+
// is proved by one line in the middle of a long suite, and a reviewer must not infer it from the
|
|
243
|
+
// summary. So the lines that name an identifier the criteria name are kept too, in run order. The
|
|
244
|
+
// cap keeps a broad identifier that matches every line from storing the whole run.
|
|
245
|
+
const named = identifiers.length === 0
|
|
246
|
+
? []
|
|
247
|
+
: [...new Set(middle.filter((line) => identifiers.some((id) => line.includes(id))))]
|
|
248
|
+
.slice(0, NAMED_DETAIL_LINES_MAX)
|
|
249
|
+
.map((line) => (line.length > 500 ? `${line.slice(0, 500)}…` : line));
|
|
250
|
+
const dropped = middle.length - named.length;
|
|
241
251
|
return [
|
|
242
252
|
...lines.slice(0, head),
|
|
243
253
|
`… ${dropped} line${dropped === 1 ? "" : "s"} omitted …`,
|
|
254
|
+
...named,
|
|
244
255
|
...lines.slice(lines.length - tail),
|
|
245
256
|
];
|
|
246
257
|
}
|
|
258
|
+
const NAMED_DETAIL_LINES_MAX = 40;
|
|
259
|
+
/**
|
|
260
|
+
* The test and symbol names the acceptance criteria name: every backticked string, and every
|
|
261
|
+
* snake_case, camelCase, dotted or `::` token of at least 8 characters. An ordinary English word has
|
|
262
|
+
* none of `_`, `::`, `.` or an inner capital, so it never counts.
|
|
263
|
+
*/
|
|
264
|
+
function criterionIdentifiers(acceptance) {
|
|
265
|
+
const found = new Set();
|
|
266
|
+
for (const criterion of acceptance) {
|
|
267
|
+
for (const match of criterion.matchAll(/`([^`]+)`/g)) {
|
|
268
|
+
if (match[1].trim())
|
|
269
|
+
found.add(match[1].trim());
|
|
270
|
+
}
|
|
271
|
+
for (const [token] of criterion.matchAll(/[A-Za-z_][A-Za-z0-9_]*(?:(?:::|\.)[A-Za-z_][A-Za-z0-9_]*)*/g)) {
|
|
272
|
+
if (token.length >= 8 && /_|::|\.|[a-z0-9][A-Z]/.test(token))
|
|
273
|
+
found.add(token);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return [...found];
|
|
277
|
+
}
|
|
247
278
|
/**
|
|
248
279
|
* Shorten a raw diagnostic message while keeping the end.
|
|
249
280
|
*
|
|
@@ -593,11 +624,10 @@ export async function ensureTestEvidence(input) {
|
|
|
593
624
|
}
|
|
594
625
|
const rawDetails = [
|
|
595
626
|
`$ ${command}`,
|
|
596
|
-
...
|
|
597
|
-
...stderrLines,
|
|
627
|
+
...outputLines(result),
|
|
598
628
|
`exit ${result.code}`,
|
|
599
629
|
].map((line) => line.slice(0, 4_000));
|
|
600
|
-
const details = boundedDetailLines(rawDetails, 100);
|
|
630
|
+
const details = boundedDetailLines(rawDetails, 100, criterionIdentifiers(acceptance));
|
|
601
631
|
if (details.join("\n").trim().length < TEST_EVIDENCE_DETAILS_MIN) {
|
|
602
632
|
throw new Error(`Agent report: Verification produced insufficient output for test evidence (${command})`);
|
|
603
633
|
}
|
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";
|
|
@@ -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;
|
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 {
|
|
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
|
-
/**
|
|
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.
|
|
3
|
+
"version": "0.16.150",
|
|
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": {
|