@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.
- package/app/api/bus/branches/route.ts +25 -0
- package/app/api/bus/resurrect/route.test.ts +3 -6
- package/app/api/worktree-sweep/route.test.ts +40 -0
- package/app/api/worktree-sweep/route.ts +31 -0
- package/app/api/worktree-sweep/run/route.ts +26 -0
- package/components/Health/HealthPanel.tsx +4 -1
- package/components/Health/SweepRuns.tsx +125 -0
- package/components/Merge/GlobalMergeSection.tsx +2 -0
- package/components/Merge/ProjectMergeSection.tsx +2 -0
- package/components/Merge/WorktreeSweepSection.tsx +125 -0
- package/components/Settings/NoApprovalsSection.tsx +1 -1
- package/components/Settings/SettingsDialog.tsx +1 -1
- package/data/worktree-sweep/index.ts +56 -0
- package/lib/away/report.ts +1 -1
- package/lib/db/backup.ts +49 -0
- package/lib/db/migrations/20261015T1733_gate_loop_guard.ts +40 -0
- package/lib/db/migrations/20261015T1830_drop_moved_session_columns.ts +173 -0
- package/lib/db/migrations/20261015T1900_worktree_sweep.ts +30 -0
- package/lib/db/migrations/README.md +7 -3
- package/lib/db/session-parts/columns.ts +3 -3
- package/lib/db/session-parts/index.ts +1 -0
- package/lib/db/session-parts/rows.ts +69 -0
- package/lib/db/session-parts/view.ts +14 -7
- package/lib/db/session-parts/write.ts +4 -12
- package/lib/done/remote.ts +9 -20
- package/lib/orchestrator/armed.test-helpers.ts +228 -0
- package/lib/orchestrator/armed.ts +1 -1
- package/lib/orchestrator/ask-verdict.ts +111 -0
- package/lib/orchestrator/asks.ts +26 -0
- package/lib/orchestrator/brief.ts +2 -1
- package/lib/orchestrator/escalate.ts +25 -12
- package/lib/orchestrator/external-gates.ts +1 -0
- package/lib/orchestrator/gates.ts +94 -25
- package/lib/orchestrator/held-asks.ts +5 -8
- package/lib/orchestrator/held-review.ts +8 -4
- package/lib/orchestrator/loop-guard.ts +145 -0
- package/lib/orchestrator/merge-approvals.ts +2 -2
- package/lib/orchestrator/quick.ts +1 -1
- package/lib/orchestrator/review-prompt.ts +2 -0
- package/lib/orchestrator/review.ts +14 -4
- package/lib/orchestrator/signoff.test-helpers.ts +322 -0
- package/lib/orchestrator/signoff.ts +46 -10
- package/lib/orchestrator/testing.ts +11 -14
- package/lib/orchestrator/watcher.ts +4 -0
- package/lib/recipes/test-fixtures.ts +2 -3
- package/lib/resurrect/index.ts +20 -2
- package/lib/resurrect/swept.ts +61 -0
- package/lib/stacks/testing.ts +17 -16
- package/lib/tasks/finish.ts +20 -14
- package/lib/tasks/move-testing.ts +16 -7
- package/lib/tasks/pr-activity.ts +11 -2
- package/lib/tasks/prune-branches.ts +193 -0
- package/lib/tasks/prune-scope.ts +22 -0
- package/lib/tasks/remote-branch.ts +170 -0
- package/lib/tasks/testing.ts +13 -7
- package/lib/worktree-sweep/commits.ts +65 -0
- package/lib/worktree-sweep/findOrphans.ts +139 -0
- package/lib/worktree-sweep/findSettled.ts +112 -0
- package/lib/worktree-sweep/index.ts +10 -0
- package/lib/worktree-sweep/judge.ts +31 -0
- package/lib/worktree-sweep/panes.ts +18 -0
- package/lib/worktree-sweep/runSweep.ts +243 -0
- package/lib/worktree-sweep/settings.ts +83 -0
- package/lib/worktree-sweep/start.ts +64 -0
- package/lib/worktree-sweep/state.ts +36 -0
- package/lib/worktree-sweep/store.ts +96 -0
- package/lib/worktree-sweep/types.ts +53 -0
- package/package.json +1 -1
- package/scripts/screenshots/seed.ts +19 -18
- package/server.ts +3 -0
|
@@ -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
|
+
}
|
|
@@ -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,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
|
+
}
|