@saadnvd1/agent-os 0.3.637 → 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 (211) 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/sessions/[id]/pr/route.ts +3 -3
  4. package/app/api/sessions/[id]/setup/route.ts +3 -3
  5. package/app/api/tasks/[id]/approve-plan/route.ts +3 -4
  6. package/app/api/tasks/[id]/arm/route.ts +6 -5
  7. package/app/api/tasks/[id]/chat-facts/route.ts +2 -2
  8. package/app/api/tasks/[id]/chat-tail/route.ts +2 -2
  9. package/app/api/tasks/[id]/checks/route.ts +1 -1
  10. package/app/api/tasks/[id]/diff/route.ts +1 -1
  11. package/app/api/tasks/[id]/done/route.ts +2 -2
  12. package/app/api/tasks/[id]/drop/route.ts +6 -2
  13. package/app/api/tasks/[id]/export/route.ts +3 -5
  14. package/app/api/tasks/[id]/merge/route.ts +3 -3
  15. package/app/api/tasks/[id]/message/route.ts +2 -2
  16. package/app/api/tasks/[id]/model/route.ts +5 -1
  17. package/app/api/tasks/[id]/move/route.ts +5 -1
  18. package/app/api/tasks/[id]/moved/route.ts +6 -8
  19. package/app/api/tasks/[id]/plan/route.ts +4 -6
  20. package/app/api/tasks/[id]/plan-edits/route.ts +6 -5
  21. package/app/api/tasks/[id]/priority/route.ts +4 -1
  22. package/app/api/tasks/[id]/resume/route.ts +3 -4
  23. package/app/api/tasks/[id]/stop/route.ts +2 -2
  24. package/app/api/tasks/route.ts +1 -1
  25. package/app/api/worktree-sweep/route.test.ts +40 -0
  26. package/app/api/worktree-sweep/route.ts +31 -0
  27. package/app/api/worktree-sweep/run/route.ts +26 -0
  28. package/components/Health/HealthPanel.tsx +4 -1
  29. package/components/Health/SweepRuns.tsx +125 -0
  30. package/components/Merge/GlobalMergeSection.tsx +2 -0
  31. package/components/Merge/ProjectMergeSection.tsx +2 -0
  32. package/components/Merge/WorktreeSweepSection.tsx +125 -0
  33. package/components/Settings/NoApprovalsSection.tsx +1 -1
  34. package/components/Settings/SettingsDialog.tsx +1 -1
  35. package/data/worktree-sweep/index.ts +56 -0
  36. package/lib/approvals/enable.ts +1 -1
  37. package/lib/away/report.ts +5 -5
  38. package/lib/away/run.ts +2 -2
  39. package/lib/bookmarks/store.ts +3 -3
  40. package/lib/bus/held-reply.ts +2 -2
  41. package/lib/bus/index.ts +3 -3
  42. package/lib/chat/compact-context.ts +1 -1
  43. package/lib/chat/hold.ts +3 -3
  44. package/lib/chat/refresh.ts +1 -1
  45. package/lib/chat/registry.ts +3 -3
  46. package/lib/chat/runner.ts +1 -1
  47. package/lib/chat/worker/main.ts +1 -1
  48. package/lib/db/backup.ts +49 -0
  49. package/lib/db/index.ts +5 -1
  50. package/lib/db/lumifyhub.ts +8 -9
  51. package/lib/db/machine-scope.ts +12 -0
  52. package/lib/db/migrations/20261015T1733_gate_loop_guard.ts +40 -0
  53. package/lib/db/migrations/20261015T1800_split_sessions_tables.ts +172 -0
  54. package/lib/db/migrations/20261015T1830_drop_moved_session_columns.ts +173 -0
  55. package/lib/db/migrations/20261015T1900_worktree_sweep.ts +30 -0
  56. package/lib/db/migrations/README.md +11 -0
  57. package/lib/db/queries.ts +16 -31
  58. package/lib/db/session-parts/columns.ts +186 -0
  59. package/lib/db/session-parts/fill.ts +33 -0
  60. package/lib/db/session-parts/index.ts +10 -0
  61. package/lib/db/session-parts/pauses.ts +11 -0
  62. package/lib/db/session-parts/placement.ts +11 -0
  63. package/lib/db/session-parts/plans.ts +31 -0
  64. package/lib/db/session-parts/prs.ts +31 -0
  65. package/lib/db/session-parts/rows.ts +69 -0
  66. package/lib/db/session-parts/tasks.ts +27 -0
  67. package/lib/db/session-parts/view.ts +50 -0
  68. package/lib/db/session-parts/write.ts +61 -0
  69. package/lib/done/archive.ts +2 -2
  70. package/lib/done/auto-archive.ts +1 -1
  71. package/lib/done/bulk.ts +1 -1
  72. package/lib/done/index.ts +8 -14
  73. package/lib/done/remote.ts +9 -20
  74. package/lib/health/evidence.ts +1 -1
  75. package/lib/health/facts.ts +1 -1
  76. package/lib/health/pieces.ts +1 -1
  77. package/lib/hosts/peer-actions.ts +2 -2
  78. package/lib/hosts/peer-mirror.ts +2 -2
  79. package/lib/hosts/peer-sync.ts +69 -32
  80. package/lib/learning-cards/input.ts +1 -1
  81. package/lib/learning-cards/store.ts +1 -1
  82. package/lib/models/plan-step.ts +10 -24
  83. package/lib/models/resume.ts +5 -7
  84. package/lib/notify/index.ts +1 -1
  85. package/lib/notify/push/events.ts +1 -1
  86. package/lib/notify/push/index.ts +1 -1
  87. package/lib/orchestrator/act.ts +1 -1
  88. package/lib/orchestrator/armed.test-helpers.ts +228 -0
  89. package/lib/orchestrator/armed.ts +18 -16
  90. package/lib/orchestrator/ask-approvals.ts +1 -1
  91. package/lib/orchestrator/ask-heads.ts +1 -1
  92. package/lib/orchestrator/ask-settle.ts +1 -1
  93. package/lib/orchestrator/ask-verdict.ts +111 -0
  94. package/lib/orchestrator/asks.ts +26 -0
  95. package/lib/orchestrator/brakes.ts +1 -1
  96. package/lib/orchestrator/brief.ts +2 -1
  97. package/lib/orchestrator/digest.ts +2 -2
  98. package/lib/orchestrator/escalate.ts +25 -12
  99. package/lib/orchestrator/external-gates.ts +1 -0
  100. package/lib/orchestrator/external-pr.ts +2 -2
  101. package/lib/orchestrator/facts.ts +4 -28
  102. package/lib/orchestrator/gates.ts +94 -25
  103. package/lib/orchestrator/held-asks.ts +8 -11
  104. package/lib/orchestrator/held-review.ts +8 -4
  105. package/lib/orchestrator/home.ts +2 -2
  106. package/lib/orchestrator/loop-guard.ts +145 -0
  107. package/lib/orchestrator/merge-approvals.ts +2 -2
  108. package/lib/orchestrator/overview.ts +1 -1
  109. package/lib/orchestrator/pr-wake.ts +3 -5
  110. package/lib/orchestrator/questions.ts +1 -1
  111. package/lib/orchestrator/quick-store.ts +1 -1
  112. package/lib/orchestrator/quick.ts +11 -8
  113. package/lib/orchestrator/read.ts +1 -1
  114. package/lib/orchestrator/rebase.ts +8 -11
  115. package/lib/orchestrator/reconcile.ts +2 -2
  116. package/lib/orchestrator/review-prompt.ts +2 -0
  117. package/lib/orchestrator/review.ts +14 -4
  118. package/lib/orchestrator/signoff.test-helpers.ts +322 -0
  119. package/lib/orchestrator/signoff.ts +49 -11
  120. package/lib/orchestrator/targets.ts +1 -1
  121. package/lib/orchestrator/task-state.ts +4 -2
  122. package/lib/orchestrator/testing.ts +11 -14
  123. package/lib/orchestrator/watcher.ts +4 -0
  124. package/lib/plans/features.ts +1 -1
  125. package/lib/plans/release.ts +1 -1
  126. package/lib/ports.ts +1 -1
  127. package/lib/recipes/test-fixtures.ts +2 -3
  128. package/lib/resurrect/find.ts +1 -1
  129. package/lib/resurrect/index.ts +44 -20
  130. package/lib/resurrect/swept.ts +61 -0
  131. package/lib/schedules/store.ts +1 -1
  132. package/lib/session-audit/sources.ts +3 -3
  133. package/lib/session-logs/index.ts +1 -1
  134. package/lib/session-titles.ts +1 -1
  135. package/lib/sessions/setup-progress.ts +1 -1
  136. package/lib/sessions/stop.ts +1 -1
  137. package/lib/sessions/worktree-setup.ts +2 -1
  138. package/lib/simulator/index.ts +1 -1
  139. package/lib/simulator/task-devices.ts +1 -1
  140. package/lib/stacks/board.ts +1 -1
  141. package/lib/stacks/land.ts +1 -1
  142. package/lib/stacks/restack.ts +1 -1
  143. package/lib/stacks/start.ts +2 -2
  144. package/lib/stacks/testing.ts +17 -16
  145. package/lib/stacks/tick.ts +1 -1
  146. package/lib/stacks/view.ts +1 -1
  147. package/lib/status/incremental.ts +1 -1
  148. package/lib/tasks/contract/actions.ts +35 -0
  149. package/lib/tasks/contract/authorize.ts +82 -0
  150. package/lib/tasks/contract/client.ts +296 -0
  151. package/lib/tasks/{held-remote.ts → contract/held-calls.ts} +35 -66
  152. package/lib/tasks/contract/index.ts +28 -0
  153. package/lib/tasks/{remote-plan-path.ts → contract/plan-path.ts} +2 -2
  154. package/lib/tasks/{remote-plan.ts → contract/plan.ts} +76 -110
  155. package/lib/tasks/contract/routes.ts +26 -0
  156. package/lib/tasks/contract/start.ts +122 -0
  157. package/lib/tasks/contract/sync.ts +126 -0
  158. package/lib/tasks/contract/types.ts +41 -0
  159. package/lib/tasks/{remote-views.ts → contract/views.ts} +11 -18
  160. package/lib/tasks/finish.ts +30 -23
  161. package/lib/tasks/held-replies.ts +7 -13
  162. package/lib/tasks/held.ts +14 -18
  163. package/lib/tasks/import.ts +19 -15
  164. package/lib/tasks/index.ts +34 -22
  165. package/lib/tasks/launch-gate.ts +1 -1
  166. package/lib/tasks/move-carry.ts +15 -7
  167. package/lib/tasks/move-flow.ts +29 -36
  168. package/lib/tasks/move-guard.ts +1 -1
  169. package/lib/tasks/move-recover.ts +6 -25
  170. package/lib/tasks/move-testing.ts +16 -7
  171. package/lib/tasks/move.ts +23 -23
  172. package/lib/tasks/moved-away.ts +1 -1
  173. package/lib/tasks/pause-hold.ts +13 -5
  174. package/lib/tasks/pause.ts +19 -23
  175. package/lib/tasks/plan-view.ts +1 -1
  176. package/lib/tasks/pr-activity.ts +11 -2
  177. package/lib/tasks/prune-branches.ts +193 -0
  178. package/lib/tasks/prune-scope.ts +22 -0
  179. package/lib/tasks/queue-placement.ts +1 -1
  180. package/lib/tasks/queue-rows.ts +4 -4
  181. package/lib/tasks/queue-start.ts +5 -3
  182. package/lib/tasks/queue-tick.ts +2 -2
  183. package/lib/tasks/queue.ts +14 -6
  184. package/lib/tasks/remote-branch.ts +170 -0
  185. package/lib/tasks/session.ts +7 -4
  186. package/lib/tasks/start.ts +4 -4
  187. package/lib/tasks/starter.ts +8 -11
  188. package/lib/tasks/testing.ts +13 -7
  189. package/lib/usage/calls.ts +1 -1
  190. package/lib/usage/compare.ts +1 -1
  191. package/lib/usage/quality.ts +1 -1
  192. package/lib/usage/turns.ts +1 -1
  193. package/lib/voice/vocab.ts +1 -1
  194. package/lib/worktree-sweep/commits.ts +65 -0
  195. package/lib/worktree-sweep/findOrphans.ts +139 -0
  196. package/lib/worktree-sweep/findSettled.ts +112 -0
  197. package/lib/worktree-sweep/index.ts +10 -0
  198. package/lib/worktree-sweep/judge.ts +31 -0
  199. package/lib/worktree-sweep/panes.ts +18 -0
  200. package/lib/worktree-sweep/runSweep.ts +243 -0
  201. package/lib/worktree-sweep/settings.ts +83 -0
  202. package/lib/worktree-sweep/start.ts +64 -0
  203. package/lib/worktree-sweep/state.ts +36 -0
  204. package/lib/worktree-sweep/store.ts +96 -0
  205. package/lib/worktree-sweep/types.ts +53 -0
  206. package/package.json +1 -1
  207. package/scripts/backfill-session-logs.ts +1 -1
  208. package/scripts/screenshots/seed.ts +19 -18
  209. package/server.ts +3 -0
  210. package/lib/tasks/held-routes.ts +0 -33
  211. package/lib/tasks/remote.ts +0 -255
