@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
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The sessions whose worktree the sweep may consider: settled, quiet, not
3
+ * waiting on anyone, no open PR, and old enough for their project's setting.
4
+ * Everything else is skipped with the reason.
5
+ */
6
+
7
+ import fs from "fs";
8
+ import { db, type Session } from "../db";
9
+ import { fromSqliteTime } from "../session-meta";
10
+ import { statusOf } from "../orchestrator/facts";
11
+ import { isAgentOSWorktree } from "../worktrees";
12
+ import { restoring } from "../resurrect/index";
13
+ import { inFolder } from "./panes";
14
+ import { resolveSweepDays } from "./settings";
15
+ import type { Candidate, SweepSkipped } from "./types";
16
+
17
+ const DAY_MS = 24 * 3600_000;
18
+ const SETTLED_TASKS = new Set(["merged", "dropped", "done"]);
19
+
20
+ export interface SettledDeps {
21
+ now: number;
22
+ panes: string[];
23
+ status: (s: Session) => ReturnType<typeof statusOf>;
24
+ isRestoring: (id: string) => boolean;
25
+ }
26
+
27
+ const ms = (t: string | null | undefined) =>
28
+ t ? fromSqliteTime(t).getTime() : 0;
29
+
30
+ // When it settled: the earliest of the marks it carries, else its last update;
31
+ // anything it did afterwards starts the wait again.
32
+ export function settledSinceMs(s: Session): number {
33
+ const marks = [s.merged_at, s.task_ended_at, s.settled_at, s.archived_at]
34
+ .map(ms)
35
+ .filter((t) => t > 0);
36
+ const settled = marks.length ? Math.min(...marks) : ms(s.updated_at);
37
+ return Math.max(settled, ms(s.active_at));
38
+ }
39
+
40
+ export const isSettled = (s: Session) =>
41
+ s.task_status
42
+ ? SETTLED_TASKS.has(s.task_status)
43
+ : !!(s.archived_at || s.settled_at);
44
+
45
+ function openAsks(): Set<string> {
46
+ const rows = db
47
+ .prepare(`SELECT subject FROM orchestrator_asks WHERE status = 'open'`)
48
+ .all() as Array<{ subject: string }>;
49
+ return new Set(rows.map((r) => r.subject));
50
+ }
51
+
52
+ export async function findSettled(
53
+ deps: SettledDeps
54
+ ): Promise<{ candidates: Candidate[]; skipped: SweepSkipped[] }> {
55
+ const rows = db
56
+ .prepare(
57
+ `SELECT * FROM session_rows
58
+ WHERE worktree_path IS NOT NULL AND worktree_path != ''
59
+ AND (host_id IS NULL OR host_id = 'local') AND peer_mirror = 0`
60
+ )
61
+ .all() as Session[];
62
+ const asks = openAsks();
63
+ const candidates: Candidate[] = [];
64
+ const skipped: SweepSkipped[] = [];
65
+ for (const s of rows) {
66
+ const dir = s.worktree_path!;
67
+ if (!isSettled(s) || !isAgentOSWorktree(dir) || !fs.existsSync(dir))
68
+ continue;
69
+ const skip = async (): Promise<string | null> => {
70
+ if (s.role) return "an orchestrator's";
71
+ if (s.pinned) return "pinned";
72
+ if (s.moved_to)
73
+ return "its task moved to another machine and may come back";
74
+ if (s.pr_status === "open") return "its PR is open";
75
+ const { days } = resolveSweepDays(s.project_id);
76
+ if (days === 0) return "clean-up is off for its project";
77
+ if (deps.now - settledSinceMs(s) < days * DAY_MS)
78
+ return `settled less than ${days} day${days === 1 ? "" : "s"} ago`;
79
+ if (deps.isRestoring(s.id)) return "it is being restored";
80
+ if (asks.has(`task:${s.id}`)) return "an ask about it is waiting on you";
81
+ const real = fs.realpathSync(dir);
82
+ if (deps.panes.some((p) => inFolder(p, real) || inFolder(p, dir)))
83
+ return "a terminal is open in it";
84
+ const { status, needsInput } = await deps.status(s);
85
+ if (needsInput) return "it is waiting on input";
86
+ if (status !== "idle" && status !== "dead") return `it is ${status}`;
87
+ return null;
88
+ };
89
+ const reason = await skip();
90
+ if (reason)
91
+ skipped.push({ path: dir, kind: "settled", reason, sessionId: s.id });
92
+ else
93
+ candidates.push({
94
+ path: dir,
95
+ kind: "settled",
96
+ sessionId: s.id,
97
+ prNumber: s.pr_number,
98
+ branch: s.branch_name,
99
+ });
100
+ }
101
+ return { candidates, skipped };
102
+ }
103
+
104
+ export const defaultSettledDeps = (
105
+ now = Date.now(),
106
+ panes: string[] = []
107
+ ): SettledDeps => ({
108
+ now,
109
+ panes,
110
+ status: statusOf,
111
+ isRestoring: restoring,
112
+ });
@@ -0,0 +1,10 @@
1
+ export { sweep, describeRun } from "./runSweep";
2
+ export {
3
+ sweepNow,
4
+ startWorktreeSweep,
5
+ sweepEnabled,
6
+ ownsWorktrees,
7
+ } from "./start";
8
+ export * from "./settings";
9
+ export { recentRuns, sweptAt } from "./store";
10
+ export type * from "./types";
@@ -0,0 +1,31 @@
1
+ import { describeChanges, unsavedChanges } from "../worktree-placed";
2
+ import { unpushedCommits } from "./commits";
3
+
4
+ export type Verdict = { remove: true } | { remove: false; reason: string };
5
+
6
+ /**
7
+ * Whether removing the worktree would lose anything: uncommitted changes
8
+ * (except what AgentOS placed and nobody touched), or commits that no remote
9
+ * branch, merged PR or upstream patch holds. Anything that can't be read
10
+ * keeps it.
11
+ */
12
+ export async function judgeWorktree(
13
+ path: string,
14
+ repo: string,
15
+ opts: { prHead?: string | null; branch?: string | null } = {}
16
+ ): Promise<Verdict> {
17
+ const unsaved = await unsavedChanges(path).catch(() => null);
18
+ if (!unsaved)
19
+ return { remove: false, reason: "its changes can't be checked" };
20
+ const stray = await unpushedCommits(path, repo, opts).catch(() => null);
21
+ if (!stray) return { remove: false, reason: "its commits can't be checked" };
22
+ const why = [
23
+ unsaved.length ? `uncommitted changes (${describeChanges(unsaved)})` : null,
24
+ stray.length
25
+ ? `${stray.length} commit${stray.length === 1 ? "" : "s"} no remote branch or merged PR has`
26
+ : null,
27
+ ].filter((w) => w !== null);
28
+ return why.length
29
+ ? { remove: false, reason: why.join(", and ") }
30
+ : { remove: true };
31
+ }
@@ -0,0 +1,18 @@
1
+ import { execFile } from "child_process";
2
+
3
+ // The folders open in tmux panes, so a worktree someone has a terminal in is
4
+ // left alone. Empty when tmux isn't running or can't be read.
5
+ export function openPaneFolders(timeoutMs = 3000): Promise<string[]> {
6
+ return new Promise((resolve) => {
7
+ execFile(
8
+ "tmux",
9
+ ["list-panes", "-a", "-F", "#{pane_current_path}"],
10
+ { timeout: timeoutMs },
11
+ (error, stdout) =>
12
+ resolve(error ? [] : String(stdout).split("\n").filter(Boolean))
13
+ );
14
+ });
15
+ }
16
+
17
+ export const inFolder = (folder: string, root: string) =>
18
+ folder === root || folder.startsWith(root + "/");
@@ -0,0 +1,243 @@
1
+ /**
2
+ * One clean-up run: settled sessions' worktrees and orphan folders, each
3
+ * judged, removed when nothing would be lost, skipped with a reason when not.
4
+ * The branch and the conversation stay; a restore rebuilds the worktree.
5
+ */
6
+
7
+ import fs from "fs";
8
+ import path from "path";
9
+ import { execFile } from "child_process";
10
+ import { db, type Session } from "../db";
11
+ import { git as runGit } from "../git-exec";
12
+ import { run as runCmd } from "../tasks/gh";
13
+ import { deleteWorktree, WORKTREES_DIR } from "../worktrees";
14
+ import { keepSessionFiles } from "../session-artifacts/collect";
15
+ import { currentLevel } from "../load/monitor";
16
+ import {
17
+ findSettled,
18
+ defaultSettledDeps,
19
+ type SettledDeps,
20
+ } from "./findSettled";
21
+ import { findOrphans, holdsNothing } from "./findOrphans";
22
+ import { judgeWorktree } from "./judge";
23
+ import { openPaneFolders } from "./panes";
24
+ import { finishRun, recordSwept, startRun } from "./store";
25
+ import type { Candidate, SweepSummary } from "./types";
26
+
27
+ export interface SweepDeps {
28
+ now: number;
29
+ root: string;
30
+ panes: () => Promise<string[]>;
31
+ settled: Omit<SettledDeps, "now" | "panes">;
32
+ size: (dir: string) => Promise<number>;
33
+ prHead: (repo: string, number: number) => Promise<string | null>;
34
+ // Stop early when the machine is busy.
35
+ busy: () => boolean;
36
+ }
37
+
38
+ const git = (cwd: string, ...args: string[]) =>
39
+ runGit(cwd, args, { timeout: 30_000 }).then((o) => o.trim());
40
+
41
+ function duBytes(dir: string): Promise<number> {
42
+ return new Promise((resolve) =>
43
+ execFile("du", ["-sk", dir], { timeout: 120_000 }, (error, stdout) =>
44
+ resolve(error ? 0 : (parseInt(String(stdout), 10) || 0) * 1024)
45
+ )
46
+ );
47
+ }
48
+
49
+ async function prHeadOf(repo: string, number: number): Promise<string | null> {
50
+ try {
51
+ const out = await runCmd(
52
+ "gh",
53
+ [
54
+ "pr",
55
+ "view",
56
+ String(number),
57
+ "--json",
58
+ "headRefOid",
59
+ "-q",
60
+ ".headRefOid",
61
+ ],
62
+ repo,
63
+ 30_000
64
+ );
65
+ return out.trim() || null;
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ export const defaultSweepDeps = (): SweepDeps => ({
72
+ now: Date.now(),
73
+ root: WORKTREES_DIR,
74
+ panes: openPaneFolders,
75
+ settled: defaultSettledDeps(),
76
+ size: duBytes,
77
+ prHead: prHeadOf,
78
+ busy: () => currentLevel() === "red",
79
+ });
80
+
81
+ // A path under the root, never the root itself or something outside it.
82
+ const inRoot = (root: string, p: string) => {
83
+ const rel = path.relative(path.resolve(root), path.resolve(p));
84
+ return !!rel && !rel.startsWith("..") && !path.isAbsolute(rel);
85
+ };
86
+
87
+ async function repoOfSession(c: Candidate): Promise<string | null> {
88
+ if (c.repo) return c.repo;
89
+ try {
90
+ return (
91
+ await git(
92
+ c.path,
93
+ "rev-parse",
94
+ "--path-format=absolute",
95
+ "--git-common-dir"
96
+ )
97
+ ).replace(/\/\.git\/?$/, "");
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ async function removeOne(
104
+ c: Candidate,
105
+ repo: string | null,
106
+ runId: number
107
+ ): Promise<void> {
108
+ if (c.sessionId) {
109
+ await keepSessionFiles(c.sessionId).catch(() => {});
110
+ recordSwept(c.sessionId, c.path, c.branch ?? null, runId);
111
+ }
112
+ if (repo && fs.existsSync(repo) && c.kind !== "test-leak")
113
+ await deleteWorktree(c.path, repo, false).catch(() => {});
114
+ if (fs.existsSync(c.path))
115
+ await fs.promises.rm(c.path, { recursive: true, force: true });
116
+ if (repo && fs.existsSync(repo))
117
+ await git(repo, "worktree", "prune").catch(() => "");
118
+ }
119
+
120
+ export async function sweep(
121
+ opts: { dryRun?: boolean } = {},
122
+ deps: SweepDeps = defaultSweepDeps()
123
+ ): Promise<{ runId: number; summary: SweepSummary }> {
124
+ const dryRun = !!opts.dryRun;
125
+ const runId = startRun(dryRun);
126
+ const summary: SweepSummary = { removed: [], skipped: [], freedBytes: 0 };
127
+ let error: string | null = null;
128
+ try {
129
+ const panes = await deps.panes();
130
+ const s = await findSettled({ ...deps.settled, now: deps.now, panes });
131
+ const o = await findOrphans({ now: deps.now, root: deps.root, panes });
132
+ // Reasons that need a look come before the ones that only need time.
133
+ const young = (k: { reason: string }) => /less than \d+ day/.test(k.reason);
134
+ summary.skipped.push(
135
+ ...[...s.skipped, ...o.skipped].sort(
136
+ (a, b) => Number(young(a)) - Number(young(b))
137
+ )
138
+ );
139
+ const pruned = new Set<string>();
140
+ for (const c of [...s.candidates, ...o.candidates]) {
141
+ if (deps.busy()) {
142
+ summary.skipped.push({
143
+ path: c.path,
144
+ kind: c.kind,
145
+ reason: "the machine is busy; left for the next run",
146
+ sessionId: c.sessionId,
147
+ });
148
+ continue;
149
+ }
150
+ if (!inRoot(deps.root, c.path)) continue;
151
+ const skip = (reason: string) =>
152
+ summary.skipped.push({
153
+ path: c.path,
154
+ kind: c.kind,
155
+ reason,
156
+ sessionId: c.sessionId,
157
+ });
158
+ const repo = await repoOfSession(c);
159
+ const why =
160
+ c.kind === "test-leak"
161
+ ? "a test's throwaway repository"
162
+ : "clean and nothing would be lost";
163
+ if (c.kind !== "test-leak" && (repo || c.kind === "settled")) {
164
+ if (!repo) {
165
+ // Git can't read it: it goes only when it holds nothing of anyone's.
166
+ if (!holdsNothing(c.path)) {
167
+ skip("it isn't a git worktree any more");
168
+ continue;
169
+ }
170
+ } else {
171
+ let verdict = await judgeWorktree(c.path, repo, { branch: c.branch });
172
+ // A squash merge leaves the branch's commits only in the PR's head.
173
+ if (!verdict.remove && c.prNumber) {
174
+ const prHead = await deps.prHead(repo, c.prNumber);
175
+ if (prHead)
176
+ verdict = await judgeWorktree(c.path, repo, {
177
+ branch: c.branch,
178
+ prHead,
179
+ });
180
+ }
181
+ if (!verdict.remove) {
182
+ skip(verdict.reason);
183
+ continue;
184
+ }
185
+ }
186
+ }
187
+ const bytes = await deps.size(c.path);
188
+ if (!dryRun) {
189
+ await removeOne(c, repo, runId);
190
+ if (fs.existsSync(c.path)) {
191
+ skip("removing it failed");
192
+ continue;
193
+ }
194
+ if (repo) pruned.add(repo);
195
+ }
196
+ summary.removed.push({
197
+ path: c.path,
198
+ kind: c.kind,
199
+ why,
200
+ bytes,
201
+ sessionId: c.sessionId,
202
+ });
203
+ summary.freedBytes += bytes;
204
+ }
205
+ if (!dryRun) await pruneProjects([...pruned]);
206
+ } catch (e) {
207
+ error = e instanceof Error ? e.message : String(e);
208
+ }
209
+ finishRun(runId, summary, error);
210
+ console.log(describeRun(summary, dryRun, error));
211
+ return { runId, summary };
212
+ }
213
+
214
+ // `git worktree prune` in every repository AgentOS knows, so entries for
215
+ // folders that are gone stop piling up.
216
+ async function pruneProjects(extra: string[]): Promise<void> {
217
+ const rows = db
218
+ .prepare(
219
+ `SELECT working_directory FROM projects WHERE working_directory IS NOT NULL`
220
+ )
221
+ .all() as Array<Pick<Session, "working_directory">>;
222
+ const repos = new Set([...extra, ...rows.map((r) => r.working_directory)]);
223
+ for (const repo of repos)
224
+ if (repo && fs.existsSync(repo))
225
+ await git(repo, "worktree", "prune").catch(() => "");
226
+ }
227
+
228
+ const gb = (b: number) => `${(b / 1024 ** 3).toFixed(2)} GB`;
229
+
230
+ export function describeRun(
231
+ s: SweepSummary,
232
+ dryRun = false,
233
+ error: string | null = null
234
+ ): string {
235
+ const reasons = new Map<string, number>();
236
+ for (const k of s.skipped) {
237
+ const r = k.reason.replace(/\(.*\)/, "").trim();
238
+ reasons.set(r, (reasons.get(r) ?? 0) + 1);
239
+ }
240
+ const why = [...reasons].map(([r, n]) => `${n} ${r}`).join("; ");
241
+ const leaks = s.removed.filter((r) => r.kind === "test-leak").length;
242
+ return `[worktree-sweep] ${dryRun ? "would remove" : "removed"} ${s.removed.length} (${gb(s.freedBytes)}${leaks ? `, ${leaks} test leftovers` : ""}), skipped ${s.skipped.length}${why ? `: ${why}` : ""}${error ? `; failed: ${error}` : ""}`;
243
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * How many days a settled worktree stays before the sweep removes it.
3
+ * Global, with a per-project override; 0 turns it off. Default 7.
4
+ */
5
+
6
+ import { z } from "zod";
7
+ import { db } from "../db";
8
+ import type { ResolvedDays } from "./types";
9
+
10
+ export const DEFAULT_SWEEP_DAYS = 7;
11
+ export const MAX_SWEEP_DAYS = 365;
12
+
13
+ const GLOBAL_KEY = "worktree_sweep";
14
+ const projectKey = (projectId: string) => `worktree_sweep.project.${projectId}`;
15
+
16
+ export const sweepDays = z.number().int().min(0).max(MAX_SWEEP_DAYS);
17
+ const stored = z.object({ days: sweepDays });
18
+
19
+ function read(key: string): number | null {
20
+ const row = db
21
+ .prepare(`SELECT value FROM settings WHERE key = ?`)
22
+ .get(key) as { value: string } | undefined;
23
+ if (!row) return null;
24
+ try {
25
+ const got = stored.safeParse(JSON.parse(row.value));
26
+ return got.success ? got.data.days : null;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function write(key: string, days: number | null): number | null {
33
+ if (days === null) {
34
+ db.prepare(`DELETE FROM settings WHERE key = ?`).run(key);
35
+ return null;
36
+ }
37
+ const got = sweepDays.safeParse(days);
38
+ if (!got.success)
39
+ throw new Error(
40
+ `Days must be a whole number from 0 to ${MAX_SWEEP_DAYS} (0 turns the clean-up off)`
41
+ );
42
+ db.prepare(
43
+ `INSERT INTO settings (key, value) VALUES (?, ?)
44
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
45
+ ).run(key, JSON.stringify({ days: got.data }));
46
+ return got.data;
47
+ }
48
+
49
+ export const globalSweepDays = () => read(GLOBAL_KEY);
50
+ export const setGlobalSweepDays = (days: number | null) =>
51
+ write(GLOBAL_KEY, days);
52
+ export const projectSweepDays = (projectId: string) =>
53
+ read(projectKey(projectId));
54
+ export const setProjectSweepDays = (projectId: string, days: number | null) =>
55
+ write(projectKey(projectId), days);
56
+
57
+ // The project's own setting, else the global one, else the default.
58
+ export function resolveSweepDays(
59
+ projectId: string | null | undefined
60
+ ): ResolvedDays {
61
+ const own = projectId ? projectSweepDays(projectId) : null;
62
+ if (own !== null) return { days: own, from: "project" };
63
+ const global = globalSweepDays();
64
+ if (global !== null) return { days: global, from: "global" };
65
+ return { days: DEFAULT_SWEEP_DAYS, from: "default" };
66
+ }
67
+
68
+ // Projects with their own number, for the settings page.
69
+ export function projectSweepOverrides(): Array<{
70
+ projectId: string;
71
+ days: number;
72
+ }> {
73
+ const rows = db
74
+ .prepare(
75
+ `SELECT key FROM settings WHERE key LIKE 'worktree_sweep.project.%'`
76
+ )
77
+ .all() as Array<{ key: string }>;
78
+ return rows.flatMap(({ key }) => {
79
+ const projectId = key.slice("worktree_sweep.project.".length);
80
+ const days = read(key);
81
+ return days === null ? [] : [{ projectId, days }];
82
+ });
83
+ }
@@ -0,0 +1,64 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { dbPath } from "../db/path";
4
+ import { WORKTREES_DIR } from "../worktrees";
5
+ import { sweep } from "./runSweep";
6
+
7
+ export const SWEEP_EVERY_MS = 6 * 3600_000;
8
+ export const SWEEP_FIRST_AFTER_MS = 10 * 60_000;
9
+
10
+ const OWNER_FILE = ".sweep-owner";
11
+
12
+ // A development or test instance has its own database but sees the real
13
+ // worktrees folder: every live worktree would look like an orphan to it. Only
14
+ // a production server, or one asked to, sweeps, and only a folder it owns.
15
+ export const sweepEnabled = (env: NodeJS.ProcessEnv = process.env) =>
16
+ !env.VITEST &&
17
+ (env.AGENTOS_WORKTREE_SWEEP === "1" ||
18
+ (env.NODE_ENV === "production" && env.AGENTOS_WORKTREE_SWEEP !== "0"));
19
+
20
+ // First sweep claims the folder for this database; another one's claim stands.
21
+ export function ownsWorktrees(
22
+ claim: boolean,
23
+ root = WORKTREES_DIR,
24
+ db = path.resolve(dbPath())
25
+ ): boolean {
26
+ const file = path.join(root, OWNER_FILE);
27
+ try {
28
+ return fs.readFileSync(file, "utf8").trim() === db;
29
+ } catch {
30
+ // unclaimed
31
+ }
32
+ if (!claim) return false;
33
+ fs.mkdirSync(root, { recursive: true });
34
+ fs.writeFileSync(file, db);
35
+ return true;
36
+ }
37
+
38
+ const g = globalThis as unknown as { __agentosSweeping?: boolean };
39
+
40
+ // One run at a time, however it was asked for.
41
+ export async function sweepNow(dryRun = false) {
42
+ if (g.__agentosSweeping) throw new Error("A clean-up is already running");
43
+ if (!ownsWorktrees(sweepEnabled()))
44
+ throw new Error("Another AgentOS owns the worktrees folder");
45
+ g.__agentosSweeping = true;
46
+ try {
47
+ return await sweep({ dryRun });
48
+ } finally {
49
+ g.__agentosSweeping = false;
50
+ }
51
+ }
52
+
53
+ export function startWorktreeSweep(): void {
54
+ if (!sweepEnabled()) return;
55
+ const run = () => {
56
+ sweepNow().catch((error) =>
57
+ console.error("[worktree-sweep] not run:", error.message)
58
+ );
59
+ };
60
+ setTimeout(() => {
61
+ run();
62
+ setInterval(run, SWEEP_EVERY_MS);
63
+ }, SWEEP_FIRST_AFTER_MS);
64
+ }
@@ -0,0 +1,36 @@
1
+ import { getAllProjects } from "../projects";
2
+ import {
3
+ DEFAULT_SWEEP_DAYS,
4
+ globalSweepDays,
5
+ projectSweepOverrides,
6
+ } from "./settings";
7
+ import { recentRuns } from "./store";
8
+ import type { SweepRun } from "./types";
9
+
10
+ export interface SweepState {
11
+ // Null when no number is set globally: the default applies.
12
+ days: number | null;
13
+ defaultDays: number;
14
+ overrides: Array<{ projectId: string; name: string; days: number }>;
15
+ runs: SweepRun[];
16
+ }
17
+
18
+ export function sweepState(): SweepState {
19
+ const names = new Map(getAllProjects().map((p) => [p.id, p.name]));
20
+ return {
21
+ days: globalSweepDays(),
22
+ defaultDays: DEFAULT_SWEEP_DAYS,
23
+ overrides: projectSweepOverrides().flatMap((o) =>
24
+ names.has(o.projectId)
25
+ ? [
26
+ {
27
+ projectId: o.projectId,
28
+ name: names.get(o.projectId)!,
29
+ days: o.days,
30
+ },
31
+ ]
32
+ : []
33
+ ),
34
+ runs: recentRuns(),
35
+ };
36
+ }
@@ -0,0 +1,96 @@
1
+ import { db } from "../db";
2
+ import type { SweepRun, SweepSummary } from "./types";
3
+
4
+ interface RunRow {
5
+ id: number;
6
+ started_at: string;
7
+ finished_at: string | null;
8
+ dry_run: number;
9
+ removed: number;
10
+ freed_bytes: number;
11
+ skipped: number;
12
+ details: string | null;
13
+ error: string | null;
14
+ }
15
+
16
+ // The counts are exact; the lists kept for a run stop here (a first run can
17
+ // skip thousands of folders that are simply too young).
18
+ const DETAIL_CAP = 300;
19
+
20
+ const view = (r: RunRow): SweepRun => ({
21
+ id: r.id,
22
+ startedAt: r.started_at,
23
+ finishedAt: r.finished_at,
24
+ dryRun: !!r.dry_run,
25
+ removed: r.removed,
26
+ freedBytes: r.freed_bytes,
27
+ skipped: r.skipped,
28
+ details: r.details ? (JSON.parse(r.details) as SweepSummary) : null,
29
+ error: r.error,
30
+ });
31
+
32
+ export function startRun(dryRun: boolean): number {
33
+ return Number(
34
+ db
35
+ .prepare(`INSERT INTO worktree_sweep_runs (dry_run) VALUES (?)`)
36
+ .run(dryRun ? 1 : 0).lastInsertRowid
37
+ );
38
+ }
39
+
40
+ export function finishRun(
41
+ id: number,
42
+ summary: SweepSummary,
43
+ error: string | null
44
+ ): void {
45
+ db.prepare(
46
+ `UPDATE worktree_sweep_runs SET finished_at = datetime('now'), removed = ?,
47
+ freed_bytes = ?, skipped = ?, details = ?, error = ? WHERE id = ?`
48
+ ).run(
49
+ summary.removed.length,
50
+ summary.freedBytes,
51
+ summary.skipped.length,
52
+ JSON.stringify({
53
+ ...summary,
54
+ skipped: summary.skipped.slice(0, DETAIL_CAP),
55
+ }),
56
+ error,
57
+ id
58
+ );
59
+ }
60
+
61
+ export function recentRuns(limit = 30): SweepRun[] {
62
+ return (
63
+ db
64
+ .prepare(`SELECT * FROM worktree_sweep_runs ORDER BY id DESC LIMIT ?`)
65
+ .all(limit) as RunRow[]
66
+ ).map(view);
67
+ }
68
+
69
+ export function recordSwept(
70
+ sessionId: string,
71
+ path: string,
72
+ branch: string | null,
73
+ runId: number
74
+ ): void {
75
+ db.prepare(
76
+ `INSERT INTO session_worktree_sweeps (session_id, path, branch, run_id)
77
+ VALUES (?, ?, ?, ?)
78
+ ON CONFLICT(session_id) DO UPDATE SET path = excluded.path,
79
+ branch = excluded.branch, swept_at = datetime('now'), run_id = excluded.run_id`
80
+ ).run(sessionId, path, branch, runId);
81
+ }
82
+
83
+ export function sweptAt(sessionId: string): string | null {
84
+ const row = db
85
+ .prepare(
86
+ `SELECT swept_at FROM session_worktree_sweeps WHERE session_id = ?`
87
+ )
88
+ .get(sessionId) as { swept_at: string } | undefined;
89
+ return row?.swept_at ?? null;
90
+ }
91
+
92
+ export function forgetSwept(sessionId: string): void {
93
+ db.prepare(`DELETE FROM session_worktree_sweeps WHERE session_id = ?`).run(
94
+ sessionId
95
+ );
96
+ }