@saadnvd1/agent-os 0.3.639 → 0.3.644

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 (70) hide show
  1. package/app/api/bus/branches/route.ts +25 -0
  2. package/app/api/bus/resurrect/route.test.ts +3 -6
  3. package/app/api/worktree-sweep/route.test.ts +40 -0
  4. package/app/api/worktree-sweep/route.ts +31 -0
  5. package/app/api/worktree-sweep/run/route.ts +26 -0
  6. package/components/Health/HealthPanel.tsx +4 -1
  7. package/components/Health/SweepRuns.tsx +125 -0
  8. package/components/Merge/GlobalMergeSection.tsx +2 -0
  9. package/components/Merge/ProjectMergeSection.tsx +2 -0
  10. package/components/Merge/WorktreeSweepSection.tsx +125 -0
  11. package/components/Settings/NoApprovalsSection.tsx +1 -1
  12. package/components/Settings/SettingsDialog.tsx +1 -1
  13. package/data/worktree-sweep/index.ts +56 -0
  14. package/lib/away/report.ts +1 -1
  15. package/lib/db/backup.ts +49 -0
  16. package/lib/db/migrations/20261015T1733_gate_loop_guard.ts +40 -0
  17. package/lib/db/migrations/20261015T1830_drop_moved_session_columns.ts +173 -0
  18. package/lib/db/migrations/20261015T1900_worktree_sweep.ts +30 -0
  19. package/lib/db/migrations/README.md +7 -3
  20. package/lib/db/session-parts/columns.ts +3 -3
  21. package/lib/db/session-parts/index.ts +1 -0
  22. package/lib/db/session-parts/rows.ts +69 -0
  23. package/lib/db/session-parts/view.ts +14 -7
  24. package/lib/db/session-parts/write.ts +4 -12
  25. package/lib/done/remote.ts +9 -20
  26. package/lib/orchestrator/armed.test-helpers.ts +228 -0
  27. package/lib/orchestrator/armed.ts +1 -1
  28. package/lib/orchestrator/ask-verdict.ts +111 -0
  29. package/lib/orchestrator/asks.ts +26 -0
  30. package/lib/orchestrator/brief.ts +2 -1
  31. package/lib/orchestrator/escalate.ts +25 -12
  32. package/lib/orchestrator/external-gates.ts +1 -0
  33. package/lib/orchestrator/gates.ts +94 -25
  34. package/lib/orchestrator/held-asks.ts +5 -8
  35. package/lib/orchestrator/held-review.ts +8 -4
  36. package/lib/orchestrator/loop-guard.ts +145 -0
  37. package/lib/orchestrator/merge-approvals.ts +2 -2
  38. package/lib/orchestrator/quick.ts +1 -1
  39. package/lib/orchestrator/review-prompt.ts +2 -0
  40. package/lib/orchestrator/review.ts +14 -4
  41. package/lib/orchestrator/signoff.test-helpers.ts +322 -0
  42. package/lib/orchestrator/signoff.ts +46 -10
  43. package/lib/orchestrator/testing.ts +11 -14
  44. package/lib/orchestrator/watcher.ts +4 -0
  45. package/lib/recipes/test-fixtures.ts +2 -3
  46. package/lib/resurrect/index.ts +20 -2
  47. package/lib/resurrect/swept.ts +61 -0
  48. package/lib/stacks/testing.ts +17 -16
  49. package/lib/tasks/finish.ts +20 -14
  50. package/lib/tasks/move-testing.ts +16 -7
  51. package/lib/tasks/pr-activity.ts +11 -2
  52. package/lib/tasks/prune-branches.ts +193 -0
  53. package/lib/tasks/prune-scope.ts +22 -0
  54. package/lib/tasks/remote-branch.ts +170 -0
  55. package/lib/tasks/testing.ts +13 -7
  56. package/lib/worktree-sweep/commits.ts +65 -0
  57. package/lib/worktree-sweep/findOrphans.ts +139 -0
  58. package/lib/worktree-sweep/findSettled.ts +112 -0
  59. package/lib/worktree-sweep/index.ts +10 -0
  60. package/lib/worktree-sweep/judge.ts +31 -0
  61. package/lib/worktree-sweep/panes.ts +18 -0
  62. package/lib/worktree-sweep/runSweep.ts +243 -0
  63. package/lib/worktree-sweep/settings.ts +83 -0
  64. package/lib/worktree-sweep/start.ts +64 -0
  65. package/lib/worktree-sweep/state.ts +36 -0
  66. package/lib/worktree-sweep/store.ts +96 -0
  67. package/lib/worktree-sweep/types.ts +53 -0
  68. package/package.json +1 -1
  69. package/scripts/screenshots/seed.ts +19 -18
  70. package/server.ts +3 -0
