@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 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 { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, error: boundedTail(parsed.error || stderr || parsed.resultText || `agy exited with code ${code}`, 20_000) };
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 { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, error: boundedTail(`agy returned no response: ${denial}`, 20_000) };
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: resets
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.38",
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": {