@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.
Files changed (3) hide show
  1. package/README.md +2 -1
  2. package/dist/cli.js +171 -29
  3. 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 `runner-v*` tag is pushed.
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
- await git([
558
- "-C",
559
- dir,
560
- "-c",
561
- "user.email=runner@flumecode.local",
562
- "-c",
563
- "user.name=FlumeCode Runner",
564
- "commit",
565
- "--quiet",
566
- "-m",
567
- `FlumeCode: ${jobTitle(ctx)}`
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 commitChanges(ctx, dir);
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 makeWorkspace();
804
+ const { dir, reused } = await acquireWorkspace(ctx.workspaceKey);
805
+ let prepared = false;
679
806
  try {
680
- if (ctx.kind === "init") return await processInitJob(ctx, dir);
681
- if (ctx.kind === "implement") return await processImplementJob(ctx, dir);
682
- if (ctx.kind === "revise") return await processReviseJob(ctx, dir);
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
- } finally {
685
- await cleanup(dir);
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 === "login") {
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("FlumeCode runner");
1082
+ console.log(`FlumeCode runner v${RUNNER_VERSION}`);
942
1083
  console.log("Usage:");
943
- console.log(" flumecode login # save server URL + token");
944
- console.log(" flumecode start # poll for and run jobs");
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flumecode/runner",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "FlumeCode local runner — claims jobs and drives your local Claude Code against a real checkout.",
6
6
  "bin": {