@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 +32 -1
- package/dist/src/cli/commands/run.js +57 -10
- package/dist/src/cli/run.js +1 -1
- package/dist/src/cli/runtime/dispatch-lifecycle.js +16 -1
- package/dist/src/git/types.js +15 -8
- package/package.json +1 -1
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,
|
|
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 =
|
|
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
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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);
|
package/dist/src/cli/run.js
CHANGED
|
@@ -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;
|
package/dist/src/git/types.js
CHANGED
|
@@ -74,18 +74,18 @@ export class GitCliGateway {
|
|
|
74
74
|
defaultRef
|
|
75
75
|
};
|
|
76
76
|
try {
|
|
77
|
-
if (!(await this.hasRef(localPath,
|
|
77
|
+
if (!(await this.hasRef(localPath, parentRef))) {
|
|
78
78
|
return {
|
|
79
79
|
...baseComparison,
|
|
80
|
-
status: "
|
|
81
|
-
message: "
|
|
80
|
+
status: "missing_parent_branch",
|
|
81
|
+
message: "Parent branch ref was not found locally."
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
|
-
if (!(await this.hasRef(localPath,
|
|
84
|
+
if (!(await this.hasRef(localPath, defaultRef))) {
|
|
85
85
|
return {
|
|
86
86
|
...baseComparison,
|
|
87
|
-
status: "
|
|
88
|
-
message: "
|
|
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
|
|
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
|
|
447
|
+
fetched,
|
|
448
|
+
defaultBranchSyncStatus: defaultBranchSyncStatus === "blocked_dirty" ? "blocked_dirty" : "failed",
|
|
442
449
|
defaultBranch,
|
|
443
450
|
defaultBranchSource,
|
|
444
451
|
currentBranchBefore,
|