@miraland-labs/conduit-bridge 0.16.70 → 0.16.100

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Local Bridge CLI for [Conduit](https://github.com/miralandlabs/conduit). Connects a computer to one organization, claims work, and drives a local agent.
4
4
 
5
- **Package version:** `0.16.27` — work-package execution budgets reach every agent turn, are bounded by the machine ceiling, and expose their effective source for operations. Local-fuel provider refusals make the affected lane unavailable instead of repeatedly spending attempts; an operator who changes the lane's account or plan can invalidate that observation with `ops quota-clear <driver>`. Bridge discovers bounded repository verification commands, including Make targets `test`, `check`, `verify`, `replay`, `typecheck`, `lint`, and `build`. `ops disconnect` asks before it removes the runner service and clears credentials; pass `--yes` to skip the question. `ops.env` is enrollment/bootstrap defaults only: an explicit workspace does not inherit an old repository, and project switches do not rewrite global project state. `ops enroll` exchanges a single-use token, registers the machine, binds project affinity, provisions fuel keys, brings detected drivers online, and starts the runner in one command. On Linux, `ops install` refuses any effective systemd drop-in. A switch intent remains queued until the target runner heartbeat proves Bound. After every Bridge publish, operators must re-run `ops install` (LaunchAgent pins an absolute `cli.js`).
5
+ **Package version:** `0.16.95` — Conduit-fuel lanes bind organization aliases (T144), the Anthropic base URL is the origin (T148), the investigation lane resolves its model (T154); a hosted container image lives under `hosted/` (docs/HOSTED_RUNNER.md). Earlier (0.16.27) — work-package execution budgets reach every agent turn, are bounded by the machine ceiling, and expose their effective source for operations. Local-fuel provider refusals make the affected lane unavailable instead of repeatedly spending attempts; an operator who changes the lane's account or plan can invalidate that observation with `ops quota-clear <driver>`. Bridge discovers bounded repository verification commands, including Make targets `test`, `check`, `verify`, `replay`, `typecheck`, `lint`, and `build`. `ops disconnect` asks before it removes the runner service and clears credentials; pass `--yes` to skip the question. `ops.env` is enrollment/bootstrap defaults only: an explicit workspace does not inherit an old repository, and project switches do not rewrite global project state. `ops enroll` exchanges a single-use token, registers the machine, binds project affinity, provisions fuel keys, brings detected drivers online, and starts the runner in one command. On Linux, `ops install` refuses any effective systemd drop-in. A switch intent remains queued until the target runner heartbeat proves Bound. After every Bridge publish, operators must re-run `ops install` (LaunchAgent pins an absolute `cli.js`).
6
6
 
7
7
  ## Prerequisites
8
8
 
@@ -0,0 +1,88 @@
1
+ /**
2
+ * T90: an attempt that stops answering must end, not hold its lease for ever.
3
+ *
4
+ * Field, 2026-09-07: a cursor attempt was claimed at 15:24 and was still `running` at 18:30 with
5
+ * the lease renewed on every heartbeat, while the runner had no child process and the journal had
6
+ * printed nothing since the model line. One awaited step never returned, and the Bridge had no
7
+ * clock of its own above the agent turn: the attempt could only end when that step answered.
8
+ *
9
+ * The watchdog is that clock. It carries the phase the attempt is in, prints each transition to
10
+ * the journal, and rejects `stalled` when the phase has not moved for its own budget. The phase
11
+ * name travels with the failure, so the next reader knows which step never returned.
12
+ */
13
+ /** How long a phase may stay silent after the step that can answer has finished. */
14
+ export const ATTEMPT_STALL_GRACE_MS = 15 * 60_000;
15
+ /**
16
+ * Phases that may run an agent turn or the project's own gates, so their budget is the turn budget
17
+ * plus the grace. Every other phase is bookkeeping the machine does alone: the grace is enough, and
18
+ * a shorter clock reports a stall sooner.
19
+ */
20
+ const AGENT_TURN_PHASES = new Set([
21
+ "claim",
22
+ "agent",
23
+ "verification",
24
+ "report_repair",
25
+ "land",
26
+ ]);
27
+ /** The end of an attempt that stopped answering, naming the step that never returned. */
28
+ export class AttemptStalledError extends Error {
29
+ phase;
30
+ waitedMs;
31
+ constructor(phase, waitedMs) {
32
+ super(`bridge_attempt_stalled: phase=${phase}; the step gave no answer for ${Math.round(waitedMs / 60_000)} minutes`);
33
+ this.phase = phase;
34
+ this.waitedMs = waitedMs;
35
+ this.name = "AttemptStalledError";
36
+ }
37
+ }
38
+ /** Bound one awaited step. The rejection names the phase, as a stall report does. */
39
+ export function withTimeout(promise, ms, phase) {
40
+ return new Promise((resolve, reject) => {
41
+ const timer = setTimeout(() => reject(new AttemptStalledError(phase, ms)), ms);
42
+ timer.unref?.();
43
+ promise.then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
44
+ });
45
+ }
46
+ export function createAttemptWatchdog(input) {
47
+ let fail = () => undefined;
48
+ const stalled = new Promise((_resolve, reject) => { fail = reject; });
49
+ // The attempt usually wins the race and nobody ever reads this rejection.
50
+ stalled.catch(() => undefined);
51
+ const journal = input.journal ?? ((line) => console.log(line));
52
+ let turnBudgetMs = input.turnBudgetMs;
53
+ let current = "claim";
54
+ let timer = null;
55
+ let stopped = false;
56
+ const arm = (ms) => {
57
+ if (timer)
58
+ clearTimeout(timer);
59
+ if (stopped)
60
+ return;
61
+ timer = setTimeout(() => fail(new AttemptStalledError(current, ms)), ms);
62
+ timer.unref?.();
63
+ };
64
+ const budgetFor = (name) => AGENT_TURN_PHASES.has(name) ? turnBudgetMs + ATTEMPT_STALL_GRACE_MS : ATTEMPT_STALL_GRACE_MS;
65
+ arm(budgetFor(current));
66
+ return {
67
+ stalled,
68
+ phase(name) {
69
+ if (stopped)
70
+ return;
71
+ current = name;
72
+ journal(`Assignment ${input.taskId} phase: ${name}`);
73
+ arm(budgetFor(name));
74
+ },
75
+ budget(next) {
76
+ if (stopped || !(next > 0))
77
+ return;
78
+ turnBudgetMs = next;
79
+ arm(budgetFor(current));
80
+ },
81
+ stop() {
82
+ stopped = true;
83
+ if (timer)
84
+ clearTimeout(timer);
85
+ timer = null;
86
+ },
87
+ };
88
+ }
package/dist/brief.js CHANGED
@@ -59,7 +59,8 @@ export async function isBaseCommitAncestor(workspace, baseCommit, headCommit) {
59
59
  throw error;
60
60
  }
61
61
  }
