jules-orchestrator-kit 0.38.2 → 0.41.0
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/.agent/prompts/Bolt.md +10 -7
- package/.agent/prompts/Janitor.md +6 -6
- package/.agent/prompts/Task_Template.md +6 -5
- package/.agent/rules/jules-protocol.md +1 -1
- package/JULES_RULES_TEMPLATE.md +12 -8
- package/README.md +45 -8
- package/bin/agentctl.mjs +297 -9
- package/index.mjs +15 -1
- package/package.json +1 -1
- package/scripts/ci-scope-guard.mjs +186 -0
- package/scripts/jules-merge-swarm.mjs +13 -3
- package/src/assertions.mjs +579 -0
- package/src/budget.mjs +48 -6
- package/src/config.mjs +69 -6
- package/src/dag-engine.mjs +22 -1
- package/src/engine.mjs +133 -23
- package/src/evidence.mjs +111 -2
- package/src/execution-envelope.mjs +23 -7
- package/src/flaky-ledger.mjs +4 -1
- package/src/ops/command-registry.mjs +32 -0
- package/src/ops/doctor-planner.mjs +9 -3
- package/src/ops/pr-harvest.mjs +349 -0
- package/src/provider.mjs +491 -25
- package/src/review-repair.mjs +85 -7
- package/src/risk.mjs +134 -26
- package/src/role-resolver.mjs +54 -2
- package/src/router.mjs +64 -6
- package/src/state.mjs +15 -3
- package/src/task-optimizer.mjs +9 -1
- package/src/telemetry.mjs +65 -0
- package/src/web-templates.mjs +372 -7
- package/src/wizard-init.mjs +13 -3
- package/src/wizard-task.mjs +76 -6
package/src/provider.mjs
CHANGED
|
@@ -1,6 +1,60 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join, extname } from "node:path";
|
|
2
4
|
import { redactSecrets } from "./security.mjs";
|
|
3
5
|
|
|
6
|
+
// Extensions node --check can parse as a script. .mjs/.cjs are unambiguous;
|
|
7
|
+
// plain .js is checked as a script too — a stray top-level ESM import would
|
|
8
|
+
// already have failed the Declarative/Mechanical-tier files this gate
|
|
9
|
+
// actually sees, and a false SyntaxError just forces an unnecessary (but
|
|
10
|
+
// harmless) escalation to the primary provider rather than a missed one.
|
|
11
|
+
const SYNTAX_CHECKABLE_EXTS = new Set([".js", ".mjs", ".cjs"]);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Lists working-tree files (relative paths) touched since the last commit,
|
|
15
|
+
* limited to JS extensions node --check can parse. Deleted files are
|
|
16
|
+
* excluded — there is nothing left on disk to check. Zero-dependency: shells
|
|
17
|
+
* out to `git status`, the same plumbing src/security.mjs and src/git.mjs
|
|
18
|
+
* already rely on.
|
|
19
|
+
*/
|
|
20
|
+
function listChangedSourceFiles(root) {
|
|
21
|
+
try {
|
|
22
|
+
const res = spawnSync("git", ["status", "--porcelain=v1", "--no-renames"], {
|
|
23
|
+
cwd: root,
|
|
24
|
+
encoding: "utf-8",
|
|
25
|
+
});
|
|
26
|
+
if (res.status !== 0 || !res.stdout) return [];
|
|
27
|
+
const files = [];
|
|
28
|
+
for (const line of res.stdout.split("\n")) {
|
|
29
|
+
if (!line.trim()) continue;
|
|
30
|
+
const statusCode = line.slice(0, 2);
|
|
31
|
+
if (statusCode.includes("D")) continue;
|
|
32
|
+
const filePath = line.slice(3).trim().replace(/^"|"$/g, "");
|
|
33
|
+
if (SYNTAX_CHECKABLE_EXTS.has(extname(filePath).toLowerCase())) files.push(filePath);
|
|
34
|
+
}
|
|
35
|
+
return files;
|
|
36
|
+
} catch (_) {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Runs the given files through V8's native `node --check` (parses the AST
|
|
43
|
+
* without executing it) and returns the first syntax failure found, or null.
|
|
44
|
+
*/
|
|
45
|
+
function findSyntaxError(root, files) {
|
|
46
|
+
for (const file of files) {
|
|
47
|
+
const absPath = join(root, file);
|
|
48
|
+
if (!existsSync(absPath)) continue;
|
|
49
|
+
const res = spawnSync(process.execPath, ["--check", absPath], { encoding: "utf-8" });
|
|
50
|
+
if (res.status !== 0) {
|
|
51
|
+
const stderr = redactSecrets((res.stderr || "").slice(0, 300)).trim();
|
|
52
|
+
return { file, error: stderr || `node --check exited ${res.status}` };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
4
58
|
export const JULES_PRESET = {
|
|
5
59
|
name: "jules",
|
|
6
60
|
type: "http",
|
|
@@ -40,13 +94,13 @@ export const CODEX_PRESET = {
|
|
|
40
94
|
// Google Gemini CLI (`npm i -g @google/gemini-cli`), headless mode. Reads the
|
|
41
95
|
// prompt from stdin (non-TTY input triggers headless mode without needing a
|
|
42
96
|
// dangling `-p` flag), auto-approves file/shell actions, and defaults to the
|
|
43
|
-
// Gemini 3.
|
|
44
|
-
// tasks routed by src/router.mjs. Auth via GEMINI_API_KEY
|
|
97
|
+
// Gemini 3.7 Flash model (with fallback support) — a cheap, fast tier suited
|
|
98
|
+
// for trivial/mechanical tasks routed by src/router.mjs. Auth via GEMINI_API_KEY.
|
|
45
99
|
export const GEMINI_PRESET = {
|
|
46
100
|
name: "gemini-flash",
|
|
47
101
|
type: "exec",
|
|
48
102
|
command: "gemini",
|
|
49
|
-
args: ["--model", "gemini-3.
|
|
103
|
+
args: ["--model", process.env.GEMINI_FLASH_MODEL || "gemini-3.7-flash", "--approval-mode=yolo", "--output-format", "json"],
|
|
50
104
|
promptViaStdin: true,
|
|
51
105
|
};
|
|
52
106
|
|
|
@@ -246,7 +300,7 @@ export function createProvider(spec = "jules", config = {}) {
|
|
|
246
300
|
}
|
|
247
301
|
|
|
248
302
|
const isRepoless = Boolean(task.repoless || ctx.repoless);
|
|
249
|
-
const rawRepo = task.source || ctx.source || process.env.JULES_REPO || "";
|
|
303
|
+
const rawRepo = task.source || ctx.source || config.source || process.env.JULES_REPO || "";
|
|
250
304
|
if (!rawRepo && !ctx.dryRun && providerSpec.name === "jules" && !isRepoless) {
|
|
251
305
|
throw new Error("Missing connected Jules repository source. Set JULES_REPO or pass source/repoless option.");
|
|
252
306
|
}
|
|
@@ -350,32 +404,74 @@ export function createProvider(spec = "jules", config = {}) {
|
|
|
350
404
|
}
|
|
351
405
|
|
|
352
406
|
if (!res.ok) {
|
|
353
|
-
const text = await res.text();
|
|
407
|
+
const text = await res.text().catch(() => "");
|
|
354
408
|
const cleanText = text.slice(0, 500);
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
if (res.status ===
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
409
|
+
let sanitizedText = redactSecrets(rawToken ? cleanText.split(rawToken).join("[REDACTED]") : cleanText);
|
|
410
|
+
let retryAfterHeader = res.headers ? res.headers.get("retry-after") : null;
|
|
411
|
+
let retryAfterMs = parseRetryAfter(retryAfterHeader);
|
|
412
|
+
|
|
413
|
+
if (res.status === 400 && bodyObj && typeof bodyObj === "object") {
|
|
414
|
+
const lowerText = sanitizedText.toLowerCase();
|
|
415
|
+
if (
|
|
416
|
+
lowerText.includes("deprecated") ||
|
|
417
|
+
lowerText.includes("unrecognized") ||
|
|
418
|
+
lowerText.includes("unknown field") ||
|
|
419
|
+
lowerText.includes("temperature") ||
|
|
420
|
+
lowerText.includes("thinking_budget") ||
|
|
421
|
+
lowerText.includes("top_p")
|
|
422
|
+
) {
|
|
423
|
+
// Optimistic Schema Degradation: Strip deprecated fields and transparently retry
|
|
424
|
+
delete bodyObj.temperature;
|
|
425
|
+
delete bodyObj.top_p;
|
|
426
|
+
delete bodyObj.top_k;
|
|
427
|
+
if (bodyObj.thinking_budget !== undefined) {
|
|
428
|
+
delete bodyObj.thinking_budget;
|
|
429
|
+
bodyObj.thinking_level = "high";
|
|
430
|
+
}
|
|
431
|
+
body = JSON.stringify(bodyObj);
|
|
432
|
+
try {
|
|
433
|
+
res = await fetch(url, {
|
|
434
|
+
method: providerSpec.method || "POST",
|
|
435
|
+
headers,
|
|
436
|
+
body,
|
|
437
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
438
|
+
});
|
|
439
|
+
if (!res.ok) {
|
|
440
|
+
// The retry failed too — re-derive the error context from
|
|
441
|
+
// its response instead of reporting the pre-retry 400,
|
|
442
|
+
// which by definition no longer describes the failure.
|
|
443
|
+
const retryText = await res.text().catch(() => "");
|
|
444
|
+
const cleanRetryText = retryText.slice(0, 500);
|
|
445
|
+
sanitizedText = redactSecrets(rawToken ? cleanRetryText.split(rawToken).join("[REDACTED]") : cleanRetryText);
|
|
446
|
+
retryAfterHeader = res.headers ? res.headers.get("retry-after") : null;
|
|
447
|
+
retryAfterMs = parseRetryAfter(retryAfterHeader);
|
|
448
|
+
}
|
|
449
|
+
} catch (_) {}
|
|
367
450
|
}
|
|
368
|
-
throw lastError;
|
|
369
451
|
}
|
|
370
452
|
|
|
371
|
-
if (res.
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
453
|
+
if (!res.ok) {
|
|
454
|
+
if (res.status === 429) {
|
|
455
|
+
pool.markRateLimited(rawToken, retryAfterMs ?? 60000);
|
|
456
|
+
lastError = new ProviderRateLimitError(`Provider HTTP Error (429): ${sanitizedText}`, {
|
|
457
|
+
retryAfterMs: retryAfterMs ?? 60000,
|
|
458
|
+
status: 429,
|
|
459
|
+
});
|
|
460
|
+
if (pool.size > 1 && attempt < maxAttempts - 1) {
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
throw lastError;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (res.status >= 500 && res.status < 600) {
|
|
467
|
+
throw new ProviderUnavailableError(`Provider HTTP Error (${res.status}): ${sanitizedText}`, {
|
|
468
|
+
status: res.status,
|
|
469
|
+
retryAfterMs: retryAfterMs ?? undefined,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
377
472
|
|
|
378
|
-
|
|
473
|
+
throw new Error(`Provider HTTP Error (${res.status}): ${sanitizedText}`);
|
|
474
|
+
}
|
|
379
475
|
}
|
|
380
476
|
|
|
381
477
|
pool.recordUsage(rawToken);
|
|
@@ -564,6 +660,255 @@ export function createProvider(spec = "jules", config = {}) {
|
|
|
564
660
|
|
|
565
661
|
throw new Error(`Unsupported provider type: ${providerSpec.type}`);
|
|
566
662
|
},
|
|
663
|
+
|
|
664
|
+
async getSession(sessionId, ctx = {}) {
|
|
665
|
+
if (!sessionId || typeof sessionId !== "string") {
|
|
666
|
+
throw new TypeError("getSession() requires a valid sessionId string");
|
|
667
|
+
}
|
|
668
|
+
if (!ctx || typeof ctx !== "object") ctx = {};
|
|
669
|
+
|
|
670
|
+
const pool = ctx.tokenPool || config.tokenPool || TokenPool.fromEnv(config);
|
|
671
|
+
const rawToken = pool.getNextToken() || process.env.JULES_API_KEY || (ctx.allowLegacyKey ? process.env.GEMINI_API_KEY : "") || "";
|
|
672
|
+
if (!rawToken && !ctx.dryRun && providerSpec.name === "jules") {
|
|
673
|
+
throw new MissingApiKeyError();
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (ctx.dryRun) {
|
|
677
|
+
return {
|
|
678
|
+
id: sessionId,
|
|
679
|
+
status: "active",
|
|
680
|
+
provider: providerSpec.name,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
if (providerSpec.type === "http") {
|
|
685
|
+
const getSessionUrlTemplate = providerSpec.getSessionUrl || `${providerSpec.url}/${sessionId}`;
|
|
686
|
+
const urlData = { sessionId, ...ctx };
|
|
687
|
+
const url = interpolateString(getSessionUrlTemplate, urlData);
|
|
688
|
+
|
|
689
|
+
const headerData = { ...urlData, token: rawToken };
|
|
690
|
+
const headers = {};
|
|
691
|
+
for (const [k, v] of Object.entries(providerSpec.headers || {})) {
|
|
692
|
+
const val = interpolateString(v, headerData);
|
|
693
|
+
if (val.includes("\r") || val.includes("\n")) {
|
|
694
|
+
throw new Error(`CRITICAL: Header injection attempt detected in header "${k}"`);
|
|
695
|
+
}
|
|
696
|
+
headers[k] = val;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const requestedTimeout = Number(ctx.timeoutMs ?? config.timeoutMs ?? providerSpec.timeoutMs ?? 30_000);
|
|
700
|
+
const timeoutMs = Number.isFinite(requestedTimeout) && requestedTimeout > 0 ? requestedTimeout : 30_000;
|
|
701
|
+
const maxRetries = Number.isInteger(ctx.maxRetries) ? ctx.maxRetries : 3;
|
|
702
|
+
const initialDelayMs = Number.isInteger(ctx.initialDelayMs) ? ctx.initialDelayMs : 500;
|
|
703
|
+
|
|
704
|
+
let lastErr = null;
|
|
705
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
706
|
+
if (attempt > 0) {
|
|
707
|
+
const delay = initialDelayMs * Math.pow(2, attempt - 1);
|
|
708
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
let res;
|
|
712
|
+
try {
|
|
713
|
+
res = await fetch(url, {
|
|
714
|
+
method: "GET",
|
|
715
|
+
headers,
|
|
716
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
717
|
+
});
|
|
718
|
+
} catch (err) {
|
|
719
|
+
if (err.name === "TimeoutError" || err.name === "AbortError" || err.code === "ABORT_ERR") {
|
|
720
|
+
lastErr = new ProviderUnavailableError(`Provider HTTP Timeout (${timeoutMs}ms): ${err.message}`, { status: 504 });
|
|
721
|
+
if (attempt < maxRetries) continue;
|
|
722
|
+
throw lastErr;
|
|
723
|
+
}
|
|
724
|
+
throw err;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
if (!res.ok) {
|
|
728
|
+
const text = await res.text();
|
|
729
|
+
const cleanText = text.slice(0, 500);
|
|
730
|
+
const sanitizedText = redactSecrets(rawToken ? cleanText.split(rawToken).join("[REDACTED]") : cleanText);
|
|
731
|
+
|
|
732
|
+
if (res.status === 404) {
|
|
733
|
+
lastErr = new Error(`Provider HTTP Error (404 Not Found): ${sanitizedText}`);
|
|
734
|
+
lastErr.status = 404;
|
|
735
|
+
if (attempt < maxRetries) continue;
|
|
736
|
+
throw lastErr;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
if (res.status === 429) {
|
|
740
|
+
const retryAfterHeader = res.headers ? res.headers.get("retry-after") : null;
|
|
741
|
+
const retryAfterMs = parseRetryAfter(retryAfterHeader);
|
|
742
|
+
pool.markRateLimited(rawToken, retryAfterMs ?? 60000);
|
|
743
|
+
lastErr = new ProviderRateLimitError(`Provider HTTP Error (429): ${sanitizedText}`, {
|
|
744
|
+
retryAfterMs: retryAfterMs ?? 60000,
|
|
745
|
+
status: 429,
|
|
746
|
+
});
|
|
747
|
+
if (attempt < maxRetries) continue;
|
|
748
|
+
throw lastErr;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if (res.status >= 500 && res.status < 600) {
|
|
752
|
+
lastErr = new ProviderUnavailableError(`Provider HTTP Error (${res.status}): ${sanitizedText}`, {
|
|
753
|
+
status: res.status,
|
|
754
|
+
});
|
|
755
|
+
if (attempt < maxRetries) continue;
|
|
756
|
+
throw lastErr;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const err = new Error(`Provider HTTP Error (${res.status}): ${sanitizedText}`);
|
|
760
|
+
err.status = res.status;
|
|
761
|
+
throw err;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
let json;
|
|
765
|
+
try {
|
|
766
|
+
json = await res.json();
|
|
767
|
+
} catch (err) {
|
|
768
|
+
throw new ProviderSchemaError(`Provider Payload Error: Invalid JSON response: ${err.message}`, {
|
|
769
|
+
status: res.status,
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
return {
|
|
774
|
+
id: json.id || json.name || sessionId,
|
|
775
|
+
status: json.state || json.status || "active",
|
|
776
|
+
raw: json,
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
if (providerSpec.type === "exec") {
|
|
782
|
+
return { id: sessionId, status: "completed" };
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
throw new Error(`Unsupported provider type: ${providerSpec.type}`);
|
|
786
|
+
},
|
|
787
|
+
|
|
788
|
+
async approvePlan(sessionId, ctx = {}) {
|
|
789
|
+
if (!sessionId || typeof sessionId !== "string") {
|
|
790
|
+
throw new TypeError("approvePlan() requires a valid sessionId string");
|
|
791
|
+
}
|
|
792
|
+
if (!ctx || typeof ctx !== "object") ctx = {};
|
|
793
|
+
|
|
794
|
+
const pool = ctx.tokenPool || config.tokenPool || TokenPool.fromEnv(config);
|
|
795
|
+
const rawToken = pool.getNextToken() || process.env.JULES_API_KEY || (ctx.allowLegacyKey ? process.env.GEMINI_API_KEY : "") || "";
|
|
796
|
+
if (!rawToken && !ctx.dryRun && providerSpec.name === "jules") {
|
|
797
|
+
throw new MissingApiKeyError();
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
if (ctx.dryRun) {
|
|
801
|
+
return {
|
|
802
|
+
id: sessionId,
|
|
803
|
+
status: "approved",
|
|
804
|
+
approved: true,
|
|
805
|
+
provider: providerSpec.name,
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if (providerSpec.type === "http") {
|
|
810
|
+
const approvePlanUrlTemplate = providerSpec.approvePlanUrl || `${providerSpec.url}/${sessionId}:approvePlan`;
|
|
811
|
+
const urlData = { sessionId, ...ctx };
|
|
812
|
+
const url = interpolateString(approvePlanUrlTemplate, urlData);
|
|
813
|
+
|
|
814
|
+
const headerData = { ...urlData, token: rawToken };
|
|
815
|
+
const headers = {};
|
|
816
|
+
for (const [k, v] of Object.entries(providerSpec.headers || {})) {
|
|
817
|
+
const val = interpolateString(v, headerData);
|
|
818
|
+
if (val.includes("\r") || val.includes("\n")) {
|
|
819
|
+
throw new Error(`CRITICAL: Header injection attempt detected in header "${k}"`);
|
|
820
|
+
}
|
|
821
|
+
headers[k] = val;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const requestedTimeout = Number(ctx.timeoutMs ?? config.timeoutMs ?? providerSpec.timeoutMs ?? 30_000);
|
|
825
|
+
const timeoutMs = Number.isFinite(requestedTimeout) && requestedTimeout > 0 ? requestedTimeout : 30_000;
|
|
826
|
+
const maxRetries = Number.isInteger(ctx.maxRetries) ? ctx.maxRetries : 3;
|
|
827
|
+
const initialDelayMs = Number.isInteger(ctx.initialDelayMs) ? ctx.initialDelayMs : 500;
|
|
828
|
+
|
|
829
|
+
let lastErr = null;
|
|
830
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
831
|
+
if (attempt > 0) {
|
|
832
|
+
const delay = initialDelayMs * Math.pow(2, attempt - 1);
|
|
833
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
let res;
|
|
837
|
+
try {
|
|
838
|
+
res = await fetch(url, {
|
|
839
|
+
method: "POST",
|
|
840
|
+
headers,
|
|
841
|
+
body: JSON.stringify(ctx.body || {}),
|
|
842
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
843
|
+
});
|
|
844
|
+
} catch (err) {
|
|
845
|
+
if (err.name === "TimeoutError" || err.name === "AbortError" || err.code === "ABORT_ERR") {
|
|
846
|
+
lastErr = new ProviderUnavailableError(`Provider HTTP Timeout (${timeoutMs}ms): ${err.message}`, { status: 504 });
|
|
847
|
+
if (attempt < maxRetries) continue;
|
|
848
|
+
throw lastErr;
|
|
849
|
+
}
|
|
850
|
+
throw err;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
if (!res.ok) {
|
|
854
|
+
const text = await res.text();
|
|
855
|
+
const cleanText = text.slice(0, 500);
|
|
856
|
+
const sanitizedText = redactSecrets(rawToken ? cleanText.split(rawToken).join("[REDACTED]") : cleanText);
|
|
857
|
+
|
|
858
|
+
if (res.status === 404) {
|
|
859
|
+
lastErr = new Error(`Provider HTTP Error (404 Not Found): ${sanitizedText}`);
|
|
860
|
+
lastErr.status = 404;
|
|
861
|
+
if (attempt < maxRetries) continue;
|
|
862
|
+
throw lastErr;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
if (res.status === 429) {
|
|
866
|
+
const retryAfterHeader = res.headers ? res.headers.get("retry-after") : null;
|
|
867
|
+
const retryAfterMs = parseRetryAfter(retryAfterHeader);
|
|
868
|
+
pool.markRateLimited(rawToken, retryAfterMs ?? 60000);
|
|
869
|
+
lastErr = new ProviderRateLimitError(`Provider HTTP Error (429): ${sanitizedText}`, {
|
|
870
|
+
retryAfterMs: retryAfterMs ?? 60000,
|
|
871
|
+
status: 429,
|
|
872
|
+
});
|
|
873
|
+
if (attempt < maxRetries) continue;
|
|
874
|
+
throw lastErr;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
if (res.status >= 500 && res.status < 600) {
|
|
878
|
+
lastErr = new ProviderUnavailableError(`Provider HTTP Error (${res.status}): ${sanitizedText}`, {
|
|
879
|
+
status: res.status,
|
|
880
|
+
});
|
|
881
|
+
if (attempt < maxRetries) continue;
|
|
882
|
+
throw lastErr;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
const err = new Error(`Provider HTTP Error (${res.status}): ${sanitizedText}`);
|
|
886
|
+
err.status = res.status;
|
|
887
|
+
throw err;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
let json;
|
|
891
|
+
try {
|
|
892
|
+
json = await res.json();
|
|
893
|
+
} catch (_) {
|
|
894
|
+
json = {};
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
return {
|
|
898
|
+
id: json.id || json.name || sessionId,
|
|
899
|
+
status: json.state || "approved",
|
|
900
|
+
approved: true,
|
|
901
|
+
raw: json,
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
if (providerSpec.type === "exec") {
|
|
907
|
+
return { id: sessionId, status: "approved", approved: true };
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
throw new Error(`Unsupported provider type: ${providerSpec.type}`);
|
|
911
|
+
},
|
|
567
912
|
};
|
|
568
913
|
}
|
|
569
914
|
|
|
@@ -630,8 +975,129 @@ export function createFailoverProvider(providers = ["jules"], config = {}) {
|
|
|
630
975
|
}
|
|
631
976
|
},
|
|
632
977
|
|
|
978
|
+
async getSession(sessionId, ctx = {}) {
|
|
979
|
+
const errors = [];
|
|
980
|
+
for (let i = 0; i < providerList.length; i++) {
|
|
981
|
+
const provider = providerList[i];
|
|
982
|
+
if (typeof provider.getSession !== "function") continue;
|
|
983
|
+
try {
|
|
984
|
+
const res = await provider.getSession(sessionId, ctx);
|
|
985
|
+
return { ...res, _routedProvider: provider.name, _failoverAttempts: i };
|
|
986
|
+
} catch (err) {
|
|
987
|
+
errors.push({ provider: provider.name, error: err });
|
|
988
|
+
const isRecoverable =
|
|
989
|
+
err instanceof ProviderRateLimitError ||
|
|
990
|
+
err instanceof ProviderUnavailableError ||
|
|
991
|
+
(err.status && err.status >= 500 && err.status < 600) ||
|
|
992
|
+
err.status === 429 ||
|
|
993
|
+
err.status === 404;
|
|
994
|
+
|
|
995
|
+
if (i === providerList.length - 1 || !isRecoverable) {
|
|
996
|
+
if (err && typeof err === "object") {
|
|
997
|
+
err._failoverErrors = errors;
|
|
998
|
+
}
|
|
999
|
+
throw err;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
},
|
|
1004
|
+
|
|
1005
|
+
async approvePlan(sessionId, ctx = {}) {
|
|
1006
|
+
const errors = [];
|
|
1007
|
+
for (let i = 0; i < providerList.length; i++) {
|
|
1008
|
+
const provider = providerList[i];
|
|
1009
|
+
if (typeof provider.approvePlan !== "function") continue;
|
|
1010
|
+
try {
|
|
1011
|
+
const res = await provider.approvePlan(sessionId, ctx);
|
|
1012
|
+
return { ...res, _routedProvider: provider.name, _failoverAttempts: i };
|
|
1013
|
+
} catch (err) {
|
|
1014
|
+
errors.push({ provider: provider.name, error: err });
|
|
1015
|
+
const isRecoverable =
|
|
1016
|
+
err instanceof ProviderRateLimitError ||
|
|
1017
|
+
err instanceof ProviderUnavailableError ||
|
|
1018
|
+
(err.status && err.status >= 500 && err.status < 600) ||
|
|
1019
|
+
err.status === 429 ||
|
|
1020
|
+
err.status === 404;
|
|
1021
|
+
|
|
1022
|
+
if (i === providerList.length - 1 || !isRecoverable) {
|
|
1023
|
+
if (err && typeof err === "object") {
|
|
1024
|
+
err._failoverErrors = errors;
|
|
1025
|
+
}
|
|
1026
|
+
throw err;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
},
|
|
1031
|
+
|
|
633
1032
|
validate() {
|
|
634
1033
|
return providerList.every((p) => typeof p.validate !== "function" || p.validate());
|
|
635
1034
|
},
|
|
636
1035
|
};
|
|
637
1036
|
}
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* Wraps a FAST-tier provider so its output is verified before being trusted.
|
|
1040
|
+
* Aggressive cost-router heuristics only stay safe if the cheap model cannot
|
|
1041
|
+
* silently commit broken syntax: a session that returns 200 OK from
|
|
1042
|
+
* `gemini-flash` after truncating a file mid-brace is otherwise
|
|
1043
|
+
* indistinguishable from a genuine success until CI runs.
|
|
1044
|
+
*
|
|
1045
|
+
* After the fast provider's dispatch resolves, this checks the working tree
|
|
1046
|
+
* for `.js`/`.mjs`/`.cjs` files changed since the last commit and parses each
|
|
1047
|
+
* through V8's native `node --check` (syntax only, never executed). A
|
|
1048
|
+
* SyntaxError silently re-dispatches the same task through `complexProvider`
|
|
1049
|
+
* instead of surfacing the broken result — the same "the cheap tier failed,
|
|
1050
|
+
* fall through" contract `createFailoverProvider` already uses for
|
|
1051
|
+
* rate-limits, just triggered by a verified bad output rather than a thrown
|
|
1052
|
+
* error. Sessions with no local working-tree diff (e.g. a remote HTTP
|
|
1053
|
+
* provider used as the fast tier) are a no-op: there is nothing on disk to
|
|
1054
|
+
* check, so the original result passes through unchanged.
|
|
1055
|
+
* @param {object} fastProvider - Provider object (from createProvider) whose output gets verified.
|
|
1056
|
+
* @param {object} complexProvider - Provider object to escalate to on a verified syntax failure.
|
|
1057
|
+
* @param {object} config - Loaded orchestrator config (used to resolve the repo root).
|
|
1058
|
+
* @returns {object} Provider object with the same interface as fastProvider.
|
|
1059
|
+
*/
|
|
1060
|
+
export function createSyntaxVerifiedProvider(fastProvider, complexProvider, config = {}) {
|
|
1061
|
+
return {
|
|
1062
|
+
name: fastProvider.name,
|
|
1063
|
+
|
|
1064
|
+
async dispatch(task = {}, ctx = {}) {
|
|
1065
|
+
const result = await fastProvider.dispatch(task, ctx);
|
|
1066
|
+
if (ctx.dryRun) return result;
|
|
1067
|
+
|
|
1068
|
+
const root = ctx.root || config._root || process.cwd();
|
|
1069
|
+
const changedFiles = listChangedSourceFiles(root);
|
|
1070
|
+
if (changedFiles.length === 0) return result;
|
|
1071
|
+
|
|
1072
|
+
const bad = findSyntaxError(root, changedFiles);
|
|
1073
|
+
if (!bad) return result;
|
|
1074
|
+
|
|
1075
|
+
console.warn(
|
|
1076
|
+
`[ROUTER ESCALATION] FAST tier ('${fastProvider.name}') emitted invalid syntax in ${bad.file} (${bad.error}). Escalating to '${complexProvider.name}'.`
|
|
1077
|
+
);
|
|
1078
|
+
const escalated = await complexProvider.dispatch(task, ctx);
|
|
1079
|
+
return {
|
|
1080
|
+
...escalated,
|
|
1081
|
+
_syntaxEscalated: true,
|
|
1082
|
+
_syntaxEscalationFile: bad.file,
|
|
1083
|
+
_syntaxEscalationReason: bad.error,
|
|
1084
|
+
};
|
|
1085
|
+
},
|
|
1086
|
+
|
|
1087
|
+
validate() {
|
|
1088
|
+
return typeof fastProvider.validate !== "function" || fastProvider.validate();
|
|
1089
|
+
},
|
|
1090
|
+
|
|
1091
|
+
resume(...args) {
|
|
1092
|
+
return fastProvider.resume(...args);
|
|
1093
|
+
},
|
|
1094
|
+
|
|
1095
|
+
getSession(...args) {
|
|
1096
|
+
return fastProvider.getSession(...args);
|
|
1097
|
+
},
|
|
1098
|
+
|
|
1099
|
+
approvePlan(...args) {
|
|
1100
|
+
return fastProvider.approvePlan(...args);
|
|
1101
|
+
},
|
|
1102
|
+
};
|
|
1103
|
+
}
|