@backburner/cli 0.1.7 → 0.1.9

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 CHANGED
@@ -4,6 +4,37 @@ Backburner turns GitHub issues into reviewable pull requests using coding agents
4
4
 
5
5
  Open an issue from your phone, get back to your day, and manage the work through GitHub comments, labels, and PR reviews. Your computer does the planning, coding, testing, committing, and pushing through the agent CLIs and credentials you already use. GitHub is the control plane; your computer is the worker.
6
6
 
7
+ ## From phone to pull request
8
+
9
+ Follow work from the GitHub mobile app while Backburner runs the agent workflow on your machine:
10
+
11
+ <table>
12
+ <tr>
13
+ <td align="center" width="50%">
14
+ <strong>1. Open an issue from your phone</strong>
15
+ <br />
16
+ <img src="docs/images/workflow-story/01-issue.jpg" alt="A GitHub issue describing OAuth support for hosted MCP" />
17
+ </td>
18
+ <td align="center" width="50%">
19
+ <strong>2. Review the product direction</strong>
20
+ <br />
21
+ <img src="docs/images/workflow-story/02-product-discovery.jpg" alt="An agent posts product discovery analysis on the GitHub issue" />
22
+ </td>
23
+ </tr>
24
+ <tr>
25
+ <td align="center" width="50%">
26
+ <strong>3. Approve the implementation plan</strong>
27
+ <br />
28
+ <img src="docs/images/workflow-story/03-plan-pr.jpg" alt="Backburner creates a planning pull request with product spec and plan documents" />
29
+ </td>
30
+ <td align="center" width="50%">
31
+ <strong>4. Review the completed work</strong>
32
+ <br />
33
+ <img src="docs/images/workflow-story/04-reviewed-change.jpg" alt="The implemented change is reviewed and ready for a human merge" />
34
+ </td>
35
+ </tr>
36
+ </table>
37
+
7
38
  ## How it works
8
39
 
9
40
  1. You open an issue in a repository managed by Backburner.
@@ -250,7 +281,7 @@ The normal orchestrator starts its own broker on loopback. The standalone broker
250
281
 
251
282
  ## What a run does
252
283
 
253
- Each cycle loads configuration and state, syncs enabled repositories, scans GitHub through `gh`, derives eligible tasks, starts a local GitHub broker for provider access, dispatches work to provider CLIs, and writes state, journal events, and execution logs.
284
+ Each cycle loads configuration and state, fetches and synchronizes enabled repositories, scans GitHub through `gh`, derives eligible tasks, starts a local GitHub broker for provider access, dispatches work to provider CLIs, and writes state, journal events, and execution logs. If a managed checkout has local changes, Backburner does not modify it; after a successful fetch, packet worktrees may still be created from the exact remote integration-branch ref.
254
285
 
255
286
  Backburner uses isolated git worktrees as implementation units. Generated control-plane state is readable JSON under `<outputs-dir>/state`; execution logs and artifacts are under `<outputs-dir>/logs`; journal events are under `<outputs-dir>/journal`.
256
287
 
@@ -47,6 +47,26 @@ export function buildPacketWaveAdvancementEvaluationForRun(input) {
47
47
  waveAssessments: input.waveAssessments
48
48
  });
49
49
  }