@@ -66,6 +66,7 @@ export type {
66
66
  QueuedTaskView,
67
67
  StartOutcome,
68
68
  } from "./queue-types";
69
+ import { setTask } from "../db/session-parts";
69
70
  export { afterMet, hasFinished, resolveAfter, slotState } from "./queue-rows";
70
71
  export { findQueued, listQueue, queuedView } from "./queue-view";
71
72
  export { tickOnce, tickQueue, tickQueueSoon } from "./queue-tick";
@@ -260,11 +261,18 @@ export function setPriority(id: string, priority: Priority): void {
260
261
  `UPDATE task_queue SET priority = ? WHERE id = ? AND status IN ('queued', 'failed')`
261
262
  )
262
263
  .run(priority, id).changes;
263
- const running = db
264
- .prepare(
265
- `UPDATE sessions SET priority = ? WHERE id = ? AND task_status IS NOT NULL AND archived_at IS NULL`
266
- )
267
- .run(priority === DEFAULT_PRIORITY ? null : priority, id).changes;
264
+ const running = db.transaction(() => {
265
+ const live = db
266
+ .prepare(
267
+ `SELECT 1 FROM session_rows WHERE id = ? AND task_status IS NOT NULL AND archived_at IS NULL`
268
+ )
269
+ .get(id);
270
+ return live
271
+ ? setTask(db, id, {
272
+ priority: priority === DEFAULT_PRIORITY ? null : priority,
273
+ })
274
+ : false;
275
+ })();
268
276
  if (!queued && !running) throw new Error("No such queued or running task");
