@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.
@@ -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 dropped = lines.length - head - tail;
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
- ...stdoutLines,
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 { 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.148",
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": {