50
+ /**
51
+ * Returns local repositories that can safely provide packet worktrees.
52
+ *
53
+ * Packet worktrees start at an exact `origin/<integration-branch>` ref, so a
54
+ * successfully fetched repository remains usable only when its primary
55
+ * checkout was explicitly blocked for local changes. Other failed sync states
56
+ * remain excluded because their remote refs may be stale or unavailable.
57
+ */
58
+ export function buildPacketProvisioningRepoPathMap(repoResults) {
59
+ return new Map(repoResults
60
+ .filter(isPacketProvisioningRepo)
61
+ .map((result) => [result.repoId, result.localPath]));
62
+ }
63
+ function isPacketProvisioningRepo(result) {
64
+ if (result.status === "synced") {
65
+ return true;
66
+ }
67
+ return (result.fetched &&
68
+ result.defaultBranchSyncStatus === "blocked_dirty");
69
+ }
50
70
  export async function processProviderLimitResumption(input) {
51
71
  const providerLimits = [...(input.previousState.providerLimits ?? [])];
52
72
  const currentTime = Date.parse(input.now);
@@ -726,6 +746,9 @@ export async function runCli(argv, options = {}) {
726
746
  if (retrospectiveDerivation.tasks.length > 0 || finalSkipContextHydration.changed) {
727
747
  await stateStore.writeTaskState(finalTasksToDispatch);
728
748
  }
749
+ // Workspace recovery already ran before the initial dispatch. Deferring
750
+ // further recovery until the next outer run preserves completed task
751
+ // handoffs produced earlier in this run.
729
752
  dispatchResult = await runDispatchWithMergePrepLifecycle({
730
753
  tasks: finalTasksToDispatch,
731
754
  worktrees: projectionAwareTriageWorktrees,
@@ -759,6 +782,7 @@ export async function runCli(argv, options = {}) {
759
782
  commandRunner,
760
783
  gitGateway,
761
784
  workspaceRecoveryService,
785
+ skipWorkspaceRecovery: true,
762
786
  agentPool
763
787
  });
764
788
  // Journal new executions (dedup via executionIdsBeforeCycle)
@@ -871,9 +895,7 @@ export async function runCli(argv, options = {}) {
871
895
  .filter((assessment) => assessment !== undefined)
872
896
  });
873
897
  // Packet planning artifact creation
874
- const repoPathMap = new Map(scanResult.repoResults
875
- .filter((result) => result.status === "synced")
876
- .map((result) => [result.repoId, result.localPath]));
898
+ const repoPathMap = buildPacketProvisioningRepoPathMap(scanResult.repoResults);
877
899
  let currentWorktrees = finalWorktrees;
878
900
  let currentWorkstreams = enrichedWorkstreams;
879
901
  // Preserve new packet plans before provisioning external packet artifacts.
@@ -887,13 +909,38 @@ export async function runCli(argv, options = {}) {
887
909
  if (!repo || !repoPath) {
888
910
  continue;
889
911
  }
890
- const packetWorktreeResults = await implementationWorkflowService.ensurePacketPlanningWorktreesForCurrentWave({
891
- repo,
892
- repoPath,
893
- codeRoot,
894
- parentWorkstream: workstream,
895
- existingWorktrees: currentWorktrees
896
- });
912
+ const parentIntegrationBranch = workstream.integration?.branchName;
913
+ if (!parentIntegrationBranch) {
914
+ continue;
915
+ }
916
+ try {
917
+ const comparison = await gitGateway.compareBranchSyncStatus(repoPath, parentIntegrationBranch, repo.defaultBranch ?? "main");
918
+ if (comparison.status === "missing_parent_branch" || comparison.status === "unknown") {
919
+ logger.warn(`Packet worktree start point origin/${parentIntegrationBranch} is unavailable for ${workstream.id}; will retry next cycle.`);
920
+ continue;
921
+ }
922
+ }
923
+ catch (error) {
924
+ const message = error instanceof Error ? error.message : String(error);
925
+ logger.warn(`Could not verify packet worktree start point for ${workstream.id}; will retry next cycle: ${message}`);
926
+ continue;
927
+ }
928
+ let packetWorktreeResults;
929
+ try {
930
+ packetWorktreeResults =
931
+ await implementationWorkflowService.ensurePacketPlanningWorktreesForCurrentWave({
932
+ repo,
933
+ repoPath,
934
+ codeRoot,
935
+ parentWorkstream: workstream,
936
+ existingWorktrees: currentWorktrees
937
+ });
938
+ }
939
+ catch (error) {
940
+ const message = error instanceof Error ? error.message : String(error);
941
+ logger.warn(`Failed to provision packet worktrees for ${workstream.id}; will retry next cycle: ${message}`);
942
+ continue;
943
+ }
897
944
  if (packetWorktreeResults.length > 0) {
898
945
  for (const result of packetWorktreeResults) {
899
946
  const existingIndex = currentWorktrees.findIndex((wt) => wt.id === result.worktree.id);
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { realpathSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
- export { runCli, buildPacketWaveAdvancementEvaluationForRun } from "./commands/run.js";
4
+ export { runCli, buildPacketProvisioningRepoPathMap, buildPacketWaveAdvancementEvaluationForRun } from "./commands/run.js";
5
5
  export { buildEffectiveProviderBrokerToolAllowLists } from "./runtime/provider-tools.js";
6
6
  export { runDispatchWithMergePrepLifecycle } from "./runtime/dispatch-lifecycle.js";
7
7
  export { runLoopCli, SerializedCycleRunner } from "./commands/run-loop.js";
@@ -298,7 +298,10 @@ export async function runDispatchWithMergePrepLifecycle(input) {
298
298
  await stateStore.writeTaskState(hydratedInputTasks);
299
299
  }
300
300
  // ── Phase 0: Recover workspaces of failed workspace-write tasks ─────────────
301
+ // This phase is intentionally skipped for follow-up dispatches in the same
302
+ // outer run so a failed sibling cannot erase a completed task's handoff.
301
303
  const seenRecoveryPaths = new Set();
304
+ const recoveryResultByPath = new Map();
302
305
  const existingRecoveryRecords = await stateStore.loadWorkspaceRecoveryState();
303
306
  const recoveryRecords = [...existingRecoveryRecords];
304
307
  let recoveryStateChanged = false;
@@ -311,11 +314,20 @@ export async function runDispatchWithMergePrepLifecycle(input) {
311
314
  };
312
315
  const recoveryResultByTaskId = new Map();
313
316
  for (const task of hydratedInputTasks) {
314
- if (!workspaceRecoveryService.isRecoveryNeeded(task)) {
317
+ if (input.skipWorkspaceRecovery || !workspaceRecoveryService.isRecoveryNeeded(task)) {
315
318
  continue;
316
319
  }
317
320
  const localPath = workspaceRecoveryService.resolveTaskWorkspacePath(task);
318
321
  if (localPath && seenRecoveryPaths.has(localPath)) {
322
+ const sharedRecoveryResult = recoveryResultByPath.get(localPath);
323
+ if (sharedRecoveryResult) {
324
+ recoveryResultByTaskId.set(task.id, sharedRecoveryResult);
325
+ if (sharedRecoveryResult.outcome === "recovered" ||
326
+ sharedRecoveryResult.outcome === "skipped") {
327
+ task.context.recoveryNeeded = false;
328
+ taskStateChangedByRecovery = true;
329
+ }
330
+ }
319
331
  continue;
320
332
  }
321
333
  if (localPath) {
@@ -329,6 +341,9 @@ export async function runDispatchWithMergePrepLifecycle(input) {
329
341
  counts[recoveryResult.outcome]++;
330
342
  recoveryRecords.push(recoveryResult);
331
343
  recoveryResultByTaskId.set(task.id, recoveryResult);
344
+ if (localPath) {
345
+ recoveryResultByPath.set(localPath, recoveryResult);
346
+ }
332
347
  recoveryStateChanged = true;
333
348
  if (recoveryResult.outcome === "recovered" || recoveryResult.outcome === "skipped") {
334
349
  task.context.recoveryNeeded = false;
@@ -74,18 +74,18 @@ export class GitCliGateway {
74
74
  defaultRef
75
75
  };
76
76
  try {
77
- if (!(await this.hasRef(localPath, defaultRef))) {
77
+ if (!(await this.hasRef(localPath, parentRef))) {
78
78
  return {
79
79
  ...baseComparison,
80
- status: "missing_default_branch",
81
- message: "Default branch ref was not found locally."
80
+ status: "missing_parent_branch",
81
+ message: "Parent branch ref was not found locally."
82
82
  };
83
83
  }
84
- if (!(await this.hasRef(localPath, parentRef))) {
84
+ if (!(await this.hasRef(localPath, defaultRef))) {
85
85
  return {
86
86
  ...baseComparison,
87
- status: "missing_parent_branch",
88
- message: "Parent branch ref was not found locally."
87
+ status: "missing_default_branch",
88
+ message: "Default branch ref was not found locally."
89
89
  };
90
90
  }
91
91
  const result = await this.commandRunner.run("git", ["rev-list", "--left-right", "--count", `${defaultRef}...${parentRef}`], { cwd: localPath });
@@ -382,6 +382,8 @@ export class LocalRepoSyncService {
382
382
  const existedBeforeSync = await pathExists(localPath);
383
383
  let currentBranchBefore = null;
384
384
  let currentBranchAfter = null;
385
+ let fetched = false;
386
+ let defaultBranchSyncStatus = "not_attempted";
385
387
  let defaultBranch = repo.defaultBranch ?? null;
386
388
  let defaultBranchSource = repo.defaultBranch ? "config" : "unknown";
387
389
  try {
@@ -389,6 +391,7 @@ export class LocalRepoSyncService {
389
391
  await this.gitGateway.cloneRepository(repo, localPath);
390
392
  }
391
393
  await this.gitGateway.fetchRepository(localPath);
394
+ fetched = true;
392
395
  currentBranchBefore = await this.gitGateway.getCurrentBranch(localPath);
393
396
  if (!defaultBranch) {
394
397
  try {
@@ -402,10 +405,12 @@ export class LocalRepoSyncService {
402
405
  }
403
406
  const clean = await this.gitGateway.isWorkingTreeClean(localPath);
404
407
  if (!clean) {
408
+ defaultBranchSyncStatus = "blocked_dirty";
405
409
  throw new Error("working tree is dirty; refusing to switch or fast-forward the default branch");
406
410
  }
407
411
  await this.gitGateway.ensureDefaultBranchCheckedOut(localPath, defaultBranch);
408
412
  await this.gitGateway.fastForwardDefaultBranch(localPath, defaultBranch);
413
+ defaultBranchSyncStatus = "synced";
409
414
  currentBranchAfter = await this.gitGateway.getCurrentBranch(localPath);
410
415
  return {
411
416
  repoId: repo.id,
@@ -416,7 +421,8 @@ export class LocalRepoSyncService {
416
421
  status: "synced",
417
422
  existedBeforeSync,
418
423
  cloned: !existedBeforeSync,
419
- fetched: true,
424
+ fetched,
425
+ defaultBranchSyncStatus,
420
426
  defaultBranch,
421
427
  defaultBranchSource,
422
428
  currentBranchBefore,
@@ -438,7 +444,8 @@ export class LocalRepoSyncService {
438
444
  status: "failed",
439
445
  existedBeforeSync,
440
446
  cloned: !existedBeforeSync && currentBranchAfter !== null,
441
- fetched: existedBeforeSync,
447
+ fetched,
448
+ defaultBranchSyncStatus: defaultBranchSyncStatus === "blocked_dirty" ? "blocked_dirty" : "failed",
442
449
  defaultBranch,
443
450
  defaultBranchSource,
444
451
  currentBranchBefore,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@backburner/cli",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "type": "module",
5
5
  "description": "Local GitHub-based agent orchestration CLI",
6
6
  "license": "MIT",