@@ -1,7 +1,12 @@
1
1
  // Test fixtures: a project, task sessions and a stack in the test database.
2
2
 
3
3
  import { randomUUID } from "crypto";
4
- import { db, stackQueries as q, type StackItemStatus } from "../db";
4
+ import {
5
+ db,
6
+ insertSessionRow,
7
+ stackQueries as q,
8
+ type StackItemStatus,
9
+ } from "../db";
5
10
  import { createProject } from "../projects";
6
11
 
7
12
  export interface SeedItem {
@@ -28,22 +33,18 @@ export function seedStack(repo: string, specs: SeedItem[]) {
28
33
  if (!s.branch) continue;
29
34
  const id = randomUUID();
30
35
  sessions.set(s.key, id);
31
- db.prepare(
32
- `INSERT INTO sessions (id, name, tmux_name, working_directory, project_id, task_status,
33
- branch_name, worktree_path, base_branch, pr_number)
34
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
35
- ).run(
36
+ insertSessionRow(db, {
36
37
  id,
37
- s.key,
38
- `claude-${id}`,
39
- s.worktree ?? repo,
40
- project.id,
41
- s.status === "merged" ? "merged" : "running",
42
- s.branch,
43
- s.worktree ?? null,
44
- s.baseBranch ?? null,
45
- s.pr ?? null
46
- );
38
+ name: s.key,
39
+ tmux_name: `claude-${id}`,
40
+ working_directory: s.worktree ?? repo,
41
+ project_id: project.id,
42
+ task_status: s.status === "merged" ? "merged" : "running",
43
+ branch_name: s.branch,
44
+ worktree_path: s.worktree ?? null,
45
+ base_branch: s.baseBranch ?? null,
46
+ pr_number: s.pr ?? null,
47
+ });
47
48
  }
48
49
  q.create(
49
50
  db,
@@ -16,7 +16,8 @@ import { resolveMergedTaskAsks } from "../orchestrator/ask-settle";
16
16
  import { endSessionAgent } from "../sessions/end";
17
17
  import { run } from "./gh";
18
18
  import { pushMerged } from "../notify/push/events";
19
- import { gitFetch, gitPush } from "../git-exec";
19
+ import { gitFetch } from "../git-exec";
20
+ import { deleteTaskBranch } from "./remote-branch";
20
21
  import {
21
22
  mergePolicy,
22
23
  type MergeMethod,
@@ -40,9 +41,6 @@ import { snapshotWorktree } from "../resurrect/snapshot";
40
41
  import { keepSessionFiles } from "../session-artifacts/collect";
41
42
  import { setPr, setTask } from "../db/session-parts";
42
43
 
43
- // Never deleted, whatever a setting says.
44
- export const PROTECTED_BRANCHES = new Set(["main", "master", "HEAD"]);
45
-
46
44
  export const sessionMergePolicy = (session: Session): MergePolicy =>
47
45
  mergePolicy(session.project_id ? getProject(session.project_id) : null);
48
46
 
@@ -52,7 +50,14 @@ export const sessionMergePolicy = (session: Session): MergePolicy =>
52
50
  async function cleanup(
53
51
  session: Session,
54
52
  repo: string,
55
- opts: { keepRemoteBranch?: boolean; merged?: MergedAs } = {}
53
+ opts: {
54
+ keepRemoteBranch?: boolean;
55
+ merged?: MergedAs;
56
+ // The head the PR merged or closed at.
57
+ prHead?: string | null;
58
+ // A drop of a task that never had a PR.
59
+ noPr?: boolean;
60
+ } = {}
56
61
  ): Promise<void> {
57
62
  const keepRemoteBranch = opts.keepRemoteBranch ?? false;
58
63
  await endSessionAgent(session);
@@ -68,14 +73,14 @@ async function cleanup(
68
73
  }
69
74
  // A stacked child's PR targets this branch: deleting it on origin would
70
75
  // make GitHub close that PR, and a closed PR can't be retargeted.
71
- if (
72
- session.branch_name &&
73
- !keepRemoteBranch &&
74
- !PROTECTED_BRANCHES.has(session.branch_name)
75
- ) {
76
- await gitPush(repo, ["origin", "--delete", session.branch_name]).catch(
77
- () => {}
78
- );
76
+ if (session.branch_name && !keepRemoteBranch) {
77
+ await deleteTaskBranch({
78
+ repo,
79
+ branch: session.branch_name,
80
+ prHead: opts.prHead,
81
+ noPr: opts.noPr,
82
+ keep: [session.base_branch],
83
+ });
79
84
  }
80
85
  await gitFetch(repo, ["--prune", "--quiet"]).catch(() => {});
81
86
  forgetPR(session.id);
@@ -112,6 +117,7 @@ async function finishMerge(
112
117
  await cleanup(session, repo, {
113
118
  keepRemoteBranch: stuck || !policy.delete_remote_branch,
114
119
  merged: { prHead },
120
+ prHead,
115
121
  });
116
122
  // After cleanup's fetch, so the base branch holds the merged files.
117
123
  inBackground(`re-publish docs after task ${session.id}`, () =>
@@ -257,5 +263,5 @@ export async function dropTask(id: string): Promise<void> {
257
263
  ).run(id);
258
264
  syncTaskCardInBackground(session, "dropped", pr);
259
265
  await snapshot;
260
- await cleanup(session, repo);
266
+ await cleanup(session, repo, { prHead: pr?.head, noPr: !pr });
261
267
  }
@@ -6,7 +6,7 @@ import fs from "fs";
6
6
  import os from "os";
7
7
  import path from "path";
8
8
  import { randomUUID } from "crypto";
9
- import { db, type Session } from "../db";
9
+ import { db, insertSessionRow, type Session } from "../db";
10
10
  import { createProject } from "../projects";
11
11
  import { writeTranscript } from "./transcript";
12
12
  import { gitSync } from "../git-exec";
@@ -17,7 +17,7 @@ export const git = (cwd: string, ...args: string[]) =>
17
17
  gitSync(cwd, args).trim();
18
18
 
19
19
  export const row = (id: string) =>
20
- db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as Session;
20
+ db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as Session;
21
21
 
22
22
  export interface MoveFixture {
23
23
  tmp: string;
@@ -71,11 +71,20 @@ export async function seedTask(
71
71
  git(f.repo, "worktree", "add", "-q", "-b", branch, cwd, "main");
72
72
  fs.writeFileSync(path.join(cwd, "work.txt"), "half done\n");
73
73
  const id = randomUUID();
74
- db.prepare(
75
- `INSERT INTO sessions (id, name, tmux_name, working_directory, project_id, task_status,
76
- task_prompt, branch_name, worktree_path, base_branch, claude_session_id, model)
77
- VALUES (?, 'Fix it', ?, ?, ?, 'running', 'fix the thing', ?, ?, 'main', ?, 'sonnet')`
78
- ).run(id, `claude-${id}`, cwd, f.projectId, branch, cwd, CLAUDE_ID);
74
+ insertSessionRow(db, {
75
+ id,
76
+ name: "Fix it",
77
+ tmux_name: `claude-${id}`,
78
+ working_directory: cwd,
79
+ project_id: f.projectId,
80
+ task_status: "running",
81
+ task_prompt: "fix the thing",
82
+ branch_name: branch,
83
+ worktree_path: cwd,
84
+ base_branch: "main",
85
+ claude_session_id: CLAUDE_ID,
86
+ model: "sonnet",
87
+ });
79
88
  await writeTranscript(
80
89
  cwd,
81
90
  CLAUDE_ID,
@@ -275,9 +275,19 @@ export async function failedLog(
275
275
  repo: Repo,
276
276
  url: string | null | undefined
277
277
  ): Promise<FailedLog | null> {
278
+ const raw = await failedJobLog(dir, repo, url);
279
+ return raw === null ? null : trimLog(raw);
280
+ }
281
+
282
+ // The same log whole, as gh prints it.
283
+ export async function failedJobLog(
284
+ dir: string,
285
+ repo: Repo,
286
+ url: string | null | undefined
287
+ ): Promise<string | null> {
278
288
  const job = jobOf(url);
279
289
  if (!job) return null;
280
- const raw = await run(
290
+ return run(
281
291
  "gh",
282
292
  [
283
293
  "run",
@@ -291,5 +301,4 @@ export async function failedLog(
291
301
  dir,
292
302
  60000
293
303
  );
294
- return trimLog(raw);
295
304
  }
@@ -0,0 +1,193 @@
1
+ // `aos branches prune`: the stale remote branches AgentOS tasks left behind.
2
+ // A branch is stale when it is a task's recorded branch, its latest PR is
3
+ // merged or closed at the branch's current commit, and no open PR builds on
4
+ // it. Dry run by default; deleting goes through the same checks as a merge.
5
+
6
+ import { db } from "../db";
7
+ import { run } from "./gh";
8
+ import { PROTECTED_BRANCHES, deleteTaskBranch } from "./remote-branch";
9
+
10
+ export interface PruneRepo {
11
+ dir: string;
12
+ slug: string;
13
+ projectIds: string[];
14
+ }
15
+
16
+ export interface StaleBranch {
17
+ branch: string;
18
+ pr: number;
19
+ state: "MERGED" | "CLOSED";
20
+ head: string;
21
+ }
22
+
23
+ export interface RepoPrune {
24
+ slug: string;
25
+ stale: StaleBranch[];
26
+ kept: { branch: string; why: string }[];
27
+ deleted: string[];
28
+ }
29
+
30
+ interface ListedPR {
31
+ number: number;
32
+ state: string;
33
+ headRefName: string;
34
+ headRefOid: string;
35
+ baseRefName: string;
36
+ isCrossRepository: boolean;
37
+ }
38
+
39
+ const taskBranches = (projectIds: string[]): Set<string> => {
40
+ if (!projectIds.length) return new Set();
41
+ const rows = db
42
+ .prepare(
43
+ `SELECT DISTINCT branch_name FROM session_rows
44
+ WHERE branch_name IS NOT NULL
45
+ AND project_id IN (${projectIds.map(() => "?").join(", ")})`
46
+ )
47
+ .all(...projectIds) as { branch_name: string }[];
48
+ return new Set(rows.map((r) => r.branch_name));
49
+ };
50
+
51
+ async function remoteBranches(repo: PruneRepo): Promise<Map<string, string>> {
52
+ const out = await run(
53
+ "gh",
54
+ [
55
+ "api",
56
+ `repos/${repo.slug}/branches`,
57
+ "--paginate",
58
+ "--jq",
59
+ ".[] | [.name, .commit.sha] | @tsv",
60
+ ],
61
+ repo.dir,
62
+ 120000
63
+ );
64
+ const tips = new Map<string, string>();
65
+ for (const line of out.split("\n")) {
66
+ const [name, sha] = line.split("\t");
67
+ if (name && sha) tips.set(name, sha);
68
+ }
69
+ return tips;
70
+ }
71
+
72
+ export async function pruneRepo(
73
+ repo: PruneRepo,
74
+ opts: { yes?: boolean } = {}
75
+ ): Promise<RepoPrune> {
76
+ const [tips, prsOut, defaultOut] = await Promise.all([
77
+ remoteBranches(repo),
78
+ run(
79
+ "gh",
80
+ [
81
+ "pr",
82
+ "list",
83
+ "--repo",
84
+ repo.slug,
85
+ "--state",
86
+ "all",
87
+ "--limit",
88
+ "1000",
89
+ "--json",
90
+ "number,state,headRefName,headRefOid,baseRefName,isCrossRepository",
91
+ ],
92
+ repo.dir,
93
+ 120000
94
+ ),
95
+ run(
96
+ "gh",
97
+ [
98
+ "repo",
99
+ "view",
100
+ repo.slug,
101
+ "--json",
102
+ "defaultBranchRef",
103
+ "--jq",
104
+ ".defaultBranchRef.name",
105
+ ],
106
+ repo.dir,
107
+ 20000
108
+ ),
109
+ ]);
110
+ const prs = JSON.parse(prsOut) as ListedPR[];
111
+ const defaultBranch = defaultOut.trim();
112
+ const mine = taskBranches(repo.projectIds);
113
+ const result: RepoPrune = {
114
+ slug: repo.slug,
115
+ stale: [],
116
+ kept: [],
117
+ deleted: [],
118
+ };
119
+ const keep = (branch: string, why: string) =>
120
+ result.kept.push({ branch, why });
121
+
122
+ for (const [branch, tip] of [...tips].sort()) {
123
+ if (branch === defaultBranch || PROTECTED_BRANCHES.has(branch)) continue;
124
+ if (!mine.has(branch)) {
125
+ keep(branch, "not an AgentOS task's branch");
126
+ continue;
127
+ }
128
+ const own = prs
129
+ .filter((p) => p.headRefName === branch && !p.isCrossRepository)
130
+ .sort((a, b) => b.number - a.number);
131
+ if (!own.length) {
132
+ keep(branch, "no PR");
133
+ continue;
134
+ }
135
+ const open = prs.find(
136
+ (p) =>
137
+ p.state === "OPEN" &&
138
+ (p.baseRefName === branch ||
139
+ (p.headRefName === branch && !p.isCrossRepository))
140
+ );
141
+ if (open) {
142
+ keep(branch, `open PR #${open.number}`);
143
+ continue;
144
+ }
145
+ const latest = own[0];
146
+ if (latest.headRefOid !== tip) {
147
+ keep(branch, `moved to ${tip.slice(0, 7)} after PR #${latest.number}`);
148
+ continue;
149
+ }
150
+ result.stale.push({
151
+ branch,
152
+ pr: latest.number,
153
+ state: latest.state as StaleBranch["state"],
154
+ head: latest.headRefOid,
155
+ });
156
+ }
157
+
158
+ if (opts.yes)
159
+ for (const s of result.stale) {
160
+ const out = await deleteTaskBranch({
161
+ repo: repo.dir,
162
+ slug: repo.slug,
163
+ branch: s.branch,
164
+ prHead: s.head,
165
+ keep: [defaultBranch],
166
+ });
167
+ if (out.deleted) result.deleted.push(s.branch);
168
+ else keep(s.branch, out.why);
169
+ }
170
+ return result;
171
+ }
172
+
173
+ export function pruneText(results: RepoPrune[], yes: boolean): string {
174
+ const lines: string[] = [];
175
+ let total = 0;
176
+ for (const r of results) {
177
+ lines.push(`${r.slug}:`);
178
+ const list = yes ? r.deleted : r.stale.map((s) => s.branch);
179
+ total += list.length;
180
+ for (const s of r.stale)
181
+ if (!yes || r.deleted.includes(s.branch))
182
+ lines.push(
183
+ ` ${yes ? "deleted" : "stale"} ${s.branch} (PR #${s.pr} ${s.state.toLowerCase()})`
184
+ );
185
+ for (const k of r.kept) lines.push(` kept ${k.branch} (${k.why})`);
186
+ }
187
+ lines.push(
188
+ yes
189
+ ? `Deleted ${total} branch${total === 1 ? "" : "es"}.`
190
+ : `${total} stale branch${total === 1 ? "" : "es"}. Dry run: aos branches prune --yes deletes them.`
191
+ );
192
+ return lines.join("\n");
193
+ }
@@ -0,0 +1,22 @@
1
+ import { getProject } from "../projects";
2
+ import { repoSlug } from "../git-remote";
3
+ import { scopeProjectIds, type DoneScope } from "../done/bulk";
4
+ import { expandHome } from "./session";
5
+ import type { PruneRepo } from "./prune-branches";
6
+
7
+ // The scope's local GitHub clones, one entry per repository (projects that
8
+ // share one are merged, so its branches are listed once).
9
+ export async function prunableRepos(scope: DoneScope): Promise<PruneRepo[]> {
10
+ const bySlug = new Map<string, PruneRepo>();
11
+ for (const id of scopeProjectIds(scope)) {
12
+ const p = getProject(id);
13
+ if (!p || (p.host_id && p.host_id !== "local")) continue;
14
+ const dir = expandHome(p.working_directory);
15
+ const slug = await repoSlug(dir);
16
+ if (!slug) continue;
17
+ const hit = bySlug.get(slug);
18
+ if (hit) hit.projectIds.push(id);
19
+ else bySlug.set(slug, { dir, slug, projectIds: [id] });
20
+ }
21
+ return [...bySlug.values()];
22
+ }
@@ -0,0 +1,170 @@
1
+ // Deleting a task's own branch on GitHub once its PR is merged or closed.
2
+ // Never throws: a branch that stays is a logged line, not a failed merge.
3
+
4
+ import { git, gitPush } from "../git-exec";
5
+ import { repoSlug } from "../git-remote";
6
+ import { run } from "./gh";
7
+
8
+ // Never deleted, whatever a setting says.
9
+ export const PROTECTED_BRANCHES = new Set(["main", "master", "HEAD"]);
10
+
11
+ export interface BranchDelete {
12
+ deleted: boolean;
13
+ why: string;
14
+ }
15
+
16
+ export interface DeleteBranchOpts {
17
+ repo: string;
18
+ branch: string;
19
+ // The head the PR merged or closed at; the branch goes only if it is still
20
+ // exactly this commit.
21
+ prHead: string | null | undefined;
22
+ // A dropped task that never opened a PR has no head to compare with: its
23
+ // branch is rejected work and goes whatever it holds.
24
+ noPr?: boolean;
25
+ // owner/repo, when known; read from the checkout's origin otherwise.
26
+ slug?: string | null;
27
+ // Another open PR in the repo builds on this branch (decided by the caller
28
+ // when it knows from its own records; GitHub is asked too).
29
+ hasChildren?: boolean;
30
+ // Branches this one must never be (the default and base branches).
31
+ keep?: (string | null | undefined)[];
32
+ }
33
+
34
+ const gone = (error: unknown) =>
35
+ /HTTP (404|422)|Not Found|Reference does not exist/i.test(
36
+ error instanceof Error ? error.message : String(error)
37
+ );
38
+
39
+ // repos/o/r/git/ref(s)/heads/<branch>, each path segment encoded.
40
+ export const refPath = (slug: string, branch: string, plural: boolean) =>
41
+ `repos/${slug}/git/${plural ? "refs" : "ref"}/heads/${branch
42
+ .split("/")
43
+ .map(encodeURIComponent)
44
+ .join("/")}`;
45
+
46
+ // The branch's commit on GitHub; null when it is gone.
47
+ export async function remoteTip(
48
+ repo: string,
49
+ slug: string,
50
+ branch: string
51
+ ): Promise<string | null> {
52
+ try {
53
+ const out = await run(
54
+ "gh",
55
+ ["api", refPath(slug, branch, false), "--jq", ".object.sha"],
56
+ repo,
57
+ 20000
58
+ );
59
+ return out.trim() || null;
60
+ } catch (error) {
61
+ if (gone(error)) return null;
62
+ throw error;
63
+ }
64
+ }
65
+
66
+ // Open PRs targeting the branch: a stacked child, which GitHub would close
67
+ // (and could no longer retarget) if its base vanished.
68
+ export async function openChildren(
69
+ repo: string,
70
+ slug: string,
71
+ branch: string
72
+ ): Promise<number[]> {
73
+ const out = await run(
74
+ "gh",
75
+ [
76
+ "pr",
77
+ "list",
78
+ "--repo",
79
+ slug,
80
+ "--base",
81
+ branch,
82
+ "--state",
83
+ "open",
84
+ "--json",
85
+ "number",
86
+ ],
87
+ repo,
88
+ 20000
89
+ );
90
+ return (JSON.parse(out) as { number: number }[]).map((p) => p.number);
91
+ }
92
+
93
+ async function decide(o: DeleteBranchOpts): Promise<BranchDelete> {
94
+ const { repo, branch, prHead } = o;
95
+ if (PROTECTED_BRANCHES.has(branch))
96
+ return { deleted: false, why: "a protected branch" };
97
+ if (o.keep?.includes(branch))
98
+ return { deleted: false, why: "it is a base branch" };
99
+ if (!prHead && !o.noPr)
100
+ return { deleted: false, why: "no PR head was recorded" };
101
+ if (o.hasChildren)
102
+ return { deleted: false, why: "stacked cards still build on it" };
103
+ const slug = o.slug ?? (await repoSlug(repo));
104
+ if (!slug) return deleteViaGit(repo, branch, prHead || null);
105
+
106
+ const kids = await openChildren(repo, slug, branch);
107
+ if (kids.length)
108
+ return {
109
+ deleted: false,
110
+ why: `open PR ${kids.map((n) => `#${n}`).join(", ")} is based on it`,
111
+ };
112
+ const tip = await remoteTip(repo, slug, branch);
113
+ if (!tip) return { deleted: false, why: "already gone" };
114
+ if (prHead && tip !== prHead)
115
+ return {
116
+ deleted: false,
117
+ why: `it moved to ${tip.slice(0, 7)} after the PR's ${prHead.slice(0, 7)}`,
118
+ };
119
+ try {
120
+ await run(
121
+ "gh",
122
+ ["api", "-X", "DELETE", refPath(slug, branch, true)],
123
+ repo,
124
+ 30000
125
+ );
126
+ } catch (error) {
127
+ if (!gone(error)) throw error;
128
+ }
129
+ return { deleted: true, why: "deleted" };
130
+ }
131
+
132
+ // A checkout whose origin isn't on GitHub: ask and delete through git.
133
+ async function deleteViaGit(
134
+ repo: string,
135
+ branch: string,
136
+ prHead: string | null
137
+ ): Promise<BranchDelete> {
138
+ const line = await git(
139
+ repo,
140
+ ["ls-remote", "--heads", "origin", `refs/heads/${branch}`],
141
+ { timeout: 30000 }
142
+ );
143
+ const tip = line.trim().split(/\s+/)[0];
144
+ if (!tip) return { deleted: false, why: "already gone" };
145
+ if (prHead && tip !== prHead)
146
+ return {
147
+ deleted: false,
148
+ why: `it moved to ${tip.slice(0, 7)} after the PR's ${prHead.slice(0, 7)}`,
149
+ };
150
+ await gitPush(repo, ["origin", "--delete", branch], { timeout: 60000 });
151
+ return { deleted: true, why: "deleted" };
152
+ }
153
+
154
+ export async function deleteTaskBranch(
155
+ o: DeleteBranchOpts
156
+ ): Promise<BranchDelete> {
157
+ let result: BranchDelete;
158
+ try {
159
+ result = await decide(o);
160
+ } catch (error) {
161
+ const text = error instanceof Error ? error.message : String(error);
162
+ result = {
163
+ deleted: false,
164
+ why: `couldn't delete it: ${text.split("\n")[0].slice(0, 200)}`,
165
+ };
166
+ }
167
+ if (!result.deleted)
168
+ console.log(`[tasks] kept origin/${o.branch}: ${result.why}`);
169
+ return result;
170
+ }
@@ -1,7 +1,7 @@
1
1
  // Test fixtures for task PR lookups: real repositories and worktrees, with