269
277
  tickQueueSoon();
270
278
  }
@@ -272,7 +280,7 @@ export function setPriority(id: string, priority: Priority): void {
272
280
  // A task started directly (never queued) keeps the priority it was given.
273
281
  export function stampPriority(id: string, priority: Priority | undefined) {
274
282
  if (!priority || priority === DEFAULT_PRIORITY) return;
275
- db.prepare(`UPDATE sessions SET priority = ? WHERE id = ?`).run(priority, id);
283
+ setTask(db, id, { priority });
276
284
  }
277
285
 
278
286
  export function removeQueued(id: string): void {
@@ -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
+ }
@@ -26,6 +26,7 @@ import {
26
26
  import { notifyStatusChanged } from "../status/hub";
27
27
  import type { TaskPR } from "./state";
28
28
  import { markStatusDirty } from "../status/dirty";
29
+ import { setPr } from "../db/session-parts";
29
30
 
30
31
  export const expandHome = (p: string) => p.replace(/^~/, os.homedir());
31
32
 
@@ -33,7 +34,7 @@ export const expandHome = (p: string) => p.replace(/^~/, os.homedir());
33
34
  export function taskSessions(includeArchived = true): Session[] {
34
35
  return db
35
36
  .prepare(
36
- `SELECT ${sessionListColumns(db)} FROM sessions
37
+ `SELECT ${sessionListColumns(db)} FROM session_rows
37
38
  WHERE task_status IS NOT NULL AND task_status != 'moved'
38
39
  ${includeArchived ? "" : "AND archived_at IS NULL"}
39
40
  ORDER BY created_at DESC`
@@ -205,9 +206,11 @@ export async function prFor(
205
206
  if (pr) pushPrChange(session, pr);
206
207
  }
207
208
  if (pr) {
208
- db.prepare(
209
- `UPDATE sessions SET pr_url = ?, pr_number = ?, pr_status = ? WHERE id = ?`
210
- ).run(pr.url, pr.number, pr.state.toLowerCase(), session.id);
209
+ setPr(db, session.id, {
210
+ pr_url: pr.url,
211
+ pr_number: pr.number,
212
+ pr_status: pr.state.toLowerCase() as "open" | "merged" | "closed",
213
+ });
211
214
  // Merged on GitHub rather than through AgentOS: its log is due too.
212
215
  if (pr.state === "MERGED") {
213
216
  if (session.pr_status !== "merged") pushMerged(session.id);
@@ -107,7 +107,7 @@ export async function finishTaskStart(
107
107
  earlier?: TaskSetup
108
108
  ): Promise<TaskSetup> {
109
109
  const session = db
110
- .prepare(`SELECT * FROM sessions WHERE id = ?`)
110
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
111
111
  .get(sessionId) as Session | undefined;
112
112
  if (!session?.worktree_path) throw new Error(`No task ${sessionId}`);
113
113
  const project = session.project_id ? getProject(session.project_id) : null;
@@ -133,7 +133,7 @@ export async function finishTaskStart(
133
133
  // The setup's own error is kept, for the agent's prompt when it launches.
134
134
  if (held) return done({ ...setup, status: "held" });
135
135
  const now = db
136
- .prepare(`SELECT task_status, archived_at FROM sessions WHERE id = ?`)
136
+ .prepare(`SELECT task_status, archived_at FROM session_rows WHERE id = ?`)
137
137
  .get(sessionId) as
138
138
  | { task_status: string | null; archived_at: string | null }
139
139
  | undefined;
@@ -226,7 +226,7 @@ export async function resumeTaskStarts(
226
226
  ): Promise<string[]> {
227
227
  const rows = db
228
228
  .prepare(
229
- `SELECT id, tmux_name, view, setup_ms, setup_error FROM sessions
229
+ `SELECT id, tmux_name, view, setup_ms, setup_error FROM session_rows
230
230
  WHERE setup_status = 'running' AND task_status = 'running'
231
231
  AND archived_at IS NULL`
232
232
  )
@@ -269,7 +269,7 @@ export async function resumeTaskStarts(
269
269
  export function resumeHeldStarts(): string[] {
270
270
  const rows = db
271
271
  .prepare(
272
- `SELECT id, setup_ms, setup_error FROM sessions WHERE setup_status = 'held'
272
+ `SELECT id, setup_ms, setup_error FROM session_rows WHERE setup_status = 'held'
273
273
  AND task_status = 'running' AND archived_at IS NULL`
274
274
  )
275
275
  .all() as ({ id: string } & SetupColumns)[];
@@ -4,6 +4,7 @@
4
4
 
5
5
  import path from "path";
6
6
  import { db } from "../db";
7
+ import { setPlacement } from "../db/session-parts";
7
8
 
8
9
  export type StartVia =
9
10
  | "session"
@@ -59,7 +60,7 @@ export function callerSession(input: {
59
60
  const cwd = path.resolve(input.cwd);
60
61
  const rows = db
61
62
  .prepare(
62
- `SELECT id, role, worktree_path FROM sessions
63
+ `SELECT id, role, worktree_path FROM session_rows
63
64
  WHERE worktree_path IS NOT NULL AND archived_at IS NULL
64
65
  AND task_status IS NOT NULL
65
66
  AND (host_id IS NULL OR host_id = 'local')`
@@ -111,14 +112,10 @@ export function orchestratorOf(startedBy: string | null | undefined) {
111
112
  }
112
113
 
113
114
  export function recordStart(id: string, s: Starter): void {
114
- db.prepare(
115
- `UPDATE sessions SET started_by = ?, parent_session_id = (SELECT id FROM sessions WHERE id = ?), started_via = ?,
116
- started_by_device = COALESCE(?, started_by_device) WHERE id = ?`
117
- ).run(
118
- s.startedBy,
119
- s.parentSessionId,
120
- s.startedVia,
121
- s.startedByDevice ?? null,
122
- id
123
- );
115
+ db.transaction(() => {
116
+ db.prepare(
117
+ `UPDATE sessions SET started_by = ?, parent_session_id = (SELECT id FROM sessions WHERE id = ?), started_via = ? WHERE id = ?`
118
+ ).run(s.startedBy, s.parentSessionId, s.startedVia, id);
119
+ setPlacement(db, id, { started_by_device: s.startedByDevice ?? undefined });
120
+ })();
124
121
  }
@@ -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;
@@ -97,7 +97,7 @@ export function sessionContext(sessionId: string): SessionContext {
97
97
  const s = db
98
98
  .prepare(
99
99
  `SELECT name, workspace_id, project_id, model_provider, role, task_status,
100
- plan_phase, started_by FROM sessions WHERE id = ?`
100
+ plan_phase, started_by FROM session_rows WHERE id = ?`
101
101
  )
102
102
  .get(sessionId) as
103
103
  | {
@@ -225,7 +225,7 @@ export function taskOutcomes(ids: string[]): Map<string, TaskOutcome> {
225
225
  const marks = ids.map(() => "?").join(",");
226
226
  const tasks = db
227
227
  .prepare(
228
- `SELECT id, task_status, plan_decided FROM sessions
228
+ `SELECT id, task_status, plan_decided FROM session_rows
229
229
  WHERE id IN (${marks}) AND task_status IS NOT NULL`
230
230
  )
231
231
  .all(...ids) as {
@@ -82,7 +82,7 @@ export function mergedTasks(from: number, to: number): MergedTask[] {
82
82
  db
83
83
  .prepare(
84
84
  `SELECT id, project_id AS projectId, pr_number AS prNumber, merged_at AS mergedAt
85
- FROM sessions WHERE task_status = 'merged' AND merged_at >= ? AND merged_at < ?`
85
+ FROM session_rows WHERE task_status = 'merged' AND merged_at >= ? AND merged_at < ?`
86
86
  )
87
87
  .all(sqlTime(from), sqlTime(to)) as MergedTask[]
88
88
  ).filter((t) => t.mergedAt);
@@ -61,7 +61,7 @@ export function recordTurn(
61
61
  const current = db
62
62
  .prepare(
63
63
  `SELECT name, workspace_id, model, model_provider, role, task_status,
64
- plan_phase, started_by FROM sessions WHERE id = ?`
64
+ plan_phase, started_by FROM session_rows WHERE id = ?`
65
65
  )
66
66
  .get(session.id) as
67
67
  | Pick<
@@ -44,7 +44,7 @@ export function vocabFor(workspaceId: string): string[] {
44
44
  workspaceId
45
45
  );
46
46
  const sessions = names(
47
- `SELECT s.name FROM sessions s
47
+ `SELECT s.name FROM session_rows s
48
48
  JOIN projects p ON p.id = s.project_id
49
49
  WHERE p.workspace_id = ?
50
50
  AND (s.task_status IS NULL OR s.task_status NOT IN ('merged','dropped','done','moved'))
@@ -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
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Folders in the worktrees root that no session owns. Old ones go, after the
3
+ * same checks as a settled worktree; the throwaway repos tests once left
4
+ * there (they hold nothing of anyone's) go without them.
5
+ */
6
+
7
+ import fs from "fs";
8
+ import os from "os";
9
+ import path from "path";
10
+ import { db } from "../db";
11
+ import { mainCheckoutOf } from "../worktrees";
12
+ import { resolveSweepDays } from "./settings";
13
+ import { inFolder } from "./panes";
14
+ import type { Candidate, SweepSkipped } from "./types";
15
+
16
+ const DAY_MS = 24 * 3600_000;
17
+
18
+ // What a test's quick-change worktree is called (lib/orchestrator/quick.test.ts).
19
+ const TEST_LEAK = /^(aos-quick-repo-|repo-quick-)/;
20
+ // Kept in a folder AgentOS made that holds no work: placed or rebuilt files.
21
+ const DISPOSABLE = new Set([
22
+ "node_modules",
23
+ ".next",
24
+ ".turbo",
25
+ "dist",
26
+ "build",
27
+ ".git",
28
+ ".dev-tmux",
29
+ ".agent-os",
30
+ ]);
31
+
32
+ const real = (p: string) => {
33
+ try {
34
+ return fs.realpathSync(p);
35
+ } catch {
36
+ return path.resolve(p);
37
+ }
38
+ };
39
+
40
+ // Every folder a session row names, on any machine's row: never an orphan.
41
+ function owned(): Set<string> {
42
+ const rows = db
43
+ .prepare(
44
+ `SELECT worktree_path AS a, working_directory AS b FROM session_rows`
45
+ )
46
+ .all() as Array<{ a: string | null; b: string | null }>;
47
+ const out = new Set<string>();
48
+ for (const r of rows) for (const p of [r.a, r.b]) if (p) out.add(real(p));
49
+ return out;
50
+ }
51
+
52
+ // Where a worktree's .git file points; null when it has none.
53
+ function gitdirOf(dir: string): string | null {
54
+ try {
55
+ const m = /^gitdir:\s*(.+)$/m.exec(
56
+ fs.readFileSync(path.join(dir, ".git"), "utf8")
57
+ );
58
+ return m ? m[1].trim() : null;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ const inTemp = (p: string) =>
65
+ [os.tmpdir(), real(os.tmpdir())].some((t) => inFolder(p, t));
66
+
67
+ // Nothing in it but what AgentOS placed or rebuilds: no work to lose.
68
+ export const holdsNothing = (dir: string) =>
69
+ fs
70
+ .readdirSync(dir)
71
+ .filter((f) => !DISPOSABLE.has(f))
72
+ .every((f) => f.startsWith(".env"));
73
+
74
+ const newestMs = (dir: string) => {
75
+ let t = 0;
76
+ for (const p of [dir, path.join(dir, ".git")])
77
+ try {
78
+ t = Math.max(t, fs.statSync(p).mtimeMs);
79
+ } catch {
80
+ // not there
81
+ }
82
+ return t;
83
+ };
84
+
85
+ export interface OrphanDeps {
86
+ now: number;
87
+ root: string;
88
+ panes: string[];
89
+ }
90
+
91
+ export async function findOrphans(deps: OrphanDeps): Promise<{
92
+ candidates: Candidate[];
93
+ skipped: SweepSkipped[];
94
+ }> {
95
+ const { days } = resolveSweepDays(null);
96
+ const candidates: Candidate[] = [];
97
+ const skipped: SweepSkipped[] = [];
98
+ if (days === 0 || !fs.existsSync(deps.root)) return { candidates, skipped };
99
+ const mine = owned();
100
+ for (const entry of fs.readdirSync(deps.root, { withFileTypes: true })) {
101
+ if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
102
+ const dir = path.join(deps.root, entry.name);
103
+ if (mine.has(real(dir))) continue;
104
+ const skip = (reason: string) =>
105
+ skipped.push({ path: dir, kind: "orphan", reason });
106
+ if (deps.now - newestMs(dir) < days * DAY_MS) {
107
+ skip(`touched less than ${days} day${days === 1 ? "" : "s"} ago`);
108
+ continue;
109
+ }
110
+ if (deps.panes.some((p) => inFolder(p, real(dir)) || inFolder(p, dir))) {
111
+ skip("a terminal is open in it");
112
+ continue;
113
+ }
114
+ const gitdir = gitdirOf(dir);
115
+ const registered = !!gitdir && fs.existsSync(gitdir);
116
+ const leak =
117
+ TEST_LEAK.test(entry.name) &&
118
+ !!gitdir &&
119
+ inTemp(real(path.dirname(gitdir)));
120
+ if (leak) {
121
+ candidates.push({
122
+ path: dir,
123
+ kind: "test-leak",
124
+ repo: registered ? path.resolve(gitdir!, "../../..") : undefined,
125
+ });
126
+ continue;
127
+ }
128
+ if (registered) {
129
+ const repo = await mainCheckoutOf(dir);
130
+ if (!repo) skip("its repository can't be found");
131
+ else candidates.push({ path: dir, kind: "orphan", repo });
132
+ continue;
133
+ }
134
+ // Not a git worktree any more: git can't say what's in it.
135
+ if (holdsNothing(dir)) candidates.push({ path: dir, kind: "orphan" });
136
+ else skip("it isn't a git worktree and holds files git can't check");
137
+ }
138
+ return { candidates, skipped };
139
+ }