@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 +81 -15
- package/dist/drivers.js +64 -1
- package/dist/execution.js +138 -2
- package/dist/investigation.js +13 -1
- package/dist/ops.js +28 -10
- package/dist/preflight.js +20 -2
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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,
|
package/dist/investigation.js
CHANGED
|
@@ -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
|
|
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", ...
|
|
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",
|
|
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
|
-
//
|
|
535
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|