2
2
  // gh faked by branch.
3
3
 
4
- import { db, type Session } from "../db";
4
+ import { db, insertSessionRow, type Session } from "../db";
5
5
  import { createProject } from "../projects";
6
6
  import type { FindPROpts } from "./gh";
7
7
  import type { TaskPR } from "./state";
@@ -53,13 +53,19 @@ export function seedTask(
53
53
  workingDirectory: repo,
54
54
  });
55
55
  const id = crypto.randomUUID();
56
- db.prepare(
57
- `INSERT INTO sessions (id, name, tmux_name, working_directory, project_id,
58
- task_status, branch_name, base_branch, worktree_path)
59
- VALUES (?, 'Read and execute the brief', ?, ?, ?, 'running', ?, 'main', ?)`
60
- ).run(id, `claude-${id}`, worktree, project.id, branch, worktree);
56
+ insertSessionRow(db, {
57
+ id,
58
+ name: "Read and execute the brief",
59
+ tmux_name: `claude-${id}`,
60
+ working_directory: worktree,
61
+ project_id: project.id,
62
+ task_status: "running",
63
+ branch_name: branch,
64
+ base_branch: "main",
65
+ worktree_path: worktree,
66
+ });
61
67
  return taskRow(id);
62
68
  }
63
69
 
