@flumecode/runner 0.2.0 → 0.3.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/README.md +2 -1
- package/dist/cli.js +171 -29
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -45,7 +45,8 @@ pnpm --filter @flumecode/runner dev login # or: dev start
|
|
|
45
45
|
|
|
46
46
|
`pnpm --filter @flumecode/runner build` produces the published single-file bundle
|
|
47
47
|
(`dist/cli.js`); it is published to npm by the `release-runner` GitHub workflow
|
|
48
|
-
when a
|
|
48
|
+
when a GitHub Release is created (publishing the version in `package.json`, and
|
|
49
|
+
skipping if that version is already on npm).
|
|
49
50
|
|
|
50
51
|
## What a job does
|
|
51
52
|
|
package/dist/cli.js
CHANGED
|
@@ -39,8 +39,34 @@ function readVersion() {
|
|
|
39
39
|
}
|
|
40
40
|
var RUNNER_VERSION = readVersion();
|
|
41
41
|
var RUNNER_VERSION_HEADER = "x-flumecode-runner-version";
|
|
42
|
+
var RUNNER_MIN_VERSION_HEADER = "x-flumecode-min-runner-version";
|
|
43
|
+
function compareVersions(a, b) {
|
|
44
|
+
const parse = (v) => (v.split("-")[0] ?? v).split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
45
|
+
const pa = parse(a);
|
|
46
|
+
const pb = parse(b);
|
|
47
|
+
const len = Math.max(pa.length, pb.length);
|
|
48
|
+
for (let i = 0; i < len; i++) {
|
|
49
|
+
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
50
|
+
if (diff !== 0) return diff;
|
|
51
|
+
}
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
42
54
|
|
|
43
55
|
// src/api.ts
|
|
56
|
+
var OUTDATED_WARN_INTERVAL_MS = 60 * 6e4;
|
|
57
|
+
var lastOutdatedWarnAt = 0;
|
|
58
|
+
function noteServerVersion(res) {
|
|
59
|
+
const min = res.headers.get(RUNNER_MIN_VERSION_HEADER)?.trim();
|
|
60
|
+
if (!min || RUNNER_VERSION === "unknown") return;
|
|
61
|
+
if (compareVersions(RUNNER_VERSION, min) >= 0) return;
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
if (now - lastOutdatedWarnAt < OUTDATED_WARN_INTERVAL_MS) return;
|
|
64
|
+
lastOutdatedWarnAt = now;
|
|
65
|
+
console.warn(
|
|
66
|
+
`\u26A0\uFE0F This runner (v${RUNNER_VERSION}) is outdated \u2014 the server expects v${min} or newer.
|
|
67
|
+
Update with: npm install -g @flumecode/runner@latest`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
44
70
|
async function claimJob(config) {
|
|
45
71
|
const res = await fetch(`${config.serverUrl}/api/runner/jobs/claim`, {
|
|
46
72
|
method: "POST",
|
|
@@ -49,6 +75,7 @@ async function claimJob(config) {
|
|
|
49
75
|
[RUNNER_VERSION_HEADER]: RUNNER_VERSION
|
|
50
76
|
}
|
|
51
77
|
});
|
|
78
|
+
noteServerVersion(res);
|
|
52
79
|
if (res.status === 204) return null;
|
|
53
80
|
if (res.status === 401) {
|
|
54
81
|
throw new Error("Runner token rejected (401). Re-run `login` with a fresh token.");
|
|
@@ -66,6 +93,7 @@ async function reportJob(config, jobId, result) {
|
|
|
66
93
|
},
|
|
67
94
|
body: JSON.stringify(result)
|
|
68
95
|
});
|
|
96
|
+
noteServerVersion(res);
|
|
69
97
|
if (!res.ok) throw new Error(`complete failed: ${res.status} ${await safeText(res)}`);
|
|
70
98
|
}
|
|
71
99
|
async function reportHeartbeat(config, claudeCode) {
|
|
@@ -78,6 +106,7 @@ async function reportHeartbeat(config, claudeCode) {
|
|
|
78
106
|
},
|
|
79
107
|
body: JSON.stringify({ claudeCode })
|
|
80
108
|
});
|
|
109
|
+
noteServerVersion(res);
|
|
81
110
|
if (!res.ok) throw new Error(`heartbeat failed: ${res.status} ${await safeText(res)}`);
|
|
82
111
|
}
|
|
83
112
|
async function safeText(res) {
|
|
@@ -460,6 +489,25 @@ function buildDocumentPrompt(ctx) {
|
|
|
460
489
|
lines.push("", "When done, reply with a one- or two-line summary of the wiki changes you made.");
|
|
461
490
|
return lines.join("\n");
|
|
462
491
|
}
|
|
492
|
+
function buildRepairPrompt(ctx, hookLog) {
|
|
493
|
+
const lines = [
|
|
494
|
+
`You are "${ctx.agentName}", fixing a failed pre-commit check in the repository ${ctx.repo.fullName}, checked out in your current working directory.`,
|
|
495
|
+
`The changes from the previous step are still uncommitted in the working tree. When the runner tried to commit them, the repository's pre-commit hook \u2014 which runs the project's own checks (lint / typecheck / unit tests) \u2014 failed. Make the working tree pass those checks: fix the failing code or tests at their root. Do NOT delete or skip tests, weaken assertions, or disable the checks to silence the failure. Preserve the intent of the original change; repair only what's broken. Do NOT commit or push \u2014 the runner re-commits once the checks pass.`,
|
|
496
|
+
"",
|
|
497
|
+
"These coding guidelines apply to all code produced in this run:",
|
|
498
|
+
"",
|
|
499
|
+
loadRule("coding-guideline"),
|
|
500
|
+
"",
|
|
501
|
+
"# Pre-commit hook output",
|
|
502
|
+
"",
|
|
503
|
+
"```",
|
|
504
|
+
hookLog,
|
|
505
|
+
"```",
|
|
506
|
+
"",
|
|
507
|
+
"When done, reply with a one-line summary of what you fixed."
|
|
508
|
+
];
|
|
509
|
+
return lines.join("\n");
|
|
510
|
+
}
|
|
463
511
|
function buildInitPrompt(ctx) {
|
|
464
512
|
return [
|
|
465
513
|
`You are "${ctx.agentName}" initializing FlumeCode for the repository ${ctx.repo.fullName}, checked out in your current working directory.`,
|
|
@@ -513,6 +561,48 @@ async function installDependencies(dir) {
|
|
|
513
561
|
async function makeWorkspace() {
|
|
514
562
|
return mkdtemp(join3(tmpdir(), WORKSPACE_PREFIX));
|
|
515
563
|
}
|
|
564
|
+
var MAX_WORKSPACES = 8;
|
|
565
|
+
var workspaceRegistry = /* @__PURE__ */ new Map();
|
|
566
|
+
async function acquireWorkspace(key) {
|
|
567
|
+
const existing = workspaceRegistry.get(key);
|
|
568
|
+
if (existing !== void 0 && existsSync2(existing)) {
|
|
569
|
+
workspaceRegistry.delete(key);
|
|
570
|
+
workspaceRegistry.set(key, existing);
|
|
571
|
+
return { dir: existing, reused: true };
|
|
572
|
+
}
|
|
573
|
+
const dir = await makeWorkspace();
|
|
574
|
+
workspaceRegistry.set(key, dir);
|
|
575
|
+
if (workspaceRegistry.size > MAX_WORKSPACES) {
|
|
576
|
+
const oldest = workspaceRegistry.keys().next().value;
|
|
577
|
+
const oldDir = workspaceRegistry.get(oldest);
|
|
578
|
+
workspaceRegistry.delete(oldest);
|
|
579
|
+
rm(oldDir, { recursive: true, force: true }).catch(() => {
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
return { dir, reused: false };
|
|
583
|
+
}
|
|
584
|
+
async function discardWorkspace(key) {
|
|
585
|
+
const dir = workspaceRegistry.get(key);
|
|
586
|
+
workspaceRegistry.delete(key);
|
|
587
|
+
if (dir !== void 0) {
|
|
588
|
+
await cleanup(dir).catch(() => {
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
async function prepareAtSha(ctx, dir, reused) {
|
|
593
|
+
if (!reused) {
|
|
594
|
+
await cloneAtSha(ctx, dir);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
await git(["-C", dir, "remote", "set-url", "origin", cloneUrl(ctx)]);
|
|
598
|
+
}
|
|
599
|
+
async function prepareResumingBranch(ctx, dir, reused) {
|
|
600
|
+
if (!reused) {
|
|
601
|
+
return cloneResumingBranch(ctx, dir);
|
|
602
|
+
}
|
|
603
|
+
await git(["-C", dir, "remote", "set-url", "origin", cloneUrl(ctx)]);
|
|
604
|
+
return { resumed: true };
|
|
605
|
+
}
|
|
516
606
|
async function sweepWorkspaces() {
|
|
517
607
|
const base = tmpdir();
|
|
518
608
|
let entries;
|
|
@@ -552,20 +642,36 @@ async function hasChanges(dir) {
|
|
|
552
642
|
const { stdout: stdout2 } = await git(["-C", dir, "status", "--porcelain"]);
|
|
553
643
|
return stdout2.trim().length > 0;
|
|
554
644
|
}
|
|
645
|
+
var PreCommitError = class extends Error {
|
|
646
|
+
constructor(log) {
|
|
647
|
+
super("pre-commit checks failed");
|
|
648
|
+
this.log = log;
|
|
649
|
+
this.name = "PreCommitError";
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
function commitFailureLog(err) {
|
|
653
|
+
const e = err;
|
|
654
|
+
const parts = [e.stdout, e.stderr].map((s) => typeof s === "string" ? s.trim() : "").filter((s) => s.length > 0);
|
|
655
|
+
return parts.length > 0 ? parts.join("\n") : e.message ?? String(err);
|
|
656
|
+
}
|
|
555
657
|
async function commitChanges(ctx, dir) {
|
|
556
658
|
if (!await hasChanges(dir)) return false;
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
659
|
+
try {
|
|
660
|
+
await git([
|
|
661
|
+
"-C",
|
|
662
|
+
dir,
|
|
663
|
+
"-c",
|
|
664
|
+
"user.email=runner@flumecode.local",
|
|
665
|
+
"-c",
|
|
666
|
+
"user.name=FlumeCode Runner",
|
|
667
|
+
"commit",
|
|
668
|
+
"--quiet",
|
|
669
|
+
"-m",
|
|
670
|
+
`FlumeCode: ${jobTitle(ctx)}`
|
|
671
|
+
]);
|
|
672
|
+
} catch (err) {
|
|
673
|
+
throw new PreCommitError(commitFailureLog(err));
|
|
674
|
+
}
|
|
569
675
|
return true;
|
|
570
676
|
}
|
|
571
677
|
async function pushBranch(ctx, dir) {
|
|
@@ -643,17 +749,37 @@ async function cleanup(dir) {
|
|
|
643
749
|
var IDLE_MS = 5e3;
|
|
644
750
|
var ORCHESTRATOR_MODEL = "sonnet";
|
|
645
751
|
var ORCHESTRATOR_MAX_TURNS = 80;
|
|
752
|
+
var MAX_COMMIT_REPAIRS = 2;
|
|
646
753
|
var INIT_MAX_TURNS = 200;
|
|
647
754
|
var DOCUMENT_MAX_TURNS = 120;
|
|
648
755
|
var HEARTBEAT_MS = 5 * 6e4;
|
|
649
756
|
async function pushAndOpenPr(ctx, dir, opts = { rebase: true }) {
|
|
650
|
-
const committed = await
|
|
757
|
+
const committed = await commitWithRepair(ctx, dir);
|
|
651
758
|
if (!committed) return { kind: "none" };
|
|
652
759
|
if (opts.rebase) await rebaseOntoMergeBranch(ctx, dir);
|
|
653
760
|
await pushBranch(ctx, dir);
|
|
654
761
|
const pr = await openPullRequest(ctx);
|
|
655
762
|
return pr ? { kind: "pr", pr } : { kind: "pushed" };
|
|
656
763
|
}
|
|
764
|
+
async function commitWithRepair(ctx, dir) {
|
|
765
|
+
for (let attempt = 1; ; attempt++) {
|
|
766
|
+
try {
|
|
767
|
+
return await commitChanges(ctx, dir);
|
|
768
|
+
} catch (err) {
|
|
769
|
+
if (!(err instanceof PreCommitError) || attempt > MAX_COMMIT_REPAIRS) throw err;
|
|
770
|
+
console.warn(
|
|
771
|
+
` pre-commit checks failed (repair ${attempt}/${MAX_COMMIT_REPAIRS}) \u2014 asking the agent to fix\u2026`
|
|
772
|
+
);
|
|
773
|
+
await runClaudeCode({
|
|
774
|
+
cwd: dir,
|
|
775
|
+
prompt: buildRepairPrompt(ctx, err.log),
|
|
776
|
+
permissionMode: ctx.permissionMode,
|
|
777
|
+
model: ORCHESTRATOR_MODEL,
|
|
778
|
+
maxTurns: ORCHESTRATOR_MAX_TURNS
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
657
783
|
function outcomeBanner(outcome, opts) {
|
|
658
784
|
const wikiNote = opts.documented ? " (with wiki updates)" : "";
|
|
659
785
|
switch (outcome.kind) {
|
|
@@ -675,20 +801,35 @@ function outcomeBanner(outcome, opts) {
|
|
|
675
801
|
}
|
|
676
802
|
}
|
|
677
803
|
async function processJob(ctx) {
|
|
678
|
-
const dir = await
|
|
804
|
+
const { dir, reused } = await acquireWorkspace(ctx.workspaceKey);
|
|
805
|
+
let prepared = false;
|
|
679
806
|
try {
|
|
680
|
-
if (ctx.kind === "init")
|
|
681
|
-
|
|
682
|
-
|
|
807
|
+
if (ctx.kind === "init") {
|
|
808
|
+
await prepareAtSha(ctx, dir, reused);
|
|
809
|
+
prepared = true;
|
|
810
|
+
return await processInitJob(ctx, dir);
|
|
811
|
+
}
|
|
812
|
+
if (ctx.kind === "implement") {
|
|
813
|
+
const { resumed } = await prepareResumingBranch(ctx, dir, reused);
|
|
814
|
+
prepared = true;
|
|
815
|
+
return await processImplementJob(ctx, dir, resumed);
|
|
816
|
+
}
|
|
817
|
+
if (ctx.kind === "revise") {
|
|
818
|
+
const { resumed } = await prepareResumingBranch(ctx, dir, reused);
|
|
819
|
+
prepared = true;
|
|
820
|
+
return await processReviseJob(ctx, dir, resumed);
|
|
821
|
+
}
|
|
822
|
+
await prepareAtSha(ctx, dir, reused);
|
|
823
|
+
prepared = true;
|
|
683
824
|
return await processChatJob(ctx, dir);
|
|
684
|
-
}
|
|
685
|
-
await
|
|
825
|
+
} catch (err) {
|
|
826
|
+
if (!prepared) await discardWorkspace(ctx.workspaceKey);
|
|
827
|
+
throw err;
|
|
686
828
|
}
|
|
687
829
|
}
|
|
688
830
|
async function processInitJob(ctx, dir) {
|
|
689
831
|
console.log(`
|
|
690
832
|
\u25B6 Init ${ctx.jobId} \u2014 ${ctx.repo.fullName} on ${ctx.repo.checkoutBranch}`);
|
|
691
|
-
await cloneAtSha(ctx, dir);
|
|
692
833
|
const summary = (await runClaudeCode({
|
|
693
834
|
cwd: dir,
|
|
694
835
|
prompt: buildInitPrompt(ctx),
|
|
@@ -706,7 +847,6 @@ async function processInitJob(ctx, dir) {
|
|
|
706
847
|
async function processChatJob(ctx, dir) {
|
|
707
848
|
console.log(`
|
|
708
849
|
\u25B6 Job ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
|
|
709
|
-
await cloneAtSha(ctx, dir);
|
|
710
850
|
const orchestrating = ctx.permissionMode !== "plan";
|
|
711
851
|
const installResult = orchestrating ? await installDependencies(dir) : null;
|
|
712
852
|
const result = await runClaudeCode({
|
|
@@ -748,10 +888,9 @@ async function processChatJob(ctx, dir) {
|
|
|
748
888
|
reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented });
|
|
749
889
|
return { text: reply, widgets: [] };
|
|
750
890
|
}
|
|
751
|
-
async function processImplementJob(ctx, dir) {
|
|
891
|
+
async function processImplementJob(ctx, dir, resumed) {
|
|
752
892
|
console.log(`
|
|
753
893
|
\u25B6 Implement ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
|
|
754
|
-
const { resumed } = await cloneResumingBranch(ctx, dir);
|
|
755
894
|
const installResult = await installDependencies(dir);
|
|
756
895
|
const result = await runClaudeCode({
|
|
757
896
|
cwd: dir,
|
|
@@ -785,10 +924,9 @@ async function processImplementJob(ctx, dir) {
|
|
|
785
924
|
reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented });
|
|
786
925
|
return { text: reply, widgets: [], ...outcome.kind === "pr" ? { pr: outcome.pr } : {} };
|
|
787
926
|
}
|
|
788
|
-
async function processReviseJob(ctx, dir) {
|
|
927
|
+
async function processReviseJob(ctx, dir, resumed) {
|
|
789
928
|
console.log(`
|
|
790
929
|
\u25B6 Revise ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
|
|
791
|
-
const { resumed } = await cloneResumingBranch(ctx, dir);
|
|
792
930
|
const installResult = await installDependencies(dir);
|
|
793
931
|
const result = await runClaudeCode({
|
|
794
932
|
cwd: dir,
|
|
@@ -933,14 +1071,18 @@ function parseFlags(args) {
|
|
|
933
1071
|
}
|
|
934
1072
|
var command = process.argv[2];
|
|
935
1073
|
var rest = process.argv.slice(3);
|
|
936
|
-
if (command === "
|
|
1074
|
+
if (command === "--version" || command === "-v" || command === "version") {
|
|
1075
|
+
console.log(RUNNER_VERSION);
|
|
1076
|
+
process.exit(0);
|
|
1077
|
+
} else if (command === "login") {
|
|
937
1078
|
void login(rest);
|
|
938
1079
|
} else if (command === "start") {
|
|
939
1080
|
void start();
|
|
940
1081
|
} else {
|
|
941
|
-
console.log(
|
|
1082
|
+
console.log(`FlumeCode runner v${RUNNER_VERSION}`);
|
|
942
1083
|
console.log("Usage:");
|
|
943
|
-
console.log(" flumecode login
|
|
944
|
-
console.log(" flumecode start
|
|
1084
|
+
console.log(" flumecode login # save server URL + token");
|
|
1085
|
+
console.log(" flumecode start # poll for and run jobs");
|
|
1086
|
+
console.log(" flumecode --version # print the runner version");
|
|
945
1087
|
process.exit(command ? 1 : 0);
|
|
946
1088
|
}
|