@algosuite/vo-mcp 0.2.0-beta.1 → 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 -33
- 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 +214 -0
- package/dist/pair-cli.js.map +7 -0
- package/dist/runner-cli.js +1365 -185
- package/dist/runner-cli.js.map +4 -4
- package/dist/set-key-cli.js +170 -0
- package/dist/set-key-cli.js.map +7 -0
- package/package.json +1 -1
package/dist/runner-cli.js
CHANGED
|
@@ -155,12 +155,65 @@ import fs from "node:fs";
|
|
|
155
155
|
function repoRoot() {
|
|
156
156
|
return process.env.VO_CODE_RUNNER_REPO || process.cwd();
|
|
157
157
|
}
|
|
158
|
+
function clonesRoot() {
|
|
159
|
+
return process.env.VO_CODE_RUNNER_CLONES_ROOT || "";
|
|
160
|
+
}
|
|
161
|
+
var VALID_REPO_SLUG = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
|
162
|
+
var TRACKED_WORKTREES = /* @__PURE__ */ new Map();
|
|
158
163
|
function sanitize(value, fallback) {
|
|
159
164
|
const cleaned = String(value || "").trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
160
165
|
return cleaned || fallback;
|
|
161
166
|
}
|
|
167
|
+
function cloneDirForSlug(repoSlug, clonesRootDir) {
|
|
168
|
+
if (!clonesRootDir || !repoSlug || !VALID_REPO_SLUG.test(String(repoSlug))) return null;
|
|
169
|
+
const [owner, name] = String(repoSlug).split("/");
|
|
170
|
+
if (owner === "." || owner === ".." || name === "." || name === "..") return null;
|
|
171
|
+
if (owner.startsWith("-") || name.startsWith("-")) return null;
|
|
172
|
+
return path.join(clonesRootDir, `${sanitize(owner, "owner")}__${sanitize(name, "repo")}`);
|
|
173
|
+
}
|
|
174
|
+
function resolveTaskRoot(repoSlug) {
|
|
175
|
+
const root = clonesRoot();
|
|
176
|
+
if (root && !path.isAbsolute(root)) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`[vo-mcp runner] VO_CODE_RUNNER_CLONES_ROOT must be an absolute path (got '${root}')`
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
const dir = cloneDirForSlug(repoSlug, root);
|
|
182
|
+
if (!dir) return { root: repoRoot(), multiRepo: false };
|
|
183
|
+
if (!fs.existsSync(path.join(dir, ".git"))) {
|
|
184
|
+
fs.mkdirSync(clonesRoot(), { recursive: true });
|
|
185
|
+
const [owner, name] = String(repoSlug).split("/");
|
|
186
|
+
const tmpDir = `${dir}.tmp-${process.pid}-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`;
|
|
187
|
+
const cl = spawnSync(
|
|
188
|
+
"git",
|
|
189
|
+
["clone", "--no-tags", `https://github.com/${owner}/${name}.git`, tmpDir],
|
|
190
|
+
{ encoding: "utf8", timeout: 6e5 }
|
|
191
|
+
);
|
|
192
|
+
if (cl.status !== 0 || !fs.existsSync(path.join(tmpDir, ".git"))) {
|
|
193
|
+
try {
|
|
194
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
195
|
+
} catch {
|
|
196
|
+
}
|
|
197
|
+
throw new Error(
|
|
198
|
+
`[vo-mcp runner] clone failed for ${repoSlug}: ${String(cl.stderr || cl.error || "").slice(0, 200)}`
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
fs.renameSync(tmpDir, dir);
|
|
203
|
+
} catch {
|
|
204
|
+
try {
|
|
205
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
206
|
+
} catch {
|
|
207
|
+
}
|
|
208
|
+
if (!fs.existsSync(path.join(dir, ".git"))) {
|
|
209
|
+
throw new Error(`[vo-mcp runner] clone race left no usable clone for ${repoSlug}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return { root: dir, multiRepo: true };
|
|
214
|
+
}
|
|
162
215
|
function createFixWorktree(kind, error = {}) {
|
|
163
|
-
const root =
|
|
216
|
+
const { root, multiRepo } = resolveTaskRoot(error.repo);
|
|
164
217
|
const safeKind = sanitize(kind, "task");
|
|
165
218
|
const safeTarget = sanitize(error.source || error.tester || "run", "run").slice(0, 24);
|
|
166
219
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
@@ -174,18 +227,56 @@ function createFixWorktree(kind, error = {}) {
|
|
|
174
227
|
{ cwd: root, encoding: "utf8", timeout: 12e4 }
|
|
175
228
|
);
|
|
176
229
|
if (add.status === 0 && fs.existsSync(worktreeDir)) {
|
|
230
|
+
TRACKED_WORKTREES.set(worktreeName, { worktreeDir, root });
|
|
177
231
|
return { worktreeDir, worktreeName };
|
|
178
232
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
233
|
+
const detail = String(add.stderr || add.error || "").slice(0, 200);
|
|
234
|
+
if (multiRepo) {
|
|
235
|
+
throw new Error(`[vo-mcp runner] worktree create failed for ${error.repo}: ${detail}`);
|
|
236
|
+
}
|
|
237
|
+
console.error(`[vo-mcp runner] worktree isolation unavailable: ${detail}`);
|
|
182
238
|
return { worktreeDir: root, worktreeName: "" };
|
|
183
239
|
}
|
|
184
240
|
function cleanupFixWorktree(worktreeName) {
|
|
185
241
|
if (!worktreeName) return;
|
|
186
|
-
const
|
|
187
|
-
const
|
|
242
|
+
const tracked = TRACKED_WORKTREES.get(worktreeName);
|
|
243
|
+
const root = tracked ? tracked.root : repoRoot();
|
|
244
|
+
const worktreeDir = tracked ? tracked.worktreeDir : path.join(root, ".agent-worktrees", worktreeName);
|
|
188
245
|
spawnSync("git", ["worktree", "remove", "--force", worktreeDir], { cwd: root, timeout: 12e4 });
|
|
246
|
+
TRACKED_WORKTREES.delete(worktreeName);
|
|
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;
|
|
189
280
|
}
|
|
190
281
|
|
|
191
282
|
// src/runner/spend-cap-shim.mjs
|
|
@@ -324,6 +415,57 @@ function createControlPlaneClient({
|
|
|
324
415
|
if (!res.ok) throw new Error(`weekly-tokens failed: HTTP ${res.status}`);
|
|
325
416
|
return true;
|
|
326
417
|
},
|
|
418
|
+
/**
|
|
419
|
+
* Send a liveness heartbeat (M2). The control-plane upserts it under the
|
|
420
|
+
* authenticated operator so the web shows a TRUE "runner online" signal.
|
|
421
|
+
* Best-effort caller; throws on 401/non-ok so the daemon can log + retry.
|
|
422
|
+
*/
|
|
423
|
+
async postHeartbeat({ runnerId, operatorId, uptimeSec, activeTasks, version, servedRepos, servedOperators }) {
|
|
424
|
+
const body = { runner_id: runnerId };
|
|
425
|
+
if (operatorId) body.operator_id = operatorId;
|
|
426
|
+
if (typeof uptimeSec === "number") body.uptime_sec = uptimeSec;
|
|
427
|
+
if (typeof activeTasks === "number") body.active_tasks = activeTasks;
|
|
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
|
+
}
|
|
433
|
+
const res = await req("POST", "/api/v1/runner/heartbeat", body);
|
|
434
|
+
if (res.status === 401) {
|
|
435
|
+
cachedFirebaseToken = null;
|
|
436
|
+
throw new Error("heartbeat unauthorized (401)");
|
|
437
|
+
}
|
|
438
|
+
if (!res.ok) throw new Error(`heartbeat failed: HTTP ${res.status}`);
|
|
439
|
+
return true;
|
|
440
|
+
},
|
|
441
|
+
/**
|
|
442
|
+
* Mint a short-lived (~1h), repo-scoped GitHub App installation token for
|
|
443
|
+
* THIS runner's operator (M3). The control-plane keys the mint on the
|
|
444
|
+
* authenticated operator (ctx.operator_id), so the token covers only that
|
|
445
|
+
* operator's installation.
|
|
446
|
+
*
|
|
447
|
+
* Returns { token, expiresAt } on success, or `null` on ANY non-success —
|
|
448
|
+
* 503 (App not configured / dormant), 404 (operator has no installation),
|
|
449
|
+
* other non-2xx, or a network error. `null` is the signal to the caller to
|
|
450
|
+
* fall back to the runner's own ambient `gh` auth, so a dormant or
|
|
451
|
+
* unmapped App NEVER blocks a PR the runner could open itself. Never throws.
|
|
452
|
+
*
|
|
453
|
+
* The minted token is only usable for push + PR if the GitHub App grants
|
|
454
|
+
* BOTH `Contents: write` (git push) AND `Pull requests: write` (gh pr
|
|
455
|
+
* create) — see docs/vo/github-app-setup-2026-06-18.md. A token missing
|
|
456
|
+
* either scope fails at push (→ ambient fallback) or at `gh pr create`.
|
|
457
|
+
*/
|
|
458
|
+
async getInstallationToken() {
|
|
459
|
+
try {
|
|
460
|
+
const res = await req("POST", "/api/v1/github/installation-token", {});
|
|
461
|
+
if (!res.ok) return null;
|
|
462
|
+
const json = await res.json();
|
|
463
|
+
if (!json || !json.token) return null;
|
|
464
|
+
return { token: json.token, expiresAt: json.expires_at || null };
|
|
465
|
+
} catch {
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
},
|
|
327
469
|
/**
|
|
328
470
|
* Read the operator's dispatch-mode config (Fast→Ultracode effort setting).
|
|
329
471
|
* Returns the mode string ('fast'|'standard'|'deep'|'ultra'|'ultracode'),
|
|
@@ -344,7 +486,152 @@ function createControlPlaneClient({
|
|
|
344
486
|
|
|
345
487
|
// ../../scripts/virtual-office/code-runner/claude-runner.mjs
|
|
346
488
|
import { spawn } from "node:child_process";
|
|
489
|
+
import { spawnSync as spawnSync4 } from "node:child_process";
|
|
490
|
+
|
|
491
|
+
// ../../scripts/virtual-office/code-runner/anthropic-key-store.mjs
|
|
492
|
+
import { createRequire as createRequire2 } from "node:module";
|
|
347
493
|
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
494
|
+
var require2 = createRequire2(import.meta.url);
|
|
495
|
+
var KEY_SERVICE = "algosuite-vo";
|
|
496
|
+
var KEY_ACCOUNT = "anthropic-api-key";
|
|
497
|
+
var _entryCtor;
|
|
498
|
+
var _loadTried = false;
|
|
499
|
+
function defaultEntryCtor() {
|
|
500
|
+
if (_loadTried) return _entryCtor;
|
|
501
|
+
_loadTried = true;
|
|
502
|
+
try {
|
|
503
|
+
_entryCtor = require2("@napi-rs/keyring").Entry;
|
|
504
|
+
} catch {
|
|
505
|
+
_entryCtor = null;
|
|
506
|
+
}
|
|
507
|
+
return _entryCtor;
|
|
508
|
+
}
|
|
509
|
+
function getAnthropicKey({ EntryCtor = defaultEntryCtor() } = {}) {
|
|
510
|
+
if (!EntryCtor) return null;
|
|
511
|
+
try {
|
|
512
|
+
return new EntryCtor(KEY_SERVICE, KEY_ACCOUNT).getPassword() || null;
|
|
513
|
+
} catch {
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
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
|
+
}
|
|
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
|
+
}
|
|
528
|
+
if (baseEnv.ANTHROPIC_API_KEY) return { ...baseEnv };
|
|
529
|
+
const key = getKey();
|
|
530
|
+
return key ? { ...baseEnv, ANTHROPIC_API_KEY: key } : { ...baseEnv };
|
|
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
|
+
}
|
|
633
|
+
|
|
634
|
+
// ../../scripts/virtual-office/code-runner/claude-runner.mjs
|
|
348
635
|
var DEFAULT_PERMISSION_MODE = "acceptEdits";
|
|
349
636
|
function extractText(content) {
|
|
350
637
|
if (typeof content === "string") return content.trim();
|
|
@@ -379,7 +666,7 @@ function parseStreamEvent(line) {
|
|
|
379
666
|
}
|
|
380
667
|
return null;
|
|
381
668
|
}
|
|
382
|
-
function buildClaudeArgs({ permissionMode = DEFAULT_PERMISSION_MODE, maxTurns, model } = {}) {
|
|
669
|
+
function buildClaudeArgs({ permissionMode = DEFAULT_PERMISSION_MODE, maxTurns, model, env: env2 = process.env } = {}) {
|
|
383
670
|
const args = [
|
|
384
671
|
"-p",
|
|
385
672
|
"--output-format",
|
|
@@ -394,12 +681,14 @@ function buildClaudeArgs({ permissionMode = DEFAULT_PERMISSION_MODE, maxTurns, m
|
|
|
394
681
|
if (model) {
|
|
395
682
|
args.push("--model", String(model));
|
|
396
683
|
}
|
|
684
|
+
args.push(...context7McpArgs(env2));
|
|
397
685
|
return args;
|
|
398
686
|
}
|
|
399
|
-
function
|
|
687
|
+
function runAgentTask({
|
|
688
|
+
runner,
|
|
400
689
|
prompt,
|
|
401
690
|
cwd,
|
|
402
|
-
|
|
691
|
+
bin = runner.binary,
|
|
403
692
|
permissionMode,
|
|
404
693
|
maxTurns,
|
|
405
694
|
model,
|
|
@@ -409,19 +698,39 @@ function runClaudeTask({
|
|
|
409
698
|
shouldCancel = async () => false,
|
|
410
699
|
cancelPollMs = 5e3,
|
|
411
700
|
maxWallClockMs = 0,
|
|
412
|
-
spawnImpl = spawn
|
|
701
|
+
spawnImpl = spawn,
|
|
702
|
+
sandbox = null
|
|
413
703
|
}) {
|
|
414
|
-
return new Promise((
|
|
415
|
-
const args =
|
|
416
|
-
const
|
|
704
|
+
return new Promise((resolve2) => {
|
|
705
|
+
const args = runner.buildArgs({ permissionMode, maxTurns, model, prompt });
|
|
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, {
|
|
417
730
|
cwd,
|
|
418
|
-
env:
|
|
731
|
+
env: spawnEnv,
|
|
419
732
|
stdio: ["pipe", "pipe", "pipe"],
|
|
420
|
-
|
|
421
|
-
// (without it: spawn ENOENT). Safe because the prompt goes via stdin
|
|
422
|
-
// below, never argv, so the shell never sees untrusted input.
|
|
423
|
-
shell: process.platform === "win32",
|
|
424
|
-
windowsHide: true
|
|
733
|
+
...spawnOpts
|
|
425
734
|
});
|
|
426
735
|
try {
|
|
427
736
|
child.stdin.write(String(prompt));
|
|
@@ -456,7 +765,7 @@ function runClaudeTask({
|
|
|
456
765
|
while ((nl = buffer.indexOf("\n")) >= 0) {
|
|
457
766
|
const line = buffer.slice(0, nl);
|
|
458
767
|
buffer = buffer.slice(nl + 1);
|
|
459
|
-
const evt =
|
|
768
|
+
const evt = runner.parseEvent(line);
|
|
460
769
|
if (!evt) continue;
|
|
461
770
|
if (evt.kind === "progress") {
|
|
462
771
|
try {
|
|
@@ -480,7 +789,7 @@ function runClaudeTask({
|
|
|
480
789
|
child.on("error", (err) => {
|
|
481
790
|
clearInterval(poll);
|
|
482
791
|
if (wallTimer) clearTimeout(wallTimer);
|
|
483
|
-
|
|
792
|
+
resolve2({ ...result, ok: false, summary: `spawn error: ${err.message}` });
|
|
484
793
|
});
|
|
485
794
|
const poll = setInterval(() => {
|
|
486
795
|
Promise.resolve().then(() => shouldCancel()).then((cancel) => {
|
|
@@ -496,7 +805,7 @@ function runClaudeTask({
|
|
|
496
805
|
clearInterval(poll);
|
|
497
806
|
if (wallTimer) clearTimeout(wallTimer);
|
|
498
807
|
if (timedOut) {
|
|
499
|
-
|
|
808
|
+
resolve2({
|
|
500
809
|
...result,
|
|
501
810
|
ok: false,
|
|
502
811
|
timedOut: true,
|
|
@@ -505,13 +814,13 @@ function runClaudeTask({
|
|
|
505
814
|
return;
|
|
506
815
|
}
|
|
507
816
|
if (killed) {
|
|
508
|
-
|
|
817
|
+
resolve2({ ...result, ok: false, killed: true, summary: "cancelled by operator" });
|
|
509
818
|
return;
|
|
510
819
|
}
|
|
511
820
|
if (!result.summary && code !== 0) {
|
|
512
|
-
result.summary = stderrTail.slice(-500) ||
|
|
821
|
+
result.summary = stderrTail.slice(-500) || `${bin} exited ${code}`;
|
|
513
822
|
}
|
|
514
|
-
|
|
823
|
+
resolve2({ ...result, ok: result.ok && code === 0, summary: augmentAuthError(result.summary) });
|
|
515
824
|
});
|
|
516
825
|
});
|
|
517
826
|
}
|
|
@@ -531,6 +840,18 @@ var ClaudeRunner = class {
|
|
|
531
840
|
windowsHide: true
|
|
532
841
|
};
|
|
533
842
|
}
|
|
843
|
+
/**
|
|
844
|
+
* Fill ANTHROPIC_API_KEY from the OS keychain when not already set (M4 BYO),
|
|
845
|
+
* so a friend who ran `vo-mcp set-key` authenticates without an env var.
|
|
846
|
+
* Explicit env wins; no key stored → unchanged (Claude Code login as before).
|
|
847
|
+
*/
|
|
848
|
+
applyAuthEnv(env2 = process.env) {
|
|
849
|
+
return withAnthropicKey(env2);
|
|
850
|
+
}
|
|
851
|
+
/** Describe which Anthropic auth source the spawn will use (for runner logs). */
|
|
852
|
+
describeAuth(env2 = process.env) {
|
|
853
|
+
return describeAnthropicAuthSource(env2);
|
|
854
|
+
}
|
|
534
855
|
/**
|
|
535
856
|
* Best-effort auth check: is `claude` on PATH and can we verify login?
|
|
536
857
|
* Never throws. If we can't cheaply detect auth, we return installed:true
|
|
@@ -538,30 +859,34 @@ var ClaudeRunner = class {
|
|
|
538
859
|
*/
|
|
539
860
|
async checkAuth() {
|
|
540
861
|
try {
|
|
541
|
-
const
|
|
862
|
+
const probe = spawnSync4("claude", ["--version"], {
|
|
542
863
|
shell: process.platform === "win32",
|
|
543
864
|
windowsHide: true,
|
|
544
865
|
timeout: 3e3,
|
|
545
866
|
stdio: "ignore"
|
|
546
867
|
});
|
|
547
|
-
if (error) {
|
|
868
|
+
if (probe.error) {
|
|
548
869
|
return {
|
|
549
870
|
installed: false,
|
|
550
871
|
authenticated: false,
|
|
551
|
-
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."
|
|
552
873
|
};
|
|
553
874
|
}
|
|
554
|
-
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) {
|
|
555
880
|
return {
|
|
556
881
|
installed: true,
|
|
557
882
|
authenticated: false,
|
|
558
|
-
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."
|
|
559
884
|
};
|
|
560
885
|
}
|
|
561
886
|
return {
|
|
562
887
|
installed: true,
|
|
563
888
|
authenticated: true,
|
|
564
|
-
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)"
|
|
565
890
|
};
|
|
566
891
|
} catch (err) {
|
|
567
892
|
return {
|
|
@@ -574,10 +899,368 @@ var ClaudeRunner = class {
|
|
|
574
899
|
};
|
|
575
900
|
var claudeRunner = new ClaudeRunner();
|
|
576
901
|
|
|
902
|
+
// ../../scripts/virtual-office/code-runner/codex-runner.mjs
|
|
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";
|
|
906
|
+
|
|
907
|
+
// ../../scripts/virtual-office/code-runner/agent-key-store.mjs
|
|
908
|
+
import { createRequire as createRequire3 } from "node:module";
|
|
909
|
+
var require3 = createRequire3(import.meta.url);
|
|
910
|
+
var KEY_SERVICE2 = "algosuite-vo";
|
|
911
|
+
var PROVIDER_ENV = {
|
|
912
|
+
anthropic: ["ANTHROPIC_API_KEY"],
|
|
913
|
+
openai: ["OPENAI_API_KEY", "CODEX_API_KEY"],
|
|
914
|
+
cursor: ["CURSOR_API_KEY"]
|
|
915
|
+
};
|
|
916
|
+
var PROVIDER_ALIAS = {
|
|
917
|
+
claude: "anthropic",
|
|
918
|
+
anthropic: "anthropic",
|
|
919
|
+
codex: "openai",
|
|
920
|
+
openai: "openai",
|
|
921
|
+
cursor: "cursor"
|
|
922
|
+
};
|
|
923
|
+
function resolveProvider(name) {
|
|
924
|
+
const key = String(name || "").trim().toLowerCase();
|
|
925
|
+
return PROVIDER_ALIAS[key] || null;
|
|
926
|
+
}
|
|
927
|
+
function accountFor(provider) {
|
|
928
|
+
return `${provider}-api-key`;
|
|
929
|
+
}
|
|
930
|
+
var _entryCtor2;
|
|
931
|
+
var _loadTried2 = false;
|
|
932
|
+
function defaultEntryCtor2() {
|
|
933
|
+
if (_loadTried2) return _entryCtor2;
|
|
934
|
+
_loadTried2 = true;
|
|
935
|
+
try {
|
|
936
|
+
_entryCtor2 = require3("@napi-rs/keyring").Entry;
|
|
937
|
+
} catch {
|
|
938
|
+
_entryCtor2 = null;
|
|
939
|
+
}
|
|
940
|
+
return _entryCtor2;
|
|
941
|
+
}
|
|
942
|
+
function getAgentKey(provider, { EntryCtor = defaultEntryCtor2() } = {}) {
|
|
943
|
+
const p = resolveProvider(provider);
|
|
944
|
+
if (!p || !EntryCtor) return null;
|
|
945
|
+
try {
|
|
946
|
+
return new EntryCtor(KEY_SERVICE2, accountFor(p)).getPassword() || null;
|
|
947
|
+
} catch {
|
|
948
|
+
return null;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
function withAgentKey(provider, baseEnv = {}, { getKey = getAgentKey } = {}) {
|
|
952
|
+
const p = resolveProvider(provider);
|
|
953
|
+
const vars = p && PROVIDER_ENV[p] || [];
|
|
954
|
+
const out = { ...baseEnv };
|
|
955
|
+
if (!p || vars.length === 0) return out;
|
|
956
|
+
if (vars.some((v) => out[v])) return out;
|
|
957
|
+
const key = getKey(p);
|
|
958
|
+
if (!key) return out;
|
|
959
|
+
for (const v of vars) out[v] = key;
|
|
960
|
+
return out;
|
|
961
|
+
}
|
|
962
|
+
|
|
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
|
+
}
|
|
990
|
+
function buildCodexArgs({ model } = {}) {
|
|
991
|
+
const args = ["exec", "--json", "-c", 'approval_policy="never"', "--sandbox", "danger-full-access"];
|
|
992
|
+
if (model) {
|
|
993
|
+
args.push("--model", String(model));
|
|
994
|
+
}
|
|
995
|
+
args.push("-");
|
|
996
|
+
return args;
|
|
997
|
+
}
|
|
998
|
+
function itemText(item) {
|
|
999
|
+
if (!item) return "";
|
|
1000
|
+
if (typeof item.text === "string") return item.text;
|
|
1001
|
+
if (typeof item.message === "string") return item.message;
|
|
1002
|
+
if (Array.isArray(item.content)) {
|
|
1003
|
+
return item.content.map((b) => typeof b === "string" ? b : typeof b?.text === "string" ? b.text : "").join("");
|
|
1004
|
+
}
|
|
1005
|
+
return "";
|
|
1006
|
+
}
|
|
1007
|
+
function parseCodexEvent(line) {
|
|
1008
|
+
const trimmed = String(line || "").trim();
|
|
1009
|
+
if (!trimmed) return null;
|
|
1010
|
+
let evt;
|
|
1011
|
+
try {
|
|
1012
|
+
evt = JSON.parse(trimmed);
|
|
1013
|
+
} catch {
|
|
1014
|
+
return null;
|
|
1015
|
+
}
|
|
1016
|
+
if (!evt || typeof evt !== "object") return null;
|
|
1017
|
+
const type = evt.type;
|
|
1018
|
+
if (type === "item.completed" && evt.item) {
|
|
1019
|
+
const it = evt.item.type;
|
|
1020
|
+
if (it === "agent_message" || it === "assistant_message") {
|
|
1021
|
+
const text = itemText(evt.item).trim();
|
|
1022
|
+
return text ? { kind: "progress", text } : null;
|
|
1023
|
+
}
|
|
1024
|
+
return null;
|
|
1025
|
+
}
|
|
1026
|
+
if (type === "turn.completed") {
|
|
1027
|
+
return { kind: "result", isError: false, costUsd: null, summary: "completed", numTurns: null };
|
|
1028
|
+
}
|
|
1029
|
+
if (type === "turn.failed" || type === "error") {
|
|
1030
|
+
const msg = evt.error && (evt.error.message || evt.error) || evt.message || "codex run failed";
|
|
1031
|
+
return { kind: "result", isError: true, costUsd: null, summary: String(msg), numTurns: null };
|
|
1032
|
+
}
|
|
1033
|
+
return null;
|
|
1034
|
+
}
|
|
1035
|
+
var CodexRunner = class {
|
|
1036
|
+
get binary() {
|
|
1037
|
+
return resolveCodexBinary();
|
|
1038
|
+
}
|
|
1039
|
+
buildArgs(opts = {}) {
|
|
1040
|
+
return buildCodexArgs(opts);
|
|
1041
|
+
}
|
|
1042
|
+
parseEvent(line) {
|
|
1043
|
+
return parseCodexEvent(line);
|
|
1044
|
+
}
|
|
1045
|
+
getSpawnOptions({ bin } = {}) {
|
|
1046
|
+
const effectiveBin = String(bin || this.binary || "");
|
|
1047
|
+
return {
|
|
1048
|
+
shell: process.platform === "win32" && !/\.exe$/i.test(effectiveBin),
|
|
1049
|
+
windowsHide: true
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
/**
|
|
1053
|
+
* Fill the OpenAI credential env var(s) (OPENAI_API_KEY / CODEX_API_KEY) from
|
|
1054
|
+
* the OS keychain when not already set, so a BYO friend who ran
|
|
1055
|
+
* `vo-mcp set-key --provider codex` authenticates without an env var. Explicit
|
|
1056
|
+
* env wins; no key stored → unchanged (a prior `codex login` still works).
|
|
1057
|
+
*/
|
|
1058
|
+
applyAuthEnv(env2 = process.env) {
|
|
1059
|
+
return withAgentKey("openai", env2);
|
|
1060
|
+
}
|
|
1061
|
+
/** Best-effort: is `codex` on PATH? Never throws. */
|
|
1062
|
+
async checkAuth() {
|
|
1063
|
+
try {
|
|
1064
|
+
const bin = this.binary;
|
|
1065
|
+
const { status, error } = spawnSync5(bin, ["--version"], {
|
|
1066
|
+
...this.getSpawnOptions({ bin }),
|
|
1067
|
+
windowsHide: true,
|
|
1068
|
+
timeout: 3e3,
|
|
1069
|
+
stdio: "ignore"
|
|
1070
|
+
});
|
|
1071
|
+
if (error) {
|
|
1072
|
+
return { installed: false, authenticated: false, message: `codex not found on PATH: ${error.message}` };
|
|
1073
|
+
}
|
|
1074
|
+
if (status !== 0) {
|
|
1075
|
+
return { installed: true, authenticated: false, message: "codex exists but --version failed (auth unclear)" };
|
|
1076
|
+
}
|
|
1077
|
+
return { installed: true, authenticated: true, message: "codex binary found (auth check is best-effort)" };
|
|
1078
|
+
} catch (err) {
|
|
1079
|
+
return { installed: false, authenticated: false, message: `checkAuth probe failed: ${err.message}` };
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
};
|
|
1083
|
+
var codexRunner = new CodexRunner();
|
|
1084
|
+
|
|
1085
|
+
// ../../scripts/virtual-office/code-runner/cursor-runner.mjs
|
|
1086
|
+
import { spawnSync as spawnSync6 } from "node:child_process";
|
|
1087
|
+
function buildCursorArgs({ model, prompt } = {}) {
|
|
1088
|
+
const args = ["-p", "--output-format", "stream-json", "--force"];
|
|
1089
|
+
if (model) {
|
|
1090
|
+
args.push("--model", String(model));
|
|
1091
|
+
}
|
|
1092
|
+
const p = String(prompt ?? "");
|
|
1093
|
+
if (p.length > 0) {
|
|
1094
|
+
args.push(p);
|
|
1095
|
+
}
|
|
1096
|
+
return args;
|
|
1097
|
+
}
|
|
1098
|
+
function messageText(message) {
|
|
1099
|
+
if (!message) return "";
|
|
1100
|
+
const content = message.content;
|
|
1101
|
+
if (typeof content === "string") return content;
|
|
1102
|
+
if (Array.isArray(content)) {
|
|
1103
|
+
return content.map((b) => typeof b === "string" ? b : typeof b?.text === "string" ? b.text : "").join("");
|
|
1104
|
+
}
|
|
1105
|
+
return "";
|
|
1106
|
+
}
|
|
1107
|
+
function parseCursorEvent(line) {
|
|
1108
|
+
const trimmed = String(line || "").trim();
|
|
1109
|
+
if (!trimmed) return null;
|
|
1110
|
+
let evt;
|
|
1111
|
+
try {
|
|
1112
|
+
evt = JSON.parse(trimmed);
|
|
1113
|
+
} catch {
|
|
1114
|
+
return null;
|
|
1115
|
+
}
|
|
1116
|
+
if (!evt || typeof evt !== "object") return null;
|
|
1117
|
+
if (evt.type === "assistant") {
|
|
1118
|
+
const text = messageText(evt.message).trim();
|
|
1119
|
+
return text ? { kind: "progress", text } : null;
|
|
1120
|
+
}
|
|
1121
|
+
if (evt.type === "result") {
|
|
1122
|
+
const isError = Boolean(evt.is_error) || evt.subtype === "error";
|
|
1123
|
+
return {
|
|
1124
|
+
kind: "result",
|
|
1125
|
+
isError,
|
|
1126
|
+
costUsd: null,
|
|
1127
|
+
summary: typeof evt.result === "string" && evt.result.length > 0 ? evt.result : evt.subtype || (isError ? "error" : "completed"),
|
|
1128
|
+
numTurns: null
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
return null;
|
|
1132
|
+
}
|
|
1133
|
+
var CursorRunner = class {
|
|
1134
|
+
get binary() {
|
|
1135
|
+
return "cursor-agent";
|
|
1136
|
+
}
|
|
1137
|
+
buildArgs(opts = {}) {
|
|
1138
|
+
return buildCursorArgs(opts);
|
|
1139
|
+
}
|
|
1140
|
+
parseEvent(line) {
|
|
1141
|
+
return parseCursorEvent(line);
|
|
1142
|
+
}
|
|
1143
|
+
getSpawnOptions() {
|
|
1144
|
+
return {
|
|
1145
|
+
shell: process.platform === "win32",
|
|
1146
|
+
windowsHide: true
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* Fill CURSOR_API_KEY from the OS keychain when not already set, so a BYO
|
|
1151
|
+
* friend who ran `vo-mcp set-key --provider cursor` authenticates without an
|
|
1152
|
+
* env var. Explicit env wins; no key stored → a prior `cursor-agent login`.
|
|
1153
|
+
*/
|
|
1154
|
+
applyAuthEnv(env2 = process.env) {
|
|
1155
|
+
return withAgentKey("cursor", env2);
|
|
1156
|
+
}
|
|
1157
|
+
/** Best-effort: is `cursor-agent` on PATH? Never throws. */
|
|
1158
|
+
async checkAuth() {
|
|
1159
|
+
try {
|
|
1160
|
+
const { status, error } = spawnSync6("cursor-agent", ["--version"], {
|
|
1161
|
+
shell: process.platform === "win32",
|
|
1162
|
+
windowsHide: true,
|
|
1163
|
+
timeout: 3e3,
|
|
1164
|
+
stdio: "ignore"
|
|
1165
|
+
});
|
|
1166
|
+
if (error) {
|
|
1167
|
+
return { installed: false, authenticated: false, message: `cursor-agent not found on PATH: ${error.message}` };
|
|
1168
|
+
}
|
|
1169
|
+
if (status !== 0) {
|
|
1170
|
+
return { installed: true, authenticated: false, message: "cursor-agent exists but --version failed (auth unclear)" };
|
|
1171
|
+
}
|
|
1172
|
+
return {
|
|
1173
|
+
installed: true,
|
|
1174
|
+
authenticated: true,
|
|
1175
|
+
message: "cursor-agent found (EXPERIMENTAL: headless mode may need a TTY; auth check is best-effort)"
|
|
1176
|
+
};
|
|
1177
|
+
} catch (err) {
|
|
1178
|
+
return { installed: false, authenticated: false, message: `checkAuth probe failed: ${err.message}` };
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
};
|
|
1182
|
+
var cursorRunner = new CursorRunner();
|
|
1183
|
+
|
|
1184
|
+
// ../../scripts/virtual-office/code-runner/agent-runner-interface.mjs
|
|
1185
|
+
function validateAgentRunner(runner) {
|
|
1186
|
+
if (!runner || typeof runner !== "object") {
|
|
1187
|
+
throw new TypeError("AgentRunner must be an object");
|
|
1188
|
+
}
|
|
1189
|
+
if (typeof runner.binary !== "string" || runner.binary.length === 0) {
|
|
1190
|
+
throw new TypeError("AgentRunner.binary must be a non-empty string");
|
|
1191
|
+
}
|
|
1192
|
+
if (typeof runner.buildArgs !== "function") {
|
|
1193
|
+
throw new TypeError("AgentRunner.buildArgs must be a function");
|
|
1194
|
+
}
|
|
1195
|
+
if (typeof runner.parseEvent !== "function") {
|
|
1196
|
+
throw new TypeError("AgentRunner.parseEvent must be a function");
|
|
1197
|
+
}
|
|
1198
|
+
if (typeof runner.getSpawnOptions !== "function") {
|
|
1199
|
+
throw new TypeError("AgentRunner.getSpawnOptions must be a function");
|
|
1200
|
+
}
|
|
1201
|
+
if (typeof runner.checkAuth !== "function") {
|
|
1202
|
+
throw new TypeError("AgentRunner.checkAuth must be a function");
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// ../../scripts/virtual-office/code-runner/resolve-runner.mjs
|
|
1207
|
+
var DEFAULT_AGENT = "claude";
|
|
1208
|
+
var RUNNERS = {
|
|
1209
|
+
claude: claudeRunner,
|
|
1210
|
+
codex: codexRunner,
|
|
1211
|
+
cursor: cursorRunner
|
|
1212
|
+
};
|
|
1213
|
+
function listAgents() {
|
|
1214
|
+
return Object.keys(RUNNERS);
|
|
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
|
+
}
|
|
1232
|
+
function resolveRunner(env2 = process.env, { warn = () => {
|
|
1233
|
+
} } = {}) {
|
|
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();
|
|
1237
|
+
let agent = raw;
|
|
1238
|
+
let fellBack = false;
|
|
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
|
+
}
|
|
1246
|
+
if (!runner) {
|
|
1247
|
+
try {
|
|
1248
|
+
warn(`unknown VO_CODE_RUNNER_AGENT "${raw}"; falling back to "${DEFAULT_AGENT}" (known: ${listAgents().join(", ")})`);
|
|
1249
|
+
} catch {
|
|
1250
|
+
}
|
|
1251
|
+
agent = DEFAULT_AGENT;
|
|
1252
|
+
runner = RUNNERS[DEFAULT_AGENT];
|
|
1253
|
+
fellBack = true;
|
|
1254
|
+
}
|
|
1255
|
+
validateAgentRunner(runner);
|
|
1256
|
+
const runnerBin = env2.VO_CODE_RUNNER_BIN || (inferred && inferred.agent === agent ? inferred.bin : "") || (agent === "claude" ? env2.VO_CODE_RUNNER_CLAUDE_BIN : "") || runner.binary;
|
|
1257
|
+
return { agent, runner, runnerBin, fellBack };
|
|
1258
|
+
}
|
|
1259
|
+
|
|
577
1260
|
// ../../scripts/virtual-office/code-runner/rate-limit-resume.mjs
|
|
578
1261
|
import { appendFileSync, mkdirSync as mkdirSync2 } from "node:fs";
|
|
579
1262
|
import { homedir as homedir2 } from "node:os";
|
|
580
|
-
import { join as
|
|
1263
|
+
import { join as join3, dirname as dirname2 } from "node:path";
|
|
581
1264
|
|
|
582
1265
|
// ../../scripts/ci/rate-limit-detector-core.mjs
|
|
583
1266
|
var RATE_LIMIT_RE = /\b(?:usage limit reached|usage limit|rate[ _-]?limit(?:ed|_error)?|too many requests|\b429\b|limit (?:will )?reset)/i;
|
|
@@ -612,7 +1295,7 @@ function detectRateLimit(text, { now = null } = {}) {
|
|
|
612
1295
|
|
|
613
1296
|
// ../../scripts/virtual-office/code-runner/rate-limit-resume.mjs
|
|
614
1297
|
function resumeQueuePath() {
|
|
615
|
-
return
|
|
1298
|
+
return join3(homedir2(), ".claude", "resume-queue.jsonl");
|
|
616
1299
|
}
|
|
617
1300
|
function buildResumeEntry({ task = {}, resumeAfter = null, summary = "", at } = {}) {
|
|
618
1301
|
return {
|
|
@@ -674,9 +1357,65 @@ function classifyFailureForResume({
|
|
|
674
1357
|
}
|
|
675
1358
|
|
|
676
1359
|
// ../../scripts/virtual-office/code-runner/publish.mjs
|
|
677
|
-
import { spawnSync as
|
|
678
|
-
|
|
679
|
-
|
|
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
|
+
}
|
|
1417
|
+
function run(cmd, args, cwd, { timeout = 18e4, raw = false, env: env2 } = {}) {
|
|
1418
|
+
const r = spawnSync7(cmd, args, { cwd, encoding: "utf8", timeout, ...env2 ? { env: env2 } : {} });
|
|
680
1419
|
if (r.error) throw r.error;
|
|
681
1420
|
if (r.status !== 0) {
|
|
682
1421
|
throw new Error(`${cmd} ${args[0]} failed (exit ${r.status}): ${(r.stderr || "").slice(-300)}`);
|
|
@@ -735,6 +1474,84 @@ function listCommittedFiles(cwd, base = "origin/main") {
|
|
|
735
1474
|
function compactTitle(s, max = 100) {
|
|
736
1475
|
return String(s || "").replace(/\s+/g, " ").trim().slice(0, max) || "code-task";
|
|
737
1476
|
}
|
|
1477
|
+
function installationTokenEnv(githubToken, baseEnv = process.env) {
|
|
1478
|
+
if (!githubToken) return void 0;
|
|
1479
|
+
return { ...baseEnv, GH_TOKEN: githubToken, GITHUB_TOKEN: githubToken };
|
|
1480
|
+
}
|
|
1481
|
+
function pushArgs(branch, { withToken = false } = {}) {
|
|
1482
|
+
if (withToken) {
|
|
1483
|
+
return [
|
|
1484
|
+
"-c",
|
|
1485
|
+
"credential.helper=",
|
|
1486
|
+
"-c",
|
|
1487
|
+
"credential.helper=!gh auth git-credential",
|
|
1488
|
+
"push",
|
|
1489
|
+
"origin",
|
|
1490
|
+
branch
|
|
1491
|
+
];
|
|
1492
|
+
}
|
|
1493
|
+
return ["push", "origin", branch];
|
|
1494
|
+
}
|
|
1495
|
+
function pushPlan(branch, githubToken) {
|
|
1496
|
+
if (githubToken) {
|
|
1497
|
+
return {
|
|
1498
|
+
primary: { args: pushArgs(branch, { withToken: true }), env: installationTokenEnv(githubToken), tokenUsed: true },
|
|
1499
|
+
fallback: { args: pushArgs(branch), env: void 0, tokenUsed: false }
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
return { primary: { args: pushArgs(branch), env: void 0, tokenUsed: false }, fallback: null };
|
|
1503
|
+
}
|
|
1504
|
+
function pushBranch(worktreeDir, branch, githubToken, runFn = run) {
|
|
1505
|
+
const { primary, fallback } = pushPlan(branch, githubToken);
|
|
1506
|
+
try {
|
|
1507
|
+
runFn("git", primary.args, worktreeDir, { env: primary.env });
|
|
1508
|
+
return primary.tokenUsed;
|
|
1509
|
+
} catch (err) {
|
|
1510
|
+
if (!fallback) throw err;
|
|
1511
|
+
runFn("git", fallback.args, worktreeDir, { env: fallback.env });
|
|
1512
|
+
return fallback.tokenUsed;
|
|
1513
|
+
}
|
|
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
|
+
}
|
|
738
1555
|
function openCodeTaskPr(worktreeDir, files, {
|
|
739
1556
|
title,
|
|
740
1557
|
body,
|
|
@@ -744,63 +1561,43 @@ function openCodeTaskPr(worktreeDir, files, {
|
|
|
744
1561
|
maxFiles = 200,
|
|
745
1562
|
// Safety net: the agent already COMMITTED its work to a branch (despite the
|
|
746
1563
|
// preamble). Skip add+commit; just push the existing branch and open the PR.
|
|
747
|
-
alreadyCommitted = false
|
|
1564
|
+
alreadyCommitted = false,
|
|
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
|
|
748
1571
|
} = {}) {
|
|
749
1572
|
if (!Array.isArray(files) || files.length === 0) {
|
|
750
1573
|
throw new Error("openCodeTaskPr: no files to commit");
|
|
751
1574
|
}
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
throw new Error("openCodeTaskPr: only scratch files, nothing to commit");
|
|
755
|
-
}
|
|
756
|
-
const toAdd = cleaned.slice(0, maxFiles);
|
|
757
|
-
run("git", ["config", "user.name", botName], worktreeDir);
|
|
758
|
-
run("git", ["config", "user.email", botEmail], worktreeDir);
|
|
759
|
-
let branch = "";
|
|
760
|
-
try {
|
|
761
|
-
branch = run("git", ["branch", "--show-current"], worktreeDir, { timeout: 3e4 });
|
|
762
|
-
} catch {
|
|
763
|
-
}
|
|
1575
|
+
let branch;
|
|
1576
|
+
let truncated = false;
|
|
764
1577
|
if (alreadyCommitted) {
|
|
765
|
-
|
|
766
|
-
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
767
|
-
branch = `${branchPrefix}-${stamp}`;
|
|
768
|
-
run("git", ["checkout", "-b", branch], worktreeDir);
|
|
769
|
-
}
|
|
770
|
-
run("git", ["push", "origin", branch], worktreeDir);
|
|
1578
|
+
branch = resolveOrCreateBranch(worktreeDir, branchPrefix);
|
|
771
1579
|
} else {
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
run("git", ["fetch", "origin", "main"], worktreeDir, { timeout: 6e4 });
|
|
776
|
-
run("git", ["checkout", "-b", branch, "origin/main"], worktreeDir);
|
|
777
|
-
}
|
|
778
|
-
for (const f of toAdd) {
|
|
779
|
-
run("git", ["add", "--", f], worktreeDir, { timeout: 6e4 });
|
|
780
|
-
}
|
|
781
|
-
const commitMsg = compactTitle(title, 180);
|
|
782
|
-
run("git", ["commit", "--no-verify", "-m", commitMsg], worktreeDir);
|
|
783
|
-
run("git", ["push", "origin", branch], worktreeDir);
|
|
1580
|
+
const committed = commitWorkLocally(worktreeDir, files, { title, branchPrefix, botName, botEmail, maxFiles });
|
|
1581
|
+
branch = committed.branch;
|
|
1582
|
+
truncated = committed.truncated;
|
|
784
1583
|
}
|
|
785
|
-
const
|
|
786
|
-
"
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
"
|
|
793
|
-
branch,
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
],
|
|
799
|
-
worktreeDir
|
|
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") }
|
|
800
1597
|
);
|
|
801
1598
|
const m = out.match(/https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/);
|
|
802
1599
|
if (!m) throw new Error("gh pr create returned no parseable PR URL");
|
|
803
|
-
return { prUrl: m[0], prNumber: Number(m[1]), branch, truncated
|
|
1600
|
+
return { prUrl: m[0], prNumber: Number(m[1]), branch, truncated };
|
|
804
1601
|
}
|
|
805
1602
|
|
|
806
1603
|
// ../../scripts/virtual-office/code-runner/dispatch-onboarding.mjs
|
|
@@ -866,11 +1663,11 @@ ${String(taskPrompt ?? "").trim()}
|
|
|
866
1663
|
|
|
867
1664
|
// ../../scripts/virtual-office/code-runner/session-spool-forwarder.mjs
|
|
868
1665
|
import { homedir as homedir3 } from "node:os";
|
|
869
|
-
import { join as
|
|
1666
|
+
import { join as join4 } from "node:path";
|
|
870
1667
|
import { readdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
871
1668
|
import { createHash } from "node:crypto";
|
|
872
|
-
var SPOOL_DIR =
|
|
873
|
-
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");
|
|
874
1671
|
var STALE_MS = 60 * 60 * 1e3;
|
|
875
1672
|
var ACTIVE_SILENCE_MS = 10 * 60 * 1e3;
|
|
876
1673
|
function deriveUuid(seed) {
|
|
@@ -902,9 +1699,9 @@ async function readSpool(spoolDir = SPOOL_DIR) {
|
|
|
902
1699
|
for (const f of files) {
|
|
903
1700
|
if (!f.endsWith(".json")) continue;
|
|
904
1701
|
try {
|
|
905
|
-
const record = JSON.parse(await readFile(
|
|
1702
|
+
const record = JSON.parse(await readFile(join4(spoolDir, f), "utf8"));
|
|
906
1703
|
if (record && typeof record.session_key === "string") {
|
|
907
|
-
out.push({ full:
|
|
1704
|
+
out.push({ full: join4(spoolDir, f), record });
|
|
908
1705
|
}
|
|
909
1706
|
} catch {
|
|
910
1707
|
}
|
|
@@ -983,14 +1780,266 @@ async function forwardSessionSpool(deps) {
|
|
|
983
1780
|
return { forwarded, pruned };
|
|
984
1781
|
}
|
|
985
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
|
+
|
|
1980
|
+
// ../../scripts/virtual-office/code-runner/loop-ticks.mjs
|
|
1981
|
+
var HEARTBEAT_MS = 6e4;
|
|
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
|
+
}) {
|
|
1993
|
+
let lastSessionForward = 0;
|
|
1994
|
+
let lastHeartbeat = 0;
|
|
1995
|
+
let lastResumeSchedule = 0;
|
|
1996
|
+
let resumeRunning = false;
|
|
1997
|
+
return function tick() {
|
|
1998
|
+
const now = nowFn();
|
|
1999
|
+
if (cfg.sessionForwardSec > 0 && now - lastSessionForward >= cfg.sessionForwardSec * 1e3) {
|
|
2000
|
+
lastSessionForward = now;
|
|
2001
|
+
forwardSessionSpool({
|
|
2002
|
+
baseUrl: String(env2.VO_CONTROL_PLANE_URL || "").replace(/\/$/, ""),
|
|
2003
|
+
token: env2.VO_CONTROL_PLANE_ADMIN_TOKEN || "",
|
|
2004
|
+
operatorSeed: cfg.operatorSeed
|
|
2005
|
+
}).catch(() => {
|
|
2006
|
+
});
|
|
2007
|
+
}
|
|
2008
|
+
if (now - lastHeartbeat >= HEARTBEAT_MS) {
|
|
2009
|
+
lastHeartbeat = now;
|
|
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
|
+
});
|
|
2031
|
+
}
|
|
2032
|
+
};
|
|
2033
|
+
}
|
|
2034
|
+
|
|
986
2035
|
// ../../scripts/virtual-office/code-runner/pr-watcher.mjs
|
|
987
2036
|
import { homedir as homedir4 } from "node:os";
|
|
988
|
-
import { join as
|
|
2037
|
+
import { join as join6 } from "node:path";
|
|
989
2038
|
import { readFile as readFile2, writeFile as writeFile2, mkdir } from "node:fs/promises";
|
|
990
|
-
import { spawnSync as
|
|
2039
|
+
import { spawnSync as spawnSync8 } from "node:child_process";
|
|
991
2040
|
var CI_FIX_MARKER = "[VO-CI-FIX]";
|
|
992
2041
|
function ghViewPr(prNumber, repo) {
|
|
993
|
-
const r =
|
|
2042
|
+
const r = spawnSync8(
|
|
994
2043
|
"gh",
|
|
995
2044
|
["pr", "view", String(prNumber), "-R", repo, "--json", "state,statusCheckRollup,headRefName"],
|
|
996
2045
|
{ encoding: "utf8", timeout: 3e4 }
|
|
@@ -998,7 +2047,7 @@ function ghViewPr(prNumber, repo) {
|
|
|
998
2047
|
if (r.status !== 0) throw new Error((r.stderr || "gh pr view failed").slice(-200));
|
|
999
2048
|
return JSON.parse(r.stdout || "{}");
|
|
1000
2049
|
}
|
|
1001
|
-
var DEFAULT_STATE_FILE =
|
|
2050
|
+
var DEFAULT_STATE_FILE = join6(homedir4(), ".vo", "dispatched-prs.json");
|
|
1002
2051
|
var FAIL_CONCLUSIONS = /* @__PURE__ */ new Set([
|
|
1003
2052
|
"FAILURE",
|
|
1004
2053
|
"TIMED_OUT",
|
|
@@ -1061,7 +2110,7 @@ async function readState(stateFile) {
|
|
|
1061
2110
|
}
|
|
1062
2111
|
async function writeState(stateFile, state) {
|
|
1063
2112
|
try {
|
|
1064
|
-
await mkdir(
|
|
2113
|
+
await mkdir(join6(stateFile, ".."), { recursive: true });
|
|
1065
2114
|
await writeFile2(stateFile, JSON.stringify(state, null, 2), "utf8");
|
|
1066
2115
|
} catch {
|
|
1067
2116
|
}
|
|
@@ -1078,7 +2127,7 @@ async function trackDispatchedPr({ prNumber, repo, branch, taskId }, { stateFile
|
|
|
1078
2127
|
};
|
|
1079
2128
|
await writeState(stateFile, state);
|
|
1080
2129
|
}
|
|
1081
|
-
async function runWatchCycle({ viewPr, enqueueFix, log:
|
|
2130
|
+
async function runWatchCycle({ viewPr, enqueueFix, log: log3 = () => {
|
|
1082
2131
|
}, now = () => Date.now(), maxFixAttempts = 1, stateFile = DEFAULT_STATE_FILE }) {
|
|
1083
2132
|
const state = await readState(stateFile);
|
|
1084
2133
|
const prNumbers = Object.keys(state);
|
|
@@ -1091,7 +2140,7 @@ async function runWatchCycle({ viewPr, enqueueFix, log: log2 = () => {
|
|
|
1091
2140
|
try {
|
|
1092
2141
|
view = await viewPr(prNumber, entry.repo);
|
|
1093
2142
|
} catch (err) {
|
|
1094
|
-
|
|
2143
|
+
log3(`watch: pr #${prNumber} view failed: ${err.message}`);
|
|
1095
2144
|
continue;
|
|
1096
2145
|
}
|
|
1097
2146
|
checked += 1;
|
|
@@ -1101,21 +2150,21 @@ async function runWatchCycle({ viewPr, enqueueFix, log: log2 = () => {
|
|
|
1101
2150
|
if (action === "untrack") {
|
|
1102
2151
|
delete state[prNumber];
|
|
1103
2152
|
untracked += 1;
|
|
1104
|
-
|
|
2153
|
+
log3(`watch: pr #${prNumber} is ${pr.state} \u2014 untracked`);
|
|
1105
2154
|
} else if (action === "fix") {
|
|
1106
2155
|
entry.fixAttempts = (entry.fixAttempts || 0) + 1;
|
|
1107
2156
|
entry.lastCheckedAt = now();
|
|
1108
2157
|
try {
|
|
1109
2158
|
await enqueueFix({ prNumber: Number(prNumber), repo: entry.repo, branch: pr.branch || entry.branch, failedChecks: pr.failedChecks });
|
|
1110
2159
|
fixed += 1;
|
|
1111
|
-
|
|
2160
|
+
log3(`watch: pr #${prNumber} CI failing (${pr.failedChecks.join(", ") || "unknown"}) \u2014 dispatched fix ${entry.fixAttempts}/${maxFixAttempts}`);
|
|
1112
2161
|
} catch (err) {
|
|
1113
2162
|
entry.enqueueErrors = (entry.enqueueErrors || 0) + 1;
|
|
1114
2163
|
if (entry.enqueueErrors >= MAX_ENQUEUE_ERRORS) {
|
|
1115
|
-
|
|
2164
|
+
log3(`watch: pr #${prNumber} fix enqueue failed ${entry.enqueueErrors}x \u2014 giving up: ${err.message}`);
|
|
1116
2165
|
} else {
|
|
1117
2166
|
entry.fixAttempts = Math.max(0, (entry.fixAttempts || 1) - 1);
|
|
1118
|
-
|
|
2167
|
+
log3(`watch: pr #${prNumber} fix enqueue failed (${entry.enqueueErrors}/${MAX_ENQUEUE_ERRORS}): ${err.message}`);
|
|
1119
2168
|
}
|
|
1120
2169
|
}
|
|
1121
2170
|
} else {
|
|
@@ -1127,17 +2176,17 @@ async function runWatchCycle({ viewPr, enqueueFix, log: log2 = () => {
|
|
|
1127
2176
|
if (cappedFailing && e.trackedAt && now() - e.trackedAt > STALE_MS2) {
|
|
1128
2177
|
delete state[n];
|
|
1129
2178
|
untracked += 1;
|
|
1130
|
-
|
|
2179
|
+
log3(`watch: pr #${n} capped + failing + tracked >24h ago \u2014 pruned from watch state`);
|
|
1131
2180
|
}
|
|
1132
2181
|
}
|
|
1133
2182
|
await writeState(stateFile, state);
|
|
1134
2183
|
return { checked, fixed, untracked };
|
|
1135
2184
|
}
|
|
1136
|
-
function makeWatchRunner({ client, viewPr = ghViewPr, log:
|
|
2185
|
+
function makeWatchRunner({ client, viewPr = ghViewPr, log: log3, maxFixAttempts }) {
|
|
1137
2186
|
return () => runWatchCycle({
|
|
1138
2187
|
viewPr,
|
|
1139
2188
|
enqueueFix: ({ prNumber, repo, branch, failedChecks }) => client.enqueueCodeTask({ repo, prompt: buildCiFixPrompt({ prNumber, repo, branch, failedChecks }) }),
|
|
1140
|
-
log:
|
|
2189
|
+
log: log3,
|
|
1141
2190
|
maxFixAttempts
|
|
1142
2191
|
});
|
|
1143
2192
|
}
|
|
@@ -1198,15 +2247,15 @@ function buildControlHandler({ getStatus, requestStop, allowedOrigin }) {
|
|
|
1198
2247
|
res.end(JSON.stringify({ ok: false, error: "not_found" }));
|
|
1199
2248
|
};
|
|
1200
2249
|
}
|
|
1201
|
-
function startControlServer({ port, getStatus, requestStop, allowedOrigin, log:
|
|
2250
|
+
function startControlServer({ port, getStatus, requestStop, allowedOrigin, log: log3 = () => {
|
|
1202
2251
|
} }) {
|
|
1203
2252
|
const server = createServer(buildControlHandler({ getStatus, requestStop, allowedOrigin }));
|
|
1204
|
-
server.on("error", (e) =>
|
|
1205
|
-
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})`));
|
|
1206
2255
|
server.unref?.();
|
|
1207
2256
|
return server;
|
|
1208
2257
|
}
|
|
1209
|
-
function startDaemonControl({ cfg, requestStop, getActiveCount, isRunning, startedAt, log:
|
|
2258
|
+
function startDaemonControl({ cfg, requestStop, getActiveCount, isRunning, startedAt, log: log3 = () => {
|
|
1210
2259
|
} }) {
|
|
1211
2260
|
if (!cfg.controlEnabled) return null;
|
|
1212
2261
|
return startControlServer({
|
|
@@ -1218,12 +2267,13 @@ function startDaemonControl({ cfg, requestStop, getActiveCount, isRunning, start
|
|
|
1218
2267
|
pid: process.pid,
|
|
1219
2268
|
runnerId: cfg.runnerId,
|
|
1220
2269
|
servedRepos: cfg.servedRepos,
|
|
2270
|
+
servedOperators: cfg.servedOperators,
|
|
1221
2271
|
watchEnabled: cfg.watchEnabled,
|
|
1222
2272
|
activeTasks: getActiveCount(),
|
|
1223
2273
|
startedAt: new Date(startedAt).toISOString(),
|
|
1224
2274
|
uptimeSec: Math.round((Date.now() - startedAt) / 1e3)
|
|
1225
2275
|
}),
|
|
1226
|
-
log:
|
|
2276
|
+
log: log3
|
|
1227
2277
|
});
|
|
1228
2278
|
}
|
|
1229
2279
|
|
|
@@ -1527,6 +2577,59 @@ async function resolveModelFamily(family, options = {}) {
|
|
|
1527
2577
|
}
|
|
1528
2578
|
|
|
1529
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
|
+
}
|
|
1530
2633
|
function classifyTier(prompt) {
|
|
1531
2634
|
const text = String(prompt || "").trim();
|
|
1532
2635
|
if (!text) return "mid";
|
|
@@ -1543,30 +2646,34 @@ function classifyTier(prompt) {
|
|
|
1543
2646
|
}
|
|
1544
2647
|
return "mid";
|
|
1545
2648
|
}
|
|
1546
|
-
async function resolveModelForTier(tier, { resolveModelFamily: resolver = resolveModelFamily } = {}) {
|
|
2649
|
+
async function resolveModelForTier(tier, { agent = DEFAULT_AGENT2, resolveModelFamily: resolver = resolveModelFamily } = {}) {
|
|
1547
2650
|
const t = String(tier || "mid").trim();
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
return
|
|
1555
|
-
}
|
|
1556
|
-
const resolved = await resolver(
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
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 } = {}) {
|
|
1560
2664
|
const tier = task.tier && task.tier !== "auto" ? task.tier : classifyTier(task.prompt);
|
|
1561
|
-
const model = await resolveModelForTier(tier);
|
|
2665
|
+
const model = await resolveModelForTier(tier, { agent });
|
|
1562
2666
|
return { tier, model };
|
|
1563
2667
|
}
|
|
1564
2668
|
|
|
1565
2669
|
// ../../scripts/virtual-office/code-runner/apply-effort-mode.mjs
|
|
1566
|
-
async function resolveEffortDispatch({ client, task, env: env2, basePrompt, resolveModel = resolveTaskModel }) {
|
|
2670
|
+
async function resolveEffortDispatch({ client, task, agent = "claude", env: env2, basePrompt, resolveModel = resolveTaskModel }) {
|
|
1567
2671
|
const dispatchMode = await client.getDispatchMode().catch(() => "standard");
|
|
1568
2672
|
const effortConfig = resolveEffortMode(dispatchMode);
|
|
1569
|
-
const { tier, model } = await resolveModel(
|
|
2673
|
+
const { tier, model } = await resolveModel(
|
|
2674
|
+
{ ...task, tier: task.tier ?? effortConfig.tier },
|
|
2675
|
+
{ agent }
|
|
2676
|
+
);
|
|
1570
2677
|
return {
|
|
1571
2678
|
dispatchMode,
|
|
1572
2679
|
tier,
|
|
@@ -1619,8 +2726,73 @@ function describeClaimScoping(cfg = {}, env2 = {}) {
|
|
|
1619
2726
|
return lines;
|
|
1620
2727
|
}
|
|
1621
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
|
+
|
|
1622
2794
|
// ../../scripts/virtual-office/code-runner-daemon.mjs
|
|
1623
|
-
function
|
|
2795
|
+
function log2(msg) {
|
|
1624
2796
|
console.log(`[code-runner ${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
|
|
1625
2797
|
}
|
|
1626
2798
|
var RATE_LIMIT_RESUME_ENABLED = process.env.VO_RATE_LIMIT_RESUME === "1";
|
|
@@ -1628,7 +2800,8 @@ var parseList = (s) => String(s || "").split(/[\s,]+/).map((x) => x.trim()).filt
|
|
|
1628
2800
|
function loadConfig(env2 = process.env) {
|
|
1629
2801
|
return {
|
|
1630
2802
|
runnerId: env2.VO_CODE_RUNNER_ID || `vo-code-runner-${os.hostname()}`,
|
|
1631
|
-
|
|
2803
|
+
// BYO multi-agent: {agent, runner, runnerBin} — VO_CODE_RUNNER_AGENT (claude|codex).
|
|
2804
|
+
...resolveRunner(env2, { warn: (m) => log2(`agent-select: ${m}`) }),
|
|
1632
2805
|
permissionMode: env2.VO_CODE_RUNNER_PERMISSION_MODE || "acceptEdits",
|
|
1633
2806
|
maxConcurrency: Math.max(1, Number(env2.VO_CODE_TASK_MAX_CONCURRENCY || 2) || 2),
|
|
1634
2807
|
pollSec: Math.max(1, Number(env2.VO_CODE_RUNNER_POLL_SEC || 5) || 5),
|
|
@@ -1643,9 +2816,8 @@ function loadConfig(env2 = process.env) {
|
|
|
1643
2816
|
sessionForwardSec: Math.max(0, Number(env2.VO_SESSION_FORWARD_SEC ?? 30) || 0),
|
|
1644
2817
|
operatorSeed: env2.VO_LOCAL_OPERATOR_SEED || env2.VO_CODE_RUNNER_ID || `local-${os.hostname()}`,
|
|
1645
2818
|
cancelPollMs: Math.max(1e3, Number(env2.VO_CODE_RUNNER_CANCEL_POLL_MS || 2500) || 2500),
|
|
1646
|
-
//
|
|
1647
|
-
|
|
1648
|
-
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)),
|
|
1649
2821
|
// Active PR watcher: monitor each dispatched PR's CI + auto-dispatch ONE fix
|
|
1650
2822
|
// on failure (never auto-merges). Off: VO_CODE_RUNNER_WATCH=0; cap/interval below.
|
|
1651
2823
|
watchEnabled: env2.VO_CODE_RUNNER_WATCH !== "0",
|
|
@@ -1663,10 +2835,10 @@ var numOrUndef = (x) => typeof x === "number" ? x : void 0;
|
|
|
1663
2835
|
async function safeProgress(client, id, patch) {
|
|
1664
2836
|
try {
|
|
1665
2837
|
const r = await client.postProgress(id, patch);
|
|
1666
|
-
if (r && r.terminal)
|
|
2838
|
+
if (r && r.terminal) log2(`task ${id} is terminal server-side; stopping updates`);
|
|
1667
2839
|
return r;
|
|
1668
2840
|
} catch (err) {
|
|
1669
|
-
|
|
2841
|
+
log2(`progress post failed for ${id}: ${err.message}`);
|
|
1670
2842
|
return null;
|
|
1671
2843
|
}
|
|
1672
2844
|
}
|
|
@@ -1698,16 +2870,19 @@ function buildPrBody(task, run2, files) {
|
|
|
1698
2870
|
async function processOneTask(client, task, cfg) {
|
|
1699
2871
|
const id = task.code_task_id;
|
|
1700
2872
|
let worktreeName = "";
|
|
2873
|
+
let preserveReason = null;
|
|
1701
2874
|
try {
|
|
1702
|
-
const wt = createFixWorktree("code-task", { source: id.slice(0, 8) });
|
|
2875
|
+
const wt = createFixWorktree("code-task", { source: id.slice(0, 8), repo: task.repo });
|
|
1703
2876
|
worktreeName = wt.worktreeName;
|
|
1704
|
-
|
|
1705
|
-
await
|
|
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})` });
|
|
1706
2880
|
const cap = typeof task.max_budget_usd === "number" ? task.max_budget_usd : resolveSpendCapUsd();
|
|
1707
|
-
const run2 = await
|
|
2881
|
+
const run2 = await runAgentTask({
|
|
2882
|
+
runner: cfg.runner,
|
|
2883
|
+
bin: cfg.runnerBin,
|
|
1708
2884
|
prompt: effortPrompt,
|
|
1709
2885
|
cwd: wt.worktreeDir,
|
|
1710
|
-
claudeBin: cfg.claudeBin,
|
|
1711
2886
|
permissionMode: effectivePermissionMode,
|
|
1712
2887
|
maxTurns: effectiveMaxTurns,
|
|
1713
2888
|
model,
|
|
@@ -1723,10 +2898,12 @@ async function processOneTask(client, task, cfg) {
|
|
|
1723
2898
|
maxWallClockMs: cfg.maxWallClockMs
|
|
1724
2899
|
});
|
|
1725
2900
|
if (run2.killed) {
|
|
1726
|
-
|
|
2901
|
+
preserveReason = "cancelled by operator \u2014 work preserved for recovery";
|
|
2902
|
+
log2(`task ${id} cancelled by operator`);
|
|
1727
2903
|
return;
|
|
1728
2904
|
}
|
|
1729
2905
|
if (run2.timedOut) {
|
|
2906
|
+
preserveReason = "wall-clock timeout \u2014 partial work preserved for recovery";
|
|
1730
2907
|
await safeProgress(client, id, {
|
|
1731
2908
|
status: "failed",
|
|
1732
2909
|
message: run2.summary,
|
|
@@ -1736,18 +2913,22 @@ async function processOneTask(client, task, cfg) {
|
|
|
1736
2913
|
return;
|
|
1737
2914
|
}
|
|
1738
2915
|
if (typeof task.max_turns === "number" && typeof run2.numTurns === "number" && run2.numTurns > task.max_turns) {
|
|
1739
|
-
|
|
2916
|
+
log2(`task ${id} WARNING: agent ran ${run2.numTurns} turns > max_turns ${task.max_turns}`);
|
|
1740
2917
|
}
|
|
1741
2918
|
if (typeof run2.costUsd === "number" && cap > 0 && run2.costUsd > cap) {
|
|
1742
|
-
|
|
1743
|
-
`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`
|
|
1744
|
-
);
|
|
2919
|
+
log2(`task ${id}: usage ~$${run2.costUsd.toFixed(2)} (est, API-equivalent, not billed on a subscription) exceeded soft cap $${cap}; publishing anyway`);
|
|
1745
2920
|
}
|
|
2921
|
+
let partial = false;
|
|
1746
2922
|
if (!run2.ok) {
|
|
1747
2923
|
const v = classifyFailureForResume({ enabled: RATE_LIMIT_RESUME_ENABLED, run: run2, task });
|
|
1748
|
-
if (v.rateLimited)
|
|
1749
|
-
|
|
1750
|
-
|
|
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`);
|
|
1751
2932
|
}
|
|
1752
2933
|
let files = listChangedFiles(wt.worktreeDir);
|
|
1753
2934
|
let alreadyCommitted = false;
|
|
@@ -1756,33 +2937,37 @@ async function processOneTask(client, task, cfg) {
|
|
|
1756
2937
|
if (committed.length > 0) {
|
|
1757
2938
|
files = committed;
|
|
1758
2939
|
alreadyCommitted = true;
|
|
1759
|
-
|
|
2940
|
+
log2(`task ${id}: agent committed ${committed.length} file(s) to a branch; recovering`);
|
|
1760
2941
|
}
|
|
1761
2942
|
}
|
|
1762
2943
|
const scratch = files.filter(isAgentScratch);
|
|
1763
2944
|
if (scratch.length > 0) {
|
|
1764
2945
|
files = files.filter((f) => !isAgentScratch(f));
|
|
1765
|
-
|
|
2946
|
+
log2(`task ${id}: dropped ${scratch.length} scratch file(s): ${scratch.join(", ")}`);
|
|
1766
2947
|
}
|
|
1767
2948
|
if (files.length === 0) {
|
|
1768
2949
|
await safeProgress(client, id, {
|
|
1769
|
-
status: "failed",
|
|
1770
|
-
message: "agent made no file changes",
|
|
1771
|
-
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),
|
|
1772
2953
|
cost_usd: numOrUndef(run2.costUsd)
|
|
1773
2954
|
});
|
|
2955
|
+
if (!partial) log2(`task ${id}: agent completed successfully with no changes (already fixed)`);
|
|
1774
2956
|
return;
|
|
1775
2957
|
}
|
|
1776
2958
|
const fresh = await client.getTask(id).catch(() => null);
|
|
1777
2959
|
if (fresh && fresh.status === "cancelled") {
|
|
1778
|
-
|
|
2960
|
+
log2(`task ${id} cancelled before PR open; discarding changes`);
|
|
1779
2961
|
return;
|
|
1780
2962
|
}
|
|
1781
2963
|
await safeProgress(client, id, { message: `opening PR for ${files.length} changed file(s)` });
|
|
2964
|
+
const githubToken = (await client.getInstallationToken())?.token ?? null;
|
|
1782
2965
|
const pr = openCodeTaskPr(wt.worktreeDir, files, {
|
|
1783
|
-
title:
|
|
2966
|
+
title: `${partial ? "\u26A0 PARTIAL (timed out) \u2014 " : ""}code-task: ${task.prompt}`,
|
|
1784
2967
|
body: buildPrBody(task, run2, files),
|
|
1785
|
-
alreadyCommitted
|
|
2968
|
+
alreadyCommitted,
|
|
2969
|
+
githubToken,
|
|
2970
|
+
draft: partial
|
|
1786
2971
|
});
|
|
1787
2972
|
await safeProgress(client, id, {
|
|
1788
2973
|
status: "pr_opened",
|
|
@@ -1792,18 +2977,19 @@ async function processOneTask(client, task, cfg) {
|
|
|
1792
2977
|
result: String(run2.summary).slice(0, 2e3),
|
|
1793
2978
|
cost_usd: numOrUndef(run2.costUsd)
|
|
1794
2979
|
});
|
|
1795
|
-
|
|
1796
|
-
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)) {
|
|
1797
2982
|
await trackDispatchedPr({
|
|
1798
2983
|
prNumber: pr.prNumber,
|
|
1799
2984
|
repo: task.repo,
|
|
1800
2985
|
branch: pr.branch,
|
|
1801
2986
|
taskId: id
|
|
1802
|
-
}).catch((e) =>
|
|
2987
|
+
}).catch((e) => log2(`watch: track failed for #${pr.prNumber}: ${e.message}`));
|
|
1803
2988
|
}
|
|
1804
2989
|
} catch (err) {
|
|
1805
2990
|
const msg = err && err.message ? err.message : String(err);
|
|
1806
|
-
|
|
2991
|
+
log2(`task ${id} error: ${msg}`);
|
|
2992
|
+
preserveReason = `runner error: ${msg}`.slice(0, 280);
|
|
1807
2993
|
await safeProgress(client, id, {
|
|
1808
2994
|
status: "failed",
|
|
1809
2995
|
message: `runner error: ${msg}`.slice(0, 1500),
|
|
@@ -1811,7 +2997,7 @@ async function processOneTask(client, task, cfg) {
|
|
|
1811
2997
|
}).catch(() => {
|
|
1812
2998
|
});
|
|
1813
2999
|
} finally {
|
|
1814
|
-
if (worktreeName)
|
|
3000
|
+
if (worktreeName) finalizeWorktree(worktreeName, { preserveReason, taskId: id, repo: task.repo, prompt: task.prompt });
|
|
1815
3001
|
}
|
|
1816
3002
|
}
|
|
1817
3003
|
async function main({ env: env2 = process.env, once: once2 = false } = {}) {
|
|
@@ -1822,10 +3008,11 @@ async function main({ env: env2 = process.env, once: once2 = false } = {}) {
|
|
|
1822
3008
|
const stop = (sig) => {
|
|
1823
3009
|
if (stopping) return;
|
|
1824
3010
|
stopping = true;
|
|
1825
|
-
|
|
3011
|
+
log2(`${sig} received \u2014 draining ${active} active task(s), no new claims`);
|
|
1826
3012
|
};
|
|
1827
3013
|
process.on("SIGINT", () => stop("SIGINT"));
|
|
1828
3014
|
process.on("SIGTERM", () => stop("SIGTERM"));
|
|
3015
|
+
installProcessSafetyNet({ log: log2 });
|
|
1829
3016
|
const startedAt = Date.now();
|
|
1830
3017
|
const controlServer = startDaemonControl({
|
|
1831
3018
|
cfg,
|
|
@@ -1833,33 +3020,26 @@ async function main({ env: env2 = process.env, once: once2 = false } = {}) {
|
|
|
1833
3020
|
getActiveCount: () => active,
|
|
1834
3021
|
isRunning: () => !stopping,
|
|
1835
3022
|
startedAt,
|
|
1836
|
-
log
|
|
3023
|
+
log: log2
|
|
1837
3024
|
});
|
|
1838
|
-
|
|
1839
|
-
`up as ${cfg.runnerId} \u2192 ${env2.VO_CONTROL_PLANE_URL} (concurrency ${cfg.maxConcurrency}, poll ${cfg.pollSec}s, once=${once2})`
|
|
3025
|
+
log2(
|
|
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})`
|
|
1840
3027
|
);
|
|
1841
|
-
for (const line of describeClaimScoping(cfg, env2))
|
|
1842
|
-
|
|
3028
|
+
for (const line of describeClaimScoping(cfg, env2)) log2(line);
|
|
3029
|
+
log2(
|
|
1843
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)"
|
|
1844
3031
|
);
|
|
1845
|
-
let lastSessionForward = 0;
|
|
1846
3032
|
let lastWatchCycle = 0;
|
|
1847
|
-
const runWatch = makeWatchRunner({ client, log, maxFixAttempts: cfg.watchMaxFix });
|
|
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 });
|
|
1848
3036
|
while (!stopping) {
|
|
1849
|
-
|
|
1850
|
-
lastSessionForward = Date.now();
|
|
1851
|
-
forwardSessionSpool({
|
|
1852
|
-
baseUrl: String(env2.VO_CONTROL_PLANE_URL || "").replace(/\/$/, ""),
|
|
1853
|
-
token: env2.VO_CONTROL_PLANE_ADMIN_TOKEN || "",
|
|
1854
|
-
operatorSeed: cfg.operatorSeed
|
|
1855
|
-
}).catch(() => {
|
|
1856
|
-
});
|
|
1857
|
-
}
|
|
3037
|
+
loopTick();
|
|
1858
3038
|
if (cfg.watchEnabled && Date.now() - lastWatchCycle >= cfg.watchIntervalSec * 1e3) {
|
|
1859
3039
|
lastWatchCycle = Date.now();
|
|
1860
3040
|
runWatch().then((r) => {
|
|
1861
|
-
if (r.checked > 0)
|
|
1862
|
-
}).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}`));
|
|
1863
3043
|
}
|
|
1864
3044
|
if (active >= cfg.maxConcurrency) {
|
|
1865
3045
|
await sleep(cfg.pollSec * 1e3);
|
|
@@ -1868,21 +3048,24 @@ async function main({ env: env2 = process.env, once: once2 = false } = {}) {
|
|
|
1868
3048
|
let task;
|
|
1869
3049
|
try {
|
|
1870
3050
|
task = await client.claim(cfg.runnerId, cfg.servedRepos, cfg.servedOperators);
|
|
3051
|
+
backoff.onSuccess();
|
|
1871
3052
|
} catch (err) {
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
3053
|
+
if (once2) {
|
|
3054
|
+
log2(`claim error: ${err.message}`);
|
|
3055
|
+
break;
|
|
3056
|
+
}
|
|
3057
|
+
await sleep(backoff.onFailure(err));
|
|
1875
3058
|
continue;
|
|
1876
3059
|
}
|
|
1877
3060
|
if (!task) {
|
|
1878
3061
|
if (once2) {
|
|
1879
|
-
|
|
3062
|
+
log2("no pending task; --once exiting");
|
|
1880
3063
|
break;
|
|
1881
3064
|
}
|
|
1882
3065
|
await sleep(cfg.pollSec * 1e3);
|
|
1883
3066
|
continue;
|
|
1884
3067
|
}
|
|
1885
|
-
|
|
3068
|
+
log2(`claimed task ${task.code_task_id} (${task.repo})`);
|
|
1886
3069
|
active += 1;
|
|
1887
3070
|
const done = processOneTask(client, task, cfg).finally(() => {
|
|
1888
3071
|
active -= 1;
|
|
@@ -1896,12 +3079,9 @@ async function main({ env: env2 = process.env, once: once2 = false } = {}) {
|
|
|
1896
3079
|
await sleep(500);
|
|
1897
3080
|
}
|
|
1898
3081
|
if (controlServer) controlServer.close();
|
|
1899
|
-
|
|
3082
|
+
log2("stopped");
|
|
1900
3083
|
}
|
|
1901
|
-
var invokedDirectly = process.argv[1] && fileURLToPath2(import.meta.url) === process.argv[1] && // Bundle-safe:
|
|
1902
|
-
// module is inlined into a bundle (e.g. @algosuite/vo-mcp's dist/runner-cli.js,
|
|
1903
|
-
// which calls main() itself — without this, `node dist/runner-cli.js` would
|
|
1904
|
-
// 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).
|
|
1905
3085
|
import.meta.url.endsWith("code-runner-daemon.mjs");
|
|
1906
3086
|
if (invokedDirectly) {
|
|
1907
3087
|
const once2 = process.argv.includes("--once");
|