Skip to content

Commit cda7ea0

Browse files
VikingLichensamwebexpertcursoragent
authored
feat(orchestrator): clean up implementation branches when AFK plan co… (#114)
* feat(orchestrator): clean up implementation branches when AFK plan completes Delete merged implementation branches after all AFK issues finish. Add deleteBranch worktree helper with quiet missing-worktree handling. Co-authored-by: Cursor <cursoragent@cursor.com> * docs: remove useless jsDoc --------- Co-authored-by: André Masson <amwebexpert@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 9b01021 commit cda7ea0

2 files changed

Lines changed: 86 additions & 5 deletions

File tree

ai-orchestrator/orchestrator.ts

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { logger } from "@lichens-innovation/ts-common/logger";
22
import { z } from "zod";
33

4+
import { isNotBlank } from "@lichens-innovation/ts-common";
45
import { setAgentLogDir } from "./utils/agent-logger.utils.ts";
56
import { loadPrompt, runAgent, runTypedAgent } from "./utils/agent.utils.ts";
67
import type { Issue, IssueWorktreeResult, OrchestratorOptions } from "./utils/orchestrator.types.ts";
78
import { Plan } from "./utils/plan.ts";
8-
import { hasCommits, withWorktree } from "./utils/worktree.utils.ts";
9+
import { deleteBranch, hasCommits, withWorktree } from "./utils/worktree.utils.ts";
910

1011
const plannerOutputSchema = z.object({
1112
issues: z.array(z.object({ id: z.string(), blockedBy: z.array(z.string()) })),
@@ -16,11 +17,17 @@ const mergerOutputSchema = z.object({
1617
failed: z.array(z.string()),
1718
});
1819

19-
type IssueWorktreeParams = {
20+
interface IssueWorktreeParams {
2021
issue: Issue;
2122
branch: string;
2223
worktreePath: string;
23-
};
24+
}
25+
26+
interface DeleteImplementationBranchesResult {
27+
deleted: string[];
28+
notFound: string[];
29+
failed: string[];
30+
}
2431

2532
export class Orchestrator {
2633
private readonly repoDir: string;
@@ -58,6 +65,10 @@ export class Orchestrator {
5865
await this.runMergePhase(completedIssues);
5966
}
6067

68+
if (this.plan.remainingAfkIssues.length === 0) {
69+
await this.cleanupImplementationBranches();
70+
}
71+
6172
logger.info("\nAll done.");
6273
}
6374

@@ -219,4 +230,42 @@ export class Orchestrator {
219230
logger.info(`Failed to merge: ${failed.join(", ")}`);
220231
}
221232
}
233+
234+
private async deleteImplementationBranches(issues: Issue[]): Promise<DeleteImplementationBranchesResult> {
235+
const results = await Promise.all(
236+
issues.map(async (issue) => {
237+
const branch = this.getBranchName(issue.id);
238+
const outcome = await deleteBranch({ branch, repoDir: this.repoDir });
239+
return { branch, outcome };
240+
})
241+
);
242+
243+
return {
244+
deleted: results.filter((r) => r.outcome === "deleted").map((r) => r.branch),
245+
notFound: results.filter((r) => r.outcome === "not-found").map((r) => r.branch),
246+
failed: results.filter((r) => r.outcome === "failed").map((r) => r.branch),
247+
};
248+
}
249+
250+
private async cleanupImplementationBranches(): Promise<void> {
251+
const passedAfk = this.plan.getAll().filter((issue) => issue.passes && issue.type === "AFK");
252+
if (passedAfk.length === 0) {
253+
return;
254+
}
255+
256+
const { deleted, notFound, failed } = await this.deleteImplementationBranches(passedAfk);
257+
258+
const summaryParts = [
259+
deleted.length > 0 ? `${deleted.length} deleted` : null,
260+
notFound.length > 0 ? `${notFound.length} already absent` : null,
261+
failed.length > 0 ? `${failed.length} failed (${failed.join(", ")})` : null,
262+
].filter(isNotBlank);
263+
264+
const message = `Cleaned up implementation branches: ${summaryParts.join(", ")}`;
265+
if (failed.length > 0) {
266+
logger.warn(message);
267+
} else {
268+
logger.info(message);
269+
}
270+
}
222271
}

ai-orchestrator/utils/worktree.utils.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,16 +74,22 @@ interface RemoveWorktreeArgs {
7474
name: string;
7575
dir: string;
7676
force?: boolean;
77+
quietIfMissing?: boolean;
7778
}
7879

79-
const removeWorktree = async ({ name, dir, force }: RemoveWorktreeArgs): Promise<void> => {
80+
const removeWorktree = async ({ name, dir, force, quietIfMissing }: RemoveWorktreeArgs): Promise<void> => {
8081
const worktreePath = getWorktreePath({ repoDir: dir, branch: name });
8182
const forceFlag = force ? ["--force"] : [];
8283
const args = ["worktree", "remove", ...forceFlag, worktreePath];
8384
try {
8485
await runGit({ args, cwd: dir });
8586
} catch (error) {
86-
logger.error(`Failed to remove worktree at "${worktreePath}"`, { error });
87+
const message = `Failed to remove worktree at "${worktreePath}"`;
88+
if (quietIfMissing) {
89+
logger.debug(message, { error });
90+
} else {
91+
logger.error(message, { error });
92+
}
8793
}
8894
};
8995

@@ -118,3 +124,29 @@ export const hasCommits = ({ branch, repoDir }: HasCommitsArgs): boolean => {
118124
return false;
119125
}
120126
};
127+
128+
export type DeleteBranchResult = "deleted" | "not-found" | "failed";
129+
130+
export interface DeleteBranchArgs {
131+
branch: string;
132+
repoDir: string;
133+
}
134+
135+
export const deleteBranch = async ({ branch, repoDir }: DeleteBranchArgs): Promise<DeleteBranchResult> => {
136+
await removeWorktree({ name: branch, dir: repoDir, force: true, quietIfMissing: true });
137+
const exists = await branchExists({ branch, repoDir });
138+
139+
if (!exists) {
140+
logger.debug(`Branch "${branch}" already absent`);
141+
return "not-found";
142+
}
143+
144+
try {
145+
await runGit({ args: ["branch", "-d", branch], cwd: repoDir });
146+
logger.info(`Deleted branch "${branch}"`);
147+
return "deleted";
148+
} catch (error) {
149+
logger.warn(`Could not delete branch "${branch}"`, { error });
150+
return "failed";
151+
}
152+
};

0 commit comments

Comments
 (0)