@miraland-labs/conduit-bridge 0.16.40 → 0.16.42
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 +117 -16
- package/dist/ensure-test-evidence.js +4 -1
- package/dist/execution.js +25 -68
- 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";
|
|
@@ -27,7 +28,7 @@ function deliveryLanguageRule(language) {
|
|
|
27
28
|
return null;
|
|
28
29
|
}
|
|
29
30
|
export const evidenceKinds = ["change", "test", "preview", "research", "documentation"];
|
|
30
|
-
export const agentReportTemplate = '{"outcome": "one-paragraph summary", "changes": ["path from the repository root — what changed"], "verification": ["command — result"], "acceptance_results": [{"criterion": "
|
|
31
|
+
export const agentReportTemplate = '{"outcome": "one-paragraph summary", "changes": ["path from the repository root — what changed"], "verification": ["command — result"], "acceptance_results": [{"criterion": "criterion key, for example c1", "status": "met|not_met|unknown", "unverified_reason": "omit unless status is unknown", "unverified_detail": "one sentence naming what was missing; omit unless status is unknown"}], "evidence": [{"kind": "change|test|preview|research|documentation", "name": "concise evidence name", "uri": "external URL if one exists", "digest": "optional digest", "details": ["observable result"], "acceptance_criteria": ["criterion keys this evidence supports, for example c1"]}], "assumptions": [], "risks": [], "limitations": [], "head_commit": "full sha of your final commit, omit if none"}';
|
|
31
32
|
/**
|
|
32
33
|
* Resolve an operator's ordered tier candidates against the live model list.
|
|
33
34
|
* Picks the first safe candidate the CLI currently offers; without a live list
|
|
@@ -96,8 +97,12 @@ export function buildAssignmentPrompt(context) {
|
|
|
96
97
|
lines.push("", `SCOPE\n${scope.map((item) => `- ${item}`).join("\n")}`);
|
|
97
98
|
if (boundaries?.length)
|
|
98
99
|
lines.push("", `BOUNDARIES — never violate these\n${boundaries.map((item) => `- ${item}`).join("\n")}`);
|
|
99
|
-
if (acceptance?.length)
|
|
100
|
-
|
|
100
|
+
if (acceptance?.length) {
|
|
101
|
+
// Each criterion carries a key. The agent reports the key, not the prose: criteria are long
|
|
102
|
+
// sentences with backticks and quotes, and a retyped sentence made the delivery invalid at
|
|
103
|
+
// "Preparing delivery" although the work was done.
|
|
104
|
+
lines.push("", "ACCEPTANCE CRITERIA — the delivery is judged against these. The key in brackets identifies each one.", ...acceptanceCriteriaLines(acceptance), "", "Name each criterion by its key — \"c1\", not the sentence — in acceptance_results[].criterion and in evidence[].acceptance_criteria. You do not need to retype the criterion text; a retyped sentence that differs makes the whole delivery invalid.", "Start from this report skeleton and change only the statuses, the reasons, and the evidence you map to each key:", acceptanceReportSkeleton(acceptance));
|
|
105
|
+
}
|
|
101
106
|
if (evidence?.length && !spec.instruction) {
|
|
102
107
|
lines.push("", "REQUIRED EVIDENCE");
|
|
103
108
|
for (const kind of evidence) {
|
|
@@ -299,6 +304,53 @@ export function criterionKey(value) {
|
|
|
299
304
|
.replace(/[.\u3002]+$/, "")
|
|
300
305
|
.toLowerCase();
|
|
301
306
|
}
|
|
307
|
+
/**
|
|
308
|
+
* The key of one acceptance criterion: `c1` for the first approved criterion, `c2` for the second.
|
|
309
|
+
*
|
|
310
|
+
* The key is derived from the approved order at prompt time and at parse time. It is not stored:
|
|
311
|
+
* the approved criteria list of the execution epoch is the only source of the order, so a key
|
|
312
|
+
* cannot disagree with the contract it names.
|
|
313
|
+
*/
|
|
314
|
+
export function criterionKeyLabel(index) {
|
|
315
|
+
return `c${index + 1}`;
|
|
316
|
+
}
|
|
317
|
+
/** A criterion reference that is a key: `c1`, `C1`, `[c1]`, or the same with spaces around it. */
|
|
318
|
+
const CRITERION_KEY_REFERENCE = /^\[?\s*c(\d{1,3})\s*\]?$/i;
|
|
319
|
+
/**
|
|
320
|
+
* Resolve one criterion reference from the agent report to the approved criterion text.
|
|
321
|
+
*
|
|
322
|
+
* The reference is the exact text (with the `criterionKey` normalization) or the key. The text is
|
|
323
|
+
* tried first, so a criterion whose own text is "c1" keeps its identity. The result is null when
|
|
324
|
+
* the reference names no approved criterion.
|
|
325
|
+
*/
|
|
326
|
+
export function resolveCriterionReference(value, acceptance) {
|
|
327
|
+
const key = criterionKey(value);
|
|
328
|
+
const byText = acceptance.find((criterion) => criterionKey(criterion) === key);
|
|
329
|
+
if (byText !== undefined)
|
|
330
|
+
return byText;
|
|
331
|
+
const match = CRITERION_KEY_REFERENCE.exec(value.trim());
|
|
332
|
+
if (!match)
|
|
333
|
+
return null;
|
|
334
|
+
return acceptance[Number(match[1]) - 1] ?? null;
|
|
335
|
+
}
|
|
336
|
+
/** True when the reference has the shape of a key. Used to tell an unknown key from free prose. */
|
|
337
|
+
function isCriterionKeyReference(value) {
|
|
338
|
+
return CRITERION_KEY_REFERENCE.test(value.trim());
|
|
339
|
+
}
|
|
340
|
+
/** Render the approved criteria as `[c1] <text>` lines for a prompt. */
|
|
341
|
+
export function acceptanceCriteriaLines(acceptance) {
|
|
342
|
+
return acceptance.map((criterion, index) => `- [${criterionKeyLabel(index)}] ${criterion}`);
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* The pre-filled acceptance_results skeleton for the prompt: one entry per key, status unknown.
|
|
346
|
+
* The agent changes the statuses and adds the evidence instead of composing the list, because
|
|
347
|
+
* composing it means retyping long criterion prose, and a retyped sentence made the delivery
|
|
348
|
+
* invalid at "Preparing delivery" on most first deliveries.
|
|
349
|
+
*/
|
|
350
|
+
export function acceptanceReportSkeleton(acceptance) {
|
|
351
|
+
const entries = acceptance.map((_, index) => ({ criterion: criterionKeyLabel(index), status: "unknown" }));
|
|
352
|
+
return JSON.stringify({ acceptance_results: entries });
|
|
353
|
+
}
|
|
302
354
|
/** Bound one criterion for an error message. A criterion holds up to 4 000 characters. */
|
|
303
355
|
function quoteCriterion(value) {
|
|
304
356
|
const text = value.trim().replace(/\s+/g, " ");
|
|
@@ -335,13 +387,34 @@ export function parseAgentReport(text, acceptance) {
|
|
|
335
387
|
const at = issue?.path?.length ? ` at ${issue.path.join(".")}` : "";
|
|
336
388
|
throw new Error(`Agent report is invalid${at}: ${issue?.message ?? "unknown validation error"}`);
|
|
337
389
|
}
|
|
338
|
-
|
|
390
|
+
// Map every criterion reference — in the results and in the evidence — back to the approved text
|
|
391
|
+
// before anything else reads it. Downstream (the packet, the reviewer, Today) sees text only; the
|
|
392
|
+
// key is transport. An unknown key is refused here, because a key the agent invented cannot be
|
|
393
|
+
// mapped and silently leaves a criterion unwitnessed.
|
|
394
|
+
const unknownKeys = [];
|
|
395
|
+
const toApprovedText = (value) => {
|
|
396
|
+
const text = resolveCriterionReference(value, acceptance);
|
|
397
|
+
if (text !== null)
|
|
398
|
+
return text;
|
|
399
|
+
if (isCriterionKeyReference(value))
|
|
400
|
+
unknownKeys.push(value.trim());
|
|
401
|
+
return value;
|
|
402
|
+
};
|
|
403
|
+
const acceptanceResults = parsed.data.acceptance_results.map((item) => ({ ...item, criterion: toApprovedText(item.criterion) }));
|
|
404
|
+
const evidence = parsed.data.evidence.map((item) => ({ ...item, acceptance_criteria: item.acceptance_criteria.map(toApprovedText) }));
|
|
405
|
+
if (unknownKeys.length) {
|
|
406
|
+
const valid = acceptance.length
|
|
407
|
+
? acceptance.map((criterion, index) => `${criterionKeyLabel(index)} = ${quoteCriterion(criterion)}`).join("; ")
|
|
408
|
+
: "none — this package has no acceptance criteria";
|
|
409
|
+
throw new Error(`Agent report names a criterion key that does not exist (${[...new Set(unknownKeys)].join(", ")}). The valid keys are: ${valid}`);
|
|
410
|
+
}
|
|
411
|
+
const reportedKeys = acceptanceResults.map((item) => criterionKey(item.criterion));
|
|
339
412
|
const approvedKeys = new Set(acceptance.map(criterionKey));
|
|
340
|
-
const duplicated =
|
|
413
|
+
const duplicated = acceptanceResults
|
|
341
414
|
.filter((item, index) => reportedKeys.indexOf(criterionKey(item.criterion)) !== index)
|
|
342
415
|
.map((item) => item.criterion);
|
|
343
416
|
const missing = acceptance.filter((criterion) => !reportedKeys.includes(criterionKey(criterion)));
|
|
344
|
-
const unapproved =
|
|
417
|
+
const unapproved = acceptanceResults
|
|
345
418
|
.filter((item) => !approvedKeys.has(criterionKey(item.criterion)))
|
|
346
419
|
.map((item) => item.criterion);
|
|
347
420
|
if (missing.length || duplicated.length || unapproved.length) {
|
|
@@ -357,10 +430,11 @@ export function parseAgentReport(text, acceptance) {
|
|
|
357
430
|
// Missing evidence mappings cannot support a "met" claim. Preserve the evidence itself, but
|
|
358
431
|
// downgrade only the unsupported result to unknown so the existing quality loop can assess the
|
|
359
432
|
// completed work instead of throwing the whole implementation away.
|
|
360
|
-
const mappedCriteria = new Set(
|
|
433
|
+
const mappedCriteria = new Set(evidence.flatMap((item) => item.acceptance_criteria.map(criterionKey)));
|
|
361
434
|
return {
|
|
362
435
|
...parsed.data,
|
|
363
|
-
|
|
436
|
+
evidence,
|
|
437
|
+
acceptance_results: acceptanceResults.map((item) => {
|
|
364
438
|
const { unverified_reason: reported, unverified_detail: detail, ...rest } = item;
|
|
365
439
|
const status = item.status === "met" && !mappedCriteria.has(criterionKey(item.criterion))
|
|
366
440
|
? "unknown"
|
|
@@ -524,7 +598,7 @@ export const claudeCodeDriver = {
|
|
|
524
598
|
}
|
|
525
599
|
const sessionId = message?.session_id ?? null;
|
|
526
600
|
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) };
|
|
601
|
+
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
602
|
}
|
|
529
603
|
return { status: "completed", resultText: message.result ?? "", sessionId };
|
|
530
604
|
},
|
|
@@ -625,7 +699,7 @@ export const codexDriver = {
|
|
|
625
699
|
const parsed = parseCodexJsonl(stdout);
|
|
626
700
|
const resultText = parsed.resultText ?? (stdout || null);
|
|
627
701
|
if (code !== 0) {
|
|
628
|
-
return { status: "failed", resultText, sessionId: parsed.sessionId, error: boundedTail(stderr || stdout || `codex exited with code ${code}`, 20_000) };
|
|
702
|
+
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
703
|
}
|
|
630
704
|
return { status: "completed", resultText, sessionId: parsed.sessionId };
|
|
631
705
|
},
|
|
@@ -757,7 +831,7 @@ export const cursorDriver = {
|
|
|
757
831
|
const { code, stdout, stderr } = configured;
|
|
758
832
|
const parsed = parseCursorOutput(stdout);
|
|
759
833
|
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) };
|
|
834
|
+
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
835
|
}
|
|
762
836
|
return { status: "completed", resultText: parsed.resultText, sessionId: parsed.sessionId };
|
|
763
837
|
},
|
|
@@ -924,7 +998,7 @@ export const openCodeDriver = {
|
|
|
924
998
|
const { code, stdout, stderr } = await withOpenCodePermissions(input.workspace, () => execute(input.executable ?? "opencode", args, input.workspace, agentTurnTimeoutMs(input), fuelSource === "conduit" ? input.fuel : undefined, fuelSource));
|
|
925
999
|
const parsed = parseOpenCodeOutput(stdout);
|
|
926
1000
|
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) };
|
|
1001
|
+
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
1002
|
}
|
|
929
1003
|
return { status: "completed", resultText: parsed.resultText, sessionId: parsed.sessionId };
|
|
930
1004
|
},
|
|
@@ -989,7 +1063,7 @@ export const kiroDriver = {
|
|
|
989
1063
|
args.push(input.prompt);
|
|
990
1064
|
const { code, stdout, stderr } = await execute(executable, args, input.workspace, agentTurnTimeoutMs(input), undefined, "local");
|
|
991
1065
|
if (code !== 0) {
|
|
992
|
-
return { status: "failed", resultText: stdout || null, sessionId: null, error: boundedTail(stderr || stdout || `kiro-cli exited with code ${code}`, 20_000) };
|
|
1066
|
+
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
1067
|
}
|
|
994
1068
|
return { status: "completed", resultText: stdout, sessionId: null };
|
|
995
1069
|
},
|
|
@@ -1218,6 +1292,24 @@ async function withAntigravityPermissions(workspace, allow, run) {
|
|
|
1218
1292
|
release();
|
|
1219
1293
|
}
|
|
1220
1294
|
}
|
|
1295
|
+
/**
|
|
1296
|
+
* The typed signal for a refused agy run: the envelope's own status and reason become the witness,
|
|
1297
|
+
* and the vendor's vocabulary names the kind — so the control plane can hold a spent allowance
|
|
1298
|
+
* without any generic prose table knowing agy at all.
|
|
1299
|
+
*/
|
|
1300
|
+
function agySignal(code, status, message, stderr, stdout) {
|
|
1301
|
+
const kind = vendorKindForAntigravity(status, message);
|
|
1302
|
+
if (kind === "unknown")
|
|
1303
|
+
return unknownAgentSignal({ exit_code: code, stderr, stdout });
|
|
1304
|
+
return {
|
|
1305
|
+
phase: "agent",
|
|
1306
|
+
witness: "vendor",
|
|
1307
|
+
kind,
|
|
1308
|
+
exit_code: code,
|
|
1309
|
+
...(status ? { vendor_status: status.slice(0, 200) } : {}),
|
|
1310
|
+
...(message ? { vendor_message: boundedTail(message, 2_000) } : {}),
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1221
1313
|
export const antigravityDriver = {
|
|
1222
1314
|
name: "antigravity",
|
|
1223
1315
|
async listModels(executable = "agy", workspace = process.cwd()) {
|
|
@@ -1260,12 +1352,20 @@ export const antigravityDriver = {
|
|
|
1260
1352
|
// it is the only text that names the cause (a spent allowance and when it refills).
|
|
1261
1353
|
const refused = code !== 0 || (parsed.status !== null && parsed.status.toUpperCase() !== "SUCCESS");
|
|
1262
1354
|
if (refused) {
|
|
1263
|
-
return {
|
|
1355
|
+
return {
|
|
1356
|
+
status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId,
|
|
1357
|
+
signal: agySignal(code, parsed.status, parsed.error, stderr, stdout),
|
|
1358
|
+
error: boundedTail(parsed.error || stderr || parsed.resultText || `agy exited with code ${code}`, 20_000),
|
|
1359
|
+
};
|
|
1264
1360
|
}
|
|
1265
1361
|
// An empty response with a denial on stderr is a refused run: report it instead of an empty reply.
|
|
1266
1362
|
const denial = parsed.resultText?.trim() ? null : antigravityDenialNotice(stderr);
|
|
1267
1363
|
if (denial) {
|
|
1268
|
-
return {
|
|
1364
|
+
return {
|
|
1365
|
+
status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId,
|
|
1366
|
+
signal: { phase: "agent", witness: "vendor", kind: "denied_tool", vendor_status: parsed.status ?? "ERROR", vendor_message: boundedTail(denial, 2_000) },
|
|
1367
|
+
error: boundedTail(`agy returned no response: ${denial}`, 20_000),
|
|
1368
|
+
};
|
|
1269
1369
|
}
|
|
1270
1370
|
return { status: "completed", resultText: parsed.resultText, sessionId: parsed.sessionId };
|
|
1271
1371
|
},
|
|
@@ -1318,6 +1418,7 @@ export const piDriver = {
|
|
|
1318
1418
|
status: "failed",
|
|
1319
1419
|
resultText: parsed.resultText,
|
|
1320
1420
|
sessionId: parsed.sessionId,
|
|
1421
|
+
signal: unknownAgentSignal({ exit_code: code, stderr, stdout }),
|
|
1321
1422
|
error: boundedTail(stderr || parsed.resultText || `pi exited with code ${code}`, 20_000),
|
|
1322
1423
|
};
|
|
1323
1424
|
}
|
|
@@ -1450,7 +1551,7 @@ export const grokDriver = {
|
|
|
1450
1551
|
const { code, stdout, stderr } = await execute(executable, grokRunArgs(input), input.workspace, agentTurnTimeoutMs(input), undefined, "local");
|
|
1451
1552
|
const parsed = parseGrokOutput(stdout);
|
|
1452
1553
|
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) };
|
|
1554
|
+
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
1555
|
}
|
|
1455
1556
|
return { status: "completed", resultText: parsed.resultText, sessionId: parsed.sessionId };
|
|
1456
1557
|
},
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { execFile } from "node:child_process";
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
|
+
import { resolveCriterionReference } from "./driver.js";
|
|
8
9
|
import { isBoundedVerificationCommand, isRunnableVerificationCommand } from "./execution-class.js";
|
|
9
10
|
const execFileAsync = promisify(execFile);
|
|
10
11
|
const TEST_EVIDENCE_DETAILS_MIN = 32;
|
|
@@ -287,8 +288,10 @@ export function criteriaForTestEvidence(report, command, acceptance = []) {
|
|
|
287
288
|
const mapped = new Set();
|
|
288
289
|
for (const item of sources) {
|
|
289
290
|
for (const criterion of item.acceptance_criteria ?? []) {
|
|
291
|
+
// The agent may name a criterion by its key (c1). The witness carries the approved text, so
|
|
292
|
+
// that a key never reaches the packet and the criterion is not left unwitnessed.
|
|
290
293
|
if (criterion)
|
|
291
|
-
mapped.add(criterion);
|
|
294
|
+
mapped.add(resolveCriterionReference(criterion, acceptance) ?? criterion);
|
|
292
295
|
}
|
|
293
296
|
}
|
|
294
297
|
// A criterion that names the command verbatim is proved by that command's witness, whether or
|
package/dist/execution.js
CHANGED
|
@@ -4,7 +4,7 @@ import { z } from "zod";
|
|
|
4
4
|
import { resolveAgentTimeout } from "./execution-budget.js";
|
|
5
5
|
import { ConduitRequestError } from "./client.js";
|
|
6
6
|
import { redactSecrets, saveDriverOutcome, saveDriverQuota } from "./config.js";
|
|
7
|
-
import { DRIVERS, agentReportTemplate, buildAssignmentPrompt, evidenceKinds, extractAgentReportJsonText, fuelEndpoint, normalizeEvidenceDigest, parseAgentReport, parseJsonObjectCandidate, pickModelCandidate, requireStampedExecutionClass, tierForRisk } from "./driver.js";
|
|
7
|
+
import { DRIVERS, acceptanceCriteriaLines, acceptanceReportSkeleton, agentReportTemplate, buildAssignmentPrompt, evidenceKinds, extractAgentReportJsonText, fuelEndpoint, normalizeEvidenceDigest, parseAgentReport, parseJsonObjectCandidate, pickModelCandidate, requireStampedExecutionClass, tierForRisk } from "./driver.js";
|
|
8
8
|
import { assertClassFloor } from "./execution-class.js";
|
|
9
9
|
import { pickDriverForClaim, recordDriverQuota, resolveDriverFuel, resolveDriverFuelProvenance, supportsReadOnlyDiagnosis, clearDriverOutcome, recordDriverTimeout } from "./drivers.js";
|
|
10
10
|
import { attemptWorktreePath, createAttemptWorktree, proveResumeWorktree, quarantineAttemptWorktree, removeAttemptWorktree, } from "./attempt-worktree.js";
|
|
@@ -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;
|
|
@@ -1721,8 +1675,8 @@ function buildLandContinuationPrompt(input) {
|
|
|
1721
1675
|
"APPROVED CHANGE SCOPE",
|
|
1722
1676
|
...(scope.length ? scope.map((path) => `- ${path}`) : ["- (none — stay within the plan)"]),
|
|
1723
1677
|
"",
|
|
1724
|
-
"APPROVED ACCEPTANCE CRITERIA",
|
|
1725
|
-
...(input.acceptance.length ? input.acceptance
|
|
1678
|
+
"APPROVED ACCEPTANCE CRITERIA — name each one by its key, not by its text",
|
|
1679
|
+
...(input.acceptance.length ? acceptanceCriteriaLines(input.acceptance) : ["- None"]),
|
|
1726
1680
|
"",
|
|
1727
1681
|
"REQUIRED DELIVERY SHAPE",
|
|
1728
1682
|
agentReportTemplate,
|
|
@@ -1840,8 +1794,11 @@ function buildDeliveryRepairPrompt(parseError, previousReply, acceptance) {
|
|
|
1840
1794
|
"REQUIRED DELIVERY SHAPE",
|
|
1841
1795
|
agentReportTemplate,
|
|
1842
1796
|
"",
|
|
1843
|
-
"APPROVED ACCEPTANCE CRITERIA",
|
|
1844
|
-
...(acceptance.length ? acceptance
|
|
1797
|
+
"APPROVED ACCEPTANCE CRITERIA — name each one by its key, not by its text",
|
|
1798
|
+
...(acceptance.length ? acceptanceCriteriaLines(acceptance) : ["- None"]),
|
|
1799
|
+
...(acceptance.length
|
|
1800
|
+
? ["", "Start from this report skeleton and change only the statuses, the reasons, and the evidence:", acceptanceReportSkeleton(acceptance)]
|
|
1801
|
+
: []),
|
|
1845
1802
|
"",
|
|
1846
1803
|
"PARSE ERROR",
|
|
1847
1804
|
redactSecrets(parseError),
|
|
@@ -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.42",
|
|
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": {
|