64
70
  export const taskRow = (id: string) =>
65
- db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as Session;
71
+ db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as Session;
@@ -0,0 +1,65 @@
1
+ import { getDefaultBranch } from "../git";
2
+ import { git as runGit } from "../git-exec";
3
+
4
+ const git = (cwd: string, ...args: string[]) =>
5
+ runGit(cwd, args, { timeout: 60_000 }).then((out) => out.trim());
6
+
7
+ const lines = (out: string) => out.split("\n").filter(Boolean);
8
+
9
+ /**
10
+ * Commits on the worktree's HEAD that nothing else holds: not on any remote
11
+ * branch, not in the PR a merge took, and with no patch-equivalent commit on
12
+ * the base or its own remote branch (a rewritten history re-lands the same
13
+ * changes under new hashes). The shas, so a caller can count and name them.
14
+ */
15
+ export async function unpushedCommits(
16
+ worktree: string,
17
+ repo: string,
18
+ opts: { prHead?: string | null; branch?: string | null } = {}
19
+ ): Promise<string[]> {
20
+ const head =
21
+ opts.prHead &&
22
+ (await git(
23
+ worktree,
24
+ "rev-parse",
25
+ "--verify",
26
+ "--quiet",
27
+ `${opts.prHead}^{commit}`
28
+ )
29
+ .then(() => [opts.prHead!])
30
+ .catch(() => []));
31
+ const stray = lines(
32
+ await git(
33
+ worktree,
34
+ "rev-list",
35
+ "HEAD",
36
+ "--not",
37
+ ...(head || []),
38
+ "--remotes"
39
+ )
40
+ );
41
+ if (!stray.length) return [];
42
+ const base = await getDefaultBranch(repo).catch(() => "main");
43
+ const upstreams: string[] = [];
44
+ for (const ref of [
45
+ `origin/${base}`,
46
+ opts.branch && `origin/${opts.branch}`,
47
+ ]) {
48
+ if (
49
+ ref &&
50
+ (await git(worktree, "rev-parse", "--verify", "--quiet", ref)
51
+ .then(() => true)
52
+ .catch(() => false))
53
+ )
54
+ upstreams.push(ref);
55
+ }
56
+ const left = new Set(stray);
57
+ for (const ref of upstreams) {
58
+ if (!left.size) break;
59
+ // "- <sha>" is a commit the upstream already has the same patch for.
60
+ const cherry = lines(await git(worktree, "cherry", ref, "HEAD"));
61
+ for (const l of cherry)
62
+ if (l.startsWith("- ")) left.delete(l.slice(2).trim());
63
+ }
64
+ return [...left];
65
+ }