@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.
@@ -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 spawnSync2 } from "node:child_process";
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((resolve) => {
704
+ return new Promise((resolve2) => {
553
705
  const args = runner.buildArgs({ permissionMode, maxTurns, model, prompt });
554
- const child = spawnImpl(bin, args, {
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
- // BYO auth: each runner fills its provider's credential env var(s) from the
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
- // getSpawnOptions carries the per-runner shell/windowsHide posture. On
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
- resolve({ ...result, ok: false, summary: `spawn error: ${err.message}` });
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
- resolve({
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
- resolve({ ...result, ok: false, killed: true, summary: "cancelled by operator" });
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
- resolve({ ...result, ok: result.ok && code === 0 });
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 { status, error } = spawnSync2("claude", ["--version"], {
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: `claude not found on PATH: ${error.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 binary exists but --version failed (auth unclear)"
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 spawnSync3 } from "node:child_process";
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-auto"];
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 "codex";
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 { status, error } = spawnSync3("codex", ["--version"], {
861
- shell: process.platform === "win32",
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 spawnSync4 } from "node:child_process";
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 } = spawnSync4("cursor-agent", ["--version"], {
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 raw = String(env2.VO_CODE_RUNNER_AGENT || env2.VO_AGENT || DEFAULT_AGENT).trim().toLowerCase();
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 join2, dirname as dirname2 } from "node:path";
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 join2(homedir2(), ".claude", "resume-queue.jsonl");
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 spawnSync5 } from "node:child_process";
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 = spawnSync5(cmd, args, { cwd, encoding: "utf8", timeout, ...env2 ? { env: env2 } : {} });
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, the push + PR
1241
- // authenticate as the App (the runner needs no repo write access of its own);
1242
- // a push failure transparently falls back to the runner's ambient gh auth.
1243
- // Absent → today's behavior (ambient gh) exactly.
1244
- githubToken = null
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
- const cleaned = files.filter((f) => !isAgentScratch(f));
1250
- if (!alreadyCommitted && cleaned.length === 0) {
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
- if (!branch || branch === "main" || branch === "HEAD") {
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
- if (!branch || branch === "main" || branch === "HEAD") {
1271
- const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
1272
- branch = `${branchPrefix}-${stamp}`;
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 out = run(
1284
- "gh",
1285
- [
1286
- "pr",
1287
- "create",
1288
- "--base",
1289
- "main",
1290
- "--head",
1291
- branch,
1292
- "--title",
1293
- compactTitle(title),
1294
- "--body",
1295
- String(body || "")
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: cleaned.length > maxFiles };
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 join3 } from "node:path";
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 = join3(homedir3(), ".vo", "session-spool");
1372
- var CLOUD_MAP_FILE = join3(homedir3(), ".vo", "session-cloud-map.json");
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(join3(spoolDir, f), "utf8"));
1702
+ const record = JSON.parse(await readFile(join4(spoolDir, f), "utf8"));
1405
1703
  if (record && typeof record.session_key === "string") {
1406
- out.push({ full: join3(spoolDir, f), record });
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
- function makeLoopTicks({ client, cfg, env: env2, log: log2, getActive }) {
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 = Date.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
- client.postHeartbeat({ runnerId: cfg.runnerId, uptimeSec: Math.floor(process.uptime()), activeTasks: getActive() }).catch((e) => log2(`heartbeat failed: ${e.message}`));
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 join4 } from "node:path";
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 spawnSync6 } from "node:child_process";
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 = spawnSync6(
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 = join4(homedir4(), ".vo", "dispatched-prs.json");
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(join4(stateFile, ".."), { recursive: true });
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: log2 = () => {
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
- log2(`watch: pr #${prNumber} view failed: ${err.message}`);
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
- log2(`watch: pr #${prNumber} is ${pr.state} \u2014 untracked`);
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
- log2(`watch: pr #${prNumber} CI failing (${pr.failedChecks.join(", ") || "unknown"}) \u2014 dispatched fix ${entry.fixAttempts}/${maxFixAttempts}`);
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
- log2(`watch: pr #${prNumber} fix enqueue failed ${entry.enqueueErrors}x \u2014 giving up: ${err.message}`);
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
- log2(`watch: pr #${prNumber} fix enqueue failed (${entry.enqueueErrors}/${MAX_ENQUEUE_ERRORS}): ${err.message}`);
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
- log2(`watch: pr #${n} capped + failing + tracked >24h ago \u2014 pruned from watch state`);
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: log2, maxFixAttempts }) {
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: log2,
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: log2 = () => {
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) => log2(`control server error: ${e.message} (in-product runner control disabled)`));
1727
- server.listen(port, "127.0.0.1", () => log2(`control server on http://127.0.0.1:${port} (allow ${allowedOrigin})`));
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: log2 = () => {
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: log2
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
- if (t === "cheap") {
2071
- const resolved2 = await resolver("anthropic-balanced");
2072
- return resolved2 || "claude-sonnet-4-6";
2073
- }
2074
- if (t === "best") {
2075
- const resolved2 = await resolver("anthropic-flagship");
2076
- return resolved2 || "claude-opus-4-8";
2077
- }
2078
- const resolved = await resolver("anthropic-flagship");
2079
- return resolved || "claude-opus-4-7";
2080
- }
2081
- async function resolveTaskModel(task) {
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({ ...task, tier: task.tier ?? effortConfig.tier });
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 log(msg) {
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) => log(`agent-select: ${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
- // HARD enforced spend bound (max_budget_usd can only be checked post-hoc).
2170
- // Default 30 min; set 0 to disable.
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) log(`task ${id} is terminal server-side; stopping updates`);
2838
+ if (r && r.terminal) log2(`task ${id} is terminal server-side; stopping updates`);
2190
2839
  return r;
2191
2840
  } catch (err) {
2192
- log(`progress post failed for ${id}: ${err.message}`);
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
- const { dispatchMode, tier, model, permissionMode: effectivePermissionMode, maxTurns: effectiveMaxTurns, prompt: effortPrompt } = await resolveEffortDispatch({ client, task, env: process.env, basePrompt: composeDispatchPrompt(task.prompt, { repo: task.repo }) });
2228
- await safeProgress(client, id, { message: `${cfg.runnerId} spawning ${cfg.agent}:${model} (${tier}, effort ${dispatchMode})` });
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
- log(`task ${id} cancelled by operator`);
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
- log(`task ${id} WARNING: agent ran ${run2.numTurns} turns > max_turns ${task.max_turns}`);
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
- log(
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) log(`task ${id}: RATE_LIMITED (resumeAfter=${v.resumeAfter || "backoff"}); queued (${v.recorded ? "ok" : "queue-write-failed"})`);
2273
- await safeProgress(client, id, { ...v.progress, cost_usd: numOrUndef(run2.costUsd) });
2274
- return;
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
- log(`task ${id}: agent committed ${committed.length} file(s) to a branch; recovering`);
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
- log(`task ${id}: dropped ${scratch.length} scratch file(s): ${scratch.join(", ")}`);
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
- log(`task ${id} cancelled before PR open; discarding changes`);
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: `code-task: ${task.prompt}`,
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
- log(`task ${id} \u2192 PR ${pr.prUrl}`);
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) => log(`watch: track failed for #${pr.prNumber}: ${e.message}`));
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
- log(`task ${id} error: ${msg}`);
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) cleanupFixWorktree(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
- log(`${sig} received \u2014 draining ${active} active task(s), no new claims`);
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
- log(
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)) log(line);
2368
- log(
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) log(`watch: ${r.checked} PR(s) checked, ${r.fixed} fix(es), ${r.untracked} untracked`);
2380
- }).catch((e) => log(`watch cycle error: ${e.message}`));
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
- log(`claim error: ${err.message}`);
2391
- if (once2) break;
2392
- await sleep(cfg.pollSec * 1e3);
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
- log("no pending task; --once exiting");
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
- log(`claimed task ${task.code_task_id} (${task.repo})`);
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
- log("stopped");
3082
+ log2("stopped");
2418
3083
  }
2419
- var invokedDirectly = process.argv[1] && fileURLToPath2(import.meta.url) === process.argv[1] && // Bundle-safe: only self-start when THIS file is the real entry, not when the
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");