@miraland-labs/conduit-bridge 0.16.38 → 0.16.41
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/driver.js +46 -9
- package/dist/execution.js +17 -63
- package/dist/failure-signal.js +180 -0
- package/dist/quota-reset.js +88 -0
- package/package.json +1 -1
package/dist/driver.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { boundedTail } from "./ensure-test-evidence.js";
|
|
3
3
|
import { resolveAgentTimeout } from "./execution-budget.js";
|
|
4
|
+
import { unknownAgentSignal, vendorKindForAntigravity } from "./failure-signal.js";
|
|
4
5
|
import { existsSync } from "node:fs";
|
|
5
6
|
import { mkdir, readFile, rm, rmdir, writeFile } from "node:fs/promises";
|
|
6
7
|
import { join, resolve } from "node:path";
|
|
@@ -524,7 +525,7 @@ export const claudeCodeDriver = {
|
|
|
524
525
|
}
|
|
525
526
|
const sessionId = message?.session_id ?? null;
|
|
526
527
|
if (code !== 0 || !message || message.is_error) {
|
|
527
|
-
return { status: "failed", resultText: message?.result ?? null, sessionId, error: boundedTail(message?.result || stderr || `agent exited with code ${code}`, 20_000) };
|
|
528
|
+
return { status: "failed", resultText: message?.result ?? null, sessionId, signal: unknownAgentSignal({ exit_code: code, stderr, stdout }), error: boundedTail(message?.result || stderr || `agent exited with code ${code}`, 20_000) };
|
|
528
529
|
}
|
|
529
530
|
return { status: "completed", resultText: message.result ?? "", sessionId };
|
|
530
531
|
},
|
|
@@ -625,7 +626,7 @@ export const codexDriver = {
|
|
|
625
626
|
const parsed = parseCodexJsonl(stdout);
|
|
626
627
|
const resultText = parsed.resultText ?? (stdout || null);
|
|
627
628
|
if (code !== 0) {
|
|
628
|
-
return { status: "failed", resultText, sessionId: parsed.sessionId, error: boundedTail(stderr || stdout || `codex exited with code ${code}`, 20_000) };
|
|
629
|
+
return { status: "failed", resultText, sessionId: parsed.sessionId, signal: unknownAgentSignal({ exit_code: code, stderr, stdout }), error: boundedTail(stderr || stdout || `codex exited with code ${code}`, 20_000) };
|
|
629
630
|
}
|
|
630
631
|
return { status: "completed", resultText, sessionId: parsed.sessionId };
|
|
631
632
|
},
|
|
@@ -757,7 +758,7 @@ export const cursorDriver = {
|
|
|
757
758
|
const { code, stdout, stderr } = configured;
|
|
758
759
|
const parsed = parseCursorOutput(stdout);
|
|
759
760
|
if (code !== 0 || parsed.isError) {
|
|
760
|
-
return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, error: boundedTail(stderr || parsed.resultText || `cursor agent exited with code ${code}`, 20_000) };
|
|
761
|
+
return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, signal: unknownAgentSignal({ exit_code: code, stderr, stdout }), error: boundedTail(stderr || parsed.resultText || `cursor agent exited with code ${code}`, 20_000) };
|
|
761
762
|
}
|
|
762
763
|
return { status: "completed", resultText: parsed.resultText, sessionId: parsed.sessionId };
|
|
763
764
|
},
|
|
@@ -924,7 +925,7 @@ export const openCodeDriver = {
|
|
|
924
925
|
const { code, stdout, stderr } = await withOpenCodePermissions(input.workspace, () => execute(input.executable ?? "opencode", args, input.workspace, agentTurnTimeoutMs(input), fuelSource === "conduit" ? input.fuel : undefined, fuelSource));
|
|
925
926
|
const parsed = parseOpenCodeOutput(stdout);
|
|
926
927
|
if (code !== 0 || parsed.isError) {
|
|
927
|
-
return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, error: boundedTail(stderr || parsed.resultText || `opencode exited with code ${code}`, 20_000) };
|
|
928
|
+
return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, signal: unknownAgentSignal({ exit_code: code, stderr, stdout }), error: boundedTail(stderr || parsed.resultText || `opencode exited with code ${code}`, 20_000) };
|
|
928
929
|
}
|
|
929
930
|
return { status: "completed", resultText: parsed.resultText, sessionId: parsed.sessionId };
|
|
930
931
|
},
|
|
@@ -989,7 +990,7 @@ export const kiroDriver = {
|
|
|
989
990
|
args.push(input.prompt);
|
|
990
991
|
const { code, stdout, stderr } = await execute(executable, args, input.workspace, agentTurnTimeoutMs(input), undefined, "local");
|
|
991
992
|
if (code !== 0) {
|
|
992
|
-
return { status: "failed", resultText: stdout || null, sessionId: null, error: boundedTail(stderr || stdout || `kiro-cli exited with code ${code}`, 20_000) };
|
|
993
|
+
return { status: "failed", resultText: stdout || null, sessionId: null, signal: unknownAgentSignal({ exit_code: code, stderr, stdout }), error: boundedTail(stderr || stdout || `kiro-cli exited with code ${code}`, 20_000) };
|
|
993
994
|
}
|
|
994
995
|
return { status: "completed", resultText: stdout, sessionId: null };
|
|
995
996
|
},
|
|
@@ -1071,6 +1072,15 @@ export function antigravityPermissionProjection(executionClass, grants, options
|
|
|
1071
1072
|
* — Claude Code merely skips one — and a run that had already committed died opening its own PR URL
|
|
1072
1073
|
* with `read_url`, which no assignment without external_network may hold.
|
|
1073
1074
|
*/
|
|
1075
|
+
/**
|
|
1076
|
+
* agy's shell tool starts in agy's own scratch directory, not in the workspace it was given. Left
|
|
1077
|
+
* unanchored, an agent went looking for the repository on disk, found an older checkout first, and
|
|
1078
|
+
* committed a correct file into a stale worktree there. The prompt therefore names the worktree and
|
|
1079
|
+
* makes every command run inside it.
|
|
1080
|
+
*/
|
|
1081
|
+
export function antigravityWorkspaceRule(workspace) {
|
|
1082
|
+
return `Working directory: ${workspace}. This is the checked-out attempt worktree; run every command with this as its Cwd, and read and write files only under it. Do not search the file system for other checkouts of the repository, and do not use any other directory.`;
|
|
1083
|
+
}
|
|
1074
1084
|
export const antigravityPromptRule = "Antigravity: do not use read_url, browser, or web tools unless this assignment lists external_network; do not open the pull request URL after creating it — report the URL from the `gh pr create` output. A denied tool ends the whole run, so use only the tools this assignment allows.";
|
|
1075
1085
|
export function antigravityRunArgs(input, mode, timeoutMs) {
|
|
1076
1086
|
const args = ["--mode", mode];
|
|
@@ -1209,6 +1219,24 @@ async function withAntigravityPermissions(workspace, allow, run) {
|
|
|
1209
1219
|
release();
|
|
1210
1220
|
}
|
|
1211
1221
|
}
|
|
1222
|
+
/**
|
|
1223
|
+
* The typed signal for a refused agy run: the envelope's own status and reason become the witness,
|
|
1224
|
+
* and the vendor's vocabulary names the kind — so the control plane can hold a spent allowance
|
|
1225
|
+
* without any generic prose table knowing agy at all.
|
|
1226
|
+
*/
|
|
1227
|
+
function agySignal(code, status, message, stderr, stdout) {
|
|
1228
|
+
const kind = vendorKindForAntigravity(status, message);
|
|
1229
|
+
if (kind === "unknown")
|
|
1230
|
+
return unknownAgentSignal({ exit_code: code, stderr, stdout });
|
|
1231
|
+
return {
|
|
1232
|
+
phase: "agent",
|
|
1233
|
+
witness: "vendor",
|
|
1234
|
+
kind,
|
|
1235
|
+
exit_code: code,
|
|
1236
|
+
...(status ? { vendor_status: status.slice(0, 200) } : {}),
|
|
1237
|
+
...(message ? { vendor_message: boundedTail(message, 2_000) } : {}),
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1212
1240
|
export const antigravityDriver = {
|
|
1213
1241
|
name: "antigravity",
|
|
1214
1242
|
async listModels(executable = "agy", workspace = process.cwd()) {
|
|
@@ -1240,7 +1268,7 @@ export const antigravityDriver = {
|
|
|
1240
1268
|
const timeoutMs = agentTurnTimeoutMs(input);
|
|
1241
1269
|
let executed;
|
|
1242
1270
|
try {
|
|
1243
|
-
executed = await withAntigravityPermissions(input.workspace, allow, () => execute(input.executable ?? "agy", antigravityRunArgs({ prompt: `${input.prompt}\n\n${antigravityPromptRule}`, grants: input.grants, resumeSessionId: input.resumeSessionId, model: input.model }, mode, timeoutMs), input.workspace, timeoutMs, undefined, "local"));
|
|
1271
|
+
executed = await withAntigravityPermissions(input.workspace, allow, () => execute(input.executable ?? "agy", antigravityRunArgs({ prompt: `${antigravityWorkspaceRule(input.workspace)}\n\n${input.prompt}\n\n${antigravityPromptRule}`, grants: input.grants, resumeSessionId: input.resumeSessionId, model: input.model }, mode, timeoutMs), input.workspace, timeoutMs, undefined, "local"));
|
|
1244
1272
|
}
|
|
1245
1273
|
catch (error) {
|
|
1246
1274
|
return { status: "failed", resultText: null, sessionId: null, error: error instanceof Error ? error.message : String(error) };
|
|
@@ -1251,12 +1279,20 @@ export const antigravityDriver = {
|
|
|
1251
1279
|
// it is the only text that names the cause (a spent allowance and when it refills).
|
|
1252
1280
|
const refused = code !== 0 || (parsed.status !== null && parsed.status.toUpperCase() !== "SUCCESS");
|
|
1253
1281
|
if (refused) {
|
|
1254
|
-
return {
|
|
1282
|
+
return {
|
|
1283
|
+
status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId,
|
|
1284
|
+
signal: agySignal(code, parsed.status, parsed.error, stderr, stdout),
|
|
1285
|
+
error: boundedTail(parsed.error || stderr || parsed.resultText || `agy exited with code ${code}`, 20_000),
|
|
1286
|
+
};
|
|
1255
1287
|
}
|
|
1256
1288
|
// An empty response with a denial on stderr is a refused run: report it instead of an empty reply.
|
|
1257
1289
|
const denial = parsed.resultText?.trim() ? null : antigravityDenialNotice(stderr);
|
|
1258
1290
|
if (denial) {
|
|
1259
|
-
return {
|
|
1291
|
+
return {
|
|
1292
|
+
status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId,
|
|
1293
|
+
signal: { phase: "agent", witness: "vendor", kind: "denied_tool", vendor_status: parsed.status ?? "ERROR", vendor_message: boundedTail(denial, 2_000) },
|
|
1294
|
+
error: boundedTail(`agy returned no response: ${denial}`, 20_000),
|
|
1295
|
+
};
|
|
1260
1296
|
}
|
|
1261
1297
|
return { status: "completed", resultText: parsed.resultText, sessionId: parsed.sessionId };
|
|
1262
1298
|
},
|
|
@@ -1309,6 +1345,7 @@ export const piDriver = {
|
|
|
1309
1345
|
status: "failed",
|
|
1310
1346
|
resultText: parsed.resultText,
|
|
1311
1347
|
sessionId: parsed.sessionId,
|
|
1348
|
+
signal: unknownAgentSignal({ exit_code: code, stderr, stdout }),
|
|
1312
1349
|
error: boundedTail(stderr || parsed.resultText || `pi exited with code ${code}`, 20_000),
|
|
1313
1350
|
};
|
|
1314
1351
|
}
|
|
@@ -1441,7 +1478,7 @@ export const grokDriver = {
|
|
|
1441
1478
|
const { code, stdout, stderr } = await execute(executable, grokRunArgs(input), input.workspace, agentTurnTimeoutMs(input), undefined, "local");
|
|
1442
1479
|
const parsed = parseGrokOutput(stdout);
|
|
1443
1480
|
if (code !== 0 || parsed.isError) {
|
|
1444
|
-
return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, error: boundedTail(stderr || parsed.resultText || `grok exited with code ${code}`, 20_000) };
|
|
1481
|
+
return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, signal: unknownAgentSignal({ exit_code: code, stderr, stdout }), error: boundedTail(stderr || parsed.resultText || `grok exited with code ${code}`, 20_000) };
|
|
1445
1482
|
}
|
|
1446
1483
|
return { status: "completed", resultText: parsed.resultText, sessionId: parsed.sessionId };
|
|
1447
1484
|
},
|
package/dist/execution.js
CHANGED
|
@@ -13,6 +13,7 @@ import { promisify } from "node:util";
|
|
|
13
13
|
import { buildWorkspaceBrief, ensureCommitAvailable, normalizeRepositoryUrl, resolveAttemptStartCommit } from "./brief.js";
|
|
14
14
|
import { buildSovereignExecutionFacts, bootstrapResultForWorkspace, workspaceIsClean } from "./execution-facts.js";
|
|
15
15
|
import { forgeTransportFailure } from "./transport-fault.js";
|
|
16
|
+
import { formatResetMinute, parseQuotaResetAt } from "./quota-reset.js";
|
|
16
17
|
import { headCommitOrNull } from "./git.js";
|
|
17
18
|
import { BRIDGE_PROTOCOL_VERSION } from "./preflight.js";
|
|
18
19
|
import { bridgeVersion } from "./version.js";
|
|
@@ -96,14 +97,18 @@ export function providerQuotaRefusal(message) {
|
|
|
96
97
|
*/
|
|
97
98
|
export function providerQuotaFailure(detail) {
|
|
98
99
|
const resets = /resets? in ([^.\n]{1,40})/i.exec(detail);
|
|
100
|
+
// The absolute time wins when the vendor's words parse into one — an owner reads a clock, not
|
|
101
|
+
// arithmetic. The vendor's own phrasing is the fallback, and no reset stated stays unsaid.
|
|
102
|
+
const absolute = parseQuotaResetAt(detail);
|
|
103
|
+
const refills = absolute
|
|
104
|
+
? `It refills at ${formatResetMinute(absolute)}.`
|
|
105
|
+
: resets ? `It refills in ${resets[1].trim()}.` : "";
|
|
99
106
|
return {
|
|
100
107
|
code: "driver_quota_exhausted",
|
|
101
108
|
class: "environment",
|
|
102
109
|
disposition: "hold",
|
|
103
110
|
responsible_party: "computer_operator",
|
|
104
|
-
message:
|
|
105
|
-
? `The agent lane on this computer has spent its subscription allowance. It refills in ${resets[1].trim()}.`
|
|
106
|
-
: "The agent lane on this computer has spent its subscription allowance.",
|
|
111
|
+
message: `The agent lane on this computer has spent its subscription allowance.${refills ? ` ${refills}` : ""}`,
|
|
107
112
|
next_action: "Wait for the allowance to refill, bring another agent lane online to start sooner, or point this lane's model settings at a model that still has allowance.",
|
|
108
113
|
diagnostic_detail: detail,
|
|
109
114
|
};
|
|
@@ -159,66 +164,6 @@ function timeoutFailureBody(resolution, attemptId) {
|
|
|
159
164
|
}
|
|
160
165
|
class AgentTurnTimeoutError extends Error {
|
|
161
166
|
}
|
|
162
|
-
/**
|
|
163
|
-
* When the window refills, if the vendor said so.
|
|
164
|
-
*
|
|
165
|
-
* Vendors phrase this as an absolute clock time ("resets at 3pm"), a duration ("try again in 2h
|
|
166
|
-
* 14m"), or a Unix epoch. Returning null when none matches is fine — `laneQuota` treats a record
|
|
167
|
-
* without a reset as exhausted until something proves otherwise, and the next successful run clears
|
|
168
|
-
* it. Guessing a reset time would be worse: an invented one un-darks the lane early and the owner
|
|
169
|
-
* watches the same refusal twice.
|
|
170
|
-
*/
|
|
171
|
-
export function parseQuotaResetAt(message, now = Date.now()) {
|
|
172
|
-
const epoch = /\bresets?[ _-]?(?:at|in)?\b\D{0,12}(\d{10})\b/i.exec(message);
|
|
173
|
-
if (epoch)
|
|
174
|
-
return plausibleReset(Number(epoch[1]) * 1000, now);
|
|
175
|
-
// Units must end where they claim to. Unanchored, `m` swallowed the "m" of "500ms" and of
|
|
176
|
-
// "2 months" and read both as minutes — turning a 500-millisecond backoff into an eight-hour
|
|
177
|
-
// blackout of the only local lane.
|
|
178
|
-
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);
|
|
179
|
-
if (duration && (duration[1] || duration[2] || duration[3] || duration[4])) {
|
|
180
|
-
const ms = (Number(duration[1] ?? 0) * 3_600 + Number(duration[2] ?? 0) * 60 + Number(duration[3] ?? 0)) * 1_000
|
|
181
|
-
+ Number(duration[4] ?? 0);
|
|
182
|
-
if (ms > 0)
|
|
183
|
-
return plausibleReset(now + ms, now);
|
|
184
|
-
}
|
|
185
|
-
const iso = /\bresets?\b[^.\n]{0,24}?(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)/i.exec(message);
|
|
186
|
-
if (iso)
|
|
187
|
-
return plausibleReset(Date.parse(iso[1]), now);
|
|
188
|
-
return null;
|
|
189
|
-
}
|
|
190
|
-
/**
|
|
191
|
-
* A reset time worth believing, or none at all.
|
|
192
|
-
*
|
|
193
|
-
* Two failure modes this closes. A number lifted from prose is not always a clock — a request id
|
|
194
|
-
* that happens to be ten digits reads as a date in 2286 and would dark the lane for centuries. And
|
|
195
|
-
* `new Date(x).toISOString()` *throws* on a non-finite or out-of-range value, which here would
|
|
196
|
-
* escape between the driver returning and `queueTerminal`, stranding the attempt in `agent_running`.
|
|
197
|
-
* Anything outside a plausible billing window is treated as "no reset stated", which is the honest
|
|
198
|
-
* answer and already a supported state.
|
|
199
|
-
*/
|
|
200
|
-
function plausibleReset(ms, now) {
|
|
201
|
-
if (!Number.isFinite(ms))
|
|
202
|
-
return null;
|
|
203
|
-
if (ms > now + MAX_PLAUSIBLE_RESET_MS)
|
|
204
|
-
return null;
|
|
205
|
-
// A reset that has just passed is kept, not discarded. Rejecting it returned null, which does not
|
|
206
|
-
// mean "already refilled" — it means "no reset stated", the one case trusted for a whole week. A
|
|
207
|
-
// second of clock skew or a slow stderr flush would then dark the lane for seven days instead of
|
|
208
|
-
// clearing it at once. Keeping the stale timestamp lets `laneQuota` retire it on the next read.
|
|
209
|
-
if (ms < now - MAX_STALE_RESET_MS)
|
|
210
|
-
return null;
|
|
211
|
-
try {
|
|
212
|
-
return new Date(ms).toISOString();
|
|
213
|
-
}
|
|
214
|
-
catch {
|
|
215
|
-
return null;
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
/** No vendor window runs longer than a month; past that the parse was a coincidence, not a clock. */
|
|
219
|
-
const MAX_PLAUSIBLE_RESET_MS = 31 * 24 * 3_600_000;
|
|
220
|
-
/** How far back a reset may sit and still be a real one the run simply outlived. */
|
|
221
|
-
const MAX_STALE_RESET_MS = 24 * 3_600_000;
|
|
222
167
|
/** Environment faults that must Hold — mirror CP ENVIRONMENT_FAILURES message patterns. */
|
|
223
168
|
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;
|
|
224
169
|
/** Delivery-report / grant / evidence contract defects (non-retryable rework). */
|
|
@@ -1290,10 +1235,19 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
1290
1235
|
// Keep the tree until the replay-safe terminal response explicitly says whether diagnosis was
|
|
1291
1236
|
// queued. A network fault here must not delete the evidence before Bridge can replay terminal.
|
|
1292
1237
|
retainAttemptWorktree = true;
|
|
1238
|
+
// The driver's typed cause rides beside the prose, never instead of it. A recovered
|
|
1239
|
+
// verification log describes the same run: the signal becomes Bridge's verification witness
|
|
1240
|
+
// and the prose carries the evidence, so the verify class stays typed without new tables.
|
|
1241
|
+
const signal = result.signal
|
|
1242
|
+
? verificationDetail
|
|
1243
|
+
? { ...result.signal, phase: "verification", witness: "bridge", stderr_tail: undefined, stdout_tail: undefined }
|
|
1244
|
+
: result.signal
|
|
1245
|
+
: undefined;
|
|
1293
1246
|
const response = await queueTerminal(client, taskId, { action: "fail", body: {
|
|
1294
1247
|
error: message,
|
|
1295
1248
|
retryable: retryableAgentFailure(agentMessage),
|
|
1296
1249
|
...(providerQuotaRefusal(agentMessage) ? { failure: providerQuotaFailure(agentMessage) } : {}),
|
|
1250
|
+
...(signal ? { signal } : {}),
|
|
1297
1251
|
idempotency_key: `bridge:fail:${active.attemptId}`,
|
|
1298
1252
|
} });
|
|
1299
1253
|
retainAttemptWorktree = response.retain_worktree === true;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* A typed failure signal from Bridge: what ended the run, from the witness that saw it.
|
|
4
|
+
*
|
|
5
|
+
* Bridge reports failures as prose (`error`), and both sides classify the prose with regex tables
|
|
6
|
+
* kept in step by a test. Anything the tables do not name became `agent_execution_failed` /
|
|
7
|
+
* `transient` / `retry` — the one classification that spends attempts silently. The signal rides
|
|
8
|
+
* beside the prose: drivers that hold structured knowledge (a vendor envelope status, an exit
|
|
9
|
+
* code, the denied tool) emit it, and classification reads the signal first, the regex second.
|
|
10
|
+
*
|
|
11
|
+
* This file is mirrored byte-identical as packages/conduit-bridge/src/failure-signal.ts and
|
|
12
|
+
* src/conductor/failure-signal.ts. Bridge cannot import from the control plane, and the other
|
|
13
|
+
* way round, so test/failure-signal.test.ts pins the two files together instead of trusting
|
|
14
|
+
* them to stay in step.
|
|
15
|
+
*/
|
|
16
|
+
export const failureSignalSchema = z.object({
|
|
17
|
+
phase: z.enum(["claim", "checkout", "bootstrap", "agent", "verification", "delivery_report", "land"]),
|
|
18
|
+
/** Who produced the evidence for this signal: Bridge itself, the agent CLI, or the vendor. */
|
|
19
|
+
witness: z.enum(["bridge", "agent", "vendor"]),
|
|
20
|
+
kind: z.enum(["exit_code", "timeout", "denied_tool", "quota", "usage_error", "no_output", "contract", "verification_failed", "unknown"]),
|
|
21
|
+
/** The exact command when phase is verification or bootstrap. */
|
|
22
|
+
command: z.string().max(2_000).optional(),
|
|
23
|
+
exit_code: z.number().int().nullable().optional(),
|
|
24
|
+
/** The vendor envelope's own status, e.g. agy's "ERROR". */
|
|
25
|
+
vendor_status: z.string().max(200).optional(),
|
|
26
|
+
/** The vendor envelope's own reason, bounded. */
|
|
27
|
+
vendor_message: z.string().max(2_000).optional(),
|
|
28
|
+
stderr_tail: z.string().max(2_000).optional(),
|
|
29
|
+
stdout_tail: z.string().max(2_000).optional(),
|
|
30
|
+
/** ISO time when kind is quota and the vendor said. */
|
|
31
|
+
resets_at: z.string().max(64).nullable().optional(),
|
|
32
|
+
});
|
|
33
|
+
/** A reset time as a person reads it: "2026-09-10 09:36 UTC". Every reset is stored ISO in UTC. */
|
|
34
|
+
function resetClock(resetsAt) {
|
|
35
|
+
return `${resetsAt.slice(0, 16).replace("T", " ")} UTC`;
|
|
36
|
+
}
|
|
37
|
+
/** Signal tails are bounded: one runaway log cannot fill the wire or the card. */
|
|
38
|
+
function bounded2k(text) {
|
|
39
|
+
return text.length > 2_000 ? `${text.slice(0, 1_999)}…` : text;
|
|
40
|
+
}
|
|
41
|
+
/** The signal a driver that cannot say more emits: the run ended, here is its last output. */
|
|
42
|
+
export function unknownAgentSignal(input) {
|
|
43
|
+
return {
|
|
44
|
+
phase: "agent",
|
|
45
|
+
witness: "agent",
|
|
46
|
+
kind: "unknown",
|
|
47
|
+
exit_code: input.exit_code ?? null,
|
|
48
|
+
...(input.stderr?.trim() ? { stderr_tail: bounded2k(input.stderr) } : {}),
|
|
49
|
+
...(input.stdout?.trim() ? { stdout_tail: bounded2k(input.stdout) } : {}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** The vendor's own reason, kept inside the 2,000-char message bound. */
|
|
53
|
+
function quotedVendorText(signal) {
|
|
54
|
+
const text = signal.vendor_message ?? signal.stderr_tail ?? "";
|
|
55
|
+
return text.length > 400 ? `${text.slice(0, 399)}…` : text;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Classify one typed signal. Null when the signal carries no typed verdict (`kind: "unknown"`):
|
|
59
|
+
* the caller then lets its regex tables name the cause as an upgrade, and an unknown that survives
|
|
60
|
+
* them is held by `unknownSignalHold` — never retried blind.
|
|
61
|
+
*
|
|
62
|
+
* Rules, in order:
|
|
63
|
+
* 1. quota → the lane's allowance is spent: Hold, the computer operator's wait, reset quoted.
|
|
64
|
+
* 2. denied_tool / usage_error → the driver and vendor cannot run this assignment: Hold.
|
|
65
|
+
* 3. verification run by Bridge → the verify class: diagnose the retained tree and brief a repair.
|
|
66
|
+
* 4. contract, or a delivery_report phase → the delivery contract codes: rework.
|
|
67
|
+
* 5. timeout → the turn reached its execution limit: stop.
|
|
68
|
+
*/
|
|
69
|
+
export function classifyFailureSignal(signal) {
|
|
70
|
+
if (signal.kind === "quota") {
|
|
71
|
+
const refill = signal.resets_at
|
|
72
|
+
? ` It refills at ${resetClock(signal.resets_at)}.`
|
|
73
|
+
: signal.vendor_message
|
|
74
|
+
? ` The vendor said: ${quotedVendorText(signal)}`
|
|
75
|
+
: "";
|
|
76
|
+
return {
|
|
77
|
+
code: "driver_quota_exhausted",
|
|
78
|
+
class: "environment",
|
|
79
|
+
disposition: "hold",
|
|
80
|
+
responsible_party: "computer_operator",
|
|
81
|
+
message: `The agent lane on this computer has spent its subscription allowance.${refill}`,
|
|
82
|
+
next_action: "Wait for the allowance to refill, bring another agent lane online to start sooner, or point this lane's model settings at a model that still has allowance.",
|
|
83
|
+
diagnostic_detail: signal.vendor_message ?? signal.stderr_tail,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (signal.kind === "denied_tool" || signal.kind === "usage_error") {
|
|
87
|
+
return {
|
|
88
|
+
code: "driver_incompatible",
|
|
89
|
+
class: "environment",
|
|
90
|
+
disposition: "hold",
|
|
91
|
+
responsible_party: "computer_operator",
|
|
92
|
+
message: signal.kind === "denied_tool"
|
|
93
|
+
? "The agent CLI refused a tool this assignment requires, so it cannot run here as configured."
|
|
94
|
+
: "The agent CLI rejected how this assignment was handed to it, so every retry fails the same way.",
|
|
95
|
+
next_action: "The computer operator or Bridge must fix the lane's tool mapping or driver flags. The product owner does not author technical constraints.",
|
|
96
|
+
diagnostic_detail: signal.vendor_message ?? signal.stderr_tail,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (signal.phase === "verification" && signal.witness === "bridge") {
|
|
100
|
+
return {
|
|
101
|
+
code: "agent_execution_failed",
|
|
102
|
+
class: "transient",
|
|
103
|
+
disposition: "retry",
|
|
104
|
+
responsible_party: "conduit",
|
|
105
|
+
message: "The agent run failed its own verification before delivery.",
|
|
106
|
+
next_action: "Conduit will diagnose the retained failed run and brief the next authoring attempt.",
|
|
107
|
+
diagnostic_detail: [signal.command ? `$ ${signal.command}` : null, signal.stderr_tail, signal.stdout_tail].filter(Boolean).join("\n") || undefined,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
if (signal.phase === "delivery_report" || signal.kind === "contract") {
|
|
111
|
+
return signal.phase === "delivery_report"
|
|
112
|
+
? {
|
|
113
|
+
code: "delivery_report_invalid",
|
|
114
|
+
class: "contract",
|
|
115
|
+
disposition: "rework",
|
|
116
|
+
responsible_party: "conduit",
|
|
117
|
+
message: "The delivery report did not satisfy the delivery contract.",
|
|
118
|
+
next_action: "Bridge's report-only repair turns ran; review the report defect and rework it.",
|
|
119
|
+
diagnostic_detail: signal.vendor_message ?? signal.stderr_tail,
|
|
120
|
+
}
|
|
121
|
+
: {
|
|
122
|
+
code: "execution_contract_failed",
|
|
123
|
+
class: "contract",
|
|
124
|
+
disposition: "rework",
|
|
125
|
+
responsible_party: "conduit",
|
|
126
|
+
message: "The agent could not satisfy the approved execution contract.",
|
|
127
|
+
next_action: "Review the last run and rework the same contract; change the plan only if its authority or outcome is wrong.",
|
|
128
|
+
diagnostic_detail: signal.vendor_message ?? signal.stderr_tail,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (signal.kind === "timeout") {
|
|
132
|
+
return {
|
|
133
|
+
code: "agent_turn_timeout",
|
|
134
|
+
class: "platform",
|
|
135
|
+
disposition: "stop",
|
|
136
|
+
responsible_party: "conduit",
|
|
137
|
+
message: "The agent turn reached its configured execution limit.",
|
|
138
|
+
next_action: "Conduit must compare the package budget with the computer ceiling and resume with a larger bounded turn or checkpoint. The product owner does not need to author implementation constraints.",
|
|
139
|
+
diagnostic_detail: signal.stderr_tail,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The one verdict for an unknown that survived every table: Hold, on Conduit.
|
|
146
|
+
*
|
|
147
|
+
* An unknown signal used to fall through to `transient` and burn attempts silently. The owner now
|
|
148
|
+
* sees the run's own last words — the bounded stderr tail — and Conduit owes the diagnosis, because
|
|
149
|
+
* nobody can yet name the cause.
|
|
150
|
+
*/
|
|
151
|
+
export function unknownSignalHold(signal, proseDetail) {
|
|
152
|
+
const tail = signal.stderr_tail ?? signal.stdout_tail ?? "";
|
|
153
|
+
return {
|
|
154
|
+
code: "agent_run_unexplained",
|
|
155
|
+
class: "environment",
|
|
156
|
+
disposition: "hold",
|
|
157
|
+
responsible_party: "conduit",
|
|
158
|
+
message: tail
|
|
159
|
+
? `The agent run failed and neither Bridge nor Conduit can yet name the cause. The run's last output: ${quotedVendorText(signal)}`
|
|
160
|
+
: "The agent run failed and neither Bridge nor Conduit can yet name the cause.",
|
|
161
|
+
next_action: "Conduit will diagnose the retained run before any retry. The product owner does not need to author technical constraints.",
|
|
162
|
+
diagnostic_detail: proseDetail || tail || undefined,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* agy's JSON envelope status/error mapped to a typed kind, from the vendor's own vocabulary.
|
|
167
|
+
*
|
|
168
|
+
* This is vendor knowledge, not a classification table: it reads the words agy itself prints in
|
|
169
|
+
* its envelope, so the signal can carry `quota` (etc.) and the generic tables stay empty of agy.
|
|
170
|
+
*/
|
|
171
|
+
export function vendorKindForAntigravity(vendorStatus, vendorMessage) {
|
|
172
|
+
const text = `${vendorStatus ?? ""}\n${vendorMessage ?? ""}`;
|
|
173
|
+
if (/individual quota reached|quota reached|upgrade your subscription/i.test(text))
|
|
174
|
+
return "quota";
|
|
175
|
+
if (/no output produced|headless mode cannot prompt for|was auto-denied|permission/i.test(text))
|
|
176
|
+
return "denied_tool";
|
|
177
|
+
if (/took "[^"]*" as its prompt|Usage of agy|unknown (?:option|argument)|unrecognized (?:option|argument)/i.test(text))
|
|
178
|
+
return "usage_error";
|
|
179
|
+
return "unknown";
|
|
180
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The refill time a vendor states in a quota refusal, as an absolute clock.
|
|
3
|
+
*
|
|
4
|
+
* This file is mirrored byte-identical as packages/conduit-bridge/src/quota-reset.ts and
|
|
5
|
+
* src/conductor/quota-reset.ts. Bridge cannot import from the control plane, and the other way
|
|
6
|
+
* round, so test/failure-classification.test.ts pins the two files together instead of trusting
|
|
7
|
+
* them to stay in step.
|
|
8
|
+
*
|
|
9
|
+
* Vendors phrase the refill as an absolute clock time (RFC 1123), a duration ("try again in 2h
|
|
10
|
+
* 14m", or agy's Go form "Resets in 165h48m43s"), or a Unix epoch. Returning null when none
|
|
11
|
+
* matches is fine — `laneQuota` treats a record without a reset as exhausted until something
|
|
12
|
+
* proves otherwise, and the next successful run clears it. Guessing a reset time would be worse:
|
|
13
|
+
* an invented one un-darks the lane early, and the owner watches the same refusal twice.
|
|
14
|
+
*/
|
|
15
|
+
export function parseQuotaResetAt(message, now = Date.now()) {
|
|
16
|
+
const epoch = /\bresets?[ _-]?(?:at|in)?\b\D{0,12}(\d{10})\b/i.exec(message);
|
|
17
|
+
if (epoch)
|
|
18
|
+
return plausibleReset(Number(epoch[1]) * 1000, now);
|
|
19
|
+
// Units must end where they claim to. Unanchored, `m` swallowed the "m" of "500ms" and of
|
|
20
|
+
// "2 months" and read both as minutes — turning a 500-millisecond backoff into an eight-hour
|
|
21
|
+
// blackout of the only local lane.
|
|
22
|
+
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);
|
|
23
|
+
if (duration && (duration[1] || duration[2] || duration[3] || duration[4])) {
|
|
24
|
+
const ms = (Number(duration[1] ?? 0) * 3_600 + Number(duration[2] ?? 0) * 60 + Number(duration[3] ?? 0)) * 1_000
|
|
25
|
+
+ Number(duration[4] ?? 0);
|
|
26
|
+
if (ms > 0)
|
|
27
|
+
return plausibleReset(now + ms, now);
|
|
28
|
+
}
|
|
29
|
+
// Go prints the same duration with the units glued to the numbers ("Resets in 165h48m43s",
|
|
30
|
+
// under an hour "48m43s", "45m0s"), where no unit ends at a boundary and the spaced pattern
|
|
31
|
+
// above cannot see it. Match the token whole, then let the trailing `\b(?!\w)` reject a glued
|
|
32
|
+
// "ms": "1h30ms" is one hour plus 30 milliseconds, and reading that 30 as minutes is the
|
|
33
|
+
// eight-hour blackout the spaced rule guards against.
|
|
34
|
+
const compact = /\b(?:try again|retry|resets?|available again)\b[^.\n]{0,24}?\bin\s+((?:(\d{1,5})h)?(?:(\d{1,2})m)?(?:(\d{1,2})s)?)\b(?!\w)/i.exec(message);
|
|
35
|
+
if (compact && (compact[2] || compact[3] || compact[4])) {
|
|
36
|
+
const ms = Number(compact[2] ?? 0) * 3_600_000 + Number(compact[3] ?? 0) * 60_000 + Number(compact[4] ?? 0) * 1_000;
|
|
37
|
+
if (ms > 0)
|
|
38
|
+
return plausibleReset(now + ms, now);
|
|
39
|
+
}
|
|
40
|
+
const iso = /\bresets?\b[^.\n]{0,24}?(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)/i.exec(message);
|
|
41
|
+
if (iso)
|
|
42
|
+
return plausibleReset(Date.parse(iso[1]), now);
|
|
43
|
+
const rfc1123 = /\bresets?\b[^.\n]{0,24}?((?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{1,2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT)\b/i.exec(message);
|
|
44
|
+
if (rfc1123)
|
|
45
|
+
return plausibleReset(Date.parse(rfc1123[1]), now);
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A reset time worth believing, or none at all.
|
|
50
|
+
*
|
|
51
|
+
* Two failure modes this closes. A number lifted from prose is not always a clock — a request id
|
|
52
|
+
* that happens to be ten digits reads as a date in 2286 and would dark the lane for centuries. And
|
|
53
|
+
* `new Date(x).toISOString()` *throws* on a non-finite or out-of-range value, which here would
|
|
54
|
+
* escape between the driver returning and the failure being queued, stranding the attempt in a
|
|
55
|
+
* running state. Anything outside a plausible billing window is treated as "no reset stated",
|
|
56
|
+
* which is the honest answer and already a supported state.
|
|
57
|
+
*/
|
|
58
|
+
function plausibleReset(ms, now) {
|
|
59
|
+
if (!Number.isFinite(ms))
|
|
60
|
+
return null;
|
|
61
|
+
if (ms > now + MAX_PLAUSIBLE_RESET_MS)
|
|
62
|
+
return null;
|
|
63
|
+
// A reset that has just passed is kept, not discarded. Rejecting it returned null, which does not
|
|
64
|
+
// mean "already refilled" — it means "no reset stated", the one case trusted for a whole week. A
|
|
65
|
+
// second of clock skew or a slow stderr flush would then dark the lane for seven days instead of
|
|
66
|
+
// clearing it at once. Keeping the stale timestamp lets `laneQuota` retire it on the next read.
|
|
67
|
+
if (ms < now - MAX_STALE_RESET_MS)
|
|
68
|
+
return null;
|
|
69
|
+
try {
|
|
70
|
+
return new Date(ms).toISOString();
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** No vendor window runs longer than a month; past that the parse was a coincidence, not a clock. */
|
|
77
|
+
const MAX_PLAUSIBLE_RESET_MS = 31 * 24 * 3_600_000;
|
|
78
|
+
/** How far back a reset may sit and still be a real one the run simply outlived. */
|
|
79
|
+
const MAX_STALE_RESET_MS = 24 * 3_600_000;
|
|
80
|
+
/**
|
|
81
|
+
* The absolute reset as a person reads it: "2026-09-10 09:36 UTC".
|
|
82
|
+
*
|
|
83
|
+
* Every reset is stored as ISO in UTC, so the same cut of the string is the same moment on both
|
|
84
|
+
* sides of the wire, and no locale can make the two screens disagree.
|
|
85
|
+
*/
|
|
86
|
+
export function formatResetMinute(resetsAt) {
|
|
87
|
+
return `${resetsAt.slice(0, 16).replace("T", " ")} UTC`;
|
|
88
|
+
}
|
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.41",
|
|
4
4
|
"description": "Conduit Bridge CLI \u2014 join, connect, disconnect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Pi / Kiro / Antigravity / Grok Build agents for a Conduit organization",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|