@miraland-labs/conduit-bridge 0.16.42 → 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
@@ -12,7 +12,7 @@ const MANIFESTS = [
12
12
  ];
13
13
  /** Prefer test before typecheck/lint so discovery order matches what pickVerificationCommand wants. */
14
14
  const VERIFICATION_SCRIPTS = ["test", "verify", "typecheck", "lint", "build"];
15
- const MAKE_VERIFICATION_TARGETS = ["test", "check", "verify", "replay", "typecheck", "lint", "build"];
15
+ export const MAKE_VERIFICATION_TARGETS = ["test", "check", "verify", "replay", "typecheck", "lint", "build"];
16
16
  const EXCLUDED_DIRECTORIES = new Set(["node_modules", "dist", "build", "target", "coverage", ".venv", "venv"]);
17
17
  export async function buildWorkspaceBrief(workspace) {
18
18
  const entries = await readdir(workspace, { withFileTypes: true });
@@ -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,
@@ -168,6 +169,8 @@ export async function resolveAttemptStartCommit(workspace, requestedBase, claime
168
169
  * recipe body was always repository-authored.
169
170
  */
170
171
  const MAX_DECLARED_VERIFICATION = 8;
172
+ /** Keep in sync with `workspaceBriefSchema.verification` in `src/runner/routes.ts`. */
173
+ const MAX_VERIFICATION_COMMANDS = 10;
171
174
  export function parseDeclaredVerification(text) {
172
175
  const declared = [];
173
176
  for (const raw of text.split("\n")) {
@@ -182,12 +185,30 @@ export function parseDeclaredVerification(text) {
182
185
  return declared;
183
186
  }
184
187
  export async function readDeclaredVerification(workspace) {
188
+ let text;
185
189
  try {
186
- return parseDeclaredVerification(await readFile(join(workspace, ".conduit", "verification"), "utf8"));
190
+ text = await readFile(join(workspace, ".conduit", "verification"), "utf8");
187
191
  }
188
192
  catch {
189
193
  return []; // no declaration, or unreadable: discovery still applies
190
194
  }
195
+ const declared = parseDeclaredVerification(text);
196
+ // A line the shape check refuses, or a line after the eighth, was dropped with no reason given:
197
+ // the gate was then absent from every list and the operator had nothing to read. One line per
198
+ // dropped declaration says which line went and why it can go.
199
+ for (const raw of text.split("\n")) {
200
+ const line = raw.split("#")[0].trim();
201
+ if (!line || declared.includes(line))
202
+ continue;
203
+ console.warn(JSON.stringify({
204
+ event: "declared_verification_dropped",
205
+ line: line.slice(0, 200),
206
+ reason: isBoundedVerificationCommand(line)
207
+ ? `more than ${MAX_DECLARED_VERIFICATION} declared commands`
208
+ : "not a bounded verification command",
209
+ }));
210
+ }
211
+ return declared;
191
212
  }
192
213
  export async function discoverVerificationCommands(workspace, files) {
193
214
  // Declared first: the order is a preference order for every reader that takes one command.
@@ -240,5 +261,8 @@ export async function discoverVerificationCommands(workspace, files) {
240
261
  if (files.has("gradlew"))
241
262
  commands.push("./gradlew test");
242
263
  }
243
- return [...new Set(commands)];
264
+ // The heartbeat schema caps `verification` at 10. A gate-rich checkout discovers more than that,
265
+ // and the whole heartbeat was then refused: the computer went dark with no reason on any card.
266
+ // Declared commands come first, so the cap removes only the least preferred discovered names.
267
+ return [...new Set(commands)].slice(0, MAX_VERIFICATION_COMMANDS);
244
268
  }
package/dist/cli.js CHANGED
@@ -7,12 +7,12 @@ 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";
14
- import { DRIVERS } from "./driver.js";
15
- import { driversHeartbeatReport, driverLabel, heartbeatStatusForDrivers, isSupportedDriverId, listDriverLanes, onlineDriverIds, seedDriversFromDetection, seedDriverLanes, setDriverFuel, setDriversOnline, laneDispatchBlock, laneStatuses, } from "./drivers.js";
14
+ import { DRIVERS, MODEL_TIERS } from "./driver.js";
15
+ import { driversHeartbeatReport, driverLabel, heartbeatStatusForDrivers, isSupportedDriverId, listDriverLanes, onlineDriverIds, seedDriversFromDetection, seedDriverLanes, setDriverFuel, setDriverModels, setDriversOnline, formatLaneTiers, laneDispatchBlock, laneStatuses, } from "./drivers.js";
16
16
  import { buildWorkspaceBrief } from "./brief.js";
17
17
  import { parseAgentTimeoutMinutes } from "./execution-budget.js";
18
18
  import { BOOTSTRAP_RESULTS, buildSovereignExecutionFacts } from "./execution-facts.js";
@@ -21,7 +21,8 @@ import { buildOnShiftIntentProof, maybeApplyOnShiftIntent } from "./on-shift-app
21
21
  import { loadOpsEnv, OPS_VERBS, runOps } from "./ops.js";
22
22
  import { pumpExecutionSlots, renewLeases } from "./execution.js";
23
23
  import { applyRunnerToolPath, installRunnerService, uninstallRunnerService } from "./service.js";
24
- import { BRIDGE_PROTOCOL_VERSION, cachedBridgePreflight, unavailableWorkspacePreflight } from "./preflight.js";
24
+ import { BRIDGE_PROTOCOL_VERSION, cachedBridgePreflight, PREFLIGHT_CACHE_TTL_MS, unavailableWorkspacePreflight } from "./preflight.js";
25
+ import { refreshSourceWorkspace } from "./workspace-refresh.js";
25
26
  import { executeNextInvestigation } from "./investigation.js";
26
27
  import { bridgeVersion } from "./version.js";
27
28
  const [command] = process.argv.slice(2);
@@ -45,6 +46,16 @@ function parseBootstrapResult(value) {
45
46
  throw new Error(`Bootstrap result must be one of: ${BOOTSTRAP_RESULTS.join(", ")}`);
46
47
  return result;
47
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
+ }
48
59
  async function connect() {
49
60
  const { values } = parseArgs({ args: process.argv.slice(3), options: { url: { type: "string" }, code: { type: "string" }, fuel: { type: "string" } } });
50
61
  if (!values.url || !values.code)
@@ -277,7 +288,7 @@ async function finishConnection(baseUrl, data, fuelSource) {
277
288
  const client = new ConduitClient(config);
278
289
  let heartbeatOk = true;
279
290
  try {
280
- await heartbeat(client, config, null, null, unavailableWorkspacePreflight({ config }));
291
+ await heartbeat(client, config, null, null, unavailableWorkspacePreflight({ config, managedRoot: configuredManagedRoot() }));
281
292
  }
282
293
  catch {
283
294
  heartbeatOk = false;
@@ -407,7 +418,7 @@ async function initOps() {
407
418
  async function opsCommand() {
408
419
  const verb = process.argv[3];
409
420
  if (!verb || !OPS_VERBS.includes(verb)) {
410
- 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>])`);
411
422
  }
412
423
  await runOps(verb, process.argv.slice(4));
413
424
  }
@@ -444,12 +455,16 @@ async function driversCommand() {
444
455
  // Printing the count is the only way an operator sees why dispatch keeps avoiding this lane.
445
456
  const slow = lane.timeouts ? ` timeouts=${lane.timeouts} (demoted)` : "";
446
457
  console.log(` ${lane.id.padEnd(14)} ${lane.state.padEnd(8)} fuel=${lane.fuel} allowance=${lane.allowance}${when}${reset}${slow} (${lane.label})`);
458
+ // An unbound tier runs the CLI default and Holds on the first vendor abort, so the map is
459
+ // printed here — the operator learned a typo such as `fast` only from that Hold before.
460
+ console.log(` tiers ${formatLaneTiers(lane.tiers)}`);
447
461
  }
448
462
  const blocked = laneDispatchBlock(laneStatuses(config));
449
463
  console.log(blocked
450
464
  ? `Dispatch: BLOCKED — ${blocked}.`
451
465
  : `Dispatch: ready — eligible ${laneStatuses(config).filter((lane) => lane.eligible).map((lane) => lane.id).join(", ")}.`);
452
466
  const online = onlineDriverIds(config);
467
+ console.log(`Bind a tier: ${bridgeUsage("drivers", "models", "<driver-id>", MODEL_TIERS.join("|"), "<model…>")}`);
453
468
  console.log(online.length
454
469
  ? `Toggle a lane: ${bridgeUsage("drivers", "online|offline", "<id…>")}`
455
470
  : `All offline — no new claims. Bring one online: ${bridgeUsage("drivers", "online", AGENT_PLACEHOLDER)}`);
@@ -500,7 +515,20 @@ async function driversCommand() {
500
515
  console.log(`${driverLabel(id)} lane fuel set to ${mode}`);
501
516
  return;
502
517
  }
503
- throw new Error(`Usage: ${bridgeUsage("drivers", "[list|online|offline|fuel|quota]", "…")}`);
518
+ if (sub === "models") {
519
+ const id = process.argv[4]?.trim();
520
+ const tier = process.argv[5]?.trim();
521
+ const names = process.argv.slice(6).map((name) => name.trim()).filter(Boolean);
522
+ if (!id || !tier || !MODEL_TIERS.includes(tier) || !names.length) {
523
+ throw new Error(`Usage: ${bridgeUsage("drivers", "models", "<driver-id>", MODEL_TIERS.join("|"), "<model…>")}`);
524
+ }
525
+ config = seedDriverLanes(config, [id]).config;
526
+ config = setDriverModels(config, id, tier, names);
527
+ await saveConfigPrefs(config);
528
+ console.log(`${driverLabel(id)} lane ${tier} tier: ${names.join(", ")}`);
529
+ return;
530
+ }
531
+ throw new Error(`Usage: ${bridgeUsage("drivers", "[list|online|offline|fuel|models|quota]", "…")}`);
504
532
  }
505
533
  async function runner() {
506
534
  applyRunnerToolPath();
@@ -525,6 +553,7 @@ async function runner() {
525
553
  throw new Error("--ensure-checkout requires --workspace <repository-path>");
526
554
  }
527
555
  const workspace = values.workspace ? resolve(values.workspace) : null;
556
+ const managedRoot = configuredManagedRoot();
528
557
  if (workspace && values["ensure-checkout"]) {
529
558
  const result = await ensureCheckout(workspace, values["ensure-checkout"]);
530
559
  console.log(result === "cloned"
@@ -562,10 +591,13 @@ async function runner() {
562
591
  config.drivers = latest.drivers;
563
592
  config.fuelSource = latest.fuelSource;
564
593
  config.leaseCapacity = latest.leaseCapacity;
594
+ // Refresh before discovery: the brief and the readiness report must describe origin's code,
595
+ // not whatever this checkout was left on. Cadence is the readiness report's own, not the beat.
596
+ const refresh = workspace ? await reportedRefresh(workspace) : null;
565
597
  const currentBrief = workspace ? await buildWorkspaceBrief(workspace).catch(() => brief) : null;
566
598
  const preflight = workspace
567
- ? await cachedBridgePreflight({ config, workspace, brief: currentBrief ?? undefined })
568
- : unavailableWorkspacePreflight({ config });
599
+ ? await cachedBridgePreflight({ config, workspace, managedRoot, brief: currentBrief ?? undefined, refresh })
600
+ : unavailableWorkspacePreflight({ config, managedRoot });
569
601
  const hb = await heartbeat(client, config, workspace, currentBrief, preflight, undefined, bootstrapResult);
570
602
  if (hb.staleBridge) {
571
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.`);
@@ -609,13 +641,16 @@ async function runner() {
609
641
  if (workspace && onlineDriverIds(config).length) {
610
642
  // Observation runs before authoring: an owner waiting on an answer should not queue behind
611
643
  // a long delivery, and the control plane already counted this slot as busy.
612
- 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;
613
647
  }
614
648
  if (workspace && onlineDriverIds(config).length) {
615
649
  // Read the workspace again, then publish it. The last brief stands if the read fails.
616
650
  const publishWorkspaceState = async (options = {}) => {
651
+ const refresh = await reportedRefresh(workspace, options);
617
652
  const currentBrief = await buildWorkspaceBrief(workspace).catch(() => brief);
618
- const preflight = await cachedBridgePreflight({ config, workspace, brief: currentBrief ?? undefined }, options);
653
+ const preflight = await cachedBridgePreflight({ config, workspace, managedRoot, brief: currentBrief ?? undefined, refresh }, options);
619
654
  const hb = await heartbeat(client, config, workspace, currentBrief, preflight, undefined, bootstrapResult);
620
655
  return { preflight, hb };
621
656
  };
@@ -634,7 +669,9 @@ async function runner() {
634
669
  return preflight.ready && lane?.ready === true && typeof lane.version === "string" && lane.version.length > 0;
635
670
  },
636
671
  heartbeatIntervalMs: intervalMs,
637
- }, 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 });
638
675
  }
639
676
  }
640
677
  catch (error) {
@@ -648,6 +685,34 @@ async function runner() {
648
685
  await new Promise((resolveSleep) => setTimeout(resolveSleep, intervalMs));
649
686
  }
650
687
  }
688
+ /** Last state announced, so a workspace that stays diverged does not print on every cycle. */
689
+ let lastRefreshState = null;
690
+ /** Last refresh and when it ran, so the fetch keeps the readiness report's cadence. */
691
+ let lastRefresh = null;
692
+ /**
693
+ * Fast-forward the source workspace and announce only what changed.
694
+ *
695
+ * The fetch runs on the readiness report's cadence, not the heartbeat's: forced before every claim,
696
+ * and at most once every five minutes otherwise. A fetch on every fifteen-second beat is traffic
697
+ * nothing can use, because the readiness report it feeds only recomputes on that same cadence.
698
+ */
699
+ async function reportedRefresh(workspace, options = {}) {
700
+ if (!options.force && lastRefresh && Date.now() - lastRefresh.at < PREFLIGHT_CACHE_TTL_MS)
701
+ return lastRefresh.result;
702
+ const refresh = await refreshSourceWorkspace(workspace).catch(() => null);
703
+ lastRefresh = { at: Date.now(), result: refresh };
704
+ const state = refresh ? `${refresh.state}:${refresh.local_commits}` : null;
705
+ if (state !== lastRefreshState) {
706
+ lastRefreshState = state;
707
+ if (refresh?.state === "fast_forwarded") {
708
+ console.log(`Workspace fast-forwarded to origin/${refresh.branch}.`);
709
+ }
710
+ else if (refresh?.state === "diverged") {
711
+ console.error(`Workspace cannot fast-forward: ${refresh.local_commits} local-only commit(s) on ${refresh.branch}. Park or push them; this computer takes no work until it can follow origin.`);
712
+ }
713
+ }
714
+ return refresh;
715
+ }
651
716
  async function heartbeat(client, config, workspacePath, brief, preflight, intentProof, bootstrapResult) {
652
717
  const online = onlineDriverIds(config);
653
718
  const status = heartbeatStatusForDrivers(online);
@@ -660,6 +725,16 @@ async function heartbeat(client, config, workspacePath, brief, preflight, intent
660
725
  bridgeProtocol: BRIDGE_PROTOCOL_VERSION,
661
726
  bootstrapResult,
662
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
+ };
663
738
  const response = await client.request("/runner/v1/heartbeat", { method: "POST", body: JSON.stringify({
664
739
  status,
665
740
  capabilities: config.capabilities,
@@ -669,7 +744,7 @@ async function heartbeat(client, config, workspacePath, brief, preflight, intent
669
744
  bridge_version: version,
670
745
  bridge_protocol: BRIDGE_PROTOCOL_VERSION,
671
746
  drivers: driversHeartbeatReport(config, config.activeAttempts),
672
- preflight,
747
+ preflight: preflightToSend,
673
748
  execution_facts: executionFacts,
674
749
  ...(intentProof ? { on_shift_intent_proof: intentProof } : {}),
675
750
  ...(brief ? { workspace_brief: brief } : {}),
package/dist/client.js CHANGED
@@ -2,12 +2,44 @@ import { removeRuntimeAttempt, saveConfigPrefs, saveRuntime } from "./config.js"
2
2
  export class ConduitRequestError extends Error {
3
3
  status;
4
4
  code;
5
- constructor(message, status, code) {
5
+ details;
6
+ constructor(message, status, code,
7
+ /** The control plane's own structured reason, e.g. which execution fact changed. */
8
+ details) {
6
9
  super(message);
7
10
  this.status = status;
8
11
  this.code = code;
12
+ this.details = details;
9
13
  }
10
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;
11
43
  export class ConduitClient {
12
44
  config;
13
45
  persistRuntime;
@@ -32,11 +64,14 @@ export class ConduitClient {
32
64
  async request(path, init = {}) {
33
65
  const response = await fetch(`${this.config.baseUrl}${path}`, {
34
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),
35
70
  headers: { authorization: `Bearer ${this.config.runnerKey}`, "content-type": "application/json", ...init.headers },
36
71
  });
37
72
  const data = await response.json();
38
73
  if (!response.ok)
39
- throw new ConduitRequestError(data.error?.message ?? `Conduit request failed (${response.status})`, response.status, data.error?.code);
74
+ throw new ConduitRequestError(data.error?.message ?? `Conduit request failed (${response.status})`, response.status, data.error?.code, data.error?.details);
40
75
  return data;
41
76
  }
42
77
  async claim(taskId, attemptId, extras) {
@@ -53,6 +88,7 @@ export class ConduitClient {
53
88
  ...(extras?.driverId ? { driver_id: extras.driverId } : {}),
54
89
  ...(extras?.fuelMode ? { fuel_mode: extras.fuelMode } : {}),
55
90
  ...(extras?.fuelProvenance ? { fuel_provenance: extras.fuelProvenance } : {}),
91
+ ...(extras?.startCommit ? { start_commit: extras.startCommit } : {}),
56
92
  }),
57
93
  });
58
94
  const active = {
@@ -65,6 +101,7 @@ export class ConduitClient {
65
101
  ...(extras?.fuelMode ? { fuelMode: extras.fuelMode } : {}),
66
102
  ...(extras?.executionKind ? { executionKind: extras.executionKind } : {}),
67
103
  ...(extras?.sourceAttemptId ? { sourceAttemptId: extras.sourceAttemptId } : {}),
104
+ ...(extras?.sourceWorkspace ? { sourceWorkspace: extras.sourceWorkspace } : {}),
68
105
  };
69
106
  this.config.activeAttempts[taskId] = active;
70
107
  await this.persistRuntime(this.config);
package/dist/config.js CHANGED
@@ -2,6 +2,7 @@ import { chmod, mkdir, open, readFile, rename, unlink, writeFile, stat } from "n
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { homedir } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
+ import { MODEL_TIERS } from "./driver.js";
5
6
  /** Serialize config writes in-process so concurrent claim/phase updates cannot clobber each other. */
6
7
  let saveChain = Promise.resolve();
7
8
  /** Default when join omits --capacity. */
@@ -84,18 +85,59 @@ export async function loadConfigIfPresent() {
84
85
  if (!lane || typeof lane !== "object")
85
86
  continue;
86
87
  const state = lane.state === "online" ? "online" : "offline";
88
+ const modelsPolicy = lane.models_policy === "declared" ? "declared" : undefined;
87
89
  const fuel = lane.fuel === "local" || lane.fuel === "conduit" ? lane.fuel : undefined;
88
90
  // Every field a lane carries must be listed here. This sanitizer duplicates the shape check in
89
91
  // `normalizeDrivers` (drivers.ts) — importing it would close the loop config → drivers → driver
90
92
  // → config — so a new lane field has to be added in both, and forgetting here is silent: the
91
93
  // value survives in memory, then vanishes on the next load. `quota` was lost exactly that way.
92
94
  const quota = plainQuota(lane.quota);
93
- cleaned[id] = { state, ...(fuel ? { fuel } : {}), ...(quota ? { quota } : {}) };
95
+ const models = plainTierModels(lane.models, `drivers.${id}.models`);
96
+ cleaned[id] = { state, ...(modelsPolicy ? { models_policy: modelsPolicy } : {}), ...(fuel ? { fuel } : {}), ...(quota ? { quota } : {}),
97
+ ...(models && Object.keys(models).length ? { models } : {}) };
94
98
  }
95
99
  config.drivers = cleaned;
96
100
  }
101
+ if (config.models !== undefined)
102
+ config.models = plainTierModels(config.models, "models");
97
103
  return config;
98
104
  }
105
+ /** Tier keys already reported. The runner reloads the config every 15 seconds; one warning is enough. */
106
+ const warnedTierKeys = new Set();
107
+ /**
108
+ * Keep only the three intelligence tiers, and name each other key one time.
109
+ *
110
+ * A key such as `fast` is not a tier, so it binds nothing: every package of that risk runs the
111
+ * CLI default and the first vendor abort Holds. Dropping the key silently is what made that typo
112
+ * findable only from the Hold, hours later.
113
+ */
114
+ function plainTierModels(raw, where) {
115
+ if (!raw || typeof raw !== "object")
116
+ return undefined;
117
+ const kept = {};
118
+ for (const [key, value] of Object.entries(raw)) {
119
+ if (MODEL_TIERS.includes(key)) {
120
+ if (typeof value === "string" || Array.isArray(value))
121
+ kept[key] = value;
122
+ continue;
123
+ }
124
+ const named = `${where}.${key}`;
125
+ if (warnedTierKeys.has(named))
126
+ continue;
127
+ warnedTierKeys.add(named);
128
+ console.warn(`Bridge configuration: \`${named}\` is not an intelligence tier and is ignored. The tiers are ${MODEL_TIERS.join(", ")}.`);
129
+ }
130
+ return kept;
131
+ }
132
+ /**
133
+ * Model candidates for one tier on one lane, best first: the lane's own map, then the machine-wide
134
+ * map. One reader for every surface — resolution, the preflight report and the operator printers.
135
+ */
136
+ export function tierCandidates(config, driverId, tier) {
137
+ const configured = config.drivers?.[driverId]?.models?.[tier] ?? config.models?.[tier];
138
+ const listed = !configured ? [] : (Array.isArray(configured) ? configured : [configured]);
139
+ return listed.filter((candidate) => typeof candidate === "string" && candidate.trim().length > 0);
140
+ }
99
141
  /** A stored quota record, or nothing. Malformed is dropped rather than thrown — see normalizeQuota. */
100
142
  function plainQuota(raw) {
101
143
  if (!raw || typeof raw !== "object")
@@ -104,7 +146,10 @@ function plainQuota(raw) {
104
146
  return undefined;
105
147
  if (raw.resets_at !== null && typeof raw.resets_at !== "string")
106
148
  return undefined;
107
- 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 } : {}) };
108
153
  }
109
154
  async function loadRuntime() {
110
155
  try {
@@ -394,5 +439,14 @@ export function suggestMachineName(host, installationId) {
394
439
  return `${base.slice(0, 199 - suffix.length)}-${suffix}`;
395
440
  }
396
441
  export function redactSecrets(value) {
397
- return value.replace(/(runner_sk_|gateway_sk_|lease_|connect_)[A-Za-z0-9-]+/g, "$1[redacted]");
442
+ // Conduit's own key classes, then the provider and forge shapes an agent's own environment
443
+ // carries (ANTHROPIC_API_KEY, XAI_API_KEY, GEMINI_API_KEY, a GitHub token, a bearer header) —
444
+ // an agent that prints its environment on failure must not leak them through this terminal.
445
+ return value
446
+ .replace(/(runner_sk_|gateway_sk_|lease_|connect_)[A-Za-z0-9-]+/g, "$1[redacted]")
447
+ .replace(/sk-[A-Za-z0-9_-]{8,}/g, "sk-[redacted]")
448
+ .replace(/xai-[A-Za-z0-9]+/g, "xai-[redacted]")
449
+ .replace(/AIza[0-9A-Za-z_-]{35}/g, "AIza[redacted]")
450
+ .replace(/(ghp_|gho_|github_pat_|glpat-)[A-Za-z0-9_-]+/g, "$1[redacted]")
451
+ .replace(/Bearer\s+\S+/g, "Bearer [redacted]");
398
452
  }