@miraland-labs/conduit-bridge 0.16.13 → 0.16.15

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/config.js CHANGED
@@ -85,12 +85,27 @@ export async function loadConfigIfPresent() {
85
85
  continue;
86
86
  const state = lane.state === "online" ? "online" : "offline";
87
87
  const fuel = lane.fuel === "local" || lane.fuel === "conduit" ? lane.fuel : undefined;
88
- cleaned[id] = fuel ? { state, fuel } : { state };
88
+ // Every field a lane carries must be listed here. This sanitizer duplicates the shape check in
89
+ // `normalizeDrivers` (drivers.ts) — importing it would close the loop config → drivers → driver
90
+ // → config — so a new lane field has to be added in both, and forgetting here is silent: the
91
+ // value survives in memory, then vanishes on the next load. `quota` was lost exactly that way.
92
+ const quota = plainQuota(lane.quota);
93
+ cleaned[id] = { state, ...(fuel ? { fuel } : {}), ...(quota ? { quota } : {}) };
89
94
  }
90
95
  config.drivers = cleaned;
91
96
  }
92
97
  return config;
93
98
  }
99
+ /** A stored quota record, or nothing. Malformed is dropped rather than thrown — see normalizeQuota. */
100
+ function plainQuota(raw) {
101
+ if (!raw || typeof raw !== "object")
102
+ return undefined;
103
+ if (typeof raw.exhausted !== "boolean" || typeof raw.observed_at !== "string")
104
+ return undefined;
105
+ if (raw.resets_at !== null && typeof raw.resets_at !== "string")
106
+ return undefined;
107
+ return { exhausted: raw.exhausted, resets_at: raw.resets_at, observed_at: raw.observed_at };
108
+ }
94
109
  async function loadRuntime() {
95
110
  try {
96
111
  const raw = JSON.parse(await readFile(runtimePath, "utf8"));
@@ -169,6 +184,22 @@ export function mergeRuntimeState(disk, snapshot) {
169
184
  tombstones: Object.keys(tombstones).length ? tombstones : undefined,
170
185
  };
171
186
  }
187
+ /**
188
+ * Quota is runner-learned state, not an operator preference. A prefs writer may have loaded its
189
+ * snapshot before another process recorded or cleared quota, so disk is authoritative for that one
190
+ * field. Lane presence and every operator-owned lane setting still come from the writer's snapshot.
191
+ */
192
+ function mergeCurrentDriverQuota(disk, snapshot) {
193
+ if (!snapshot || !disk)
194
+ return snapshot;
195
+ const drivers = {};
196
+ for (const [id, lane] of Object.entries(snapshot)) {
197
+ const { quota: _staleQuota, ...prefs } = lane;
198
+ const quota = plainQuota(disk.drivers?.[id]?.quota);
199
+ drivers[id] = { ...prefs, ...(quota ? { quota } : {}) };
200
+ }
201
+ return drivers;
202
+ }
172
203
  /**
173
204
  * Persist Bridge config. Operator prefs and runtime attempt state are written as separate files under
174
205
  * a cross-process lock so `drivers online|offline` cannot erase an in-flight claim (and vice versa).
@@ -178,26 +209,25 @@ export async function saveConfig(config, options = {}) {
178
209
  const run = async () => {
179
210
  await withConfigLock(async () => {
180
211
  await mkdir(directory, { recursive: true, mode: 0o700 });
212
+ let diskPrefs = null;
213
+ try {
214
+ diskPrefs = JSON.parse(await readFile(path, "utf8"));
215
+ }
216
+ catch { /* no prior config */ }
181
217
  // One-time migrate: if runtime.json is missing, preserve legacy activeAttempts from config.json.
182
218
  const existingRuntime = await loadRuntime();
