@groupchatai/claude-runner 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +368 -34
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { spawn } from "child_process";
|
|
5
|
-
import { readFileSync } from "fs";
|
|
4
|
+
import { spawn, execFileSync } from "child_process";
|
|
5
|
+
import { readFileSync, readdirSync, statSync, writeFileSync, existsSync, rmSync } from "fs";
|
|
6
6
|
import path from "path";
|
|
7
7
|
var API_URL = "https://groupchat.ai";
|
|
8
8
|
var CONVEX_URL = "https://fantastic-jay-464.convex.cloud";
|
|
@@ -116,7 +116,7 @@ Due: ${new Date(detail.task.dueDate).toLocaleDateString()}`);
|
|
|
116
116
|
"- NEVER run `gh pr merge`. Do NOT merge any PR.",
|
|
117
117
|
"- NEVER run `gh pr close`. Do NOT close any PR.",
|
|
118
118
|
"- NEVER run `git push --force` or `git push -f`. No force pushes.",
|
|
119
|
-
"- When you are done, provide a clear summary of what you accomplished
|
|
119
|
+
"- When you are done, provide a clear summary of what you accomplished."
|
|
120
120
|
].join("\n")
|
|
121
121
|
);
|
|
122
122
|
return parts.join("\n");
|
|
@@ -240,7 +240,7 @@ var ALLOWED_TOOLS = [
|
|
|
240
240
|
"TodoWrite",
|
|
241
241
|
"NotebookEdit"
|
|
242
242
|
];
|
|
243
|
-
function spawnClaudeCode(prompt, config, runOptions, resumeSessionId) {
|
|
243
|
+
function spawnClaudeCode(prompt, config, runOptions, resumeSessionId, cwdOverride) {
|
|
244
244
|
const format = config.verbose ? "stream-json" : "json";
|
|
245
245
|
const args = [];
|
|
246
246
|
if (resumeSessionId) {
|
|
@@ -262,9 +262,8 @@ function spawnClaudeCode(prompt, config, runOptions, resumeSessionId) {
|
|
|
262
262
|
args.push("--model", model);
|
|
263
263
|
}
|
|
264
264
|
const child = spawn("claude", args, {
|
|
265
|
-
cwd: config.workDir,
|
|
266
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
267
|
-
env: { ...process.env }
|
|
265
|
+
cwd: cwdOverride ?? config.workDir,
|
|
266
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
268
267
|
});
|
|
269
268
|
const pid = child.pid ?? 0;
|
|
270
269
|
const output = new Promise((resolve, reject) => {
|
|
@@ -445,8 +444,205 @@ function runShellCommand(cmd, args, cwd) {
|
|
|
445
444
|
});
|
|
446
445
|
}
|
|
447
446
|
var sessionCache = /* @__PURE__ */ new Map();
|
|
448
|
-
|
|
449
|
-
|
|
447
|
+
var runCounter = 0;
|
|
448
|
+
var WORKTREE_DIR = ".agent-worktrees";
|
|
449
|
+
var WORKTREE_PREFIX = "task-";
|
|
450
|
+
function worktreeNameForTask(taskId) {
|
|
451
|
+
return `${WORKTREE_PREFIX}${taskId.replace(/[^a-zA-Z0-9_-]/g, "-").slice(-12)}`;
|
|
452
|
+
}
|
|
453
|
+
function execGit(args, cwd) {
|
|
454
|
+
return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: "pipe" }).trim();
|
|
455
|
+
}
|
|
456
|
+
function getDefaultBranch(repoDir) {
|
|
457
|
+
try {
|
|
458
|
+
const out = execGit(["symbolic-ref", "refs/remotes/origin/HEAD", "--short"], repoDir);
|
|
459
|
+
return out.replace("origin/", "");
|
|
460
|
+
} catch {
|
|
461
|
+
return "main";
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
function createWorktree(repoDir, taskId) {
|
|
465
|
+
const name = worktreeNameForTask(taskId);
|
|
466
|
+
const branchName = `agent/${name}-${Date.now()}`;
|
|
467
|
+
const worktreeBase = path.join(repoDir, WORKTREE_DIR);
|
|
468
|
+
const worktreePath = path.join(worktreeBase, name);
|
|
469
|
+
if (existsSync(worktreePath)) {
|
|
470
|
+
return worktreePath;
|
|
471
|
+
}
|
|
472
|
+
const baseBranch = getDefaultBranch(repoDir);
|
|
473
|
+
try {
|
|
474
|
+
execGit(["fetch", "origin", baseBranch, "--quiet"], repoDir);
|
|
475
|
+
} catch {
|
|
476
|
+
}
|
|
477
|
+
execGit(["worktree", "add", "-b", branchName, worktreePath, `origin/${baseBranch}`], repoDir);
|
|
478
|
+
writeFileSync(path.join(worktreePath, ".agent-branch"), branchName, "utf-8");
|
|
479
|
+
return worktreePath;
|
|
480
|
+
}
|
|
481
|
+
async function listOurWorktrees(workDir) {
|
|
482
|
+
const worktreeDir = path.join(workDir, WORKTREE_DIR);
|
|
483
|
+
let entries;
|
|
484
|
+
try {
|
|
485
|
+
entries = readdirSync(worktreeDir).filter((e) => e.startsWith(WORKTREE_PREFIX));
|
|
486
|
+
} catch {
|
|
487
|
+
return [];
|
|
488
|
+
}
|
|
489
|
+
const results = [];
|
|
490
|
+
for (const name of entries) {
|
|
491
|
+
const wtPath = path.join(worktreeDir, name);
|
|
492
|
+
let branch = `agent/${name}`;
|
|
493
|
+
try {
|
|
494
|
+
branch = readFileSync(path.join(wtPath, ".agent-branch"), "utf-8").trim();
|
|
495
|
+
} catch {
|
|
496
|
+
}
|
|
497
|
+
let ageMs = 0;
|
|
498
|
+
let age = "unknown";
|
|
499
|
+
try {
|
|
500
|
+
const stat = statSync(wtPath);
|
|
501
|
+
ageMs = Date.now() - stat.mtimeMs;
|
|
502
|
+
if (ageMs < 6e4) age = "just now";
|
|
503
|
+
else if (ageMs < 36e5) age = `${Math.floor(ageMs / 6e4)}m ago`;
|
|
504
|
+
else if (ageMs < 864e5) age = `${Math.floor(ageMs / 36e5)}h ago`;
|
|
505
|
+
else age = `${Math.floor(ageMs / 864e5)}d ago`;
|
|
506
|
+
} catch {
|
|
507
|
+
}
|
|
508
|
+
let isMerged = false;
|
|
509
|
+
try {
|
|
510
|
+
const merged = await runShellCommand(
|
|
511
|
+
"git",
|
|
512
|
+
["branch", "--merged", "main", "--list", branch],
|
|
513
|
+
workDir
|
|
514
|
+
);
|
|
515
|
+
isMerged = merged.trim().length > 0;
|
|
516
|
+
} catch {
|
|
517
|
+
}
|
|
518
|
+
let hasUnpushed = false;
|
|
519
|
+
try {
|
|
520
|
+
const log = await runShellCommand(
|
|
521
|
+
"git",
|
|
522
|
+
["log", `origin/${branch}..${branch}`, "--oneline"],
|
|
523
|
+
wtPath
|
|
524
|
+
);
|
|
525
|
+
hasUnpushed = log.trim().length > 0;
|
|
526
|
+
} catch {
|
|
527
|
+
try {
|
|
528
|
+
const log = await runShellCommand(
|
|
529
|
+
"git",
|
|
530
|
+
["log", `origin/main..${branch}`, "--oneline"],
|
|
531
|
+
workDir
|
|
532
|
+
);
|
|
533
|
+
hasUnpushed = log.trim().length > 0;
|
|
534
|
+
} catch {
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
results.push({
|
|
538
|
+
name,
|
|
539
|
+
path: wtPath,
|
|
540
|
+
branch,
|
|
541
|
+
age,
|
|
542
|
+
ageMs,
|
|
543
|
+
isMerged,
|
|
544
|
+
hasUnpushed: hasUnpushed && !isMerged,
|
|
545
|
+
safeToRemove: isMerged || !hasUnpushed && ageMs > 36e5
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
return results;
|
|
549
|
+
}
|
|
550
|
+
async function removeWorktree(workDir, info) {
|
|
551
|
+
try {
|
|
552
|
+
await runShellCommand("git", ["worktree", "remove", info.path, "--force"], workDir);
|
|
553
|
+
try {
|
|
554
|
+
await runShellCommand("git", ["branch", "-D", info.branch], workDir);
|
|
555
|
+
} catch {
|
|
556
|
+
}
|
|
557
|
+
return true;
|
|
558
|
+
} catch (err) {
|
|
559
|
+
console.error(` Failed to remove ${info.name}: ${err instanceof Error ? err.message : err}`);
|
|
560
|
+
return false;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
function removeWorktreeSimple(repoDir, worktreePath) {
|
|
564
|
+
let branchName;
|
|
565
|
+
try {
|
|
566
|
+
branchName = readFileSync(path.join(worktreePath, ".agent-branch"), "utf-8").trim();
|
|
567
|
+
} catch {
|
|
568
|
+
}
|
|
569
|
+
try {
|
|
570
|
+
execGit(["worktree", "remove", worktreePath, "--force"], repoDir);
|
|
571
|
+
} catch {
|
|
572
|
+
try {
|
|
573
|
+
if (existsSync(worktreePath)) rmSync(worktreePath, { recursive: true, force: true });
|
|
574
|
+
execGit(["worktree", "prune"], repoDir);
|
|
575
|
+
} catch {
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (branchName) {
|
|
579
|
+
try {
|
|
580
|
+
execGit(["branch", "-D", branchName], repoDir);
|
|
581
|
+
} catch {
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
async function startupSweep(workDir) {
|
|
586
|
+
const worktrees = await listOurWorktrees(workDir);
|
|
587
|
+
if (worktrees.length === 0) return;
|
|
588
|
+
const safe = worktrees.filter((w) => w.safeToRemove);
|
|
589
|
+
const unsafe = worktrees.filter((w) => !w.safeToRemove);
|
|
590
|
+
if (safe.length > 0) {
|
|
591
|
+
console.log(`\u{1F9F9} Cleaning up ${safe.length} stale worktree(s)\u2026`);
|
|
592
|
+
for (const wt of safe) {
|
|
593
|
+
const label = wt.isMerged ? "merged" : "stale";
|
|
594
|
+
const ok = await removeWorktree(workDir, wt);
|
|
595
|
+
if (ok) console.log(` \u2705 Removed ${wt.name} (${label}, ${wt.age})`);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
if (unsafe.length > 0) {
|
|
599
|
+
console.log(` \u26A0\uFE0F ${unsafe.length} worktree(s) with unmerged changes kept:`);
|
|
600
|
+
for (const wt of unsafe) {
|
|
601
|
+
console.log(` ${wt.name} (${wt.age})`);
|
|
602
|
+
}
|
|
603
|
+
console.log(` Run "npx @groupchatai/claude-runner cleanup" to manage them.
|
|
604
|
+
`);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
async function interactiveCleanup(workDir) {
|
|
608
|
+
const worktrees = await listOurWorktrees(workDir);
|
|
609
|
+
if (worktrees.length === 0) {
|
|
610
|
+
console.log("\n\u{1F33F} No agent worktrees found.\n");
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
console.log(`
|
|
614
|
+
\u{1F33F} Found ${worktrees.length} worktree(s):
|
|
615
|
+
`);
|
|
616
|
+
for (const wt of worktrees) {
|
|
617
|
+
const icon = wt.safeToRemove ? "\u2705" : "\u26A0\uFE0F ";
|
|
618
|
+
const status = wt.isMerged ? "Branch merged" : wt.hasUnpushed ? "Branch NOT merged, has unpushed commits" : "Branch NOT merged";
|
|
619
|
+
const safety = wt.safeToRemove ? "Safe to remove" : "Has unmerged work";
|
|
620
|
+
console.log(` ${icon} ${wt.name} (${wt.branch})`);
|
|
621
|
+
console.log(` ${status} \u2022 Created ${wt.age} \u2022 ${safety}`);
|
|
622
|
+
}
|
|
623
|
+
const safe = worktrees.filter((w) => w.safeToRemove);
|
|
624
|
+
const unsafe = worktrees.filter((w) => !w.safeToRemove);
|
|
625
|
+
if (safe.length > 0) {
|
|
626
|
+
console.log(`
|
|
627
|
+
Removing ${safe.length} safe worktree(s)\u2026`);
|
|
628
|
+
for (const wt of safe) {
|
|
629
|
+
const ok = await removeWorktree(workDir, wt);
|
|
630
|
+
if (ok) console.log(` \u2705 Removed ${wt.name}`);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
if (unsafe.length > 0) {
|
|
634
|
+
console.log(`
|
|
635
|
+
\u26A0\uFE0F ${unsafe.length} worktree(s) with unmerged work:`);
|
|
636
|
+
for (const wt of unsafe) {
|
|
637
|
+
console.log(` Skipping ${wt.name} \u2014 has unmerged changes`);
|
|
638
|
+
console.log(` To force remove: git worktree remove "${wt.path}" --force`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
console.log();
|
|
642
|
+
}
|
|
643
|
+
async function processRun(client, run, config, worktreeDir) {
|
|
644
|
+
const runNum = ++runCounter;
|
|
645
|
+
const runTag = ` ${C.pid}[${runNum}]${C.reset}`;
|
|
450
646
|
const log = (msg) => console.log(`${runTag} ${msg}`);
|
|
451
647
|
const logGreen = (msg) => console.log(`${runTag} ${C.green}${msg}${C.reset}`);
|
|
452
648
|
const detail = await client.getRunDetail(run.id);
|
|
@@ -478,11 +674,12 @@ async function processRun(client, run, config) {
|
|
|
478
674
|
throw err;
|
|
479
675
|
}
|
|
480
676
|
log("\u25B6 Run started");
|
|
677
|
+
const effectiveCwd = worktreeDir ?? config.workDir;
|
|
481
678
|
try {
|
|
482
|
-
if (runOptions.branch) {
|
|
679
|
+
if (!worktreeDir && runOptions.branch) {
|
|
483
680
|
log(`\u{1F33F} Checking out branch: ${runOptions.branch}`);
|
|
484
681
|
const git = spawn("git", ["checkout", runOptions.branch], {
|
|
485
|
-
cwd:
|
|
682
|
+
cwd: effectiveCwd,
|
|
486
683
|
stdio: "pipe"
|
|
487
684
|
});
|
|
488
685
|
await new Promise((resolve) => git.on("close", () => resolve()));
|
|
@@ -502,14 +699,15 @@ async function processRun(client, run, config) {
|
|
|
502
699
|
effectivePrompt,
|
|
503
700
|
config,
|
|
504
701
|
runOptions,
|
|
505
|
-
resumeSession
|
|
702
|
+
resumeSession,
|
|
703
|
+
effectiveCwd
|
|
506
704
|
);
|
|
507
705
|
log(`\u{1F916} Claude Code spawned (pid ${child.pid})${isFollowUp ? " (follow-up)" : ""}`);
|
|
508
706
|
let { stdout, rawOutput, exitCode, sessionId, streamPrUrl } = await output;
|
|
509
707
|
if (exitCode !== 0 && isFollowUp) {
|
|
510
708
|
log(`\u26A0 Session resume failed, retrying with fresh session\u2026`);
|
|
511
709
|
sessionCache.delete(run.taskId);
|
|
512
|
-
const retry = spawnClaudeCode(prompt, config, runOptions);
|
|
710
|
+
const retry = spawnClaudeCode(prompt, config, runOptions, void 0, effectiveCwd);
|
|
513
711
|
log(`\u{1F916} Claude Code spawned (pid ${retry.process.pid}) (fresh)`);
|
|
514
712
|
const retryResult = await retry.output;
|
|
515
713
|
stdout = retryResult.stdout;
|
|
@@ -521,7 +719,7 @@ async function processRun(client, run, config) {
|
|
|
521
719
|
if (sessionId) {
|
|
522
720
|
sessionCache.set(run.taskId, sessionId);
|
|
523
721
|
}
|
|
524
|
-
const pullRequestUrl = streamPrUrl ?? await detectPullRequestUrl(
|
|
722
|
+
const pullRequestUrl = streamPrUrl ?? await detectPullRequestUrl(effectiveCwd) ?? extractPullRequestUrlFromOutput(stdout) ?? extractPullRequestUrlFromOutput(rawOutput);
|
|
525
723
|
if (exitCode !== 0) {
|
|
526
724
|
const errorMsg = `Claude Code exited with code ${exitCode}:
|
|
527
725
|
\`\`\`
|
|
@@ -574,6 +772,34 @@ function loadEnvFile() {
|
|
|
574
772
|
}
|
|
575
773
|
}
|
|
576
774
|
}
|
|
775
|
+
function showHelp() {
|
|
776
|
+
console.log(`
|
|
777
|
+
Usage: npx @groupchatai/claude-runner [command] [options]
|
|
778
|
+
|
|
779
|
+
Commands:
|
|
780
|
+
(default) Start the agent runner
|
|
781
|
+
cleanup Interactively review and remove stale worktrees
|
|
782
|
+
|
|
783
|
+
Options:
|
|
784
|
+
--work-dir <path> Repo directory for Claude Code to work in (default: cwd)
|
|
785
|
+
--poll Use HTTP polling instead of WebSocket (default: websocket)
|
|
786
|
+
--poll-interval <ms> Polling interval in milliseconds (default: 5000, only with --poll)
|
|
787
|
+
--max-concurrent <n> Max concurrent tasks (default: 1)
|
|
788
|
+
--model <model> Claude model to use (passed to claude CLI)
|
|
789
|
+
--dry-run Poll and log runs without executing Claude Code
|
|
790
|
+
--once Process one batch of pending runs and exit (implies --poll)
|
|
791
|
+
--verbose Stream Claude Code activity with pid
|
|
792
|
+
--token <token> Agent token (or set GCA_TOKEN env var)
|
|
793
|
+
--no-worktree Disable git worktree isolation for runs
|
|
794
|
+
-h, --help Show this help message
|
|
795
|
+
|
|
796
|
+
Environment variables:
|
|
797
|
+
GCA_TOKEN Agent token (gca_...)
|
|
798
|
+
GCA_API_URL API URL override
|
|
799
|
+
GCA_CONVEX_URL Convex URL override
|
|
800
|
+
`);
|
|
801
|
+
process.exit(0);
|
|
802
|
+
}
|
|
577
803
|
function parseArgs() {
|
|
578
804
|
loadEnvFile();
|
|
579
805
|
const args = process.argv.slice(2);
|
|
@@ -587,7 +813,8 @@ function parseArgs() {
|
|
|
587
813
|
poll: false,
|
|
588
814
|
dryRun: false,
|
|
589
815
|
once: false,
|
|
590
|
-
verbose: false
|
|
816
|
+
verbose: false,
|
|
817
|
+
useWorktrees: true
|
|
591
818
|
};
|
|
592
819
|
for (let i = 0; i < args.length; i++) {
|
|
593
820
|
const arg = args[i];
|
|
@@ -619,6 +846,15 @@ function parseArgs() {
|
|
|
619
846
|
case "--token":
|
|
620
847
|
config.token = args[++i] ?? "";
|
|
621
848
|
break;
|
|
849
|
+
case "--help":
|
|
850
|
+
case "-h":
|
|
851
|
+
showHelp();
|
|
852
|
+
break;
|
|
853
|
+
case "--no-worktree":
|
|
854
|
+
config.useWorktrees = false;
|
|
855
|
+
break;
|
|
856
|
+
case "cleanup":
|
|
857
|
+
break;
|
|
622
858
|
default:
|
|
623
859
|
console.error(`Unknown argument: ${arg}`);
|
|
624
860
|
process.exit(1);
|
|
@@ -638,23 +874,109 @@ function parseArgs() {
|
|
|
638
874
|
async function sleep(ms) {
|
|
639
875
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
640
876
|
}
|
|
641
|
-
|
|
877
|
+
var TaskScheduler = class {
|
|
878
|
+
slots = /* @__PURE__ */ new Map();
|
|
879
|
+
processing = /* @__PURE__ */ new Set();
|
|
880
|
+
get activeTaskCount() {
|
|
881
|
+
return this.slots.size;
|
|
882
|
+
}
|
|
883
|
+
isRunActive(runId) {
|
|
884
|
+
return this.processing.has(runId);
|
|
885
|
+
}
|
|
886
|
+
hasActiveTask(taskId) {
|
|
887
|
+
return this.slots.has(taskId);
|
|
888
|
+
}
|
|
889
|
+
tryStart(run, maxConcurrent) {
|
|
890
|
+
if (this.processing.has(run.id)) return "at_limit";
|
|
891
|
+
const existingSlot = this.slots.get(run.taskId);
|
|
892
|
+
if (existingSlot) {
|
|
893
|
+
if (!existingSlot.queue.some((q) => q.id === run.id)) {
|
|
894
|
+
existingSlot.queue.push(run);
|
|
895
|
+
}
|
|
896
|
+
return "queued";
|
|
897
|
+
}
|
|
898
|
+
if (this.slots.size >= maxConcurrent) {
|
|
899
|
+
return "at_limit";
|
|
900
|
+
}
|
|
901
|
+
this.slots.set(run.taskId, { activeRunId: run.id, queue: [] });
|
|
902
|
+
this.processing.add(run.id);
|
|
903
|
+
return "start";
|
|
904
|
+
}
|
|
905
|
+
complete(run) {
|
|
906
|
+
this.processing.delete(run.id);
|
|
907
|
+
const slot = this.slots.get(run.taskId);
|
|
908
|
+
if (!slot) return void 0;
|
|
909
|
+
const next = slot.queue.shift();
|
|
910
|
+
if (next) {
|
|
911
|
+
slot.activeRunId = next.id;
|
|
912
|
+
this.processing.add(next.id);
|
|
913
|
+
return next;
|
|
914
|
+
}
|
|
915
|
+
this.slots.delete(run.taskId);
|
|
916
|
+
return void 0;
|
|
917
|
+
}
|
|
918
|
+
isEmpty() {
|
|
919
|
+
return this.slots.size === 0;
|
|
920
|
+
}
|
|
921
|
+
};
|
|
922
|
+
function handlePendingRuns(runs, scheduler, client, config) {
|
|
642
923
|
if (runs.length > 0 && config.verbose) {
|
|
643
924
|
console.log(`\u{1F4EC} ${runs.length} pending run(s)`);
|
|
644
925
|
}
|
|
645
926
|
for (const run of runs) {
|
|
646
|
-
|
|
647
|
-
if (
|
|
927
|
+
const result = scheduler.tryStart(run, config.maxConcurrent);
|
|
928
|
+
if (result === "at_limit") {
|
|
648
929
|
if (config.verbose) {
|
|
649
|
-
console.log(`\u23F8 At concurrency limit (${config.maxConcurrent}), waiting\u2026`);
|
|
930
|
+
console.log(`\u23F8 At concurrency limit (${config.maxConcurrent} tasks), waiting\u2026`);
|
|
650
931
|
}
|
|
651
932
|
break;
|
|
652
933
|
}
|
|
653
|
-
|
|
654
|
-
|
|
934
|
+
if (result === "queued") {
|
|
935
|
+
if (config.verbose) {
|
|
936
|
+
console.log(` ${C.dim}Queued follow-up for active task${C.reset}`);
|
|
937
|
+
}
|
|
938
|
+
continue;
|
|
939
|
+
}
|
|
940
|
+
processRunWithDrain(client, run, scheduler, config).catch(
|
|
941
|
+
(err) => console.error(`Unhandled error processing run ${run.id}:`, err)
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
async function processRunWithDrain(client, run, scheduler, config) {
|
|
946
|
+
let worktreeDir;
|
|
947
|
+
if (config.useWorktrees) {
|
|
948
|
+
try {
|
|
949
|
+
worktreeDir = createWorktree(config.workDir, run.taskId);
|
|
950
|
+
console.log(
|
|
951
|
+
` \u{1F333} Worktree created from main \u2192 ${path.relative(config.workDir, worktreeDir)}`
|
|
952
|
+
);
|
|
953
|
+
} catch (err) {
|
|
954
|
+
console.error(` \u26A0 Failed to create worktree, running in-place:`, err);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
let current = run;
|
|
958
|
+
while (current) {
|
|
959
|
+
try {
|
|
960
|
+
await processRun(client, current, config, worktreeDir);
|
|
961
|
+
} catch (err) {
|
|
962
|
+
console.error(`Unhandled error processing run ${current.id}:`, err);
|
|
963
|
+
}
|
|
964
|
+
current = scheduler.complete(current);
|
|
965
|
+
if (current) {
|
|
966
|
+
console.log(` \u23ED Processing queued follow-up\u2026`);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
if (worktreeDir) {
|
|
970
|
+
sessionCache.delete(run.taskId);
|
|
971
|
+
try {
|
|
972
|
+
removeWorktreeSimple(config.workDir, worktreeDir);
|
|
973
|
+
console.log(` \u{1F9F9} Worktree cleaned up`);
|
|
974
|
+
} catch (err) {
|
|
975
|
+
console.error(` \u26A0 Failed to clean up worktree:`, err);
|
|
976
|
+
}
|
|
655
977
|
}
|
|
656
978
|
}
|
|
657
|
-
async function runWithWebSocket(client, config,
|
|
979
|
+
async function runWithWebSocket(client, config, scheduler) {
|
|
658
980
|
let ConvexClient;
|
|
659
981
|
let anyApi;
|
|
660
982
|
try {
|
|
@@ -663,7 +985,7 @@ async function runWithWebSocket(client, config, activeRuns) {
|
|
|
663
985
|
} catch {
|
|
664
986
|
console.warn("\u26A0 convex package not found \u2014 falling back to HTTP polling.");
|
|
665
987
|
console.warn(" Install convex for WebSocket mode: npm i convex\n");
|
|
666
|
-
await runWithPolling(client, config,
|
|
988
|
+
await runWithPolling(client, config, scheduler);
|
|
667
989
|
return;
|
|
668
990
|
}
|
|
669
991
|
const convex = new ConvexClient(config.convexUrl);
|
|
@@ -673,7 +995,7 @@ async function runWithWebSocket(client, config, activeRuns) {
|
|
|
673
995
|
{ token: config.token },
|
|
674
996
|
(runs) => {
|
|
675
997
|
if (!runs || runs.length === 0) return;
|
|
676
|
-
handlePendingRuns(runs,
|
|
998
|
+
handlePendingRuns(runs, scheduler, client, config);
|
|
677
999
|
}
|
|
678
1000
|
);
|
|
679
1001
|
await new Promise((resolve) => {
|
|
@@ -686,7 +1008,7 @@ async function runWithWebSocket(client, config, activeRuns) {
|
|
|
686
1008
|
process.on("SIGTERM", shutdown);
|
|
687
1009
|
});
|
|
688
1010
|
}
|
|
689
|
-
async function runWithPolling(client, config,
|
|
1011
|
+
async function runWithPolling(client, config, scheduler) {
|
|
690
1012
|
console.log(`\u{1F4E1} Polling every ${config.pollInterval}ms \u2014 listening for tasks\u2026
|
|
691
1013
|
`);
|
|
692
1014
|
let running = true;
|
|
@@ -699,17 +1021,17 @@ async function runWithPolling(client, config, activeRuns) {
|
|
|
699
1021
|
if (config.once) {
|
|
700
1022
|
try {
|
|
701
1023
|
const pending = await client.listPendingRuns();
|
|
702
|
-
handlePendingRuns(pending,
|
|
1024
|
+
handlePendingRuns(pending, scheduler, client, config);
|
|
703
1025
|
} catch (err) {
|
|
704
1026
|
console.error(`Poll error: ${err instanceof Error ? err.message : err}`);
|
|
705
1027
|
}
|
|
706
|
-
while (
|
|
1028
|
+
while (!scheduler.isEmpty()) await sleep(1e3);
|
|
707
1029
|
return;
|
|
708
1030
|
}
|
|
709
1031
|
while (running) {
|
|
710
1032
|
try {
|
|
711
1033
|
const pending = await client.listPendingRuns();
|
|
712
|
-
handlePendingRuns(pending,
|
|
1034
|
+
handlePendingRuns(pending, scheduler, client, config);
|
|
713
1035
|
} catch (err) {
|
|
714
1036
|
const msg = err instanceof Error ? err.message : String(err);
|
|
715
1037
|
if (config.verbose || !msg.includes("fetch")) {
|
|
@@ -720,6 +1042,14 @@ async function runWithPolling(client, config, activeRuns) {
|
|
|
720
1042
|
}
|
|
721
1043
|
}
|
|
722
1044
|
async function main() {
|
|
1045
|
+
if (process.argv.includes("cleanup")) {
|
|
1046
|
+
loadEnvFile();
|
|
1047
|
+
const workDir = process.cwd();
|
|
1048
|
+
const workDirIdx = process.argv.indexOf("--work-dir");
|
|
1049
|
+
const resolvedDir = workDirIdx >= 0 ? path.resolve(process.argv[workDirIdx + 1] ?? ".") : workDir;
|
|
1050
|
+
await interactiveCleanup(resolvedDir);
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
723
1053
|
const config = parseArgs();
|
|
724
1054
|
const client = new GroupChatAgentClient(config.apiUrl, config.token);
|
|
725
1055
|
let me;
|
|
@@ -737,16 +1067,20 @@ async function main() {
|
|
|
737
1067
|
if (config.apiUrl !== API_URL) console.log(` API: ${config.apiUrl}`);
|
|
738
1068
|
if (config.model) console.log(` Model: ${config.model}`);
|
|
739
1069
|
if (config.dryRun) console.log(` Mode: DRY RUN`);
|
|
1070
|
+
if (!config.useWorktrees) console.log(` Worktrees: disabled`);
|
|
740
1071
|
console.log();
|
|
741
|
-
|
|
1072
|
+
if (config.useWorktrees) {
|
|
1073
|
+
await startupSweep(config.workDir);
|
|
1074
|
+
}
|
|
1075
|
+
const scheduler = new TaskScheduler();
|
|
742
1076
|
if (config.poll || config.once) {
|
|
743
|
-
await runWithPolling(client, config,
|
|
1077
|
+
await runWithPolling(client, config, scheduler);
|
|
744
1078
|
} else {
|
|
745
|
-
await runWithWebSocket(client, config,
|
|
1079
|
+
await runWithWebSocket(client, config, scheduler);
|
|
746
1080
|
}
|
|
747
|
-
if (
|
|
748
|
-
console.log(`\u23F3 Waiting for ${
|
|
749
|
-
while (
|
|
1081
|
+
if (!scheduler.isEmpty()) {
|
|
1082
|
+
console.log(`\u23F3 Waiting for ${scheduler.activeTaskCount} in-flight task(s)\u2026`);
|
|
1083
|
+
while (!scheduler.isEmpty()) {
|
|
750
1084
|
await sleep(1e3);
|
|
751
1085
|
}
|
|
752
1086
|
}
|