@miraland-labs/conduit-bridge 0.16.40 → 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 +36 -8
- 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
|
},
|
|
@@ -1218,6 +1219,24 @@ async function withAntigravityPermissions(workspace, allow, run) {
|
|
|
1218
1219
|
release();
|
|
1219
1220
|
}
|
|
1220
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
|
+
}
|
|
1221
1240
|
export const antigravityDriver = {
|
|
1222
1241
|
name: "antigravity",
|
|
1223
1242
|
async listModels(executable = "agy", workspace = process.cwd()) {
|
|
@@ -1260,12 +1279,20 @@ export const antigravityDriver = {
|
|
|
1260
1279
|
// it is the only text that names the cause (a spent allowance and when it refills).
|
|
1261
1280
|
const refused = code !== 0 || (parsed.status !== null && parsed.status.toUpperCase() !== "SUCCESS");
|
|
1262
1281
|
if (refused) {
|
|
1263
|
-
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
|
+
};
|
|
1264
1287
|
}
|
|
1265
1288
|
// An empty response with a denial on stderr is a refused run: report it instead of an empty reply.
|
|
1266
1289
|
const denial = parsed.resultText?.trim() ? null : antigravityDenialNotice(stderr);
|
|
1267
1290
|
if (denial) {
|
|
1268
|
-
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
|
+
};
|
|
1269
1296
|
}
|
|
1270
1297
|
return { status: "completed", resultText: parsed.resultText, sessionId: parsed.sessionId };
|
|
1271
1298
|
},
|
|
@@ -1318,6 +1345,7 @@ export const piDriver = {
|
|
|
1318
1345
|
status: "failed",
|
|
1319
1346
|
resultText: parsed.resultText,
|
|
1320
1347
|
sessionId: parsed.sessionId,
|
|
1348
|
+
signal: unknownAgentSignal({ exit_code: code, stderr, stdout }),
|
|
1321
1349
|
error: boundedTail(stderr || parsed.resultText || `pi exited with code ${code}`, 20_000),
|
|
1322
1350
|
};
|
|
1323
1351
|
}
|
|
@@ -1450,7 +1478,7 @@ export const grokDriver = {
|
|
|
1450
1478
|
const { code, stdout, stderr } = await execute(executable, grokRunArgs(input), input.workspace, agentTurnTimeoutMs(input), undefined, "local");
|
|
1451
1479
|
const parsed = parseGrokOutput(stdout);
|
|
1452
1480
|
if (code !== 0 || parsed.isError) {
|
|
1453
|
-
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) };
|
|
1454
1482
|
}
|
|
1455
1483
|
return { status: "completed", resultText: parsed.resultText, sessionId: parsed.sessionId };
|
|
1456
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": {
|