183
- if (!existingRuntime) {
184
- let legacyAttempts = {};
185
- let legacySessions;
186
- try {
187
- const legacy = JSON.parse(await readFile(path, "utf8"));
188
- if (legacy.activeAttempts && typeof legacy.activeAttempts === "object") {
189
- legacyAttempts = legacy.activeAttempts;
190
- }
191
- if (legacy.sessions && typeof legacy.sessions === "object") {
192
- legacySessions = legacy.sessions;
193
- }
194
- }
195
- catch { /* no prior config */ }
219
+ if (!existingRuntime && diskPrefs) {
220
+ const legacyAttempts = diskPrefs.activeAttempts && typeof diskPrefs.activeAttempts === "object"
221
+ ? diskPrefs.activeAttempts
222
+ : {};
223
+ const legacySessions = diskPrefs.sessions && typeof diskPrefs.sessions === "object"
224
+ ? diskPrefs.sessions
225
+ : undefined;
196
226
  if (Object.keys(legacyAttempts).length || legacySessions) {
197
227
  await writeJsonAtomic(runtimePath, { activeAttempts: legacyAttempts, sessions: legacySessions });
198
228
  }
199
229
  }
200
- const prefs = { ...snapshot };
230
+ const prefs = { ...snapshot, drivers: mergeCurrentDriverQuota(diskPrefs, snapshot.drivers) };
201
231
  delete prefs.activeAttempts;
202
232
  delete prefs.sessions;
203
233
  const prefsForDisk = { ...prefs, activeAttempts: {}, sessions: undefined };
@@ -219,6 +249,42 @@ export async function saveConfig(config, options = {}) {
219
249
  export async function saveConfigPrefs(config) {
220
250
  await saveConfig(config, { prefsOnly: true });
221
251
  }
252
+ /**
253
+ * Write one lane's observed quota without touching anything else on disk.
254
+ *
255
+ * A run can outlive the config it started with: the operator may take a lane offline, or change its
256
+ * fuel, while an agent is still working. Persisting the runner's whole in-memory snapshot at the end
257
+ * of that run would put the operator's change back the way it was — a `drivers offline` silently
258
+ * undone minutes later by an unrelated write. So this reads the current prefs under the same lock
259
+ * every other writer uses, edits only `drivers[id].quota`, and writes back.
260
+ *
261
+ * A lane the operator has since removed is left removed: quota describes a lane, and is worth
262
+ * nothing without one.
263
+ */
264
+ export async function saveDriverQuota(driverId, quota) {
265
+ const run = async () => {
266
+ await withConfigLock(async () => {
267
+ let prefs;
268
+ try {
269
+ prefs = JSON.parse(await readFile(path, "utf8"));
270
+ }
271
+ catch {
272
+ return; // No prefs on disk yet: nothing to attach a quota to.
273
+ }
274
+ const lane = prefs.drivers?.[driverId];
275
+ if (!lane || typeof lane !== "object")
276
+ return;
277
+ if (quota)
278
+ lane.quota = quota;
279
+ else
280
+ delete lane.quota;
281
+ await writeJsonAtomic(path, { ...prefs, activeAttempts: {}, sessions: undefined });
282
+ });
283
+ };
284
+ const next = saveChain.then(run, run);
285
+ saveChain = next.catch(() => undefined);
286
+ await next;
287
+ }
222
288
  /**
223
289
  * Claim/session write that leaves prefs untouched and merges with on-disk attempts from other
224
290
  * Bridge processes (supervisor vs MCP) instead of replacing the whole runtime snapshot.
package/dist/drivers.js CHANGED
@@ -54,10 +54,67 @@ export function normalizeDrivers(raw) {
54
54
  continue;
55
55
  const state = lane.state === "online" ? "online" : "offline";
56
56
  const fuel = lane.fuel === "local" || lane.fuel === "conduit" ? lane.fuel : undefined;
57
- out[id] = fuel ? { state, fuel } : { state };
57
+ const quota = normalizeQuota(lane.quota);
58
+ out[id] = { state, ...(fuel ? { fuel } : {}), ...(quota ? { quota } : {}) };
58
59
  }
59
60
  return out;
60
61
  }
62
+ /** Drop a malformed quota rather than fail the whole config: a bad record must not dark a lane. */
63
+ function normalizeQuota(raw) {
64
+ if (!raw || typeof raw !== "object")
65
+ return undefined;
66
+ const value = raw;
67
+ if (typeof value.exhausted !== "boolean" || typeof value.observed_at !== "string")
68
+ return undefined;
69
+ const resetsAt = typeof value.resets_at === "string" ? value.resets_at : null;
70
+ return { exhausted: value.exhausted, resets_at: resetsAt, observed_at: value.observed_at };
71
+ }
72
+ /**
73
+ * This lane's quota as of now, with a passed reset retired.
74
+ *
75
+ * The stored record says only what the vendor refused and when it said the window would refill.
76
+ * Deciding `exhausted` here rather than at write time is what keeps a refilled lane from staying
77
+ * dark until something happens to run on it — and it self-corrects a stale record, which matters
78
+ * because the preflight the control plane reads may be minutes old.
79
+ */
80
+ export function laneQuota(config, driverId, now = Date.now()) {
81
+ const quota = normalizeDrivers(config.drivers)[driverId]?.quota;
82
+ if (!quota)
83
+ return null;
84
+ if (!quota.exhausted)
85
+ return quota;
86
+ const resets = quota.resets_at ? Date.parse(quota.resets_at) : Number.NaN;
87
+ if (Number.isFinite(resets) && resets <= now)
88
+ return { ...quota, exhausted: false };
89
+ // A refusal that named no reset time cannot expire on its own, and a lane held out of
90
+ // pickDriverForClaim can never run the work that would prove it well again. That is a deadlock
91
+ // one misread error message away, so an unreset claim is only believed for so long: after the
92
+ // longest window a vendor plausibly enforces, let the lane try and be refused again if it truly
93
+ // is spent. Costs one attempt to recover; costs the whole machine not to.
94
+ const observed = Date.parse(quota.observed_at);
95
+ if (!quota.resets_at && Number.isFinite(observed) && now - observed > UNRESET_QUOTA_TRUST_MS) {
96
+ return { ...quota, exhausted: false };
97
+ }
98
+ return quota;
99
+ }
100
+ /** How long a refusal with no stated reset is trusted. A week covers the longest plan windows. */
101
+ const UNRESET_QUOTA_TRUST_MS = 7 * 24 * 60 * 60_000;
102
+ /** True when this lane is not currently refused by its vendor. Lanes without a record are free. */
103
+ export function driverFuelAvailable(config, driverId, now = Date.now()) {
104
+ if (resolveDriverFuel(config, driverId) !== "local")
105
+ return true;
106
+ return laneQuota(config, driverId, now)?.exhausted !== true;
107
+ }
108
+ /** Record (or clear) what the vendor said about this lane's window. */
109
+ export function recordDriverQuota(config, driverId, quota) {
110
+ if (!isSupportedDriverId(driverId))
111
+ return config;
112
+ const drivers = normalizeDrivers(config.drivers);
113
+ const lane = drivers[driverId] ?? (localFuelOnlyDriver(driverId) ? { state: "offline", fuel: "local" } : { state: "offline" });
114
+ const { quota: _dropped, ...rest } = lane;
115
+ drivers[driverId] = quota ? { ...rest, quota } : rest;
116
+ return { ...config, drivers };
117
+ }
61
118
  /**
62
119
  * Ensure detected (or listed) drivers exist in config. New lanes default offline (fail-closed).
63
120
  * Does not flip existing online/offline state.
@@ -160,12 +217,18 @@ export function driversHeartbeatReport(config, activeAttempts, processOnlineIds)
160
217
  /**
161
218
  * Pick an online driver for a new claim: least loaded among online (shared machine pool).
162
219
  * `processOnlineIds` overrides config online lanes (tests / harness).
220
+ *
221
+ * A lane whose subscription window is spent is excluded here rather than at each call site: there
222
+ * are five, and one forgotten would hand work to a lane whose vendor is refusing it — spending an
223
+ * attempt to relearn what the last refusal already told us. Every lane dry returns null, the same
224
+ * answer as no lane online, which the callers already handle.
163
225
  */
164
226
  export function pickDriverForClaim(config, processOnlineIds, eligible = () => true) {
165
227
  const candidates = processOnlineIds === undefined || processOnlineIds === null
166
228
  ? onlineDriverIds(config)
167
229
  : processOnlineIds;
168
230
  const online = candidates
231
+ .filter((id) => driverFuelAvailable(config, id))
169
232
  .filter((id) => isSupportedDriverId(id))
170
233
  .filter(eligible);
171
234
  if (!online.length)
package/dist/execution.js CHANGED
@@ -2,10 +2,10 @@ import { spawn } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { z } from "zod";
4
4
  import { ConduitRequestError } from "./client.js";
5
- import { redactSecrets } from "./config.js";
5
+ import { redactSecrets, saveDriverQuota } from "./config.js";
6
6
  import { DRIVERS, agentReportTemplate, buildAssignmentPrompt, evidenceKinds, extractAgentReportJsonText, fuelEndpoint, normalizeEvidenceDigest, parseAgentReport, parseJsonObjectCandidate, pickModelCandidate, requireStampedExecutionClass, tierForRisk } from "./driver.js";
7
7
  import { assertClassFloor } from "./execution-class.js";
8
- import { pickDriverForClaim, resolveDriverFuel, supportsReadOnlyDiagnosis } from "./drivers.js";
8
+ import { pickDriverForClaim, recordDriverQuota, resolveDriverFuel, supportsReadOnlyDiagnosis } from "./drivers.js";
9
9
  import { attemptWorktreePath, createAttemptWorktree, proveResumeWorktree, quarantineAttemptWorktree, removeAttemptWorktree, } from "./attempt-worktree.js";
10
10
  import { execFile } from "node:child_process";
11
11
  import { promisify } from "node:util";
@@ -68,6 +68,91 @@ export const FORGE_TRANSPORT_PATTERN = /unable to access '?https?:\/\/|error in
68
68
  export function forgeTransportFailure(message) {
69
69
  return FORGE_TRANSPORT_PATTERN.test(message);
70
70
  }
71
+ /**
72
+ * A vendor refusing a local-fuel run because the operator's subscription window is spent.
73
+ *
74
+ * Distinct from `driver_not_authenticated`: the login is fine, the allowance is not. Conflating the
75
+ * two sends the owner to re-authenticate a CLI that is already signed in, which fixes nothing and
76
+ * costs an evening. Deliberately narrow — an unmatched refusal degrades to an ordinary agent
77
+ * failure, which is exactly today's behaviour.
78
+ */
79
+ export const SUBSCRIPTION_EXHAUSTED_PATTERN = /\b(?:usage|rate)[ _-]?limit(?:ed|s)?\b|\bquota (?:exceeded|exhausted|reached)\b|\b(?:http|status|code)\W{0,3}429\b|\b429\b(?=\W{0,3}too many)|too many requests|out of (?:credits|usage)/i;
80
+ /**
81
+ * Does this failure mean the plan is spent, rather than the prompt being wrong?
82
+ *
83
+ * The distinction that matters is a context or output cap: the model refusing *this prompt* is not
84
+ * the plan refusing *this month*, and darking a lane for it is the worst outcome available — a
85
+ * refusal that carried no reset keeps the lane out of `pickDriverForClaim`, so nothing runs on it,
86
+ * so nothing can prove it well again until the trust window ages out.
87
+ *
88
+ * That is handled by requiring an explicit refusal phrase above rather than by vetoing "limit"
89
+ * separately. An earlier veto pass did both and made a concatenated error ("context limit exceeded;
90
+ * fallback failed: HTTP 429") read as healthy — hiding a genuine refusal behind an unrelated
91
+ * sentence in the same string.
92
+ */
93
+ export function subscriptionExhausted(message) {
94
+ return SUBSCRIPTION_EXHAUSTED_PATTERN.test(message);
95
+ }
96
+ /**
97
+ * When the window refills, if the vendor said so.
98
+ *
99
+ * Vendors phrase this as an absolute clock time ("resets at 3pm"), a duration ("try again in 2h
100
+ * 14m"), or a Unix epoch. Returning null when none matches is fine — `laneQuota` treats a record
101
+ * without a reset as exhausted until something proves otherwise, and the next successful run clears
102
+ * it. Guessing a reset time would be worse: an invented one un-darks the lane early and the owner
103
+ * watches the same refusal twice.
104
+ */
105
+ export function parseQuotaResetAt(message, now = Date.now()) {
106
+ const epoch = /\bresets?[ _-]?(?:at|in)?\b\D{0,12}(\d{10})\b/i.exec(message);
107
+ if (epoch)
108
+ return plausibleReset(Number(epoch[1]) * 1000, now);
109
+ // Units must end where they claim to. Unanchored, `m` swallowed the "m" of "500ms" and of
110
+ // "2 months" and read both as minutes — turning a 500-millisecond backoff into an eight-hour
111
+ // blackout of the only local lane.
112
+ const duration = /\b(?:try again|retry|resets?|available again)\b[^.\n]{0,24}?\bin\b\s*(?:(\d+)\s*(?:h|hrs?|hours?)\b(?!\w))?\s*(?:(\d+)\s*(?:m|mins?|minutes?)\b(?!\w))?\s*(?:(\d+)\s*(?:s|secs?|seconds?)\b(?!\w))?\s*(?:(\d+)\s*(?:ms|msecs?|milliseconds?)\b(?!\w))?/i.exec(message);
113
+ if (duration && (duration[1] || duration[2] || duration[3] || duration[4])) {
114
+ const ms = (Number(duration[1] ?? 0) * 3_600 + Number(duration[2] ?? 0) * 60 + Number(duration[3] ?? 0)) * 1_000
115
+ + Number(duration[4] ?? 0);
116
+ if (ms > 0)
117
+ return plausibleReset(now + ms, now);
118
+ }
119
+ const iso = /\bresets?\b[^.\n]{0,24}?(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)/i.exec(message);
120
+ if (iso)
121
+ return plausibleReset(Date.parse(iso[1]), now);
122
+ return null;
123
+ }
124
+ /**
125
+ * A reset time worth believing, or none at all.
126
+ *
127
+ * Two failure modes this closes. A number lifted from prose is not always a clock — a request id
128
+ * that happens to be ten digits reads as a date in 2286 and would dark the lane for centuries. And
129
+ * `new Date(x).toISOString()` *throws* on a non-finite or out-of-range value, which here would
130
+ * escape between the driver returning and `queueTerminal`, stranding the attempt in `agent_running`.
131
+ * Anything outside a plausible billing window is treated as "no reset stated", which is the honest
132
+ * answer and already a supported state.
133
+ */
134
+ function plausibleReset(ms, now) {
135
+ if (!Number.isFinite(ms))
136
+ return null;
137
+ if (ms > now + MAX_PLAUSIBLE_RESET_MS)
138
+ return null;
139
+ // A reset that has just passed is kept, not discarded. Rejecting it returned null, which does not
140
+ // mean "already refilled" — it means "no reset stated", the one case trusted for a whole week. A
141
+ // second of clock skew or a slow stderr flush would then dark the lane for seven days instead of
142
+ // clearing it at once. Keeping the stale timestamp lets `laneQuota` retire it on the next read.
143
+ if (ms < now - MAX_STALE_RESET_MS)
144
+ return null;
145
+ try {
146
+ return new Date(ms).toISOString();
147
+ }
148
+ catch {
149
+ return null;
150
+ }
151
+ }
152
+ /** No vendor window runs longer than a month; past that the parse was a coincidence, not a clock. */
153
+ const MAX_PLAUSIBLE_RESET_MS = 31 * 24 * 3_600_000;
154
+ /** How far back a reset may sit and still be a real one the run simply outlived. */
155
+ const MAX_STALE_RESET_MS = 24 * 3_600_000;
71
156
  /** Environment faults that must Hold — mirror CP ENVIRONMENT_FAILURES message patterns. */
72
157
  const FINALIZE_ENVIRONMENT_PATTERN = /base[_ ]not[_ ]ancestor|required base commit is not available|source[_ ]workspace[_ ]dirty|uncommitted changes|dirty workspace|workspace[_ ]head[_ ]changed|workspace[_ ]repository|workspace[_ ]unavailable|driver[_ ]not[_ ]authenticated|not logged in|no login|not authenticated|login required|no[_ ]online[_ ]driver|bridge[_ ]preflight|stale bridge|bridge version/i;
73
158
  /** Delivery-report / grant / evidence contract defects (non-retryable rework). */
@@ -87,6 +172,49 @@ export function classifyFinalizeFailure(message) {
87
172
  // Distinct from execution_contract_failed so owners see a Bridge fault, not a contract lie.
88
173
  return { retryable: false, error: `Bridge finalize interrupted: ${message}` };
89
174
  }
175
+ /**
176
+ * Learn this lane's fuel state from what the run just did.
177
+ *
178
+ * A refusal records the window; any completed run clears it, because finishing work is proof the
179
+ * allowance is back — stronger evidence than a reset time the vendor may never have given. Only
180
+ * local fuel is tracked: a `conduit`-fuelled lane spends the organization's gateway credential,
181
+ * whose ceiling is the project budget, not a personal window.
182
+ */
183
+ /**
184
+ * Observe this run's fuel signal and write it down.
185
+ *
186
+ * Persisting is not optional. The runner reloads `config.drivers` from disk every cycle so lane
187
+ * toggles apply without a restart, so a record kept only in memory is erased within seconds and the
188
+ * lane takes work again as if nothing happened — the feature looks like it works and does not.
189
+ *
190
+ * Every agent run goes through here, not just the first. A repair turn or a land continuation is
191
+ * often the request that actually crosses the limit, and an attempt that fails there would otherwise
192
+ * leave the lane eligible.
193
+ */
194
+ export async function learnDriverFuel(config, driverId, fuelSource, result) {
195
+ const drivers = observeDriverFuel(config, driverId, fuelSource, result).drivers;
196
+ if (JSON.stringify(drivers ?? {}) === JSON.stringify(config.drivers ?? {}))
197
+ return;
198
+ config.drivers = drivers;
199
+ // Only this lane's quota reaches disk. Writing the whole snapshot would carry the rest of a config
200
+ // that may be minutes stale back over the operator's newer choices — see saveDriverQuota.
201
+ // A fuel record is worth less than the run it came from: never let a disk fault fail the attempt.
202
+ await saveDriverQuota(driverId, drivers?.[driverId]?.quota).catch(() => undefined);
203
+ }
204
+ export function observeDriverFuel(config, driverId, fuelSource, result, now = Date.now()) {
205
+ if (fuelSource !== "local")
206
+ return config;
207
+ if (result.status !== "failed")
208
+ return recordDriverQuota(config, driverId, null);
209
+ const message = result.error ?? "";
210
+ if (!subscriptionExhausted(message))
211
+ return config;
212
+ return recordDriverQuota(config, driverId, {
213
+ exhausted: true,
214
+ resets_at: parseQuotaResetAt(message, now),
215
+ observed_at: new Date(now).toISOString(),
216
+ });
217
+ }
90
218
  /** Failures that require an operator/configuration change must never burn the remaining attempts. */
91
219
  export function retryableAgentFailure(message) {
92
220
  // `cannot enforce` is a driver projection refusing a class it structurally cannot bound (Pi and
@@ -842,6 +970,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
842
970
  });
843
971
  if (result.sessionId)
844
972
  config.sessions = { ...config.sessions, [taskId]: result.sessionId };
973
+ await learnDriverFuel(config, driver.name, fuelSource, result);
845
974
  if (diagnosis) {
846
975
  if (result.status === "failed") {
847
976
  const message = result.error ?? "Read-only diagnosis failed";
@@ -938,6 +1067,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
938
1067
  agentReply: reportText,
939
1068
  })) {
940
1069
  const continuation = await runLandContinuationTurn({
1070
+ config,
941
1071
  client,
942
1072
  taskId,
943
1073
  attemptId: active.attemptId,
@@ -973,6 +1103,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
973
1103
  if (requiresLandCommit({ grants, spec }) && gitEmpty && claimsWork) {
974
1104
  if (!landContinuationUsed) {
975
1105
  const continuation = await runLandContinuationTurn({
1106
+ config,
976
1107
  client,
977
1108
  taskId,
978
1109
  attemptId: active.attemptId,
@@ -1072,6 +1203,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
1072
1203
  console.error(`Assignment ${taskId} report-only repair failed: ${redactSecrets(message)}`);
1073
1204
  return;
1074
1205
  }
1206
+ await learnDriverFuel(config, driver.name, fuelSource, repaired);
1075
1207
  if (repaired.sessionId) {
1076
1208
  config.sessions = { ...config.sessions, [taskId]: repaired.sessionId };
1077
1209
  resumeSessionId = repaired.sessionId;
@@ -1133,6 +1265,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
1133
1265
  && reportClaimsRepositoryWork(report)
1134
1266
  && await isLandTreeEmpty({ workspace: attemptWorkspace, spec, grants })) {
1135
1267
  const continuation = await runLandContinuationTurn({
1268
+ config,
1136
1269
  client,
1137
1270
  taskId,
1138
1271
  attemptId: active.attemptId,
@@ -1177,6 +1310,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
1177
1310
  }
1178
1311
  try {
1179
1312
  report = await finalizeRepositoryLand({
1313
+ config,
1180
1314
  client,
1181
1315
  taskId,
1182
1316
  attemptId: active.attemptId,
@@ -1346,6 +1480,7 @@ async function runLandContinuationTurn(input) {
1346
1480
  fuel: input.fuel,
1347
1481
  fuelSource: input.fuelSource,
1348
1482
  });
1483
+ await learnDriverFuel(input.config, input.driver.name, input.fuelSource ?? "conduit", continuation);
1349
1484
  if (continuation.status === "failed") {
1350
1485
  throw new AgentNoLandCommitError(input.spec.repository?.base_commit ?? "unknown");
1351
1486
  }
@@ -1386,6 +1521,7 @@ async function finalizeRepositoryLand(input) {
1386
1521
  throw new AgentNoLandCommitError(input.spec.repository?.base_commit ?? "unknown");
1387
1522
  }
1388
1523
  const continuation = await runLandContinuationTurn({
1524
+ config: input.config,
1389
1525
  client: input.client,
1390
1526
  taskId: input.taskId,
1391
1527
  attemptId: input.attemptId,
@@ -9,8 +9,9 @@ import { z } from "zod";
9
9
  import { createAttemptWorktree, removeAttemptWorktree } from "./attempt-worktree.js";
10
10
  import { buildWorkspaceBrief, ensureCommitAvailable, normalizeRepositoryUrl } from "./brief.js";
11
11
  import { redactSecrets } from "./config.js";
12
+ import { learnDriverFuel } from "./execution.js";
12
13
  import { DRIVERS, extractAgentReportJsonText, parseJsonObjectCandidate } from "./driver.js";
13
- import { pickDriverForClaim, supportsReadOnlyDiagnosis } from "./drivers.js";
14
+ import { pickDriverForClaim, resolveDriverFuel, supportsReadOnlyDiagnosis } from "./drivers.js";
14
15
  import { changedPathsSince } from "./execution.js";
15
16
  import { execFile } from "node:child_process";
16
17
  import { promisify } from "node:util";
@@ -21,6 +22,7 @@ const budgetSchema = z.object({
21
22
  }).passthrough();
22
23
  export const investigationAssignmentSchema = z.object({
23
24
  id: z.string().uuid(),
25
+ project_id: z.string().uuid(),
24
26
  kind: z.literal("repository"),
25
27
  question: z.string().min(1).max(4_000),
26
28
  grants: z.array(z.string()).default([]),
@@ -181,6 +183,13 @@ export async function executeNextInvestigation(client, config, workspace, brief,
181
183
  attemptId: assignment.id,
182
184
  startCommit: commit,
183
185
  });
186
+ // One value, used to run and to record. Read separately they drifted: nothing was passed to the
187
+ // driver, so it defaulted to Conduit fuel, while the result was filed against the lane's
188
+ // configured source — letting a gateway refusal mark the operator's personal subscription spent.
189
+ const investigationFuel = resolveDriverFuel(config, driver.name);
190
+ const fuel = investigationFuel === "conduit"
191
+ ? { baseUrl: config.baseUrl, gatewayKey: await client.ensureFuel(assignment.project_id) }
192
+ : undefined;
184
193
  const result = await driver.run({
185
194
  prompt: buildInvestigationPrompt(assignment, commit),
186
195
  workspace: attemptWorkspace,
@@ -190,8 +199,11 @@ export async function executeNextInvestigation(client, config, workspace, brief,
190
199
  // The same signal the read-only diagnosis lane uses; it is what denies edits in the driver.
191
200
  workRole: "diagnose",
192
201
  executionClass: "verify",
202
+ fuelSource: investigationFuel,
203
+ fuel,
193
204
  timeoutMs: Math.min(timeoutMs ?? assignment.budget.max_duration_ms, assignment.budget.max_duration_ms),
194
205
  });
206
+ await learnDriverFuel(config, driver.name, investigationFuel, result);
195
207
  if (result.status === "failed") {
196
208
  const message = result.error ?? "Investigation run failed";
197
209
  await settle(client, assignment.id, { lease_token: leaseToken, status: "failed", error: redactSecrets(message).slice(0, 2_000), retryable: true });
package/dist/ops.js CHANGED
@@ -476,6 +476,21 @@ export async function runOps(verb, argv = [], deps = {}) {
476
476
  ...(repo ? { CONDUIT_REPO: repo } : {}),
477
477
  ...(managed ? { CONDUIT_MANAGED_ROOT: managedRoot } : {}),
478
478
  });
479
+ // Resolve the local lane before redeeming the single-use token. A missing coding agent is a
480
+ // local prerequisite, not an enrollment failure, and must leave the token available to retry.
481
+ // `--no-install` remains the explicit credential-only escape hatch.
482
+ const installDrivers = !values["no-install"] && workspace
483
+ ? await (deps.resolveInstallDrivers ?? resolveInstallDrivers)({
484
+ ...env,
485
+ CONDUIT_URL: url,
486
+ CONDUIT_WORKSPACE: workspace,
487
+ CONDUIT_REPO: repo,
488
+ CONDUIT_MANAGED_ROOT: managedRoot,
489
+ loadedFrom: envPath,
490
+ }, [])
491
+ : [];
492
+ if (managed)
493
+ mkdirSync(resolve(expandOpsValue(managedRoot)), { recursive: true });
479
494
  const args = ["enroll", "--url", url, "--token", token];
480
495
  if (machine)
481
496
  args.push("--machine", machine);
@@ -484,23 +499,19 @@ export async function runOps(verb, argv = [], deps = {}) {
484
499
  runBridge(args);
485
500
  if (values["no-install"])
486
501
  return;
502
+ console.log("Enrollment complete. If background setup is interrupted, rerun: npx @miraland-labs/conduit-bridge@latest ops install");
487
503
  if (managed) {
488
504
  const root = resolve(expandOpsValue(managedRoot));
489
- mkdirSync(root, { recursive: true });
490
505
  // Bring lanes online and install the resident runner, but skip `ops install`'s doctor gate:
491
506
  // an empty managed root is legitimately not a checkout yet, and failing here would leave the
492
507
  // computer with no background process — exactly the terminal round-trip this removes.
493
508
  // Same lane resolution as `ops install` (honours CONDUIT_DRIVERS and the one-lane default)
494
509
  // rather than a second copy of detection — and it keeps the seam tests inject through.
495
- const drivers = await (deps.resolveInstallDrivers ?? resolveInstallDrivers)(loadOpsEnv(), []);
496
- if (drivers.length === 0) {
497
- throw new Error("No coding agent detected. Install one (Claude Code, Codex, Cursor, …), then re-run this command.");
498
- }
499
- for (const id of drivers) {
510
+ for (const id of installDrivers) {
500
511
  if (LOCAL_FUEL_DRIVERS.has(id))
501
512
  runBridge(["drivers", "fuel", id, "local"]);
502
513
  }
503
- runBridge(["drivers", "online", ...drivers]);
514
+ runBridge(["drivers", "online", ...installDrivers]);
504
515
  applyRunnerToolPath();
505
516
  runBridge(["install-service", "--workspace", root]);
506
517
  console.log(`Conduit manages checkouts under ${root}. Assign this computer to a project from the console — it clones and switches on its own.`);
@@ -508,7 +519,7 @@ export async function runOps(verb, argv = [], deps = {}) {
508
519
  }
509
520
  if (workspace) {
510
521
  const refreshedEnv = loadOpsEnv();
511
- await runOps("install", [], { ...deps, env: refreshedEnv });
522
+ await runOps("install", installDrivers, { ...deps, env: refreshedEnv });
512
523
  }
513
524
  return;
514
525
  }
@@ -531,8 +542,15 @@ export async function runOps(verb, argv = [], deps = {}) {
531
542
  ? `Cloned ${installEnv.CONDUIT_REPO} into ${workspace}`
532
543
  : `Workspace already matches ${installEnv.CONDUIT_REPO}`);
533
544
  }
534
- // Prove the exact local environment before installing a service that advertises availability.
535
- runBridge(["ops", "doctor"]);
545
+ // A managed root is intentionally empty until the console assigns its first repository. It
546
+ // still needs a resident runner, and `ops install` is also the recovery path after enrollment.
547
+ const waitingOnManagedAssignment = Boolean(installEnv.CONDUIT_MANAGED_ROOT
548
+ && resolve(expandOpsValue(installEnv.CONDUIT_MANAGED_ROOT)) === workspace
549
+ && !installEnv.CONDUIT_REPO);
550
+ if (!waitingOnManagedAssignment) {
551
+ // Prove the exact local environment before installing a service that advertises availability.
552
+ runBridge(["ops", "doctor"]);
553
+ }
536
554
  const installArgs = ["install-service", "--workspace", workspace];
537
555
  if (installEnv.CONDUIT_REPO)
538
556
  installArgs.push("--ensure-checkout", installEnv.CONDUIT_REPO);
package/dist/preflight.js CHANGED
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
3
3
  import { promisify } from "node:util";
4
4
  import { buildWorkspaceBrief, normalizeRepositoryUrl } from "./brief.js";
5
5
  import { hasAntigravityLogin, hasClaudeLogin, hasGrokLogin, hasOpenAiLogin, hasOpenCodeLogin, resolveCodexExecutable, } from "./driver.js";
6
- import { localFuelOnlyDriver, onlineDriverIds, resolveDriverFuel, supportsReadOnlyDiagnosis } from "./drivers.js";
6
+ import { laneQuota, localFuelOnlyDriver, onlineDriverIds, resolveDriverFuel, supportsReadOnlyDiagnosis } from "./drivers.js";
7
7
  /** Protocol 3: read-only diagnosis can reuse a retained failed-attempt worktree. */
8
8
  // 4: each driver snapshot reports diagnosis_read_only, so the control plane can require a
9
9
  // capable diagnostic lane *before* dispatching instead of learning it from a failed attempt.
@@ -116,9 +116,22 @@ export async function runBridgePreflight(input, deps = {}) {
116
116
  if (fuel === "local" && !await localAuthenticationReady(driver, input.workspace, run)) {
117
117
  return { issue: { code: "driver_not_authenticated", driver }, snapshot: { id: driver, version: versionText, ready: false, diagnosis_read_only: diagnosisReadOnly } };
118
118
  }
119
- return { issue: null, snapshot: { id: driver, version: versionText, ready: true, diagnosis_read_only: diagnosisReadOnly } };
119
+ // Reported, not probed: the window state was learned from the vendor's last refusal and read
120
+ // back through laneQuota, which retires a passed reset. Nothing is executed here, so a lane
121
+ // running dry costs the heartbeat nothing.
122
+ const quota = fuel === "local" ? laneQuota(input.config, driver) ?? undefined : undefined;
123
+ return {
124
+ issue: null,
125
+ snapshot: { id: driver, version: versionText, ready: quota?.exhausted !== true, diagnosis_read_only: diagnosisReadOnly, ...(quota ? { quota } : {}) },
126
+ };
120
127
  }));
121
128
  issues.push(...driverChecks.map((check) => check.issue).filter((issue) => issue !== null));
129
+ // One dry lane is not a broken machine — the others still take work, and marking the machine
130
+ // unready over it would strand a fuelled lane. Only a fleet with nothing left to spend is an
131
+ // issue, which is also what keeps `ready === (issues.length === 0)` true either way.
132
+ if (driverChecks.length > 0 && driverChecks.every((check) => check.snapshot.quota?.exhausted === true)) {
133
+ issues.push({ code: "driver_quota_exhausted" });
134
+ }
122
135
  return { ready: issues.length === 0, checked_at: new Date().toISOString(), workspace_clean: workspaceClean,
123
136
  drivers: driverChecks.map((check) => check.snapshot), models_fingerprint: modelsFingerprint(input.config), issues };
124
137
  }
@@ -165,5 +178,10 @@ export function describePreflightIssue(issue) {
165
178
  return `Agent CLI is missing from PATH${lane}`;
166
179
  if (issue.code === "driver_not_authenticated")
167
180
  return `Agent CLI is not logged in${lane}`;
181
+ // Named before the fallback: an exhausted lane fell through to "requires local fuel", which sends
182
+ // the operator to change a fuel setting when the lane is configured correctly and simply spent.
183
+ if (issue.code === "driver_quota_exhausted") {
184
+ return "Every online agent lane has spent its subscription allowance; work resumes when a window refills, or bring another lane online";
185
+ }
168
186
  return `Agent lane requires local fuel${lane}`;
169
187
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miraland-labs/conduit-bridge",
3
- "version": "0.16.13",
3
+ "version": "0.16.15",
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": {