62
- async function fetchOrigin(workspace) {
62
+ /** Refresh every origin ref; deepens a shallow clone enough to see a commit a tip fetch missed. */
63
+ export async function fetchOrigin(workspace) {
63
64
  await execFileAsync("git", ["-C", workspace, "fetch", "--no-tags", "origin"], {
64
65
  timeout: 120_000,
65
66
  windowsHide: true,
package/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import { createInterface } from "node:readline/promises";
7
7
  import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync } from "node:fs";
8
8
  import { stdin as input, stdout as output } from "node:process";
9
9
  import { fileURLToPath } from "node:url";
10
- import { ConduitClient, ConduitRequestError } from "./client.js";
10
+ import { assignmentPollBehindServer, ConduitClient, ConduitRequestError } from "./client.js";
11
11
  import { BRIDGE_LEASE_CAPACITY, BRIDGE_MAX_LEASE_CAPACITY, clampLeaseCapacity, clearLocalConnection, clearPendingConnection, loadConfig, loadConfigIfPresent, loadOrCreateInstallationId, loadPendingConnection, redactSecrets, saveConfig, saveConfigPrefs, saveDriverQuota, savePendingConnection, suggestMachineName, } from "./config.js";
12
12
  import { runMcp } from "./mcp.js";
13
13
  import { detectInstalledClients, localFuelOnlyClients, suggestFuelSource } from "./detect.js";
@@ -46,6 +46,16 @@ function parseBootstrapResult(value) {
46
46
  throw new Error(`Bootstrap result must be one of: ${BOOTSTRAP_RESULTS.join(", ")}`);
47
47
  return result;
48
48
  }
49
+ /** Managed authority comes from this Bridge installation's ops.env, never from the control plane. */
50
+ function configuredManagedRoot() {
51
+ try {
52
+ const configured = loadOpsEnv().CONDUIT_MANAGED_ROOT.trim();
53
+ return configured ? resolve(configured) : undefined;
54
+ }
55
+ catch {
56
+ return undefined;
57
+ }
58
+ }
49
59
  async function connect() {
50
60
  const { values } = parseArgs({ args: process.argv.slice(3), options: { url: { type: "string" }, code: { type: "string" }, fuel: { type: "string" } } });
51
61
  if (!values.url || !values.code)
@@ -278,7 +288,7 @@ async function finishConnection(baseUrl, data, fuelSource) {
278
288
  const client = new ConduitClient(config);
279
289
  let heartbeatOk = true;
280
290
  try {
281
- await heartbeat(client, config, null, null, unavailableWorkspacePreflight({ config }));
291
+ await heartbeat(client, config, null, null, unavailableWorkspacePreflight({ config, managedRoot: configuredManagedRoot() }));
282
292
  }
283
293
  catch {
284
294
  heartbeatOk = false;
@@ -408,7 +418,7 @@ async function initOps() {
408
418
  async function opsCommand() {
409
419
  const verb = process.argv[3];
410
420
  if (!verb || !OPS_VERBS.includes(verb)) {
411
- throw new Error(`Usage: ${bridgeUsage("ops", "<connect|install|switch|online|offline|quota-clear|status|doctor|disconnect|uninstall>", "[driver…]")} (install also takes --workspace <path> [--repo <url>])`);
421
+ throw new Error(`Usage: ${bridgeUsage("ops", "<connect|install|switch|online|offline|quota-clear|status|workspace|doctor|disconnect|uninstall>", "[driver…]")} (install also takes --workspace <path> [--repo <url>])`);
412
422
  }
413
423
  await runOps(verb, process.argv.slice(4));
414
424
  }
@@ -543,6 +553,7 @@ async function runner() {
543
553
  throw new Error("--ensure-checkout requires --workspace <repository-path>");
544
554
  }
545
555
  const workspace = values.workspace ? resolve(values.workspace) : null;
556
+ const managedRoot = configuredManagedRoot();
546
557
  if (workspace && values["ensure-checkout"]) {
547
558
  const result = await ensureCheckout(workspace, values["ensure-checkout"]);
548
559
  console.log(result === "cloned"
@@ -585,8 +596,8 @@ async function runner() {
585
596
  const refresh = workspace ? await reportedRefresh(workspace) : null;
586
597
  const currentBrief = workspace ? await buildWorkspaceBrief(workspace).catch(() => brief) : null;
587
598
  const preflight = workspace
588
- ? await cachedBridgePreflight({ config, workspace, brief: currentBrief ?? undefined, refresh })
589
- : unavailableWorkspacePreflight({ config });
599
+ ? await cachedBridgePreflight({ config, workspace, managedRoot, brief: currentBrief ?? undefined, refresh })
600
+ : unavailableWorkspacePreflight({ config, managedRoot });
590
601
  const hb = await heartbeat(client, config, workspace, currentBrief, preflight, undefined, bootstrapResult);
591
602
  if (hb.staleBridge) {
592
603
  console.error(`Bridge update required: running protocol ${BRIDGE_PROTOCOL_VERSION}; control plane requires protocol ${hb.requiredProtocol ?? "unknown"} (minimum Bridge ${hb.minimumVersion ?? "unknown"}). Reinstall the latest Bridge, then restart this runner.`);
@@ -630,14 +641,16 @@ async function runner() {
630
641
  if (workspace && onlineDriverIds(config).length) {
631
642
  // Observation runs before authoring: an owner waiting on an answer should not queue behind
632
643
  // a long delivery, and the control plane already counted this slot as busy.
633
- progressed = await executeNextInvestigation(client, config, workspace, brief, timeoutMs) || progressed;
644
+ progressed = await executeNextInvestigation(client, config, workspace, brief, timeoutMs, {
645
+ managedRoot: hb.on_shift?.managed_workspace_root ?? null,
646
+ }) || progressed;
634
647
  }
635
648
  if (workspace && onlineDriverIds(config).length) {
636
649
  // Read the workspace again, then publish it. The last brief stands if the read fails.
637
650
  const publishWorkspaceState = async (options = {}) => {
638
651
  const refresh = await reportedRefresh(workspace, options);
639
652
  const currentBrief = await buildWorkspaceBrief(workspace).catch(() => brief);
640
- const preflight = await cachedBridgePreflight({ config, workspace, brief: currentBrief ?? undefined, refresh }, options);
653
+ const preflight = await cachedBridgePreflight({ config, workspace, managedRoot, brief: currentBrief ?? undefined, refresh }, options);
641
654
  const hb = await heartbeat(client, config, workspace, currentBrief, preflight, undefined, bootstrapResult);
642
655
  return { preflight, hb };
643
656
  };
@@ -656,7 +669,9 @@ async function runner() {
656
669
  return preflight.ready && lane?.ready === true && typeof lane.version === "string" && lane.version.length > 0;
657
670
  },
658
671
  heartbeatIntervalMs: intervalMs,
659
- }, running, {});
672
+ // T86: a computer on shift for several projects moves to the assignment's repository
673
+ // under this root when it claims.
674
+ }, running, { managedRoot: hb.on_shift?.managed_workspace_root ?? null });
660
675
  }
661
676
  }
662
677
  catch (error) {
@@ -710,6 +725,16 @@ async function heartbeat(client, config, workspacePath, brief, preflight, intent
710
725
  bridgeProtocol: BRIDGE_PROTOCOL_VERSION,
711
726
  bootstrapResult,
712
727
  });
728
+ // T98: an assignments poll that has failed schema validation flags this runner as behind the
729
+ // server, so the control plane can tell the operator to restart it instead of leaving the runner
730
+ // to fail every poll silently. Merged in here rather than into runBridgePreflight itself: the
731
+ // preflight probe is cached for minutes at a time, and this fact must reach the very next
732
+ // heartbeat.
733
+ const preflightToSend = {
734
+ ...preflight,
735
+ ...(assignmentPollBehindServer() ? { runner_behind_server: true } : {}),
736
+ ...(process.env.CONDUIT_RUNNER_SERVICE === "container" ? { runner_service: "container" } : {}),
737
+ };
713
738
  const response = await client.request("/runner/v1/heartbeat", { method: "POST", body: JSON.stringify({
714
739
  status,
715
740
  capabilities: config.capabilities,
@@ -719,7 +744,7 @@ async function heartbeat(client, config, workspacePath, brief, preflight, intent
719
744
  bridge_version: version,
720
745
  bridge_protocol: BRIDGE_PROTOCOL_VERSION,
721
746
  drivers: driversHeartbeatReport(config, config.activeAttempts),
722
- preflight,
747
+ preflight: preflightToSend,
723
748
  execution_facts: executionFacts,
724
749
  ...(intentProof ? { on_shift_intent_proof: intentProof } : {}),
725
750
  ...(brief ? { workspace_brief: brief } : {}),
package/dist/client.js CHANGED
@@ -12,6 +12,34 @@ export class ConduitRequestError extends Error {
12
12
  this.details = details;
13
13
  }
14
14
  }
15
+ // T98: a server wire-shape change (e.g. T96's released_worktree_attempt_ids) can move ahead of an
16
+ // older runner's schema. The old failure mode printed the full zod issue dump on every poll — one
17
+ // stranded runner logged the same 25-issue dump every cycle and never said what to do about it.
18
+ // This runner logs one short line per minute instead, and flags itself on the next heartbeat so the
19
+ // control plane can tell the operator to restart it.
20
+ let assignmentPollShapeUnderstood = true;
21
+ let lastAssignmentPollShapeWarningAt = 0;
22
+ const ASSIGNMENT_POLL_SHAPE_WARNING_INTERVAL_MS = 60_000;
23
+ /** Call when the /runner/v1/assignments response fails schema validation. */
24
+ export function reportAssignmentPollShapeMismatch(bridgeVersion) {
25
+ assignmentPollShapeUnderstood = false;
26
+ const now = Date.now();
27
+ if (now - lastAssignmentPollShapeWarningAt < ASSIGNMENT_POLL_SHAPE_WARNING_INTERVAL_MS)
28
+ return;
29
+ lastAssignmentPollShapeWarningAt = now;
30
+ console.error(`assignment poll: response shape not understood — this runner (v${bridgeVersion}) is behind the server; restart it`);
31
+ }
32
+ /** True once an assignments poll has failed schema validation. Read by the next heartbeat. */
33
+ export function assignmentPollBehindServer() {
34
+ return !assignmentPollShapeUnderstood;
35
+ }
36
+ /** Test-only: put the module back to its just-started state between cases. */
37
+ export function resetAssignmentPollShapeState() {
38
+ assignmentPollShapeUnderstood = true;
39
+ lastAssignmentPollShapeWarningAt = 0;
40
+ }
41
+ /** The longest one control-plane call may take before the caller gets an error instead of a wait. */
42
+ const CONTROL_PLANE_TIMEOUT_MS = 120_000;
15
43
  export class ConduitClient {
16
44
  config;
17
45
  persistRuntime;
@@ -36,6 +64,9 @@ export class ConduitClient {
36
64
  async request(path, init = {}) {
37
65
  const response = await fetch(`${this.config.baseUrl}${path}`, {
38
66
  ...init,
67
+ // T90: every control-plane call of an attempt is bounded. A request that never answers used
68
+ // to be an awaited step with no clock above it, and the attempt could not end.
69
+ signal: init.signal ?? AbortSignal.timeout(CONTROL_PLANE_TIMEOUT_MS),
39
70
  headers: { authorization: `Bearer ${this.config.runnerKey}`, "content-type": "application/json", ...init.headers },
40
71
  });
41
72
  const data = await response.json();
@@ -57,6 +88,7 @@ export class ConduitClient {
57
88
  ...(extras?.driverId ? { driver_id: extras.driverId } : {}),
58
89
  ...(extras?.fuelMode ? { fuel_mode: extras.fuelMode } : {}),
59
90
  ...(extras?.fuelProvenance ? { fuel_provenance: extras.fuelProvenance } : {}),
91
+ ...(extras?.startCommit ? { start_commit: extras.startCommit } : {}),
60
92
  }),
61
93
  });
62
94
  const active = {
@@ -69,6 +101,7 @@ export class ConduitClient {
69
101
  ...(extras?.fuelMode ? { fuelMode: extras.fuelMode } : {}),
70
102
  ...(extras?.executionKind ? { executionKind: extras.executionKind } : {}),
71
103
  ...(extras?.sourceAttemptId ? { sourceAttemptId: extras.sourceAttemptId } : {}),
104
+ ...(extras?.sourceWorkspace ? { sourceWorkspace: extras.sourceWorkspace } : {}),
72
105
  };
73
106
  this.config.activeAttempts[taskId] = active;
74
107
  await this.persistRuntime(this.config);
package/dist/config.js CHANGED
@@ -146,7 +146,10 @@ function plainQuota(raw) {
146
146
  return undefined;
147
147
  if (raw.resets_at !== null && typeof raw.resets_at !== "string")
148
148
  return undefined;
149
- return { exhausted: raw.exhausted, resets_at: raw.resets_at, observed_at: raw.observed_at };
149
+ // The vendor is half the record's key, so it must survive the write. Losing it here would make
150
+ // every stored refusal lane-wide again on the next load.
151
+ const vendor = typeof raw.vendor === "string" && raw.vendor.trim() ? raw.vendor : undefined;
152
+ return { exhausted: raw.exhausted, resets_at: raw.resets_at, observed_at: raw.observed_at, ...(vendor ? { vendor } : {}) };
150
153
  }
151
154
  async function loadRuntime() {
152
155
  try {