@flumecode/runner 0.3.2 → 0.5.0

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/cli.js CHANGED
@@ -97,6 +97,19 @@ async function claimJob(config) {
97
97
  if (!res.ok) throw new Error(`claim failed: ${res.status} ${await safeText(res)}`);
98
98
  return await res.json();
99
99
  }
100
+ async function pollJobCanceling(config, jobId) {
101
+ const res = await fetch(`${config.serverUrl}/api/runner/jobs/${jobId}/cancel`, {
102
+ method: "GET",
103
+ headers: {
104
+ authorization: `Bearer ${config.token}`,
105
+ [RUNNER_VERSION_HEADER]: RUNNER_VERSION
106
+ }
107
+ });
108
+ noteServerVersion(res);
109
+ if (!res.ok) return false;
110
+ const body = await res.json();
111
+ return body.canceling === true;
112
+ }
100
113
  async function reportJob(config, jobId, result) {
101
114
  const res = await fetch(`${config.serverUrl}/api/runner/jobs/${jobId}/complete`, {
102
115
  method: "POST",
@@ -328,6 +341,7 @@ async function runClaudeCode(opts) {
328
341
  permissionMode: opts.permissionMode,
329
342
  allowDangerouslySkipPermissions: opts.permissionMode === "bypassPermissions",
330
343
  ...opts.model ? { model: opts.model } : {},
344
+ ...opts.abortController ? { abortController: opts.abortController } : {},
331
345
  maxTurns: opts.maxTurns ?? 40,
332
346
  // Hermetic: ignore ALL config from the checked-out repo (CLAUDE.md,
333
347
  // .claude/settings.json, .claude/skills, …) and the runner owner's
@@ -358,6 +372,9 @@ async function runClaudeCode(opts) {
358
372
  }
359
373
  }
360
374
  process.stdout.write("\n");
375
+ if (opts.abortController?.signal.aborted) {
376
+ throw new Error("Run canceled by user");
377
+ }
361
378
  return { text: finalText, widgets: collected, plans: getPlans() };
362
379
  }
363
380
 
@@ -424,6 +441,20 @@ function stripFrontMatter(raw) {
424
441
  }
425
442
 
426
443
  // src/prompt.ts
444
+ function turnHeading(turn, agentName) {
445
+ if (turn.role === "user") return "User";
446
+ if (turn.failed) return `${agentName} (this run ended in an error)`;
447
+ if (turn.kind === "plan") return `${agentName} (proposed a plan)`;
448
+ if (turn.kind === "report") return `${agentName} (implementation report)`;
449
+ return agentName;
450
+ }
451
+ function appendThread(lines, ctx) {
452
+ if (!ctx.thread || ctx.thread.length === 0) return;
453
+ lines.push("", "# Conversation so far");
454
+ for (const turn of ctx.thread) {
455
+ lines.push("", `## ${turnHeading(turn, ctx.agentName)}`, turn.content);
456
+ }
457
+ }
427
458
  function buildPrompt(ctx) {
428
459
  const task = ctx.permissionMode === "plan" ? `Use the \`flumecode:request-to-plan\` skill to handle this request. You are read-only and cannot modify files \u2014 clarify any ambiguity with the user first, then produce a concrete, actionable plan (the specific changes you would make and why). Cite the relevant files. Do NOT call ExitPlanMode or write the plan to a file. When the plan is ready, call the \`submit_plan\` tool with the structured plan fields; the runner renders it into the canonical plan markdown and posts it as your comment.` : `Use the \`flumecode:implement-plan\` skill to handle this request. You are the ORCHESTRATOR: do not implement, review, or write the report yourself \u2014 follow the skill to delegate each phase to subagents via the Task tool, picking the right model for each. Do not commit or push \u2014 the runner handles that.`;
429
460
  const orient = `Before investigating raw source, check for a FlumeCode wiki at \`.flumecode/wiki/\`. If it exists, read \`.flumecode/wiki/README.md\` first \u2014 it is the index \u2014 and follow its links to the pages and source paths relevant to this request. If there is no wiki, work from the code directly.`;
@@ -447,12 +478,7 @@ function buildPrompt(ctx) {
447
478
  if (ctx.request?.body) {
448
479
  lines.push("", ctx.request.body);
449
480
  }
450
- if (ctx.thread && ctx.thread.length > 0) {
451
- lines.push("", "# Conversation so far");
452
- for (const turn of ctx.thread) {
453
- lines.push("", `## ${turn.role === "agent" ? ctx.agentName : "User"}`, turn.content);
454
- }
455
- }
481
+ appendThread(lines, ctx);
456
482
  lines.push(
457
483
  "",
458
484
  ctx.permissionMode === "plan" ? "Your final reply is posted verbatim as your comment in the thread \u2014 if you called `submit_plan`, the rendered plan is posted automatically; for clarifying questions, your reply text is posted as-is." : "Your final reply is posted verbatim as your comment in the thread \u2014 make it the implementation report your report subagent produced, with nothing added. The runner appends the pull-request link."
@@ -460,7 +486,7 @@ function buildPrompt(ctx) {
460
486
  return lines.join("\n");
461
487
  }
462
488
  function buildRevisePrompt(ctx) {
463
- const task = `Use the \`flumecode:revise-implementation\` skill to handle this turn. The plan below was already implemented (its report is included); the user is now asking to fine-tune that implementation. Decide how to respond to their latest message: if it's unclear, ask a clarifying question (as a widget); if it's a bad idea or not feasible, push back with your reasoning; if it warrants rethinking the plan, call \`submit_plan\` with a revised plan; otherwise implement the requested change. When you implement, you are the ORCHESTRATOR: delegate the work to subagents via the Task tool as the skill directs, and do not commit or push \u2014 the runner handles that, updating the existing pull request.`;
489
+ const task = `Use the \`flumecode:revise-implementation\` skill to handle this turn. The plan below was already implemented (its implementation report appears in the conversation below, tagged as such); the user is now asking to fine-tune that implementation. Decide how to respond to their latest message: if it's unclear, ask a clarifying question (as a widget); if it's a bad idea or not feasible, push back with your reasoning; if it warrants rethinking the plan, call \`submit_plan\` with a revised plan; otherwise implement the requested change. When you implement, you are the ORCHESTRATOR: delegate the work to subagents via the Task tool as the skill directs, and do not commit or push \u2014 the runner handles that, updating the existing pull request.`;
464
490
  const orient = `Before investigating raw source, check for a FlumeCode wiki at \`.flumecode/wiki/\`. If it exists, read \`.flumecode/wiki/README.md\` first \u2014 it is the index \u2014 and follow its links to the pages and source paths relevant to this change. If there is no wiki, work from the code directly.`;
465
491
  const widgets = `When you need the user to choose, ask it as a widget rather than writing the options as prose: call \`single_select\` for a one-of-N choice (radio buttons) or \`multi_select\` for a "select all that apply" choice (checkboxes). Don't add your own "Other" option \u2014 the UI always provides one. After calling a widget tool, end your turn \u2014 the user's answer comes back as their next message and starts a fresh run.`;
466
492
  const lines = [
@@ -479,21 +505,39 @@ function buildRevisePrompt(ctx) {
479
505
  if (ctx.request?.body) {
480
506
  lines.push("", ctx.request.body);
481
507
  }
482
- if (ctx.priorReport) {
483
- lines.push("", "# Latest implementation report", "", ctx.priorReport);
484
- }
485
- if (ctx.thread && ctx.thread.length > 0) {
486
- lines.push("", "# Conversation so far");
487
- for (const turn of ctx.thread) {
488
- lines.push("", `## ${turn.role === "agent" ? ctx.agentName : "User"}`, turn.content);
489
- }
490
- }
508
+ appendThread(lines, ctx);
491
509
  lines.push(
492
510
  "",
493
511
  "The last message above is the user's request for this turn. Your final reply is posted verbatim as your comment in the plan thread: if you implemented a change, make it a short report of what you changed (the runner appends the pull-request link); if you asked a question, called `submit_plan`, or pushed back, your reply text is posted as-is."
494
512
  );
495
513
  return lines.join("\n");
496
514
  }
515
+ function buildResolvePrompt(ctx) {
516
+ const mergeBranch = ctx.repo.mergeBranch ?? "the merge branch";
517
+ const task = `Use the \`flumecode:resolve-merge-conflict\` skill to handle this turn. A merge of \`${mergeBranch}\` into this branch is IN PROGRESS and has left conflict markers in your working tree. Resolve every conflicted file by correctly integrating BOTH sides \u2014 the change this session implemented (described below) and the incoming changes from \`${mergeBranch}\` \u2014 never blindly discard either side. Remove all conflict markers and verify the result builds and tests pass. Do NOT \`git add\`, commit, push, or open a pull request \u2014 the runner finalizes the merge commit and updates the existing pull request.`;
518
+ const orient = `Before investigating raw source, check for a FlumeCode wiki at \`.flumecode/wiki/\`. If it exists, read \`.flumecode/wiki/README.md\` first \u2014 it is the index \u2014 and follow its links to the pages and source paths relevant to the conflicting code. If there is no wiki, work from the code directly.`;
519
+ const lines = [
520
+ `You are "${ctx.agentName}", an autonomous coding agent resolving merge conflicts on an implemented FlumeCode plan.`,
521
+ `The repository ${ctx.repo.fullName} is checked out in your current working directory on the plan's implementation branch "${ctx.repo.checkoutBranch}" \u2014 the same branch its open pull request is built from \u2014 with an in-progress merge of "${mergeBranch}".`,
522
+ task,
523
+ orient,
524
+ "",
525
+ "These coding guidelines apply to all code produced in this run:",
526
+ "",
527
+ loadRule("coding-guideline"),
528
+ "",
529
+ `# Plan: ${ctx.request?.title ?? ""}`
530
+ ];
531
+ if (ctx.request?.body) {
532
+ lines.push("", ctx.request.body);
533
+ }
534
+ appendThread(lines, ctx);
535
+ lines.push(
536
+ "",
537
+ "Resolve the conflicts now. Your final reply is posted as a report in the plan thread: summarize which files conflicted and how you resolved each (the runner appends the pull-request link, so don't add one)."
538
+ );
539
+ return lines.join("\n");
540
+ }
497
541
  function buildDocumentPrompt(ctx) {
498
542
  const lines = [
499
543
  `You are "${ctx.agentName}" maintaining the repository wiki for ${ctx.repo.fullName}.`,
@@ -505,12 +549,7 @@ function buildDocumentPrompt(ctx) {
505
549
  if (ctx.request?.body) {
506
550
  lines.push("", ctx.request.body);
507
551
  }
508
- if (ctx.thread && ctx.thread.length > 0) {
509
- lines.push("", "# Conversation so far");
510
- for (const turn of ctx.thread) {
511
- lines.push("", `## ${turn.role === "agent" ? ctx.agentName : "User"}`, turn.content);
512
- }
513
- }
552
+ appendThread(lines, ctx);
514
553
  lines.push("", "When done, reply with a one- or two-line summary of the wiki changes you made.");
515
554
  return lines.join("\n");
516
555
  }
@@ -614,6 +653,12 @@ async function discardWorkspace(key) {
614
653
  });
615
654
  }
616
655
  }
656
+ async function resetWorkspace(dir) {
657
+ await git(["-C", dir, "reset", "--hard", "HEAD"]).catch(() => {
658
+ });
659
+ await git(["-C", dir, "clean", "-fd"]).catch(() => {
660
+ });
661
+ }
617
662
  async function prepareAtSha(ctx, dir, reused) {
618
663
  if (!reused) {
619
664
  await cloneAtSha(ctx, dir);
@@ -727,6 +772,33 @@ async function rebaseOntoMergeBranch(ctx, dir) {
727
772
  throw new RebaseConflictError(mergeBranch, files);
728
773
  }
729
774
  }
775
+ async function mergeInMergeBranch(ctx, dir) {
776
+ const { mergeBranch } = ctx.repo;
777
+ if (!mergeBranch) return { conflicted: false };
778
+ await git(["-C", dir, "fetch", "--quiet", "origin", mergeBranch]);
779
+ try {
780
+ await git([
781
+ "-C",
782
+ dir,
783
+ "-c",
784
+ "user.email=runner@flumecode.local",
785
+ "-c",
786
+ "user.name=FlumeCode Runner",
787
+ "merge",
788
+ "--no-edit",
789
+ "FETCH_HEAD"
790
+ ]);
791
+ return { conflicted: false };
792
+ } catch {
793
+ return { conflicted: true };
794
+ }
795
+ }
796
+ async function listUnmergedPaths(dir) {
797
+ const { stdout: stdout2 } = await git(["-C", dir, "diff", "--name-only", "--diff-filter=U"]).catch(() => ({
798
+ stdout: ""
799
+ }));
800
+ return stdout2.split("\n").map((line) => line.trim()).filter(Boolean);
801
+ }
730
802
  async function openPullRequest(ctx) {
731
803
  const { owner, name, cloneToken, checkoutBranch, mergeBranch } = ctx.repo;
732
804
  if (!mergeBranch) return null;
@@ -772,21 +844,23 @@ async function cleanup(dir) {
772
844
 
773
845
  // src/run.ts
774
846
  var IDLE_MS = 5e3;
847
+ var CANCEL_POLL_MS = 2500;
775
848
  var ORCHESTRATOR_MODEL = "sonnet";
776
849
  var ORCHESTRATOR_MAX_TURNS = 80;
777
850
  var MAX_COMMIT_REPAIRS = 2;
778
851
  var INIT_MAX_TURNS = 200;
779
852
  var DOCUMENT_MAX_TURNS = 120;
780
853
  var HEARTBEAT_MS = 5 * 6e4;
781
- async function pushAndOpenPr(ctx, dir, opts = { rebase: true }) {
782
- const committed = await commitWithRepair(ctx, dir);
854
+ async function pushAndOpenPr(ctx, dir, abort, opts = { rebase: true }) {
855
+ if (abort.signal.aborted) throw new Error("Run canceled by user");
856
+ const committed = await commitWithRepair(ctx, dir, abort);
783
857
  if (!committed) return { kind: "none" };
784
858
  if (opts.rebase) await rebaseOntoMergeBranch(ctx, dir);
785
859
  await pushBranch(ctx, dir);
786
860
  const pr = await openPullRequest(ctx);
787
861
  return pr ? { kind: "pr", pr } : { kind: "pushed" };
788
862
  }
789
- async function commitWithRepair(ctx, dir) {
863
+ async function commitWithRepair(ctx, dir, abort) {
790
864
  for (let attempt = 1; ; attempt++) {
791
865
  try {
792
866
  return await commitChanges(ctx, dir);
@@ -800,7 +874,8 @@ async function commitWithRepair(ctx, dir) {
800
874
  prompt: buildRepairPrompt(ctx, err.log),
801
875
  permissionMode: ctx.permissionMode,
802
876
  model: ORCHESTRATOR_MODEL,
803
- maxTurns: ORCHESTRATOR_MAX_TURNS
877
+ maxTurns: ORCHESTRATOR_MAX_TURNS,
878
+ abortController: abort
804
879
  });
805
880
  }
806
881
  }
@@ -825,51 +900,61 @@ function outcomeBanner(outcome, opts) {
825
900
  \u2139\uFE0F **No code changes were made** \u2014 ${opts.noChange ?? "there was nothing to open a pull request for."}`;
826
901
  }
827
902
  }
828
- async function processJob(ctx) {
903
+ async function processJob(ctx, abort = new AbortController()) {
829
904
  const { dir, reused } = await acquireWorkspace(ctx.workspaceKey);
830
905
  let prepared = false;
831
906
  try {
832
907
  if (ctx.kind === "init") {
833
908
  await prepareAtSha(ctx, dir, reused);
834
909
  prepared = true;
835
- return await processInitJob(ctx, dir);
910
+ return await processInitJob(ctx, dir, abort);
836
911
  }
837
912
  if (ctx.kind === "implement") {
838
913
  const { resumed } = await prepareResumingBranch(ctx, dir, reused);
839
914
  prepared = true;
840
- return await processImplementJob(ctx, dir, resumed);
915
+ return await processImplementJob(ctx, dir, resumed, abort);
841
916
  }
842
917
  if (ctx.kind === "revise") {
843
918
  const { resumed } = await prepareResumingBranch(ctx, dir, reused);
844
919
  prepared = true;
845
- return await processReviseJob(ctx, dir, resumed);
920
+ return await processReviseJob(ctx, dir, resumed, abort);
921
+ }
922
+ if (ctx.kind === "resolve") {
923
+ await prepareResumingBranch(ctx, dir, reused);
924
+ prepared = true;
925
+ return await processResolveJob(ctx, dir, abort);
846
926
  }
847
927
  await prepareAtSha(ctx, dir, reused);
848
928
  prepared = true;
849
- return await processChatJob(ctx, dir);
929
+ return await processChatJob(ctx, dir, abort);
850
930
  } catch (err) {
851
- if (!prepared) await discardWorkspace(ctx.workspaceKey);
931
+ if (abort.signal.aborted && prepared) {
932
+ await resetWorkspace(dir);
933
+ } else if (!prepared) {
934
+ await discardWorkspace(ctx.workspaceKey);
935
+ }
852
936
  throw err;
853
937
  }
854
938
  }
855
- async function processInitJob(ctx, dir) {
939
+ async function processInitJob(ctx, dir, abort) {
856
940
  console.log(`
857
941
  \u25B6 Init ${ctx.jobId} \u2014 ${ctx.repo.fullName} on ${ctx.repo.checkoutBranch}`);
858
942
  const summary = (await runClaudeCode({
859
943
  cwd: dir,
860
944
  prompt: buildInitPrompt(ctx),
861
945
  permissionMode: ctx.permissionMode,
862
- maxTurns: INIT_MAX_TURNS
946
+ maxTurns: INIT_MAX_TURNS,
947
+ abortController: abort
863
948
  })).text.trim();
864
949
  let reply = summary || "(the agent produced no summary)";
865
- const outcome = await pushAndOpenPr(ctx, dir);
950
+ const outcome = await pushAndOpenPr(ctx, dir, abort);
866
951
  reply += outcomeBanner(outcome, {
867
952
  branch: ctx.repo.checkoutBranch,
868
953
  noChange: "no files were generated; the wiki may already exist."
869
954
  });
870
955
  return { text: reply, widgets: [] };
871
956
  }
872
- async function processChatJob(ctx, dir) {
957
+ async function processChatJob(ctx, dir, abort) {
873
958
  console.log(`
874
959
  \u25B6 Job ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
875
960
  const orchestrating = ctx.permissionMode !== "plan";
@@ -878,6 +963,7 @@ async function processChatJob(ctx, dir) {
878
963
  cwd: dir,
879
964
  prompt: buildPrompt(ctx),
880
965
  permissionMode: ctx.permissionMode,
966
+ abortController: abort,
881
967
  ...orchestrating ? { model: ORCHESTRATOR_MODEL, maxTurns: ORCHESTRATOR_MAX_TURNS } : {}
882
968
  });
883
969
  const summary = result.text.trim();
@@ -902,18 +988,19 @@ async function processChatJob(ctx, dir) {
902
988
  cwd: dir,
903
989
  prompt: buildDocumentPrompt(ctx),
904
990
  permissionMode: ctx.permissionMode,
905
- maxTurns: DOCUMENT_MAX_TURNS
991
+ maxTurns: DOCUMENT_MAX_TURNS,
992
+ abortController: abort
906
993
  });
907
994
  documented = true;
908
995
  } catch (err) {
909
996
  console.warn(` wiki update skipped: ${errorMessage2(err)}`);
910
997
  }
911
998
  }
912
- const outcome = await pushAndOpenPr(ctx, dir);
999
+ const outcome = await pushAndOpenPr(ctx, dir, abort);
913
1000
  reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented });
914
1001
  return { text: reply, widgets: [] };
915
1002
  }
916
- async function processImplementJob(ctx, dir, resumed) {
1003
+ async function processImplementJob(ctx, dir, resumed, abort) {
917
1004
  console.log(`
918
1005
  \u25B6 Implement ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
919
1006
  const installResult = await installDependencies(dir);
@@ -922,7 +1009,8 @@ async function processImplementJob(ctx, dir, resumed) {
922
1009
  prompt: buildPrompt(ctx),
923
1010
  permissionMode: ctx.permissionMode,
924
1011
  model: ORCHESTRATOR_MODEL,
925
- maxTurns: ORCHESTRATOR_MAX_TURNS
1012
+ maxTurns: ORCHESTRATOR_MAX_TURNS,
1013
+ abortController: abort
926
1014
  });
927
1015
  let reply = result.text.trim() || "(the agent produced no report)";
928
1016
  if (installResult.status === "failed") {
@@ -938,18 +1026,19 @@ async function processImplementJob(ctx, dir, resumed) {
938
1026
  cwd: dir,
939
1027
  prompt: buildDocumentPrompt(ctx),
940
1028
  permissionMode: ctx.permissionMode,
941
- maxTurns: DOCUMENT_MAX_TURNS
1029
+ maxTurns: DOCUMENT_MAX_TURNS,
1030
+ abortController: abort
942
1031
  });
943
1032
  documented = true;
944
1033
  } catch (err) {
945
1034
  console.warn(` wiki update skipped: ${errorMessage2(err)}`);
946
1035
  }
947
1036
  }
948
- const outcome = await pushAndOpenPr(ctx, dir, { rebase: !resumed });
1037
+ const outcome = await pushAndOpenPr(ctx, dir, abort, { rebase: !resumed });
949
1038
  reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented });
950
1039
  return { text: reply, widgets: [], ...outcome.kind === "pr" ? { pr: outcome.pr } : {} };
951
1040
  }
952
- async function processReviseJob(ctx, dir, resumed) {
1041
+ async function processReviseJob(ctx, dir, resumed, abort) {
953
1042
  console.log(`
954
1043
  \u25B6 Revise ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
955
1044
  const installResult = await installDependencies(dir);
@@ -958,7 +1047,8 @@ async function processReviseJob(ctx, dir, resumed) {
958
1047
  prompt: buildRevisePrompt(ctx),
959
1048
  permissionMode: ctx.permissionMode,
960
1049
  model: ORCHESTRATOR_MODEL,
961
- maxTurns: ORCHESTRATOR_MAX_TURNS
1050
+ maxTurns: ORCHESTRATOR_MAX_TURNS,
1051
+ abortController: abort
962
1052
  });
963
1053
  const summary = result.text.trim();
964
1054
  let reply = summary || "(the agent produced no reply)";
@@ -980,14 +1070,15 @@ async function processReviseJob(ctx, dir, resumed) {
980
1070
  cwd: dir,
981
1071
  prompt: buildDocumentPrompt(ctx),
982
1072
  permissionMode: ctx.permissionMode,
983
- maxTurns: DOCUMENT_MAX_TURNS
1073
+ maxTurns: DOCUMENT_MAX_TURNS,
1074
+ abortController: abort
984
1075
  });
985
1076
  documented = true;
986
1077
  } catch (err) {
987
1078
  console.warn(` wiki update skipped: ${errorMessage2(err)}`);
988
1079
  }
989
1080
  }
990
- const outcome = await pushAndOpenPr(ctx, dir, { rebase: !resumed });
1081
+ const outcome = await pushAndOpenPr(ctx, dir, abort, { rebase: !resumed });
991
1082
  if (outcome.kind !== "none") {
992
1083
  reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented });
993
1084
  }
@@ -998,6 +1089,44 @@ async function processReviseJob(ctx, dir, resumed) {
998
1089
  ...result.plans?.length ? { plans: result.plans } : {}
999
1090
  };
1000
1091
  }
1092
+ async function processResolveJob(ctx, dir, abort) {
1093
+ console.log(`
1094
+ \u25B6 Resolve ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
1095
+ const installResult = await installDependencies(dir);
1096
+ const { conflicted } = await mergeInMergeBranch(ctx, dir);
1097
+ let reply;
1098
+ if (conflicted) {
1099
+ const result = await runClaudeCode({
1100
+ cwd: dir,
1101
+ prompt: buildResolvePrompt(ctx),
1102
+ permissionMode: ctx.permissionMode,
1103
+ model: ORCHESTRATOR_MODEL,
1104
+ maxTurns: ORCHESTRATOR_MAX_TURNS,
1105
+ abortController: abort
1106
+ });
1107
+ reply = result.text.trim() || "(the agent produced no report)";
1108
+ const unresolved = await listUnmergedPaths(dir);
1109
+ if (unresolved.length > 0) {
1110
+ throw new Error(
1111
+ `Could not fully resolve the merge \u2014 ${unresolved.length} file(s) still conflict: ${unresolved.join(", ")}`
1112
+ );
1113
+ }
1114
+ } else {
1115
+ reply = `Merged \`${ctx.repo.mergeBranch ?? "the merge branch"}\` into \`${ctx.repo.checkoutBranch}\` cleanly \u2014 there were no conflicts to resolve.`;
1116
+ }
1117
+ if (installResult.status === "failed") {
1118
+ reply += `
1119
+
1120
+ > \u26A0\uFE0F Dependencies failed to install (\`${installResult.manager}\`); tests may not have run.`;
1121
+ }
1122
+ if (abort.signal.aborted) throw new Error("Run canceled by user");
1123
+ await commitWithRepair(ctx, dir, abort);
1124
+ await pushBranch(ctx, dir);
1125
+ const pr = await openPullRequest(ctx);
1126
+ const outcome = pr ? { kind: "pr", pr } : { kind: "pushed" };
1127
+ reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch });
1128
+ return { text: reply, widgets: [], ...pr ? { pr } : {} };
1129
+ }
1001
1130
  async function heartbeat(config) {
1002
1131
  const health = await checkClaudeCode();
1003
1132
  if (health.ready) {
@@ -1016,43 +1145,78 @@ async function pollLoop(config) {
1016
1145
  const swept = await sweepWorkspaces();
1017
1146
  if (swept > 0) console.log(`Cleared ${swept} stale workspace${swept === 1 ? "" : "s"}.`);
1018
1147
  await heartbeat(config);
1019
- let nextHeartbeatAt = Date.now() + HEARTBEAT_MS;
1020
- for (; ; ) {
1021
- if (Date.now() >= nextHeartbeatAt) {
1148
+ let heartbeatTimer;
1149
+ const scheduleHeartbeat = () => {
1150
+ heartbeatTimer = setTimeout(async () => {
1022
1151
  await heartbeat(config);
1023
- nextHeartbeatAt = Date.now() + HEARTBEAT_MS;
1024
- }
1025
- let ctx = null;
1026
- try {
1027
- ctx = await claimJob(config);
1028
- } catch (err) {
1029
- console.error(`Claim error: ${errorMessage2(err)}`);
1030
- await sleep(IDLE_MS);
1031
- continue;
1032
- }
1033
- if (!ctx) {
1034
- await sleep(IDLE_MS);
1035
- continue;
1036
- }
1037
- try {
1038
- const { text, widgets, pr, plans } = await processJob(ctx);
1039
- await reportJob(config, ctx.jobId, {
1040
- status: "done",
1041
- text,
1042
- widgets,
1043
- pr,
1044
- ...plans?.length ? { plans } : {}
1045
- });
1046
- console.log(`\u2713 Job ${ctx.jobId} done`);
1047
- } catch (err) {
1048
- const message = errorMessage2(err);
1049
- console.error(`\u2717 Job ${ctx.jobId} failed: ${message}`);
1152
+ scheduleHeartbeat();
1153
+ }, HEARTBEAT_MS);
1154
+ };
1155
+ scheduleHeartbeat();
1156
+ try {
1157
+ for (; ; ) {
1158
+ let ctx = null;
1050
1159
  try {
1051
- await reportJob(config, ctx.jobId, { status: "error", error: message });
1052
- } catch (reportErr) {
1053
- console.error(` (also failed to report the error: ${errorMessage2(reportErr)})`);
1160
+ ctx = await claimJob(config);
1161
+ } catch (err) {
1162
+ console.error(`Claim error: ${errorMessage2(err)}`);
1163
+ await sleep(IDLE_MS);
1164
+ continue;
1165
+ }
1166
+ if (!ctx) {
1167
+ await sleep(IDLE_MS);
1168
+ continue;
1169
+ }
1170
+ const abort = new AbortController();
1171
+ let stopPolling = false;
1172
+ const scheduleCancelPoll = () => {
1173
+ setTimeout(async () => {
1174
+ if (stopPolling) return;
1175
+ try {
1176
+ if (await pollJobCanceling(config, ctx.jobId)) {
1177
+ console.log(`\u23F9 Stop requested for job ${ctx.jobId} \u2014 aborting\u2026`);
1178
+ abort.abort();
1179
+ return;
1180
+ }
1181
+ } catch {
1182
+ }
1183
+ if (!stopPolling) scheduleCancelPoll();
1184
+ }, CANCEL_POLL_MS);
1185
+ };
1186
+ scheduleCancelPoll();
1187
+ try {
1188
+ const { text, widgets, pr, plans } = await processJob(ctx, abort);
1189
+ await reportJob(config, ctx.jobId, {
1190
+ status: "done",
1191
+ text,
1192
+ widgets,
1193
+ pr,
1194
+ ...plans?.length ? { plans } : {}
1195
+ });
1196
+ console.log(`\u2713 Job ${ctx.jobId} done`);
1197
+ } catch (err) {
1198
+ if (abort.signal.aborted) {
1199
+ console.log(`\u23F9 Job ${ctx.jobId} canceled`);
1200
+ try {
1201
+ await reportJob(config, ctx.jobId, { status: "canceled" });
1202
+ } catch (reportErr) {
1203
+ console.error(` (failed to report the cancellation: ${errorMessage2(reportErr)})`);
1204
+ }
1205
+ } else {
1206
+ const message = errorMessage2(err);
1207
+ console.error(`\u2717 Job ${ctx.jobId} failed: ${message}`);
1208
+ try {
1209
+ await reportJob(config, ctx.jobId, { status: "error", error: message });
1210
+ } catch (reportErr) {
1211
+ console.error(` (also failed to report the error: ${errorMessage2(reportErr)})`);
1212
+ }
1213
+ }
1214
+ } finally {
1215
+ stopPolling = true;
1054
1216
  }
1055
1217
  }
1218
+ } finally {
1219
+ if (heartbeatTimer) clearTimeout(heartbeatTimer);
1056
1220
  }
1057
1221
  }
1058
1222
  function sleep(ms) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flumecode/runner",
3
- "version": "0.3.2",
3
+ "version": "0.5.0",
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": {
@@ -0,0 +1,70 @@
1
+ ---
2
+ name: resolve-merge-conflict
3
+ description: >-
4
+ Resolve the merge conflicts left by an in-progress merge of a coding session's
5
+ merge branch into its pull-request branch. Use in resolve-job runs. The working
6
+ tree already has conflict markers; integrate both sides of every conflict
7
+ correctly, verify the build/tests, and leave the resolved changes in the working
8
+ tree. Never `git add`, commit, push, or open a PR — the runner finalizes the merge.
9
+ ---
10
+
11
+ # resolve-merge-conflict
12
+
13
+ The plan in the context above was **already implemented** and has an open pull
14
+ request. Its merge branch (e.g. `main`) has since advanced and now **conflicts** with
15
+ the PR branch. The runner has already started a `git merge` of the merge branch into
16
+ your current branch, so your working tree contains the **conflict markers**
17
+ (`<<<<<<<`, `=======`, `>>>>>>>`). Your job is to resolve every conflict so the PR
18
+ becomes mergeable again — without losing this session's work or the incoming changes.
19
+
20
+ ## You are stateless — orient yourself first
21
+
22
+ You see the whole session thread (the plan and its implementation report) but keep
23
+ **no memory** between turns. Read it first: the plan and report tell you what this
24
+ branch was building, which is exactly the intent you must preserve when you choose how
25
+ to integrate each conflict. Check the FlumeCode wiki (`.flumecode/wiki/README.md`) if
26
+ one exists to understand the conflicting code.
27
+
28
+ ## Step 1 — Find every conflict
29
+
30
+ - `git status` shows the unmerged paths; `git diff --diff-filter=U` shows the
31
+ conflicting hunks. Work through **all** of them — a single leftover marker fails the
32
+ run.
33
+
34
+ ## Step 2 — Resolve each conflict correctly
35
+
36
+ For each conflicted file, understand **both** sides before editing:
37
+
38
+ - **Ours** (the conflict's first side) is this session's implementation — the change
39
+ the plan/report describes.
40
+ - **Theirs** (the second side) is what landed on the merge branch since.
41
+
42
+ Integrate them so **both** intents survive. Do **not** blindly pick a side or delete
43
+ one half to make the markers go away — that silently drops work. When the two changes
44
+ are genuinely incompatible, prefer keeping this session's implementation intent while
45
+ adapting it to the incoming code (e.g. a renamed function, a moved file, a new
46
+ signature). Remove every conflict marker; the file must read as clean, correct code.
47
+
48
+ You may delegate isolated, well-scoped resolutions to **Task** subagents, but give
49
+ each a self-contained prompt (the conflicting file's content, both intents, and the
50
+ coding guidelines verbatim) — subagents start blank.
51
+
52
+ ## Step 3 — Verify
53
+
54
+ Run the project's build and tests to confirm the resolved tree is correct (the
55
+ runner's commit will also run the repo's pre-commit hook, so failing checks would
56
+ block the merge anyway). Fix anything the merge broke.
57
+
58
+ ## Never
59
+
60
+ - Never `git add`, `git commit`, `git merge --continue`, `git push`, or open a PR —
61
+ the runner stages the resolved tree, finalizes the merge commit, and updates the
62
+ **existing** pull request. A new PR must never be created.
63
+ - Never leave a conflict marker behind, and never resolve a conflict by discarding one
64
+ side's intent.
65
+
66
+ ## Your final reply
67
+
68
+ Your last message **is** the report posted to the session thread. Write it for the
69
+ user: list which files conflicted and, briefly, how you resolved each, plus how you
70
+ verified (build/tests). The runner appends the pull-request link, so don't add one.