@algosuite/vo-mcp 0.2.0-beta.2 → 0.2.0-beta.4
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/README.md +154 -153
- package/bin/vo-mcp +38 -38
- package/dist/autostart-cli.js +79 -2
- package/dist/autostart-cli.js.map +2 -2
- package/dist/cli.js +1020 -745
- package/dist/cli.js.map +4 -4
- package/dist/index.js +111 -96
- package/dist/index.js.map +3 -3
- package/dist/install-cli.js +88 -26
- package/dist/install-cli.js.map +3 -3
- package/dist/login-cli.js +0 -0
- package/dist/login-cli.js.map +1 -1
- package/dist/pair-cli.js +0 -0
- package/dist/pair-cli.js.map +1 -1
- package/dist/runner-cli.js +848 -186
- package/dist/runner-cli.js.map +4 -4
- package/dist/set-key-cli.js +0 -0
- package/dist/set-key-cli.js.map +1 -1
- package/package.json +1 -1
package/dist/runner-cli.js
CHANGED
|
@@ -245,6 +245,39 @@ function cleanupFixWorktree(worktreeName) {
|
|
|
245
245
|
spawnSync("git", ["worktree", "remove", "--force", worktreeDir], { cwd: root, timeout: 12e4 });
|
|
246
246
|
TRACKED_WORKTREES.delete(worktreeName);
|
|
247
247
|
}
|
|
248
|
+
function finalizeWorktree(worktreeName, meta = {}) {
|
|
249
|
+
if (!worktreeName) return;
|
|
250
|
+
if (meta.preserveReason) {
|
|
251
|
+
preserveFailedWorktree(worktreeName, { ...meta, reason: meta.preserveReason });
|
|
252
|
+
} else {
|
|
253
|
+
cleanupFixWorktree(worktreeName);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
function preserveFailedWorktree(worktreeName, meta = {}) {
|
|
257
|
+
if (!worktreeName) return null;
|
|
258
|
+
const tracked = TRACKED_WORKTREES.get(worktreeName);
|
|
259
|
+
const root = tracked ? tracked.root : repoRoot();
|
|
260
|
+
const worktreeDir = tracked ? tracked.worktreeDir : path.join(root, ".agent-worktrees", worktreeName);
|
|
261
|
+
const entry = {
|
|
262
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
263
|
+
worktreeName,
|
|
264
|
+
worktreeDir,
|
|
265
|
+
branch: meta.branch || `vo/${worktreeName}`,
|
|
266
|
+
taskId: meta.taskId || null,
|
|
267
|
+
repo: meta.repo || null,
|
|
268
|
+
prompt: String(meta.prompt || "").slice(0, 300),
|
|
269
|
+
reason: String(meta.reason || "task failed").slice(0, 300)
|
|
270
|
+
};
|
|
271
|
+
try {
|
|
272
|
+
const ledger = path.join(root, ".agent-worktrees", "recovery-ledger.jsonl");
|
|
273
|
+
fs.mkdirSync(path.dirname(ledger), { recursive: true });
|
|
274
|
+
fs.appendFileSync(ledger, JSON.stringify(entry) + "\n", "utf8");
|
|
275
|
+
console.error(`[vo-mcp runner] Preserved worktree ${worktreeName} (reason: ${entry.reason})`);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
console.error(`[vo-mcp runner] Failed to write recovery ledger: ${err.message}`);
|
|
278
|
+
}
|
|
279
|
+
return entry;
|
|
280
|
+
}
|
|
248
281
|
|
|
249
282
|
// src/runner/spend-cap-shim.mjs
|
|
250
283
|
function resolveSpendCapUsd(value = process.env.VO_SPEND_CAP_USD ?? process.env.VO_CODE_DISPATCH_CAP_USD) {
|
|
@@ -387,11 +420,16 @@ function createControlPlaneClient({
|
|
|
387
420
|
* authenticated operator so the web shows a TRUE "runner online" signal.
|
|
388
421
|
* Best-effort caller; throws on 401/non-ok so the daemon can log + retry.
|
|
389
422
|
*/
|
|
390
|
-
async postHeartbeat({ runnerId, uptimeSec, activeTasks, version }) {
|
|
423
|
+
async postHeartbeat({ runnerId, operatorId, uptimeSec, activeTasks, version, servedRepos, servedOperators }) {
|
|
391
424
|
const body = { runner_id: runnerId };
|
|
425
|
+
if (operatorId) body.operator_id = operatorId;
|
|
392
426
|
if (typeof uptimeSec === "number") body.uptime_sec = uptimeSec;
|
|
393
427
|
if (typeof activeTasks === "number") body.active_tasks = activeTasks;
|
|
394
428
|
if (version) body.version = version;
|
|
429
|
+
if (Array.isArray(servedRepos) && servedRepos.length > 0) body.served_repos = servedRepos;
|
|
430
|
+
if (Array.isArray(servedOperators) && servedOperators.length > 0) {
|
|
431
|
+
body.served_operator_ids = servedOperators;
|
|
432
|
+
}
|
|
395
433
|
const res = await req("POST", "/api/v1/runner/heartbeat", body);
|
|
396
434
|
if (res.status === 401) {
|
|
397
435
|
cachedFirebaseToken = null;
|
|
@@ -448,10 +486,11 @@ function createControlPlaneClient({
|
|
|
448
486
|
|
|
449
487
|
// ../../scripts/virtual-office/code-runner/claude-runner.mjs
|
|
450
488
|
import { spawn } from "node:child_process";
|
|
451
|
-
import { spawnSync as
|
|
489
|
+
import { spawnSync as spawnSync4 } from "node:child_process";
|
|
452
490
|
|
|
453
491
|
// ../../scripts/virtual-office/code-runner/anthropic-key-store.mjs
|
|
454
492
|
import { createRequire as createRequire2 } from "node:module";
|
|
493
|
+
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
455
494
|
var require2 = createRequire2(import.meta.url);
|
|
456
495
|
var KEY_SERVICE = "algosuite-vo";
|
|
457
496
|
var KEY_ACCOUNT = "anthropic-api-key";
|
|
@@ -475,11 +514,122 @@ function getAnthropicKey({ EntryCtor = defaultEntryCtor() } = {}) {
|
|
|
475
514
|
return null;
|
|
476
515
|
}
|
|
477
516
|
}
|
|
517
|
+
var PREFER_LOGIN_ENV = "VO_RUNNER_PREFER_LOGIN";
|
|
518
|
+
function isTruthyFlag(v) {
|
|
519
|
+
const s = String(v ?? "").trim().toLowerCase();
|
|
520
|
+
return s === "1" || s === "true" || s === "yes" || s === "on";
|
|
521
|
+
}
|
|
478
522
|
function withAnthropicKey(baseEnv = {}, { getKey = getAnthropicKey } = {}) {
|
|
523
|
+
if (isTruthyFlag(baseEnv[PREFER_LOGIN_ENV])) {
|
|
524
|
+
const next = { ...baseEnv };
|
|
525
|
+
delete next.ANTHROPIC_API_KEY;
|
|
526
|
+
return next;
|
|
527
|
+
}
|
|
479
528
|
if (baseEnv.ANTHROPIC_API_KEY) return { ...baseEnv };
|
|
480
529
|
const key = getKey();
|
|
481
530
|
return key ? { ...baseEnv, ANTHROPIC_API_KEY: key } : { ...baseEnv };
|
|
482
531
|
}
|
|
532
|
+
function describeAnthropicAuthSource(baseEnv = {}, { getKey = getAnthropicKey } = {}) {
|
|
533
|
+
if (isTruthyFlag(baseEnv[PREFER_LOGIN_ENV])) {
|
|
534
|
+
return "claude auth login (VO_RUNNER_PREFER_LOGIN set \u2014 any API key ignored)";
|
|
535
|
+
}
|
|
536
|
+
if (baseEnv.ANTHROPIC_API_KEY) return "ANTHROPIC_API_KEY from environment";
|
|
537
|
+
if (getKey()) return "ANTHROPIC_API_KEY from OS keychain";
|
|
538
|
+
return "claude auth login session (no API key set)";
|
|
539
|
+
}
|
|
540
|
+
var AUTH_ERROR_RE = /\b401\b|invalid[^.]{0,24}(authentication|credential)|authentication_error|unauthorized|not[ _-]?authenticated/i;
|
|
541
|
+
function augmentAuthError(summary) {
|
|
542
|
+
const s = String(summary ?? "");
|
|
543
|
+
if (!AUTH_ERROR_RE.test(s)) return s;
|
|
544
|
+
return `${s}
|
|
545
|
+
\u21B3 Anthropic auth failed on the runner. The \`claude\` CLI is a SEPARATE install/login from the Claude Desktop app and the Claude Code IDE extension \u2014 signing into those does NOT authenticate it. Fix: run \`claude auth login\` (Claude subscription) on the runner machine, or clear any stale ANTHROPIC_API_KEY (env / OS keychain / .env.local) and set VO_RUNNER_PREFER_LOGIN=1 \u2014 then restart the runner. Verify with \`claude -p "say hi"\`.`;
|
|
546
|
+
}
|
|
547
|
+
function probeClaudeLoginState({ spawn: spawn2 = spawnSync2 } = {}) {
|
|
548
|
+
try {
|
|
549
|
+
const st = spawn2("claude", ["auth", "status"], {
|
|
550
|
+
shell: process.platform === "win32",
|
|
551
|
+
windowsHide: true,
|
|
552
|
+
timeout: 5e3,
|
|
553
|
+
encoding: "utf8"
|
|
554
|
+
});
|
|
555
|
+
const parsed = JSON.parse(String(st.stdout || "").trim() || "{}");
|
|
556
|
+
return typeof parsed.loggedIn === "boolean" ? parsed.loggedIn : null;
|
|
557
|
+
} catch {
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ../../scripts/virtual-office/code-runner/sandbox/sandbox-docker.mjs
|
|
563
|
+
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
564
|
+
var DEFAULT_SANDBOX_IMAGE = "vo-agent-sandbox";
|
|
565
|
+
function buildDockerArgs({
|
|
566
|
+
worktreeDir,
|
|
567
|
+
image = DEFAULT_SANDBOX_IMAGE,
|
|
568
|
+
agentBin = "claude",
|
|
569
|
+
agentArgs = [],
|
|
570
|
+
passEnv = ["ANTHROPIC_API_KEY"],
|
|
571
|
+
network = "bridge",
|
|
572
|
+
memory = "4g",
|
|
573
|
+
cpus = "2",
|
|
574
|
+
pids = "512",
|
|
575
|
+
user,
|
|
576
|
+
shadowGit = true,
|
|
577
|
+
readOnlyWork = false,
|
|
578
|
+
extraDockerArgs = []
|
|
579
|
+
} = {}) {
|
|
580
|
+
if (!worktreeDir) throw new Error("buildDockerArgs: worktreeDir is required");
|
|
581
|
+
const args = [
|
|
582
|
+
"run",
|
|
583
|
+
"--rm",
|
|
584
|
+
"-i",
|
|
585
|
+
// keep stdin open so the runner can feed the prompt (injection-safe)
|
|
586
|
+
"--network",
|
|
587
|
+
String(network),
|
|
588
|
+
"--cap-drop",
|
|
589
|
+
"ALL",
|
|
590
|
+
"--security-opt",
|
|
591
|
+
"no-new-privileges",
|
|
592
|
+
"--memory",
|
|
593
|
+
String(memory),
|
|
594
|
+
"--cpus",
|
|
595
|
+
String(cpus),
|
|
596
|
+
"--pids-limit",
|
|
597
|
+
String(pids),
|
|
598
|
+
// Read-only root + tmpfs scratch: the ONLY persistent writable path is the
|
|
599
|
+
// host-backed /work mount, so the agent can't tamper with the image or
|
|
600
|
+
// stash anything off-worktree.
|
|
601
|
+
"--read-only",
|
|
602
|
+
"--tmpfs",
|
|
603
|
+
"/tmp:rw,nosuid,nodev",
|
|
604
|
+
"-e",
|
|
605
|
+
"HOME=/tmp/agent-home"
|
|
606
|
+
];
|
|
607
|
+
if (shadowGit) args.push("--tmpfs", "/work/.git:rw,nosuid,nodev,size=2m");
|
|
608
|
+
if (user) args.push("--user", String(user));
|
|
609
|
+
for (const k of passEnv) {
|
|
610
|
+
if (k && /^[A-Z_][A-Z0-9_]*$/i.test(k)) args.push("-e", k);
|
|
611
|
+
}
|
|
612
|
+
args.push("-v", `${worktreeDir}:/work${readOnlyWork ? ":ro" : ""}`, "-w", "/work");
|
|
613
|
+
args.push(...extraDockerArgs);
|
|
614
|
+
args.push(image, agentBin, ...agentArgs);
|
|
615
|
+
return args;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// ../../scripts/virtual-office/code-runner/context7-mcp.mjs
|
|
619
|
+
var CONTEXT7_URL = "https://mcp.context7.com/mcp";
|
|
620
|
+
function context7McpConfig(env2 = process.env) {
|
|
621
|
+
if (env2.VO_ENABLE_CONTEXT7 !== "1") return null;
|
|
622
|
+
const url = env2.VO_CONTEXT7_URL && env2.VO_CONTEXT7_URL.trim() || CONTEXT7_URL;
|
|
623
|
+
const server = { type: "http", url };
|
|
624
|
+
if (env2.CONTEXT7_API_KEY && env2.CONTEXT7_API_KEY.trim()) {
|
|
625
|
+
server.headers = { CONTEXT7_API_KEY: env2.CONTEXT7_API_KEY.trim() };
|
|
626
|
+
}
|
|
627
|
+
return { mcpServers: { context7: server } };
|
|
628
|
+
}
|
|
629
|
+
function context7McpArgs(env2 = process.env) {
|
|
630
|
+
const cfg = context7McpConfig(env2);
|
|
631
|
+
return cfg ? ["--mcp-config", JSON.stringify(cfg)] : [];
|
|
632
|
+
}
|
|
483
633
|
|
|
484
634
|
// ../../scripts/virtual-office/code-runner/claude-runner.mjs
|
|
485
635
|
var DEFAULT_PERMISSION_MODE = "acceptEdits";
|
|
@@ -516,7 +666,7 @@ function parseStreamEvent(line) {
|
|
|
516
666
|
}
|
|
517
667
|
return null;
|
|
518
668
|
}
|
|
519
|
-
function buildClaudeArgs({ permissionMode = DEFAULT_PERMISSION_MODE, maxTurns, model } = {}) {
|
|
669
|
+
function buildClaudeArgs({ permissionMode = DEFAULT_PERMISSION_MODE, maxTurns, model, env: env2 = process.env } = {}) {
|
|
520
670
|
const args = [
|
|
521
671
|
"-p",
|
|
522
672
|
"--output-format",
|
|
@@ -531,6 +681,7 @@ function buildClaudeArgs({ permissionMode = DEFAULT_PERMISSION_MODE, maxTurns, m
|
|
|
531
681
|
if (model) {
|
|
532
682
|
args.push("--model", String(model));
|
|
533
683
|
}
|
|
684
|
+
args.push(...context7McpArgs(env2));
|
|
534
685
|
return args;
|
|
535
686
|
}
|
|
536
687
|
function runAgentTask({
|
|
@@ -547,23 +698,39 @@ function runAgentTask({
|
|
|
547
698
|
shouldCancel = async () => false,
|
|
548
699
|
cancelPollMs = 5e3,
|
|
549
700
|
maxWallClockMs = 0,
|
|
550
|
-
spawnImpl = spawn
|
|
701
|
+
spawnImpl = spawn,
|
|
702
|
+
sandbox = null
|
|
551
703
|
}) {
|
|
552
|
-
return new Promise((
|
|
704
|
+
return new Promise((resolve2) => {
|
|
553
705
|
const args = runner.buildArgs({ permissionMode, maxTurns, model, prompt });
|
|
554
|
-
const
|
|
706
|
+
const spawnEnv = typeof runner.applyAuthEnv === "function" ? runner.applyAuthEnv(env2) : env2;
|
|
707
|
+
if (typeof runner.describeAuth === "function") {
|
|
708
|
+
try {
|
|
709
|
+
console.error(`[runner] agent auth: ${runner.describeAuth(spawnEnv)}`);
|
|
710
|
+
} catch {
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
let spawnBin = bin;
|
|
714
|
+
let spawnArgs = args;
|
|
715
|
+
let spawnOpts = runner.getSpawnOptions({ bin: spawnBin });
|
|
716
|
+
if (sandbox && sandbox.mode === "docker") {
|
|
717
|
+
spawnArgs = buildDockerArgs({
|
|
718
|
+
worktreeDir: cwd,
|
|
719
|
+
image: sandbox.image,
|
|
720
|
+
agentBin: bin,
|
|
721
|
+
agentArgs: args,
|
|
722
|
+
network: sandbox.network,
|
|
723
|
+
user: sandbox.user,
|
|
724
|
+
...sandbox.passEnv ? { passEnv: sandbox.passEnv } : {}
|
|
725
|
+
});
|
|
726
|
+
spawnBin = sandbox.dockerBin || "docker";
|
|
727
|
+
spawnOpts = { windowsHide: true };
|
|
728
|
+
}
|
|
729
|
+
const child = spawnImpl(spawnBin, spawnArgs, {
|
|
555
730
|
cwd,
|
|
556
|
-
|
|
557
|
-
// OS keychain when not already set (Claude→ANTHROPIC_API_KEY via
|
|
558
|
-
// anthropic-key-store, Codex→OPENAI_API_KEY, …). Explicit env wins; no key
|
|
559
|
-
// stored → unchanged (the CLI's own login as before).
|
|
560
|
-
env: typeof runner.applyAuthEnv === "function" ? runner.applyAuthEnv(env2) : env2,
|
|
731
|
+
env: spawnEnv,
|
|
561
732
|
stdio: ["pipe", "pipe", "pipe"],
|
|
562
|
-
|
|
563
|
-
// Windows the CLI is a `.cmd` shim → spawn needs shell:true to resolve it
|
|
564
|
-
// (without it: ENOENT). Safe because the prompt goes via stdin below,
|
|
565
|
-
// never argv, so the shell never sees untrusted input.
|
|
566
|
-
...runner.getSpawnOptions()
|
|
733
|
+
...spawnOpts
|
|
567
734
|
});
|
|
568
735
|
try {
|
|
569
736
|
child.stdin.write(String(prompt));
|
|
@@ -622,7 +789,7 @@ function runAgentTask({
|
|
|
622
789
|
child.on("error", (err) => {
|
|
623
790
|
clearInterval(poll);
|
|
624
791
|
if (wallTimer) clearTimeout(wallTimer);
|
|
625
|
-
|
|
792
|
+
resolve2({ ...result, ok: false, summary: `spawn error: ${err.message}` });
|
|
626
793
|
});
|
|
627
794
|
const poll = setInterval(() => {
|
|
628
795
|
Promise.resolve().then(() => shouldCancel()).then((cancel) => {
|
|
@@ -638,7 +805,7 @@ function runAgentTask({
|
|
|
638
805
|
clearInterval(poll);
|
|
639
806
|
if (wallTimer) clearTimeout(wallTimer);
|
|
640
807
|
if (timedOut) {
|
|
641
|
-
|
|
808
|
+
resolve2({
|
|
642
809
|
...result,
|
|
643
810
|
ok: false,
|
|
644
811
|
timedOut: true,
|
|
@@ -647,13 +814,13 @@ function runAgentTask({
|
|
|
647
814
|
return;
|
|
648
815
|
}
|
|
649
816
|
if (killed) {
|
|
650
|
-
|
|
817
|
+
resolve2({ ...result, ok: false, killed: true, summary: "cancelled by operator" });
|
|
651
818
|
return;
|
|
652
819
|
}
|
|
653
820
|
if (!result.summary && code !== 0) {
|
|
654
821
|
result.summary = stderrTail.slice(-500) || `${bin} exited ${code}`;
|
|
655
822
|
}
|
|
656
|
-
|
|
823
|
+
resolve2({ ...result, ok: result.ok && code === 0, summary: augmentAuthError(result.summary) });
|
|
657
824
|
});
|
|
658
825
|
});
|
|
659
826
|
}
|
|
@@ -681,6 +848,10 @@ var ClaudeRunner = class {
|
|
|
681
848
|
applyAuthEnv(env2 = process.env) {
|
|
682
849
|
return withAnthropicKey(env2);
|
|
683
850
|
}
|
|
851
|
+
/** Describe which Anthropic auth source the spawn will use (for runner logs). */
|
|
852
|
+
describeAuth(env2 = process.env) {
|
|
853
|
+
return describeAnthropicAuthSource(env2);
|
|
854
|
+
}
|
|
684
855
|
/**
|
|
685
856
|
* Best-effort auth check: is `claude` on PATH and can we verify login?
|
|
686
857
|
* Never throws. If we can't cheaply detect auth, we return installed:true
|
|
@@ -688,30 +859,34 @@ var ClaudeRunner = class {
|
|
|
688
859
|
*/
|
|
689
860
|
async checkAuth() {
|
|
690
861
|
try {
|
|
691
|
-
const
|
|
862
|
+
const probe = spawnSync4("claude", ["--version"], {
|
|
692
863
|
shell: process.platform === "win32",
|
|
693
864
|
windowsHide: true,
|
|
694
865
|
timeout: 3e3,
|
|
695
866
|
stdio: "ignore"
|
|
696
867
|
});
|
|
697
|
-
if (error) {
|
|
868
|
+
if (probe.error) {
|
|
698
869
|
return {
|
|
699
870
|
installed: false,
|
|
700
871
|
authenticated: false,
|
|
701
|
-
message:
|
|
872
|
+
message: "claude CLI not found on PATH \u2014 it is a SEPARATE install from the Claude Desktop app and the Claude Code IDE extension. Install: npm install -g @anthropic-ai/claude-code, then sign in: claude auth login."
|
|
702
873
|
};
|
|
703
874
|
}
|
|
704
|
-
if (status !== 0) {
|
|
875
|
+
if (probe.status !== 0) {
|
|
876
|
+
return { installed: true, authenticated: false, message: "claude binary exists but --version failed (auth unclear)" };
|
|
877
|
+
}
|
|
878
|
+
const loggedIn = probeClaudeLoginState();
|
|
879
|
+
if (loggedIn === false) {
|
|
705
880
|
return {
|
|
706
881
|
installed: true,
|
|
707
882
|
authenticated: false,
|
|
708
|
-
message: "claude
|
|
883
|
+
message: "claude CLI is installed but NOT logged in \u2014 its login is SEPARATE from the Claude Desktop app and the Claude Code IDE extension. Run: claude auth login (Claude subscription), then restart the runner."
|
|
709
884
|
};
|
|
710
885
|
}
|
|
711
886
|
return {
|
|
712
887
|
installed: true,
|
|
713
888
|
authenticated: true,
|
|
714
|
-
message: "claude binary found (auth check is best-effort)"
|
|
889
|
+
message: loggedIn === true ? "claude CLI installed and logged in (claude auth status)" : "claude binary found (login state unknown \u2014 auth check is best-effort)"
|
|
715
890
|
};
|
|
716
891
|
} catch (err) {
|
|
717
892
|
return {
|
|
@@ -725,7 +900,9 @@ var ClaudeRunner = class {
|
|
|
725
900
|
var claudeRunner = new ClaudeRunner();
|
|
726
901
|
|
|
727
902
|
// ../../scripts/virtual-office/code-runner/codex-runner.mjs
|
|
728
|
-
import { spawnSync as
|
|
903
|
+
import { spawnSync as spawnSync5 } from "node:child_process";
|
|
904
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
905
|
+
import { join as join2 } from "node:path";
|
|
729
906
|
|
|
730
907
|
// ../../scripts/virtual-office/code-runner/agent-key-store.mjs
|
|
731
908
|
import { createRequire as createRequire3 } from "node:module";
|
|
@@ -784,8 +961,34 @@ function withAgentKey(provider, baseEnv = {}, { getKey = getAgentKey } = {}) {
|
|
|
784
961
|
}
|
|
785
962
|
|
|
786
963
|
// ../../scripts/virtual-office/code-runner/codex-runner.mjs
|
|
964
|
+
function resolveCodexBinary({
|
|
965
|
+
env: env2 = process.env,
|
|
966
|
+
platform = process.platform,
|
|
967
|
+
exists = existsSync2
|
|
968
|
+
} = {}) {
|
|
969
|
+
if (platform !== "win32") return "codex";
|
|
970
|
+
const appData = String(env2.APPDATA || "").trim();
|
|
971
|
+
if (appData) {
|
|
972
|
+
const npmVendorBinary = join2(
|
|
973
|
+
appData,
|
|
974
|
+
"npm",
|
|
975
|
+
"node_modules",
|
|
976
|
+
"@openai",
|
|
977
|
+
"codex",
|
|
978
|
+
"node_modules",
|
|
979
|
+
"@openai",
|
|
980
|
+
"codex-win32-x64",
|
|
981
|
+
"vendor",
|
|
982
|
+
"x86_64-pc-windows-msvc",
|
|
983
|
+
"bin",
|
|
984
|
+
"codex.exe"
|
|
985
|
+
);
|
|
986
|
+
if (exists(npmVendorBinary)) return npmVendorBinary;
|
|
987
|
+
}
|
|
988
|
+
return "codex";
|
|
989
|
+
}
|
|
787
990
|
function buildCodexArgs({ model } = {}) {
|
|
788
|
-
const args = ["exec", "--json", "--full-
|
|
991
|
+
const args = ["exec", "--json", "-c", 'approval_policy="never"', "--sandbox", "danger-full-access"];
|
|
789
992
|
if (model) {
|
|
790
993
|
args.push("--model", String(model));
|
|
791
994
|
}
|
|
@@ -831,7 +1034,7 @@ function parseCodexEvent(line) {
|
|
|
831
1034
|
}
|
|
832
1035
|
var CodexRunner = class {
|
|
833
1036
|
get binary() {
|
|
834
|
-
return
|
|
1037
|
+
return resolveCodexBinary();
|
|
835
1038
|
}
|
|
836
1039
|
buildArgs(opts = {}) {
|
|
837
1040
|
return buildCodexArgs(opts);
|
|
@@ -839,9 +1042,10 @@ var CodexRunner = class {
|
|
|
839
1042
|
parseEvent(line) {
|
|
840
1043
|
return parseCodexEvent(line);
|
|
841
1044
|
}
|
|
842
|
-
getSpawnOptions() {
|
|
1045
|
+
getSpawnOptions({ bin } = {}) {
|
|
1046
|
+
const effectiveBin = String(bin || this.binary || "");
|
|
843
1047
|
return {
|
|
844
|
-
shell: process.platform === "win32",
|
|
1048
|
+
shell: process.platform === "win32" && !/\.exe$/i.test(effectiveBin),
|
|
845
1049
|
windowsHide: true
|
|
846
1050
|
};
|
|
847
1051
|
}
|
|
@@ -857,8 +1061,9 @@ var CodexRunner = class {
|
|
|
857
1061
|
/** Best-effort: is `codex` on PATH? Never throws. */
|
|
858
1062
|
async checkAuth() {
|
|
859
1063
|
try {
|
|
860
|
-
const
|
|
861
|
-
|
|
1064
|
+
const bin = this.binary;
|
|
1065
|
+
const { status, error } = spawnSync5(bin, ["--version"], {
|
|
1066
|
+
...this.getSpawnOptions({ bin }),
|
|
862
1067
|
windowsHide: true,
|
|
863
1068
|
timeout: 3e3,
|
|
864
1069
|
stdio: "ignore"
|
|
@@ -878,7 +1083,7 @@ var CodexRunner = class {
|
|
|
878
1083
|
var codexRunner = new CodexRunner();
|
|
879
1084
|
|
|
880
1085
|
// ../../scripts/virtual-office/code-runner/cursor-runner.mjs
|
|
881
|
-
import { spawnSync as
|
|
1086
|
+
import { spawnSync as spawnSync6 } from "node:child_process";
|
|
882
1087
|
function buildCursorArgs({ model, prompt } = {}) {
|
|
883
1088
|
const args = ["-p", "--output-format", "stream-json", "--force"];
|
|
884
1089
|
if (model) {
|
|
@@ -952,7 +1157,7 @@ var CursorRunner = class {
|
|
|
952
1157
|
/** Best-effort: is `cursor-agent` on PATH? Never throws. */
|
|
953
1158
|
async checkAuth() {
|
|
954
1159
|
try {
|
|
955
|
-
const { status, error } =
|
|
1160
|
+
const { status, error } = spawnSync6("cursor-agent", ["--version"], {
|
|
956
1161
|
shell: process.platform === "win32",
|
|
957
1162
|
windowsHide: true,
|
|
958
1163
|
timeout: 3e3,
|
|
@@ -1008,12 +1213,36 @@ var RUNNERS = {
|
|
|
1008
1213
|
function listAgents() {
|
|
1009
1214
|
return Object.keys(RUNNERS);
|
|
1010
1215
|
}
|
|
1216
|
+
function inferAgentFromBin(bin) {
|
|
1217
|
+
const raw = String(bin || "").trim().toLowerCase();
|
|
1218
|
+
if (!raw) return null;
|
|
1219
|
+
const base = raw.replace(/\\/g, "/").split("/").pop() || raw;
|
|
1220
|
+
if (base.includes("codex")) return "codex";
|
|
1221
|
+
if (base.includes("cursor-agent") || base === "cursor" || base.startsWith("cursor.")) return "cursor";
|
|
1222
|
+
if (base.includes("claude")) return "claude";
|
|
1223
|
+
return null;
|
|
1224
|
+
}
|
|
1225
|
+
function inferAgentFromEnvBin(env2) {
|
|
1226
|
+
for (const bin of [env2.VO_CODE_RUNNER_BIN, env2.VO_CODE_RUNNER_CLAUDE_BIN]) {
|
|
1227
|
+
const agent = inferAgentFromBin(bin);
|
|
1228
|
+
if (agent) return { agent, bin };
|
|
1229
|
+
}
|
|
1230
|
+
return null;
|
|
1231
|
+
}
|
|
1011
1232
|
function resolveRunner(env2 = process.env, { warn = () => {
|
|
1012
1233
|
} } = {}) {
|
|
1013
|
-
const
|
|
1234
|
+
const explicitAgent = String(env2.VO_CODE_RUNNER_AGENT || env2.VO_AGENT || "").trim();
|
|
1235
|
+
const inferred = explicitAgent ? null : inferAgentFromEnvBin(env2);
|
|
1236
|
+
const raw = String(explicitAgent || inferred?.agent || DEFAULT_AGENT).trim().toLowerCase();
|
|
1014
1237
|
let agent = raw;
|
|
1015
1238
|
let fellBack = false;
|
|
1016
1239
|
let runner = RUNNERS[agent];
|
|
1240
|
+
if (inferred && runner) {
|
|
1241
|
+
try {
|
|
1242
|
+
warn(`VO_CODE_RUNNER_AGENT not set; inferred "${agent}" from runner binary "${inferred.bin}"`);
|
|
1243
|
+
} catch {
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1017
1246
|
if (!runner) {
|
|
1018
1247
|
try {
|
|
1019
1248
|
warn(`unknown VO_CODE_RUNNER_AGENT "${raw}"; falling back to "${DEFAULT_AGENT}" (known: ${listAgents().join(", ")})`);
|
|
@@ -1024,14 +1253,14 @@ function resolveRunner(env2 = process.env, { warn = () => {
|
|
|
1024
1253
|
fellBack = true;
|
|
1025
1254
|
}
|
|
1026
1255
|
validateAgentRunner(runner);
|
|
1027
|
-
const runnerBin = env2.VO_CODE_RUNNER_BIN || (agent === "claude" ? env2.VO_CODE_RUNNER_CLAUDE_BIN : "") || runner.binary;
|
|
1256
|
+
const runnerBin = env2.VO_CODE_RUNNER_BIN || (inferred && inferred.agent === agent ? inferred.bin : "") || (agent === "claude" ? env2.VO_CODE_RUNNER_CLAUDE_BIN : "") || runner.binary;
|
|
1028
1257
|
return { agent, runner, runnerBin, fellBack };
|
|
1029
1258
|
}
|
|
1030
1259
|
|
|
1031
1260
|
// ../../scripts/virtual-office/code-runner/rate-limit-resume.mjs
|
|
1032
1261
|
import { appendFileSync, mkdirSync as mkdirSync2 } from "node:fs";
|
|
1033
1262
|
import { homedir as homedir2 } from "node:os";
|
|
1034
|
-
import { join as
|
|
1263
|
+
import { join as join3, dirname as dirname2 } from "node:path";
|
|
1035
1264
|
|
|
1036
1265
|
// ../../scripts/ci/rate-limit-detector-core.mjs
|
|
1037
1266
|
var RATE_LIMIT_RE = /\b(?:usage limit reached|usage limit|rate[ _-]?limit(?:ed|_error)?|too many requests|\b429\b|limit (?:will )?reset)/i;
|
|
@@ -1066,7 +1295,7 @@ function detectRateLimit(text, { now = null } = {}) {
|
|
|
1066
1295
|
|
|
1067
1296
|
// ../../scripts/virtual-office/code-runner/rate-limit-resume.mjs
|
|
1068
1297
|
function resumeQueuePath() {
|
|
1069
|
-
return
|
|
1298
|
+
return join3(homedir2(), ".claude", "resume-queue.jsonl");
|
|
1070
1299
|
}
|
|
1071
1300
|
function buildResumeEntry({ task = {}, resumeAfter = null, summary = "", at } = {}) {
|
|
1072
1301
|
return {
|
|
@@ -1128,9 +1357,65 @@ function classifyFailureForResume({
|
|
|
1128
1357
|
}
|
|
1129
1358
|
|
|
1130
1359
|
// ../../scripts/virtual-office/code-runner/publish.mjs
|
|
1131
|
-
import { spawnSync as
|
|
1360
|
+
import { spawnSync as spawnSync7 } from "node:child_process";
|
|
1361
|
+
|
|
1362
|
+
// ../../scripts/virtual-office/code-runner/git-resilience.mjs
|
|
1363
|
+
var TRANSIENT_CODES = /* @__PURE__ */ new Set([
|
|
1364
|
+
"ETIMEDOUT",
|
|
1365
|
+
"ECONNRESET",
|
|
1366
|
+
"ECONNREFUSED",
|
|
1367
|
+
"ENOTFOUND",
|
|
1368
|
+
"EAI_AGAIN",
|
|
1369
|
+
"ENETUNREACH",
|
|
1370
|
+
"EHOSTUNREACH",
|
|
1371
|
+
"EPIPE"
|
|
1372
|
+
]);
|
|
1373
|
+
var TRANSIENT_RE = /\b(?:ETIMEDOUT|ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH|EPIPE)\b|\b50[234]\b|timed?[ _-]?out|connection (?:reset|refused|closed|timed out)|could not resolve host|couldn't resolve host|failed to connect|unable to access|temporary failure|remote end hung up|early eof|rpc failed|the remote end hung up unexpectedly|operation timed out|gnutls_handshake|ssl_read|recv failure/i;
|
|
1374
|
+
function isTransientGitError(err) {
|
|
1375
|
+
if (!err) return false;
|
|
1376
|
+
if (err.code && TRANSIENT_CODES.has(err.code)) return true;
|
|
1377
|
+
const msg = String(err.message || err);
|
|
1378
|
+
if (/non-fast-forward|fast[- ]forward|\(fetch first\)|permission denied|authentication failed|\b40[134]\b|merge conflict|nothing to commit|did not match any/i.test(msg)) {
|
|
1379
|
+
return false;
|
|
1380
|
+
}
|
|
1381
|
+
return TRANSIENT_RE.test(msg);
|
|
1382
|
+
}
|
|
1383
|
+
function computeGitBackoffMs(attempt, { baseMs = 5e3, capMs = 3e4, rng = Math.random } = {}) {
|
|
1384
|
+
const exp = Math.min(capMs, baseMs * Math.pow(2, Math.max(0, attempt)));
|
|
1385
|
+
return Math.floor(exp / 2 + rng() * (exp / 2));
|
|
1386
|
+
}
|
|
1387
|
+
function sleepSync(ms) {
|
|
1388
|
+
if (!(ms > 0)) return;
|
|
1389
|
+
try {
|
|
1390
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
1391
|
+
} catch {
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
function retryTransient(fn, { attempts = 3, baseMs = 5e3, capMs = 3e4, sleep: sleep2 = sleepSync, rng = Math.random, onRetry } = {}) {
|
|
1395
|
+
let lastErr;
|
|
1396
|
+
for (let i = 0; i < attempts; i += 1) {
|
|
1397
|
+
try {
|
|
1398
|
+
return fn(i);
|
|
1399
|
+
} catch (err) {
|
|
1400
|
+
lastErr = err;
|
|
1401
|
+
if (i >= attempts - 1 || !isTransientGitError(err)) throw err;
|
|
1402
|
+
const delayMs = computeGitBackoffMs(i, { baseMs, capMs, rng });
|
|
1403
|
+
if (typeof onRetry === "function") onRetry({ err, attempt: i + 1, delayMs });
|
|
1404
|
+
sleep2(delayMs);
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
throw lastErr;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// ../../scripts/virtual-office/code-runner/publish.mjs
|
|
1411
|
+
function gitRetryLog(op) {
|
|
1412
|
+
return ({ attempt, delayMs, err }) => {
|
|
1413
|
+
const why = String(err && err.message || err).replace(/\s+/g, " ").slice(0, 120);
|
|
1414
|
+
console.error(`[publish] transient ${op} failure (attempt ${attempt}): ${why} \u2014 retrying in ${Math.round(delayMs / 1e3)}s`);
|
|
1415
|
+
};
|
|
1416
|
+
}
|
|
1132
1417
|
function run(cmd, args, cwd, { timeout = 18e4, raw = false, env: env2 } = {}) {
|
|
1133
|
-
const r =
|
|
1418
|
+
const r = spawnSync7(cmd, args, { cwd, encoding: "utf8", timeout, ...env2 ? { env: env2 } : {} });
|
|
1134
1419
|
if (r.error) throw r.error;
|
|
1135
1420
|
if (r.status !== 0) {
|
|
1136
1421
|
throw new Error(`${cmd} ${args[0]} failed (exit ${r.status}): ${(r.stderr || "").slice(-300)}`);
|
|
@@ -1227,6 +1512,46 @@ function pushBranch(worktreeDir, branch, githubToken, runFn = run) {
|
|
|
1227
1512
|
return fallback.tokenUsed;
|
|
1228
1513
|
}
|
|
1229
1514
|
}
|
|
1515
|
+
function resolveOrCreateBranch(worktreeDir, branchPrefix, runFn = run) {
|
|
1516
|
+
let branch = "";
|
|
1517
|
+
try {
|
|
1518
|
+
branch = runFn("git", ["branch", "--show-current"], worktreeDir, { timeout: 3e4 });
|
|
1519
|
+
} catch {
|
|
1520
|
+
}
|
|
1521
|
+
if (!branch || branch === "main" || branch === "HEAD") {
|
|
1522
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
1523
|
+
branch = `${branchPrefix}-${stamp}`;
|
|
1524
|
+
runFn("git", ["checkout", "-b", branch], worktreeDir);
|
|
1525
|
+
}
|
|
1526
|
+
return branch;
|
|
1527
|
+
}
|
|
1528
|
+
function commitWorkLocally(worktreeDir, files, { title, branchPrefix = "vo/code-task", botName = "vo-code-runner", botEmail = "vo-code-runner@algosuite.ai", maxFiles = 200, runFn = run } = {}) {
|
|
1529
|
+
const cleaned = (files || []).filter((f) => !isAgentScratch(f));
|
|
1530
|
+
if (cleaned.length === 0) throw new Error("commitWorkLocally: only scratch files, nothing to commit");
|
|
1531
|
+
const toAdd = cleaned.slice(0, maxFiles);
|
|
1532
|
+
runFn("git", ["config", "user.name", botName], worktreeDir);
|
|
1533
|
+
runFn("git", ["config", "user.email", botEmail], worktreeDir);
|
|
1534
|
+
const branch = resolveOrCreateBranch(worktreeDir, branchPrefix, runFn);
|
|
1535
|
+
for (let i = 0; i < toAdd.length; i += 100) {
|
|
1536
|
+
runFn("git", ["add", "--", ...toAdd.slice(i, i + 100)], worktreeDir, { timeout: 12e4 });
|
|
1537
|
+
}
|
|
1538
|
+
runFn("git", ["commit", "--no-verify", "-m", compactTitle(title, 180)], worktreeDir);
|
|
1539
|
+
return { branch, truncated: cleaned.length > maxFiles };
|
|
1540
|
+
}
|
|
1541
|
+
function existingPrUrl(worktreeDir, branch, githubToken = null, runFn = run) {
|
|
1542
|
+
try {
|
|
1543
|
+
const out = runFn(
|
|
1544
|
+
"gh",
|
|
1545
|
+
["pr", "list", "--head", branch, "--state", "open", "--json", "url,number", "--limit", "1"],
|
|
1546
|
+
worktreeDir,
|
|
1547
|
+
{ env: githubToken ? installationTokenEnv(githubToken) : void 0 }
|
|
1548
|
+
);
|
|
1549
|
+
const arr = JSON.parse(out || "[]");
|
|
1550
|
+
if (Array.isArray(arr) && arr[0] && arr[0].url) return { url: String(arr[0].url), number: Number(arr[0].number) };
|
|
1551
|
+
} catch {
|
|
1552
|
+
}
|
|
1553
|
+
return null;
|
|
1554
|
+
}
|
|
1230
1555
|
function openCodeTaskPr(worktreeDir, files, {
|
|
1231
1556
|
title,
|
|
1232
1557
|
body,
|
|
@@ -1237,69 +1562,42 @@ function openCodeTaskPr(worktreeDir, files, {
|
|
|
1237
1562
|
// Safety net: the agent already COMMITTED its work to a branch (despite the
|
|
1238
1563
|
// preamble). Skip add+commit; just push the existing branch and open the PR.
|
|
1239
1564
|
alreadyCommitted = false,
|
|
1240
|
-
// Optional GitHub App installation token (M3). When present,
|
|
1241
|
-
// authenticate as the App
|
|
1242
|
-
//
|
|
1243
|
-
|
|
1244
|
-
|
|
1565
|
+
// Optional GitHub App installation token (M3). When present, push + PR
|
|
1566
|
+
// authenticate as the App; a push failure transparently falls back to the
|
|
1567
|
+
// runner's ambient gh auth. Absent → ambient gh exactly.
|
|
1568
|
+
githubToken = null,
|
|
1569
|
+
// Open as DRAFT — used to auto-publish partial/timed-out work for recovery.
|
|
1570
|
+
draft = false
|
|
1245
1571
|
} = {}) {
|
|
1246
1572
|
if (!Array.isArray(files) || files.length === 0) {
|
|
1247
1573
|
throw new Error("openCodeTaskPr: no files to commit");
|
|
1248
1574
|
}
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
throw new Error("openCodeTaskPr: only scratch files, nothing to commit");
|
|
1252
|
-
}
|
|
1253
|
-
const toAdd = cleaned.slice(0, maxFiles);
|
|
1254
|
-
run("git", ["config", "user.name", botName], worktreeDir);
|
|
1255
|
-
run("git", ["config", "user.email", botEmail], worktreeDir);
|
|
1256
|
-
let branch = "";
|
|
1257
|
-
try {
|
|
1258
|
-
branch = run("git", ["branch", "--show-current"], worktreeDir, { timeout: 3e4 });
|
|
1259
|
-
} catch {
|
|
1260
|
-
}
|
|
1261
|
-
let tokenUsed;
|
|
1575
|
+
let branch;
|
|
1576
|
+
let truncated = false;
|
|
1262
1577
|
if (alreadyCommitted) {
|
|
1263
|
-
|
|
1264
|
-
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
1265
|
-
branch = `${branchPrefix}-${stamp}`;
|
|
1266
|
-
run("git", ["checkout", "-b", branch], worktreeDir);
|
|
1267
|
-
}
|
|
1268
|
-
tokenUsed = pushBranch(worktreeDir, branch, githubToken);
|
|
1578
|
+
branch = resolveOrCreateBranch(worktreeDir, branchPrefix);
|
|
1269
1579
|
} else {
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
run("git", ["fetch", "origin", "main"], worktreeDir, { timeout: 6e4 });
|
|
1274
|
-
run("git", ["checkout", "-b", branch, "origin/main"], worktreeDir);
|
|
1275
|
-
}
|
|
1276
|
-
for (const f of toAdd) {
|
|
1277
|
-
run("git", ["add", "--", f], worktreeDir, { timeout: 6e4 });
|
|
1278
|
-
}
|
|
1279
|
-
const commitMsg = compactTitle(title, 180);
|
|
1280
|
-
run("git", ["commit", "--no-verify", "-m", commitMsg], worktreeDir);
|
|
1281
|
-
tokenUsed = pushBranch(worktreeDir, branch, githubToken);
|
|
1580
|
+
const committed = commitWorkLocally(worktreeDir, files, { title, branchPrefix, botName, botEmail, maxFiles });
|
|
1581
|
+
branch = committed.branch;
|
|
1582
|
+
truncated = committed.truncated;
|
|
1282
1583
|
}
|
|
1283
|
-
const
|
|
1284
|
-
"
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
"
|
|
1291
|
-
branch,
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
],
|
|
1297
|
-
worktreeDir,
|
|
1298
|
-
{ env: tokenUsed ? installationTokenEnv(githubToken) : void 0 }
|
|
1584
|
+
const tokenUsed = retryTransient(() => pushBranch(worktreeDir, branch, githubToken), {
|
|
1585
|
+
onRetry: gitRetryLog("git push")
|
|
1586
|
+
});
|
|
1587
|
+
const existing = existingPrUrl(worktreeDir, branch, tokenUsed ? githubToken : null);
|
|
1588
|
+
if (existing) return { prUrl: existing.url, prNumber: existing.number, branch, truncated, resumed: true };
|
|
1589
|
+
const out = retryTransient(
|
|
1590
|
+
() => run(
|
|
1591
|
+
"gh",
|
|
1592
|
+
["pr", "create", "--base", "main", "--head", branch, "--title", compactTitle(title), "--body", String(body || ""), ...draft ? ["--draft"] : []],
|
|
1593
|
+
worktreeDir,
|
|
1594
|
+
{ env: tokenUsed ? installationTokenEnv(githubToken) : void 0 }
|
|
1595
|
+
),
|
|
1596
|
+
{ onRetry: gitRetryLog("gh pr create") }
|
|
1299
1597
|
);
|
|
1300
1598
|
const m = out.match(/https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/);
|
|
1301
1599
|
if (!m) throw new Error("gh pr create returned no parseable PR URL");
|
|
1302
|
-
return { prUrl: m[0], prNumber: Number(m[1]), branch, truncated
|
|
1600
|
+
return { prUrl: m[0], prNumber: Number(m[1]), branch, truncated };
|
|
1303
1601
|
}
|
|
1304
1602
|
|
|
1305
1603
|
// ../../scripts/virtual-office/code-runner/dispatch-onboarding.mjs
|
|
@@ -1365,11 +1663,11 @@ ${String(taskPrompt ?? "").trim()}
|
|
|
1365
1663
|
|
|
1366
1664
|
// ../../scripts/virtual-office/code-runner/session-spool-forwarder.mjs
|
|
1367
1665
|
import { homedir as homedir3 } from "node:os";
|
|
1368
|
-
import { join as
|
|
1666
|
+
import { join as join4 } from "node:path";
|
|
1369
1667
|
import { readdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
1370
1668
|
import { createHash } from "node:crypto";
|
|
1371
|
-
var SPOOL_DIR =
|
|
1372
|
-
var CLOUD_MAP_FILE =
|
|
1669
|
+
var SPOOL_DIR = join4(homedir3(), ".vo", "session-spool");
|
|
1670
|
+
var CLOUD_MAP_FILE = join4(homedir3(), ".vo", "session-cloud-map.json");
|
|
1373
1671
|
var STALE_MS = 60 * 60 * 1e3;
|
|
1374
1672
|
var ACTIVE_SILENCE_MS = 10 * 60 * 1e3;
|
|
1375
1673
|
function deriveUuid(seed) {
|
|
@@ -1401,9 +1699,9 @@ async function readSpool(spoolDir = SPOOL_DIR) {
|
|
|
1401
1699
|
for (const f of files) {
|
|
1402
1700
|
if (!f.endsWith(".json")) continue;
|
|
1403
1701
|
try {
|
|
1404
|
-
const record = JSON.parse(await readFile(
|
|
1702
|
+
const record = JSON.parse(await readFile(join4(spoolDir, f), "utf8"));
|
|
1405
1703
|
if (record && typeof record.session_key === "string") {
|
|
1406
|
-
out.push({ full:
|
|
1704
|
+
out.push({ full: join4(spoolDir, f), record });
|
|
1407
1705
|
}
|
|
1408
1706
|
} catch {
|
|
1409
1707
|
}
|
|
@@ -1482,13 +1780,222 @@ async function forwardSessionSpool(deps) {
|
|
|
1482
1780
|
return { forwarded, pruned };
|
|
1483
1781
|
}
|
|
1484
1782
|
|
|
1783
|
+
// ../../scripts/virtual-office/code-runner/rate-limit-resume-scheduler.mjs
|
|
1784
|
+
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync3, mkdirSync as mkdirSync3 } from "node:fs";
|
|
1785
|
+
import { dirname as dirname3, join as join5, resolve } from "node:path";
|
|
1786
|
+
|
|
1787
|
+
// ../../scripts/virtual-office/code-runner/rate-limit-resume-scheduler-core.mjs
|
|
1788
|
+
var MAX_ATTEMPTS = 5;
|
|
1789
|
+
var MAX_DISPATCH_PER_RUN = 10;
|
|
1790
|
+
var NULL_RESUME_AFTER_BACKOFF_MS = 15 * 60 * 1e3;
|
|
1791
|
+
function stableTaskKey(entry) {
|
|
1792
|
+
return `${entry && entry.repo || ""}\0${entry && entry.prompt || ""}`;
|
|
1793
|
+
}
|
|
1794
|
+
function selectDueEntries({ entries = [], now, alreadyDispatched = /* @__PURE__ */ new Set(), attemptsByKey = {} } = {}) {
|
|
1795
|
+
if (!now) throw new Error("selectDueEntries: now is required");
|
|
1796
|
+
const nowMs = new Date(now).getTime();
|
|
1797
|
+
if (!Number.isFinite(nowMs)) throw new Error("selectDueEntries: invalid now timestamp");
|
|
1798
|
+
const due = [];
|
|
1799
|
+
const exhausted = [];
|
|
1800
|
+
const seen = new Set(alreadyDispatched);
|
|
1801
|
+
for (const e of entries) {
|
|
1802
|
+
const { code_task_id, resume_after, at, attempts } = e || {};
|
|
1803
|
+
if (!code_task_id) continue;
|
|
1804
|
+
if (seen.has(code_task_id)) continue;
|
|
1805
|
+
const stableAttempts = Number(attemptsByKey[stableTaskKey(e)] || 0);
|
|
1806
|
+
if (Math.max(typeof attempts === "number" ? attempts : 0, stableAttempts) >= MAX_ATTEMPTS) {
|
|
1807
|
+
exhausted.push(e);
|
|
1808
|
+
continue;
|
|
1809
|
+
}
|
|
1810
|
+
let isDue = false;
|
|
1811
|
+
if (resume_after === null || resume_after === void 0) {
|
|
1812
|
+
const entryAtMs = new Date(at).getTime();
|
|
1813
|
+
if (Number.isFinite(entryAtMs)) {
|
|
1814
|
+
const attemptCount = typeof attempts === "number" ? attempts : 0;
|
|
1815
|
+
const backoffMs = NULL_RESUME_AFTER_BACKOFF_MS * Math.pow(2, attemptCount);
|
|
1816
|
+
const dueAtMs = entryAtMs + backoffMs;
|
|
1817
|
+
isDue = nowMs >= dueAtMs;
|
|
1818
|
+
}
|
|
1819
|
+
} else {
|
|
1820
|
+
const resumeMs = new Date(resume_after).getTime();
|
|
1821
|
+
if (Number.isFinite(resumeMs)) {
|
|
1822
|
+
isDue = nowMs >= resumeMs;
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
if (isDue) {
|
|
1826
|
+
due.push(e);
|
|
1827
|
+
seen.add(code_task_id);
|
|
1828
|
+
if (due.length >= MAX_DISPATCH_PER_RUN) break;
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
return { due, exhausted };
|
|
1832
|
+
}
|
|
1833
|
+
function reconcileQueue({ entries = [], dispatchedIds = /* @__PURE__ */ new Set(), exhaustedIds = /* @__PURE__ */ new Set() } = {}) {
|
|
1834
|
+
return entries.filter((e) => {
|
|
1835
|
+
const { code_task_id } = e || {};
|
|
1836
|
+
if (!code_task_id) return false;
|
|
1837
|
+
if (dispatchedIds.has(code_task_id)) return false;
|
|
1838
|
+
if (exhaustedIds.has(code_task_id)) return false;
|
|
1839
|
+
return true;
|
|
1840
|
+
});
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
// ../../scripts/virtual-office/code-runner/rate-limit-resume-scheduler.mjs
|
|
1844
|
+
var ATTEMPTS_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
1845
|
+
function log(msg) {
|
|
1846
|
+
console.log(`[rate-limit-scheduler ${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
|
|
1847
|
+
}
|
|
1848
|
+
function readQueue(queuePath) {
|
|
1849
|
+
if (!existsSync3(queuePath)) return [];
|
|
1850
|
+
const content = readFileSync2(queuePath, "utf-8");
|
|
1851
|
+
const lines = content.split("\n").filter((l) => l.trim());
|
|
1852
|
+
const entries = [];
|
|
1853
|
+
for (const line of lines) {
|
|
1854
|
+
try {
|
|
1855
|
+
entries.push(JSON.parse(line));
|
|
1856
|
+
} catch {
|
|
1857
|
+
log(`warn: malformed queue line: ${line.slice(0, 100)}`);
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
return entries;
|
|
1861
|
+
}
|
|
1862
|
+
function writeQueue(queuePath, entries) {
|
|
1863
|
+
mkdirSync3(dirname3(queuePath), { recursive: true });
|
|
1864
|
+
const lines = entries.map((e) => JSON.stringify(e)).join("\n");
|
|
1865
|
+
writeFileSync2(queuePath, lines + (entries.length > 0 ? "\n" : ""), "utf-8");
|
|
1866
|
+
}
|
|
1867
|
+
function attemptsStorePath() {
|
|
1868
|
+
return join5(dirname3(resumeQueuePath()), "resume-attempts.json");
|
|
1869
|
+
}
|
|
1870
|
+
function readAttemptsStore() {
|
|
1871
|
+
const p = attemptsStorePath();
|
|
1872
|
+
if (!existsSync3(p)) return {};
|
|
1873
|
+
try {
|
|
1874
|
+
const parsed = JSON.parse(readFileSync2(p, "utf-8"));
|
|
1875
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
1876
|
+
} catch {
|
|
1877
|
+
return {};
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
function writeAttemptsStore(store) {
|
|
1881
|
+
const p = attemptsStorePath();
|
|
1882
|
+
mkdirSync3(dirname3(p), { recursive: true });
|
|
1883
|
+
writeFileSync2(p, JSON.stringify(store, null, 2), "utf-8");
|
|
1884
|
+
}
|
|
1885
|
+
function countsFromStore(store) {
|
|
1886
|
+
const counts = {};
|
|
1887
|
+
for (const [k, v] of Object.entries(store)) {
|
|
1888
|
+
counts[k] = v && typeof v.count === "number" ? v.count : 0;
|
|
1889
|
+
}
|
|
1890
|
+
return counts;
|
|
1891
|
+
}
|
|
1892
|
+
function bumpAttempts(store, key, nowIso) {
|
|
1893
|
+
const prev = store[key] && typeof store[key].count === "number" ? store[key].count : 0;
|
|
1894
|
+
store[key] = { count: prev + 1, lastSeen: nowIso };
|
|
1895
|
+
}
|
|
1896
|
+
function pruneAttemptsStore(store, nowIso) {
|
|
1897
|
+
const nowMs = new Date(nowIso).getTime();
|
|
1898
|
+
const out = {};
|
|
1899
|
+
for (const [k, v] of Object.entries(store)) {
|
|
1900
|
+
const t = v && v.lastSeen ? new Date(v.lastSeen).getTime() : 0;
|
|
1901
|
+
if (Number.isFinite(t) && nowMs - t < ATTEMPTS_TTL_MS) out[k] = v;
|
|
1902
|
+
}
|
|
1903
|
+
return out;
|
|
1904
|
+
}
|
|
1905
|
+
async function runScheduler({ env: env2 = process.env } = {}) {
|
|
1906
|
+
const enabled = env2.VO_RATE_LIMIT_RESUME === "1";
|
|
1907
|
+
if (!enabled) {
|
|
1908
|
+
log("VO_RATE_LIMIT_RESUME not enabled; no-op");
|
|
1909
|
+
return { dispatched: 0, exhausted: 0, kept: 0 };
|
|
1910
|
+
}
|
|
1911
|
+
const queuePath = resumeQueuePath();
|
|
1912
|
+
const entries = readQueue(queuePath);
|
|
1913
|
+
if (entries.length === 0) {
|
|
1914
|
+
log("queue empty; nothing to do");
|
|
1915
|
+
return { dispatched: 0, exhausted: 0, kept: 0 };
|
|
1916
|
+
}
|
|
1917
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1918
|
+
const alreadyDispatched = /* @__PURE__ */ new Set();
|
|
1919
|
+
const attemptsStore = readAttemptsStore();
|
|
1920
|
+
const attemptsByKey = countsFromStore(attemptsStore);
|
|
1921
|
+
const { due, exhausted } = selectDueEntries({ entries, now, alreadyDispatched, attemptsByKey });
|
|
1922
|
+
log(`queue: ${entries.length} total, ${due.length} due, ${exhausted.length} exhausted`);
|
|
1923
|
+
if (due.length === 0 && exhausted.length === 0) {
|
|
1924
|
+
log("no due or exhausted entries; queue unchanged");
|
|
1925
|
+
writeAttemptsStore(pruneAttemptsStore(attemptsStore, now));
|
|
1926
|
+
return { dispatched: 0, exhausted: 0, kept: entries.length };
|
|
1927
|
+
}
|
|
1928
|
+
const client = createControlPlaneClient({ env: env2 });
|
|
1929
|
+
const dispatchedIds = /* @__PURE__ */ new Set();
|
|
1930
|
+
const exhaustedIds = new Set(exhausted.map((e) => e.code_task_id));
|
|
1931
|
+
for (const entry of due) {
|
|
1932
|
+
const { code_task_id, repo, prompt, attempts } = entry;
|
|
1933
|
+
try {
|
|
1934
|
+
const task = {
|
|
1935
|
+
repo,
|
|
1936
|
+
prompt,
|
|
1937
|
+
_resume_attempts: (typeof attempts === "number" ? attempts : 0) + 1
|
|
1938
|
+
};
|
|
1939
|
+
await client.enqueueCodeTask(task);
|
|
1940
|
+
bumpAttempts(attemptsStore, stableTaskKey(entry), now);
|
|
1941
|
+
log(`dispatched: ${code_task_id} (stable attempts ${attemptsStore[stableTaskKey(entry)].count})`);
|
|
1942
|
+
dispatchedIds.add(code_task_id);
|
|
1943
|
+
alreadyDispatched.add(code_task_id);
|
|
1944
|
+
} catch (err) {
|
|
1945
|
+
log(`dispatch failed for ${code_task_id}: ${err.message}`);
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
const kept = reconcileQueue({ entries, dispatchedIds, exhaustedIds });
|
|
1949
|
+
writeQueue(queuePath, kept);
|
|
1950
|
+
writeAttemptsStore(pruneAttemptsStore(attemptsStore, now));
|
|
1951
|
+
if (exhausted.length > 0) {
|
|
1952
|
+
log(`WARN: ${exhausted.length} task(s) gave up after ${MAX_ATTEMPTS} rate-limit retries and were DROPPED from the queue: ${exhausted.map((e) => e.code_task_id).join(", ")}`);
|
|
1953
|
+
}
|
|
1954
|
+
if (due.length >= MAX_DISPATCH_PER_RUN) {
|
|
1955
|
+
log(`WARN: hit the per-run dispatch cap (${MAX_DISPATCH_PER_RUN}); more rate-limited tasks remain queued and will resume on the next run`);
|
|
1956
|
+
}
|
|
1957
|
+
log(`done: dispatched ${dispatchedIds.size}, exhausted ${exhaustedIds.size}, kept ${kept.length}`);
|
|
1958
|
+
return {
|
|
1959
|
+
dispatched: dispatchedIds.size,
|
|
1960
|
+
exhausted: exhaustedIds.size,
|
|
1961
|
+
kept: kept.length
|
|
1962
|
+
};
|
|
1963
|
+
}
|
|
1964
|
+
var isMainModule = (() => {
|
|
1965
|
+
try {
|
|
1966
|
+
const argv1 = process.argv[1] ? resolve(process.argv[1]) : "";
|
|
1967
|
+
const here = new URL(import.meta.url).pathname.replace(/^\/([a-zA-Z]):\//, "$1:/");
|
|
1968
|
+
return resolve(here) === argv1;
|
|
1969
|
+
} catch {
|
|
1970
|
+
return false;
|
|
1971
|
+
}
|
|
1972
|
+
})();
|
|
1973
|
+
if (isMainModule) {
|
|
1974
|
+
runScheduler().catch((err) => {
|
|
1975
|
+
console.error("[rate-limit-scheduler] fatal:", err);
|
|
1976
|
+
process.exit(1);
|
|
1977
|
+
});
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1485
1980
|
// ../../scripts/virtual-office/code-runner/loop-ticks.mjs
|
|
1486
1981
|
var HEARTBEAT_MS = 6e4;
|
|
1487
|
-
|
|
1982
|
+
var DEFAULT_RESUME_SCHEDULE_SEC = 300;
|
|
1983
|
+
function makeLoopTicks({
|
|
1984
|
+
client,
|
|
1985
|
+
cfg,
|
|
1986
|
+
env: env2,
|
|
1987
|
+
log: log3,
|
|
1988
|
+
getActive,
|
|
1989
|
+
// Injectable for tests; default to the real scheduler + wall clock.
|
|
1990
|
+
runResumeScheduler = runScheduler,
|
|
1991
|
+
now: nowFn = () => Date.now()
|
|
1992
|
+
}) {
|
|
1488
1993
|
let lastSessionForward = 0;
|
|
1489
1994
|
let lastHeartbeat = 0;
|
|
1995
|
+
let lastResumeSchedule = 0;
|
|
1996
|
+
let resumeRunning = false;
|
|
1490
1997
|
return function tick() {
|
|
1491
|
-
const now =
|
|
1998
|
+
const now = nowFn();
|
|
1492
1999
|
if (cfg.sessionForwardSec > 0 && now - lastSessionForward >= cfg.sessionForwardSec * 1e3) {
|
|
1493
2000
|
lastSessionForward = now;
|
|
1494
2001
|
forwardSessionSpool({
|
|
@@ -1500,19 +2007,39 @@ function makeLoopTicks({ client, cfg, env: env2, log: log2, getActive }) {
|
|
|
1500
2007
|
}
|
|
1501
2008
|
if (now - lastHeartbeat >= HEARTBEAT_MS) {
|
|
1502
2009
|
lastHeartbeat = now;
|
|
1503
|
-
|
|
2010
|
+
const servedRepos = Array.isArray(cfg.servedRepos) ? cfg.servedRepos.slice(0, 100) : [];
|
|
2011
|
+
const servedOperators = Array.isArray(cfg.servedOperators) ? cfg.servedOperators.slice(0, 100) : [];
|
|
2012
|
+
const baseHeartbeat = {
|
|
2013
|
+
runnerId: cfg.runnerId,
|
|
2014
|
+
...servedRepos.length > 0 ? { servedRepos } : {},
|
|
2015
|
+
...servedOperators.length > 0 ? { servedOperators } : {},
|
|
2016
|
+
uptimeSec: Math.floor(process.uptime()),
|
|
2017
|
+
activeTasks: getActive()
|
|
2018
|
+
};
|
|
2019
|
+
const operatorIds = servedOperators.length > 0 ? servedOperators : [void 0];
|
|
2020
|
+
for (const operatorId of operatorIds) {
|
|
2021
|
+
client.postHeartbeat({ ...baseHeartbeat, ...operatorId ? { operatorId } : {} }).catch((e) => log3(`heartbeat failed: ${e.message}`));
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
const resumeSec = Number(env2.VO_RESUME_SCHEDULE_SEC) > 0 ? Number(env2.VO_RESUME_SCHEDULE_SEC) : DEFAULT_RESUME_SCHEDULE_SEC;
|
|
2025
|
+
if (!resumeRunning && now - lastResumeSchedule >= resumeSec * 1e3) {
|
|
2026
|
+
lastResumeSchedule = now;
|
|
2027
|
+
resumeRunning = true;
|
|
2028
|
+
Promise.resolve(runResumeScheduler({ env: env2 })).catch((e) => log3(`resume-scheduler tick failed: ${e.message}`)).finally(() => {
|
|
2029
|
+
resumeRunning = false;
|
|
2030
|
+
});
|
|
1504
2031
|
}
|
|
1505
2032
|
};
|
|
1506
2033
|
}
|
|
1507
2034
|
|
|
1508
2035
|
// ../../scripts/virtual-office/code-runner/pr-watcher.mjs
|
|
1509
2036
|
import { homedir as homedir4 } from "node:os";
|
|
1510
|
-
import { join as
|
|
2037
|
+
import { join as join6 } from "node:path";
|
|
1511
2038
|
import { readFile as readFile2, writeFile as writeFile2, mkdir } from "node:fs/promises";
|
|
1512
|
-
import { spawnSync as
|
|
2039
|
+
import { spawnSync as spawnSync8 } from "node:child_process";
|
|
1513
2040
|
var CI_FIX_MARKER = "[VO-CI-FIX]";
|
|
1514
2041
|
function ghViewPr(prNumber, repo) {
|
|
1515
|
-
const r =
|
|
2042
|
+
const r = spawnSync8(
|
|
1516
2043
|
"gh",
|
|
1517
2044
|
["pr", "view", String(prNumber), "-R", repo, "--json", "state,statusCheckRollup,headRefName"],
|
|
1518
2045
|
{ encoding: "utf8", timeout: 3e4 }
|
|
@@ -1520,7 +2047,7 @@ function ghViewPr(prNumber, repo) {
|
|
|
1520
2047
|
if (r.status !== 0) throw new Error((r.stderr || "gh pr view failed").slice(-200));
|
|
1521
2048
|
return JSON.parse(r.stdout || "{}");
|
|
1522
2049
|
}
|
|
1523
|
-
var DEFAULT_STATE_FILE =
|
|
2050
|
+
var DEFAULT_STATE_FILE = join6(homedir4(), ".vo", "dispatched-prs.json");
|
|
1524
2051
|
var FAIL_CONCLUSIONS = /* @__PURE__ */ new Set([
|
|
1525
2052
|
"FAILURE",
|
|
1526
2053
|
"TIMED_OUT",
|
|
@@ -1583,7 +2110,7 @@ async function readState(stateFile) {
|
|
|
1583
2110
|
}
|
|
1584
2111
|
async function writeState(stateFile, state) {
|
|
1585
2112
|
try {
|
|
1586
|
-
await mkdir(
|
|
2113
|
+
await mkdir(join6(stateFile, ".."), { recursive: true });
|
|
1587
2114
|
await writeFile2(stateFile, JSON.stringify(state, null, 2), "utf8");
|
|
1588
2115
|
} catch {
|
|
1589
2116
|
}
|
|
@@ -1600,7 +2127,7 @@ async function trackDispatchedPr({ prNumber, repo, branch, taskId }, { stateFile
|
|
|
1600
2127
|
};
|
|
1601
2128
|
await writeState(stateFile, state);
|
|
1602
2129
|
}
|
|
1603
|
-
async function runWatchCycle({ viewPr, enqueueFix, log:
|
|
2130
|
+
async function runWatchCycle({ viewPr, enqueueFix, log: log3 = () => {
|
|
1604
2131
|
}, now = () => Date.now(), maxFixAttempts = 1, stateFile = DEFAULT_STATE_FILE }) {
|
|
1605
2132
|
const state = await readState(stateFile);
|
|
1606
2133
|
const prNumbers = Object.keys(state);
|
|
@@ -1613,7 +2140,7 @@ async function runWatchCycle({ viewPr, enqueueFix, log: log2 = () => {
|
|
|
1613
2140
|
try {
|
|
1614
2141
|
view = await viewPr(prNumber, entry.repo);
|
|
1615
2142
|
} catch (err) {
|
|
1616
|
-
|
|
2143
|
+
log3(`watch: pr #${prNumber} view failed: ${err.message}`);
|
|
1617
2144
|
continue;
|
|
1618
2145
|
}
|
|
1619
2146
|
checked += 1;
|
|
@@ -1623,21 +2150,21 @@ async function runWatchCycle({ viewPr, enqueueFix, log: log2 = () => {
|
|
|
1623
2150
|
if (action === "untrack") {
|
|
1624
2151
|
delete state[prNumber];
|
|
1625
2152
|
untracked += 1;
|
|
1626
|
-
|
|
2153
|
+
log3(`watch: pr #${prNumber} is ${pr.state} \u2014 untracked`);
|
|
1627
2154
|
} else if (action === "fix") {
|
|
1628
2155
|
entry.fixAttempts = (entry.fixAttempts || 0) + 1;
|
|
1629
2156
|
entry.lastCheckedAt = now();
|
|
1630
2157
|
try {
|
|
1631
2158
|
await enqueueFix({ prNumber: Number(prNumber), repo: entry.repo, branch: pr.branch || entry.branch, failedChecks: pr.failedChecks });
|
|
1632
2159
|
fixed += 1;
|
|
1633
|
-
|
|
2160
|
+
log3(`watch: pr #${prNumber} CI failing (${pr.failedChecks.join(", ") || "unknown"}) \u2014 dispatched fix ${entry.fixAttempts}/${maxFixAttempts}`);
|
|
1634
2161
|
} catch (err) {
|
|
1635
2162
|
entry.enqueueErrors = (entry.enqueueErrors || 0) + 1;
|
|
1636
2163
|
if (entry.enqueueErrors >= MAX_ENQUEUE_ERRORS) {
|
|
1637
|
-
|
|
2164
|
+
log3(`watch: pr #${prNumber} fix enqueue failed ${entry.enqueueErrors}x \u2014 giving up: ${err.message}`);
|
|
1638
2165
|
} else {
|
|
1639
2166
|
entry.fixAttempts = Math.max(0, (entry.fixAttempts || 1) - 1);
|
|
1640
|
-
|
|
2167
|
+
log3(`watch: pr #${prNumber} fix enqueue failed (${entry.enqueueErrors}/${MAX_ENQUEUE_ERRORS}): ${err.message}`);
|
|
1641
2168
|
}
|
|
1642
2169
|
}
|
|
1643
2170
|
} else {
|
|
@@ -1649,17 +2176,17 @@ async function runWatchCycle({ viewPr, enqueueFix, log: log2 = () => {
|
|
|
1649
2176
|
if (cappedFailing && e.trackedAt && now() - e.trackedAt > STALE_MS2) {
|
|
1650
2177
|
delete state[n];
|
|
1651
2178
|
untracked += 1;
|
|
1652
|
-
|
|
2179
|
+
log3(`watch: pr #${n} capped + failing + tracked >24h ago \u2014 pruned from watch state`);
|
|
1653
2180
|
}
|
|
1654
2181
|
}
|
|
1655
2182
|
await writeState(stateFile, state);
|
|
1656
2183
|
return { checked, fixed, untracked };
|
|
1657
2184
|
}
|
|
1658
|
-
function makeWatchRunner({ client, viewPr = ghViewPr, log:
|
|
2185
|
+
function makeWatchRunner({ client, viewPr = ghViewPr, log: log3, maxFixAttempts }) {
|
|
1659
2186
|
return () => runWatchCycle({
|
|
1660
2187
|
viewPr,
|
|
1661
2188
|
enqueueFix: ({ prNumber, repo, branch, failedChecks }) => client.enqueueCodeTask({ repo, prompt: buildCiFixPrompt({ prNumber, repo, branch, failedChecks }) }),
|
|
1662
|
-
log:
|
|
2189
|
+
log: log3,
|
|
1663
2190
|
maxFixAttempts
|
|
1664
2191
|
});
|
|
1665
2192
|
}
|
|
@@ -1720,15 +2247,15 @@ function buildControlHandler({ getStatus, requestStop, allowedOrigin }) {
|
|
|
1720
2247
|
res.end(JSON.stringify({ ok: false, error: "not_found" }));
|
|
1721
2248
|
};
|
|
1722
2249
|
}
|
|
1723
|
-
function startControlServer({ port, getStatus, requestStop, allowedOrigin, log:
|
|
2250
|
+
function startControlServer({ port, getStatus, requestStop, allowedOrigin, log: log3 = () => {
|
|
1724
2251
|
} }) {
|
|
1725
2252
|
const server = createServer(buildControlHandler({ getStatus, requestStop, allowedOrigin }));
|
|
1726
|
-
server.on("error", (e) =>
|
|
1727
|
-
server.listen(port, "127.0.0.1", () =>
|
|
2253
|
+
server.on("error", (e) => log3(`control server error: ${e.message} (in-product runner control disabled)`));
|
|
2254
|
+
server.listen(port, "127.0.0.1", () => log3(`control server on http://127.0.0.1:${port} (allow ${allowedOrigin})`));
|
|
1728
2255
|
server.unref?.();
|
|
1729
2256
|
return server;
|
|
1730
2257
|
}
|
|
1731
|
-
function startDaemonControl({ cfg, requestStop, getActiveCount, isRunning, startedAt, log:
|
|
2258
|
+
function startDaemonControl({ cfg, requestStop, getActiveCount, isRunning, startedAt, log: log3 = () => {
|
|
1732
2259
|
} }) {
|
|
1733
2260
|
if (!cfg.controlEnabled) return null;
|
|
1734
2261
|
return startControlServer({
|
|
@@ -1740,12 +2267,13 @@ function startDaemonControl({ cfg, requestStop, getActiveCount, isRunning, start
|
|
|
1740
2267
|
pid: process.pid,
|
|
1741
2268
|
runnerId: cfg.runnerId,
|
|
1742
2269
|
servedRepos: cfg.servedRepos,
|
|
2270
|
+
servedOperators: cfg.servedOperators,
|
|
1743
2271
|
watchEnabled: cfg.watchEnabled,
|
|
1744
2272
|
activeTasks: getActiveCount(),
|
|
1745
2273
|
startedAt: new Date(startedAt).toISOString(),
|
|
1746
2274
|
uptimeSec: Math.round((Date.now() - startedAt) / 1e3)
|
|
1747
2275
|
}),
|
|
1748
|
-
log:
|
|
2276
|
+
log: log3
|
|
1749
2277
|
});
|
|
1750
2278
|
}
|
|
1751
2279
|
|
|
@@ -2049,6 +2577,59 @@ async function resolveModelFamily(family, options = {}) {
|
|
|
2049
2577
|
}
|
|
2050
2578
|
|
|
2051
2579
|
// ../../scripts/virtual-office/code-runner/model-router.mjs
|
|
2580
|
+
var TASK_MODEL_AGENTS = ["claude", "codex", "cursor"];
|
|
2581
|
+
var DEFAULT_AGENT2 = "claude";
|
|
2582
|
+
var AGENT_TIER_FAMILIES = {
|
|
2583
|
+
claude: {
|
|
2584
|
+
cheap: "anthropic-balanced",
|
|
2585
|
+
mid: "anthropic-flagship",
|
|
2586
|
+
best: "anthropic-flagship"
|
|
2587
|
+
},
|
|
2588
|
+
codex: {
|
|
2589
|
+
// Codex model names are account/runtime dependent. A ChatGPT-account Codex
|
|
2590
|
+
// CLI rejects some registry/fallback ids, so let the CLI choose its default.
|
|
2591
|
+
cheap: null,
|
|
2592
|
+
mid: null,
|
|
2593
|
+
best: null
|
|
2594
|
+
},
|
|
2595
|
+
// Cursor's supported remote model ids are account/runtime dependent. Let the
|
|
2596
|
+
// CLI pick its own default unless the operator overrides it elsewhere.
|
|
2597
|
+
cursor: {
|
|
2598
|
+
cheap: null,
|
|
2599
|
+
mid: null,
|
|
2600
|
+
best: null
|
|
2601
|
+
}
|
|
2602
|
+
};
|
|
2603
|
+
var AGENT_TIER_FALLBACKS = {
|
|
2604
|
+
claude: {
|
|
2605
|
+
cheap: "claude-sonnet-4-6",
|
|
2606
|
+
mid: "claude-opus-4-7",
|
|
2607
|
+
best: "claude-opus-4-8"
|
|
2608
|
+
},
|
|
2609
|
+
codex: {
|
|
2610
|
+
cheap: null,
|
|
2611
|
+
mid: null,
|
|
2612
|
+
best: null
|
|
2613
|
+
},
|
|
2614
|
+
cursor: {
|
|
2615
|
+
cheap: null,
|
|
2616
|
+
mid: null,
|
|
2617
|
+
best: null
|
|
2618
|
+
}
|
|
2619
|
+
};
|
|
2620
|
+
var AGENT_MODEL_COMPATIBILITY = {
|
|
2621
|
+
claude: (model) => /^claude-/i.test(String(model || "")),
|
|
2622
|
+
codex: (model) => /^(?:gpt-|o\d|codex)/i.test(String(model || "")),
|
|
2623
|
+
cursor: () => true
|
|
2624
|
+
};
|
|
2625
|
+
function normalizeAgent(agent = DEFAULT_AGENT2) {
|
|
2626
|
+
const normalized = String(agent || DEFAULT_AGENT2).trim().toLowerCase();
|
|
2627
|
+
return TASK_MODEL_AGENTS.includes(normalized) ? normalized : DEFAULT_AGENT2;
|
|
2628
|
+
}
|
|
2629
|
+
function modelCompatibleWithAgent(agent, model) {
|
|
2630
|
+
if (!model) return true;
|
|
2631
|
+
return AGENT_MODEL_COMPATIBILITY[agent]?.(model) ?? false;
|
|
2632
|
+
}
|
|
2052
2633
|
function classifyTier(prompt) {
|
|
2053
2634
|
const text = String(prompt || "").trim();
|
|
2054
2635
|
if (!text) return "mid";
|
|
@@ -2065,30 +2646,34 @@ function classifyTier(prompt) {
|
|
|
2065
2646
|
}
|
|
2066
2647
|
return "mid";
|
|
2067
2648
|
}
|
|
2068
|
-
async function resolveModelForTier(tier, { resolveModelFamily: resolver = resolveModelFamily } = {}) {
|
|
2649
|
+
async function resolveModelForTier(tier, { agent = DEFAULT_AGENT2, resolveModelFamily: resolver = resolveModelFamily } = {}) {
|
|
2069
2650
|
const t = String(tier || "mid").trim();
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
return
|
|
2077
|
-
}
|
|
2078
|
-
const resolved = await resolver(
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2651
|
+
const normalizedAgent = normalizeAgent(agent);
|
|
2652
|
+
const families = AGENT_TIER_FAMILIES[normalizedAgent];
|
|
2653
|
+
const fallbacks = AGENT_TIER_FALLBACKS[normalizedAgent];
|
|
2654
|
+
const effectiveTier = t === "cheap" || t === "best" ? t : "mid";
|
|
2655
|
+
const family = families[effectiveTier];
|
|
2656
|
+
if (!family) {
|
|
2657
|
+
return fallbacks[effectiveTier];
|
|
2658
|
+
}
|
|
2659
|
+
const resolved = await resolver(family);
|
|
2660
|
+
if (resolved && modelCompatibleWithAgent(normalizedAgent, resolved)) return resolved;
|
|
2661
|
+
return fallbacks[effectiveTier];
|
|
2662
|
+
}
|
|
2663
|
+
async function resolveTaskModel(task, { agent = DEFAULT_AGENT2 } = {}) {
|
|
2082
2664
|
const tier = task.tier && task.tier !== "auto" ? task.tier : classifyTier(task.prompt);
|
|
2083
|
-
const model = await resolveModelForTier(tier);
|
|
2665
|
+
const model = await resolveModelForTier(tier, { agent });
|
|
2084
2666
|
return { tier, model };
|
|
2085
2667
|
}
|
|
2086
2668
|
|
|
2087
2669
|
// ../../scripts/virtual-office/code-runner/apply-effort-mode.mjs
|
|
2088
|
-
async function resolveEffortDispatch({ client, task, env: env2, basePrompt, resolveModel = resolveTaskModel }) {
|
|
2670
|
+
async function resolveEffortDispatch({ client, task, agent = "claude", env: env2, basePrompt, resolveModel = resolveTaskModel }) {
|
|
2089
2671
|
const dispatchMode = await client.getDispatchMode().catch(() => "standard");
|
|
2090
2672
|
const effortConfig = resolveEffortMode(dispatchMode);
|
|
2091
|
-
const { tier, model } = await resolveModel(
|
|
2673
|
+
const { tier, model } = await resolveModel(
|
|
2674
|
+
{ ...task, tier: task.tier ?? effortConfig.tier },
|
|
2675
|
+
{ agent }
|
|
2676
|
+
);
|
|
2092
2677
|
return {
|
|
2093
2678
|
dispatchMode,
|
|
2094
2679
|
tier,
|
|
@@ -2141,8 +2726,73 @@ function describeClaimScoping(cfg = {}, env2 = {}) {
|
|
|
2141
2726
|
return lines;
|
|
2142
2727
|
}
|
|
2143
2728
|
|
|
2729
|
+
// ../../scripts/virtual-office/code-runner/reconnect-backoff.mjs
|
|
2730
|
+
function makeReconnectBackoff({
|
|
2731
|
+
baseMs = 5e3,
|
|
2732
|
+
capMs = 6e4,
|
|
2733
|
+
jitter = 0.2,
|
|
2734
|
+
log: log3 = () => {
|
|
2735
|
+
},
|
|
2736
|
+
random = Math.random
|
|
2737
|
+
} = {}) {
|
|
2738
|
+
let consecutiveFailures = 0;
|
|
2739
|
+
return {
|
|
2740
|
+
/**
|
|
2741
|
+
* Record a failed poll. Logs once as an outage begins, then quieter retry
|
|
2742
|
+
* lines. Returns the delay (ms) the caller should sleep before retrying.
|
|
2743
|
+
* @param {unknown} err
|
|
2744
|
+
* @returns {number} delayMs
|
|
2745
|
+
*/
|
|
2746
|
+
onFailure(err) {
|
|
2747
|
+
consecutiveFailures += 1;
|
|
2748
|
+
const exp = Math.min(capMs, baseMs * 2 ** (consecutiveFailures - 1));
|
|
2749
|
+
const delta = exp * jitter * (random() * 2 - 1);
|
|
2750
|
+
const delayMs = Math.round(Math.min(capMs, Math.max(baseMs, exp + delta)));
|
|
2751
|
+
const reason = err && err.message ? err.message : String(err);
|
|
2752
|
+
const secs = Math.max(1, Math.round(delayMs / 1e3));
|
|
2753
|
+
log3(
|
|
2754
|
+
consecutiveFailures === 1 ? `\u26A0 lost connection to control-plane \u2014 retrying in ${secs}s: ${reason}` : `\u26A0 still offline (${consecutiveFailures} consecutive) \u2014 retrying in ${secs}s: ${reason}`
|
|
2755
|
+
);
|
|
2756
|
+
return delayMs;
|
|
2757
|
+
},
|
|
2758
|
+
/**
|
|
2759
|
+
* Record a successful poll. On the FIRST success after an outage, logs
|
|
2760
|
+
* "✓ reconnected" and resets the backoff. Returns true iff it was a recovery.
|
|
2761
|
+
* @returns {boolean} reconnected
|
|
2762
|
+
*/
|
|
2763
|
+
onSuccess() {
|
|
2764
|
+
if (consecutiveFailures === 0) return false;
|
|
2765
|
+
const prior = consecutiveFailures;
|
|
2766
|
+
consecutiveFailures = 0;
|
|
2767
|
+
log3(`\u2713 reconnected to control-plane after ${prior} failed attempt(s)`);
|
|
2768
|
+
return true;
|
|
2769
|
+
},
|
|
2770
|
+
/** Current consecutive-failure count (0 ⇒ healthy). */
|
|
2771
|
+
get failures() {
|
|
2772
|
+
return consecutiveFailures;
|
|
2773
|
+
},
|
|
2774
|
+
/** True while the connection is considered degraded/offline. */
|
|
2775
|
+
get degraded() {
|
|
2776
|
+
return consecutiveFailures > 0;
|
|
2777
|
+
}
|
|
2778
|
+
};
|
|
2779
|
+
}
|
|
2780
|
+
function installProcessSafetyNet({ log: log3 = () => {
|
|
2781
|
+
}, proc = process } = {}) {
|
|
2782
|
+
if (proc.__voRunnerSafetyNet) return false;
|
|
2783
|
+
proc.__voRunnerSafetyNet = true;
|
|
2784
|
+
const describe = (e) => e && e.stack ? e.stack : e && e.message ? e.message : String(e);
|
|
2785
|
+
proc.on("unhandledRejection", (reason) => {
|
|
2786
|
+
log3(`unhandledRejection (kept alive): ${describe(reason)}`);
|
|
2787
|
+
});
|
|
2788
|
+
proc.on("uncaughtException", (err) => {
|
|
2789
|
+
log3(`uncaughtException (kept alive): ${describe(err)}`);
|
|
2790
|
+
});
|
|
2791
|
+
return true;
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2144
2794
|
// ../../scripts/virtual-office/code-runner-daemon.mjs
|
|
2145
|
-
function
|
|
2795
|
+
function log2(msg) {
|
|
2146
2796
|
console.log(`[code-runner ${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
|
|
2147
2797
|
}
|
|
2148
2798
|
var RATE_LIMIT_RESUME_ENABLED = process.env.VO_RATE_LIMIT_RESUME === "1";
|
|
@@ -2151,7 +2801,7 @@ function loadConfig(env2 = process.env) {
|
|
|
2151
2801
|
return {
|
|
2152
2802
|
runnerId: env2.VO_CODE_RUNNER_ID || `vo-code-runner-${os.hostname()}`,
|
|
2153
2803
|
// BYO multi-agent: {agent, runner, runnerBin} — VO_CODE_RUNNER_AGENT (claude|codex).
|
|
2154
|
-
...resolveRunner(env2, { warn: (m) =>
|
|
2804
|
+
...resolveRunner(env2, { warn: (m) => log2(`agent-select: ${m}`) }),
|
|
2155
2805
|
permissionMode: env2.VO_CODE_RUNNER_PERMISSION_MODE || "acceptEdits",
|
|
2156
2806
|
maxConcurrency: Math.max(1, Number(env2.VO_CODE_TASK_MAX_CONCURRENCY || 2) || 2),
|
|
2157
2807
|
pollSec: Math.max(1, Number(env2.VO_CODE_RUNNER_POLL_SEC || 5) || 5),
|
|
@@ -2166,9 +2816,8 @@ function loadConfig(env2 = process.env) {
|
|
|
2166
2816
|
sessionForwardSec: Math.max(0, Number(env2.VO_SESSION_FORWARD_SEC ?? 30) || 0),
|
|
2167
2817
|
operatorSeed: env2.VO_LOCAL_OPERATOR_SEED || env2.VO_CODE_RUNNER_ID || `local-${os.hostname()}`,
|
|
2168
2818
|
cancelPollMs: Math.max(1e3, Number(env2.VO_CODE_RUNNER_CANCEL_POLL_MS || 2500) || 2500),
|
|
2169
|
-
//
|
|
2170
|
-
|
|
2171
|
-
maxWallClockMs: Math.max(0, Number(env2.VO_CODE_RUNNER_MAX_WALL_CLOCK_MS ?? 18e5) || 18e5),
|
|
2819
|
+
// Hard cap OFF by default (0=no timer; work preserved via #7218 draft-PR). Set ms>0 to enforce; invalid→0.
|
|
2820
|
+
maxWallClockMs: ((n) => Number.isFinite(n) && n >= 0 ? n : 0)(Number(env2.VO_CODE_RUNNER_MAX_WALL_CLOCK_MS ?? NaN)),
|
|
2172
2821
|
// Active PR watcher: monitor each dispatched PR's CI + auto-dispatch ONE fix
|
|
2173
2822
|
// on failure (never auto-merges). Off: VO_CODE_RUNNER_WATCH=0; cap/interval below.
|
|
2174
2823
|
watchEnabled: env2.VO_CODE_RUNNER_WATCH !== "0",
|
|
@@ -2186,10 +2835,10 @@ var numOrUndef = (x) => typeof x === "number" ? x : void 0;
|
|
|
2186
2835
|
async function safeProgress(client, id, patch) {
|
|
2187
2836
|
try {
|
|
2188
2837
|
const r = await client.postProgress(id, patch);
|
|
2189
|
-
if (r && r.terminal)
|
|
2838
|
+
if (r && r.terminal) log2(`task ${id} is terminal server-side; stopping updates`);
|
|
2190
2839
|
return r;
|
|
2191
2840
|
} catch (err) {
|
|
2192
|
-
|
|
2841
|
+
log2(`progress post failed for ${id}: ${err.message}`);
|
|
2193
2842
|
return null;
|
|
2194
2843
|
}
|
|
2195
2844
|
}
|
|
@@ -2221,11 +2870,13 @@ function buildPrBody(task, run2, files) {
|
|
|
2221
2870
|
async function processOneTask(client, task, cfg) {
|
|
2222
2871
|
const id = task.code_task_id;
|
|
2223
2872
|
let worktreeName = "";
|
|
2873
|
+
let preserveReason = null;
|
|
2224
2874
|
try {
|
|
2225
2875
|
const wt = createFixWorktree("code-task", { source: id.slice(0, 8), repo: task.repo });
|
|
2226
2876
|
worktreeName = wt.worktreeName;
|
|
2227
|
-
|
|
2228
|
-
|
|
2877
|
+
if (!worktreeName || !wt.worktreeDir) throw new Error("worktree isolation failure \u2014 refusing to run in the main tree");
|
|
2878
|
+
const { dispatchMode, tier, model, permissionMode: effectivePermissionMode, maxTurns: effectiveMaxTurns, prompt: effortPrompt } = await resolveEffortDispatch({ client, task, agent: cfg.agent, env: process.env, basePrompt: composeDispatchPrompt(task.prompt, { repo: task.repo }) });
|
|
2879
|
+
await safeProgress(client, id, { message: `${cfg.runnerId} spawning ${cfg.agent}:${model || "default"} (${tier}, effort ${dispatchMode})` });
|
|
2229
2880
|
const cap = typeof task.max_budget_usd === "number" ? task.max_budget_usd : resolveSpendCapUsd();
|
|
2230
2881
|
const run2 = await runAgentTask({
|
|
2231
2882
|
runner: cfg.runner,
|
|
@@ -2247,10 +2898,12 @@ async function processOneTask(client, task, cfg) {
|
|
|
2247
2898
|
maxWallClockMs: cfg.maxWallClockMs
|
|
2248
2899
|
});
|
|
2249
2900
|
if (run2.killed) {
|
|
2250
|
-
|
|
2901
|
+
preserveReason = "cancelled by operator \u2014 work preserved for recovery";
|
|
2902
|
+
log2(`task ${id} cancelled by operator`);
|
|
2251
2903
|
return;
|
|
2252
2904
|
}
|
|
2253
2905
|
if (run2.timedOut) {
|
|
2906
|
+
preserveReason = "wall-clock timeout \u2014 partial work preserved for recovery";
|
|
2254
2907
|
await safeProgress(client, id, {
|
|
2255
2908
|
status: "failed",
|
|
2256
2909
|
message: run2.summary,
|
|
@@ -2260,18 +2913,22 @@ async function processOneTask(client, task, cfg) {
|
|
|
2260
2913
|
return;
|
|
2261
2914
|
}
|
|
2262
2915
|
if (typeof task.max_turns === "number" && typeof run2.numTurns === "number" && run2.numTurns > task.max_turns) {
|
|
2263
|
-
|
|
2916
|
+
log2(`task ${id} WARNING: agent ran ${run2.numTurns} turns > max_turns ${task.max_turns}`);
|
|
2264
2917
|
}
|
|
2265
2918
|
if (typeof run2.costUsd === "number" && cap > 0 && run2.costUsd > cap) {
|
|
2266
|
-
|
|
2267
|
-
`task ${id}: usage ~$${run2.costUsd.toFixed(2)} (est, API-equivalent \u2014 not billed on a subscription) exceeded soft cap $${cap}; publishing the agent's work anyway`
|
|
2268
|
-
);
|
|
2919
|
+
log2(`task ${id}: usage ~$${run2.costUsd.toFixed(2)} (est, API-equivalent, not billed on a subscription) exceeded soft cap $${cap}; publishing anyway`);
|
|
2269
2920
|
}
|
|
2921
|
+
let partial = false;
|
|
2270
2922
|
if (!run2.ok) {
|
|
2271
2923
|
const v = classifyFailureForResume({ enabled: RATE_LIMIT_RESUME_ENABLED, run: run2, task });
|
|
2272
|
-
if (v.rateLimited)
|
|
2273
|
-
|
|
2274
|
-
|
|
2924
|
+
if (v.rateLimited) {
|
|
2925
|
+
log2(`task ${id}: RATE_LIMITED (resumeAfter=${v.resumeAfter || "backoff"}); queued (${v.recorded ? "ok" : "queue-write-failed"})`);
|
|
2926
|
+
await safeProgress(client, id, { ...v.progress, cost_usd: numOrUndef(run2.costUsd) });
|
|
2927
|
+
return;
|
|
2928
|
+
}
|
|
2929
|
+
partial = true;
|
|
2930
|
+
preserveReason = `${run2.summary || "incomplete"} \u2014 partial work preserved`;
|
|
2931
|
+
log2(`task ${id}: ${run2.summary || "failed"} \u2014 publishing partial work as a draft PR`);
|
|
2275
2932
|
}
|
|
2276
2933
|
let files = listChangedFiles(wt.worktreeDir);
|
|
2277
2934
|
let alreadyCommitted = false;
|
|
@@ -2280,35 +2937,37 @@ async function processOneTask(client, task, cfg) {
|
|
|
2280
2937
|
if (committed.length > 0) {
|
|
2281
2938
|
files = committed;
|
|
2282
2939
|
alreadyCommitted = true;
|
|
2283
|
-
|
|
2940
|
+
log2(`task ${id}: agent committed ${committed.length} file(s) to a branch; recovering`);
|
|
2284
2941
|
}
|
|
2285
2942
|
}
|
|
2286
2943
|
const scratch = files.filter(isAgentScratch);
|
|
2287
2944
|
if (scratch.length > 0) {
|
|
2288
2945
|
files = files.filter((f) => !isAgentScratch(f));
|
|
2289
|
-
|
|
2946
|
+
log2(`task ${id}: dropped ${scratch.length} scratch file(s): ${scratch.join(", ")}`);
|
|
2290
2947
|
}
|
|
2291
2948
|
if (files.length === 0) {
|
|
2292
2949
|
await safeProgress(client, id, {
|
|
2293
|
-
status: "failed",
|
|
2294
|
-
message: "agent made no file changes",
|
|
2295
|
-
result: "no_changes",
|
|
2950
|
+
status: partial ? "failed" : "no_changes_needed",
|
|
2951
|
+
message: partial ? "agent made no file changes" : "agent completed \u2014 no change needed (already fixed / nothing to do)",
|
|
2952
|
+
result: partial ? "no_changes" : String(run2.summary || "no_changes_needed").slice(0, 2e3),
|
|
2296
2953
|
cost_usd: numOrUndef(run2.costUsd)
|
|
2297
2954
|
});
|
|
2955
|
+
if (!partial) log2(`task ${id}: agent completed successfully with no changes (already fixed)`);
|
|
2298
2956
|
return;
|
|
2299
2957
|
}
|
|
2300
2958
|
const fresh = await client.getTask(id).catch(() => null);
|
|
2301
2959
|
if (fresh && fresh.status === "cancelled") {
|
|
2302
|
-
|
|
2960
|
+
log2(`task ${id} cancelled before PR open; discarding changes`);
|
|
2303
2961
|
return;
|
|
2304
2962
|
}
|
|
2305
2963
|
await safeProgress(client, id, { message: `opening PR for ${files.length} changed file(s)` });
|
|
2306
2964
|
const githubToken = (await client.getInstallationToken())?.token ?? null;
|
|
2307
2965
|
const pr = openCodeTaskPr(wt.worktreeDir, files, {
|
|
2308
|
-
title:
|
|
2966
|
+
title: `${partial ? "\u26A0 PARTIAL (timed out) \u2014 " : ""}code-task: ${task.prompt}`,
|
|
2309
2967
|
body: buildPrBody(task, run2, files),
|
|
2310
2968
|
alreadyCommitted,
|
|
2311
|
-
githubToken
|
|
2969
|
+
githubToken,
|
|
2970
|
+
draft: partial
|
|
2312
2971
|
});
|
|
2313
2972
|
await safeProgress(client, id, {
|
|
2314
2973
|
status: "pr_opened",
|
|
@@ -2318,18 +2977,19 @@ async function processOneTask(client, task, cfg) {
|
|
|
2318
2977
|
result: String(run2.summary).slice(0, 2e3),
|
|
2319
2978
|
cost_usd: numOrUndef(run2.costUsd)
|
|
2320
2979
|
});
|
|
2321
|
-
|
|
2322
|
-
if (cfg.watchEnabled && !String(task.prompt || "").includes(CI_FIX_MARKER)) {
|
|
2980
|
+
log2(`task ${id} \u2192 PR ${pr.prUrl}`);
|
|
2981
|
+
if (cfg.watchEnabled && !partial && !String(task.prompt || "").includes(CI_FIX_MARKER)) {
|
|
2323
2982
|
await trackDispatchedPr({
|
|
2324
2983
|
prNumber: pr.prNumber,
|
|
2325
2984
|
repo: task.repo,
|
|
2326
2985
|
branch: pr.branch,
|
|
2327
2986
|
taskId: id
|
|
2328
|
-
}).catch((e) =>
|
|
2987
|
+
}).catch((e) => log2(`watch: track failed for #${pr.prNumber}: ${e.message}`));
|
|
2329
2988
|
}
|
|
2330
2989
|
} catch (err) {
|
|
2331
2990
|
const msg = err && err.message ? err.message : String(err);
|
|
2332
|
-
|
|
2991
|
+
log2(`task ${id} error: ${msg}`);
|
|
2992
|
+
preserveReason = `runner error: ${msg}`.slice(0, 280);
|
|
2333
2993
|
await safeProgress(client, id, {
|
|
2334
2994
|
status: "failed",
|
|
2335
2995
|
message: `runner error: ${msg}`.slice(0, 1500),
|
|
@@ -2337,7 +2997,7 @@ async function processOneTask(client, task, cfg) {
|
|
|
2337
2997
|
}).catch(() => {
|
|
2338
2998
|
});
|
|
2339
2999
|
} finally {
|
|
2340
|
-
if (worktreeName)
|
|
3000
|
+
if (worktreeName) finalizeWorktree(worktreeName, { preserveReason, taskId: id, repo: task.repo, prompt: task.prompt });
|
|
2341
3001
|
}
|
|
2342
3002
|
}
|
|
2343
3003
|
async function main({ env: env2 = process.env, once: once2 = false } = {}) {
|
|
@@ -2348,10 +3008,11 @@ async function main({ env: env2 = process.env, once: once2 = false } = {}) {
|
|
|
2348
3008
|
const stop = (sig) => {
|
|
2349
3009
|
if (stopping) return;
|
|
2350
3010
|
stopping = true;
|
|
2351
|
-
|
|
3011
|
+
log2(`${sig} received \u2014 draining ${active} active task(s), no new claims`);
|
|
2352
3012
|
};
|
|
2353
3013
|
process.on("SIGINT", () => stop("SIGINT"));
|
|
2354
3014
|
process.on("SIGTERM", () => stop("SIGTERM"));
|
|
3015
|
+
installProcessSafetyNet({ log: log2 });
|
|
2355
3016
|
const startedAt = Date.now();
|
|
2356
3017
|
const controlServer = startDaemonControl({
|
|
2357
3018
|
cfg,
|
|
@@ -2359,25 +3020,26 @@ async function main({ env: env2 = process.env, once: once2 = false } = {}) {
|
|
|
2359
3020
|
getActiveCount: () => active,
|
|
2360
3021
|
isRunning: () => !stopping,
|
|
2361
3022
|
startedAt,
|
|
2362
|
-
log
|
|
3023
|
+
log: log2
|
|
2363
3024
|
});
|
|
2364
|
-
|
|
3025
|
+
log2(
|
|
2365
3026
|
`up as ${cfg.runnerId} \u2192 ${env2.VO_CONTROL_PLANE_URL} (agent ${cfg.agent} [${cfg.runnerBin}], concurrency ${cfg.maxConcurrency}, poll ${cfg.pollSec}s, once=${once2})`
|
|
2366
3027
|
);
|
|
2367
|
-
for (const line of describeClaimScoping(cfg, env2))
|
|
2368
|
-
|
|
3028
|
+
for (const line of describeClaimScoping(cfg, env2)) log2(line);
|
|
3029
|
+
log2(
|
|
2369
3030
|
cfg.watchEnabled ? `PR watcher ON \u2014 auto-fix ${cfg.watchMaxFix}/PR on CI failure, never auto-merges, every ${cfg.watchIntervalSec}s (VO_CODE_RUNNER_WATCH=0 to disable)` : "PR watcher OFF (VO_CODE_RUNNER_WATCH=0)"
|
|
2370
3031
|
);
|
|
2371
3032
|
let lastWatchCycle = 0;
|
|
2372
|
-
const runWatch = makeWatchRunner({ client, log, maxFixAttempts: cfg.watchMaxFix });
|
|
2373
|
-
const loopTick = makeLoopTicks({ client, cfg, env: env2, log, getActive: () => active });
|
|
3033
|
+
const runWatch = makeWatchRunner({ client, log: log2, maxFixAttempts: cfg.watchMaxFix });
|
|
3034
|
+
const loopTick = makeLoopTicks({ client, cfg, env: env2, log: log2, getActive: () => active });
|
|
3035
|
+
const backoff = makeReconnectBackoff({ baseMs: cfg.pollSec * 1e3, log: log2 });
|
|
2374
3036
|
while (!stopping) {
|
|
2375
3037
|
loopTick();
|
|
2376
3038
|
if (cfg.watchEnabled && Date.now() - lastWatchCycle >= cfg.watchIntervalSec * 1e3) {
|
|
2377
3039
|
lastWatchCycle = Date.now();
|
|
2378
3040
|
runWatch().then((r) => {
|
|
2379
|
-
if (r.checked > 0)
|
|
2380
|
-
}).catch((e) =>
|
|
3041
|
+
if (r.checked > 0) log2(`watch: ${r.checked} PR(s) checked, ${r.fixed} fix(es), ${r.untracked} untracked`);
|
|
3042
|
+
}).catch((e) => log2(`watch cycle error: ${e.message}`));
|
|
2381
3043
|
}
|
|
2382
3044
|
if (active >= cfg.maxConcurrency) {
|
|
2383
3045
|
await sleep(cfg.pollSec * 1e3);
|
|
@@ -2386,21 +3048,24 @@ async function main({ env: env2 = process.env, once: once2 = false } = {}) {
|
|
|
2386
3048
|
let task;
|
|
2387
3049
|
try {
|
|
2388
3050
|
task = await client.claim(cfg.runnerId, cfg.servedRepos, cfg.servedOperators);
|
|
3051
|
+
backoff.onSuccess();
|
|
2389
3052
|
} catch (err) {
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
3053
|
+
if (once2) {
|
|
3054
|
+
log2(`claim error: ${err.message}`);
|
|
3055
|
+
break;
|
|
3056
|
+
}
|
|
3057
|
+
await sleep(backoff.onFailure(err));
|
|
2393
3058
|
continue;
|
|
2394
3059
|
}
|
|
2395
3060
|
if (!task) {
|
|
2396
3061
|
if (once2) {
|
|
2397
|
-
|
|
3062
|
+
log2("no pending task; --once exiting");
|
|
2398
3063
|
break;
|
|
2399
3064
|
}
|
|
2400
3065
|
await sleep(cfg.pollSec * 1e3);
|
|
2401
3066
|
continue;
|
|
2402
3067
|
}
|
|
2403
|
-
|
|
3068
|
+
log2(`claimed task ${task.code_task_id} (${task.repo})`);
|
|
2404
3069
|
active += 1;
|
|
2405
3070
|
const done = processOneTask(client, task, cfg).finally(() => {
|
|
2406
3071
|
active -= 1;
|
|
@@ -2414,12 +3079,9 @@ async function main({ env: env2 = process.env, once: once2 = false } = {}) {
|
|
|
2414
3079
|
await sleep(500);
|
|
2415
3080
|
}
|
|
2416
3081
|
if (controlServer) controlServer.close();
|
|
2417
|
-
|
|
3082
|
+
log2("stopped");
|
|
2418
3083
|
}
|
|
2419
|
-
var invokedDirectly = process.argv[1] && fileURLToPath2(import.meta.url) === process.argv[1] && // Bundle-safe:
|
|
2420
|
-
// module is inlined into a bundle (e.g. @algosuite/vo-mcp's dist/runner-cli.js,
|
|
2421
|
-
// which calls main() itself — without this, `node dist/runner-cli.js` would
|
|
2422
|
-
// start a SECOND daemon loop and double-claim tasks).
|
|
3084
|
+
var invokedDirectly = process.argv[1] && fileURLToPath2(import.meta.url) === process.argv[1] && // Bundle-safe: self-start only when THIS file is the real entry (not inlined into vo-mcp's runner-cli.js ⇒ double-claim).
|
|
2423
3085
|
import.meta.url.endsWith("code-runner-daemon.mjs");
|
|
2424
3086
|
if (invokedDirectly) {
|
|
2425
3087
|
const once2 = process.argv.includes("--once");
|