@saadnvd1/agent-os 0.3.662 → 0.3.664
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/sessions/[id]/route.ts +3 -3
- package/app/api/worktree-sweep/route.test.ts +19 -1
- package/app/api/worktree-sweep/route.ts +25 -6
- package/components/Health/SweepRuns.tsx +76 -1
- package/data/worktree-sweep/index.ts +15 -0
- package/lib/agentos-home.ts +11 -0
- package/lib/agents/launch.ts +3 -7
- package/lib/artifacts/store.ts +2 -5
- package/lib/chat/drivers/pi-approvals.ts +2 -2
- package/lib/chat/worker/protocol.ts +2 -2
- package/lib/connect/config.ts +2 -3
- package/lib/db/backup.ts +2 -2
- package/lib/db/migrations/20261018T1600_health_boot_deploys.ts +13 -0
- package/lib/dev-instance/processInfo.ts +48 -27
- package/lib/dev-instance/startDevServer.ts +1 -1
- package/lib/dev-instance/stopDevServer.ts +20 -17
- package/lib/dev-servers.ts +2 -1
- package/lib/done/index.ts +2 -2
- package/lib/health/boots.ts +37 -0
- package/lib/health/ci.ts +23 -2
- package/lib/health/detectors.ts +2 -1
- package/lib/health/evidence.ts +1 -1
- package/lib/health/facts.ts +3 -3
- package/lib/health/fixtures.ts +1 -0
- package/lib/health/tick.ts +6 -1
- package/lib/health/types.ts +2 -0
- package/lib/orchestrator/home.ts +2 -5
- package/lib/orchestrator/shell-guard.ts +2 -0
- package/lib/orchestrator/write-guard.ts +3 -2
- package/lib/plans/features.ts +2 -2
- package/lib/program-status/claude-hooks.ts +2 -2
- package/lib/program-status/tap.ts +2 -2
- package/lib/project-config/database.ts +71 -16
- package/lib/project-config/session.ts +18 -0
- package/lib/security/tailnet-https.ts +2 -2
- package/lib/session-artifacts/collect.ts +18 -1
- package/lib/session-artifacts/keep.ts +2 -4
- package/lib/session-audit/store.ts +2 -5
- package/lib/session-logs/folder.ts +2 -3
- package/lib/sessions/launch.ts +2 -2
- package/lib/sessions/leftovers.ts +61 -0
- package/lib/simulator/agent-device.ts +2 -2
- package/lib/tasks/finish.ts +2 -2
- package/lib/tasks/import.ts +3 -5
- package/lib/tasks/move.ts +2 -2
- package/lib/tasks/moved-away.ts +2 -0
- package/lib/tasks/state.ts +7 -3
- package/lib/tasks/ui-brief.ts +2 -2
- package/lib/worktree-deps/fastcopy.ts +2 -2
- package/lib/worktree-deps/spare.ts +2 -2
- package/lib/worktree-sweep/caches.ts +123 -0
- package/lib/worktree-sweep/databases.ts +222 -0
- package/lib/worktree-sweep/du.ts +13 -0
- package/lib/worktree-sweep/extras.ts +188 -0
- package/lib/worktree-sweep/findOrphans.ts +19 -8
- package/lib/worktree-sweep/report.ts +85 -0
- package/lib/worktree-sweep/runSweep.ts +46 -13
- package/lib/worktree-sweep/settings.ts +24 -0
- package/lib/worktree-sweep/state.ts +5 -0
- package/lib/worktree-sweep/store.ts +11 -0
- package/lib/worktree-sweep/types.ts +44 -1
- package/lib/worktrees.ts +3 -0
- package/package.json +2 -1
- package/scripts/disk-dry-run.ts +57 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The second half of a run: what tasks leave beside their worktrees. Found
|
|
3
|
+
* by databases.ts and caches.ts, removed here only when the full clean-up is
|
|
4
|
+
* on; otherwise listed as pending, with sizes, for a dry run's report.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import os from "os";
|
|
9
|
+
import path from "path";
|
|
10
|
+
import { SPARE_ROOT } from "../worktree-deps/spare";
|
|
11
|
+
import duBytes from "./du";
|
|
12
|
+
import { pgRun } from "../project-config/database";
|
|
13
|
+
import {
|
|
14
|
+
declaredDatabases,
|
|
15
|
+
dropTaskDatabase,
|
|
16
|
+
findTaskDatabases,
|
|
17
|
+
hashesOf,
|
|
18
|
+
knownSessionIds,
|
|
19
|
+
liveHashes,
|
|
20
|
+
liveSessionIds,
|
|
21
|
+
} from "./databases";
|
|
22
|
+
import { findCacheLeftovers, workspaceFromPlist } from "./caches";
|
|
23
|
+
import type { SweepDeps } from "./runSweep";
|
|
24
|
+
import type {
|
|
25
|
+
Candidate,
|
|
26
|
+
SweepRemoved,
|
|
27
|
+
SweepSkipped,
|
|
28
|
+
SweepSummary,
|
|
29
|
+
SweepTotals,
|
|
30
|
+
} from "./types";
|
|
31
|
+
import { WORKTREES_DIR } from "../worktrees";
|
|
32
|
+
|
|
33
|
+
export interface Extras {
|
|
34
|
+
candidates: Candidate[];
|
|
35
|
+
skipped: SweepSkipped[];
|
|
36
|
+
drop: (c: Candidate) => Promise<boolean>;
|
|
37
|
+
spares: { count: number; bytes: number };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const dirCount = (root: string) => {
|
|
41
|
+
try {
|
|
42
|
+
return fs
|
|
43
|
+
.readdirSync(root, { withFileTypes: true })
|
|
44
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith(".")).length;
|
|
45
|
+
} catch {
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// The size of a whole root (every worktree, every spare) takes minutes of
|
|
51
|
+
// disk reading, so it is measured at most every six hours, or again on a
|
|
52
|
+
// dry run, which is what asks for fresh numbers.
|
|
53
|
+
const ROOT_SIZE_TTL_MS = 6 * 3600_000;
|
|
54
|
+
const rootSizes = new Map<string, { at: number; bytes: number }>();
|
|
55
|
+
|
|
56
|
+
export async function cachedRootSize(
|
|
57
|
+
dir: string,
|
|
58
|
+
size: (dir: string) => Promise<number>,
|
|
59
|
+
fresh: boolean,
|
|
60
|
+
now = Date.now()
|
|
61
|
+
): Promise<number> {
|
|
62
|
+
const hit = rootSizes.get(dir);
|
|
63
|
+
if (hit && !fresh && now - hit.at < ROOT_SIZE_TTL_MS) return hit.bytes;
|
|
64
|
+
const bytes = await size(dir);
|
|
65
|
+
// 0 is what a failed or timed-out du reports: try again next time.
|
|
66
|
+
if (bytes > 0) rootSizes.set(dir, { at: now, bytes });
|
|
67
|
+
return bytes;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const forgetRootSizes = () => rootSizes.clear();
|
|
71
|
+
|
|
72
|
+
export async function defaultExtras(
|
|
73
|
+
opts: { fresh?: boolean } = {}
|
|
74
|
+
): Promise<Extras> {
|
|
75
|
+
const { servers, froms } = declaredDatabases();
|
|
76
|
+
const ids = liveSessionIds();
|
|
77
|
+
const size = (dir: string) => duBytes(dir);
|
|
78
|
+
const dbs = await findTaskDatabases({
|
|
79
|
+
now: Date.now(),
|
|
80
|
+
run: pgRun,
|
|
81
|
+
servers,
|
|
82
|
+
live: liveHashes(ids),
|
|
83
|
+
known: hashesOf(knownSessionIds()),
|
|
84
|
+
froms,
|
|
85
|
+
});
|
|
86
|
+
const derivedRoot = path.join(
|
|
87
|
+
os.homedir(),
|
|
88
|
+
"Library",
|
|
89
|
+
"Developer",
|
|
90
|
+
"Xcode",
|
|
91
|
+
"DerivedData"
|
|
92
|
+
);
|
|
93
|
+
const caches = await findCacheLeftovers({
|
|
94
|
+
now: Date.now(),
|
|
95
|
+
spareRoot: SPARE_ROOT,
|
|
96
|
+
tmpRoot: os.tmpdir(),
|
|
97
|
+
derivedRoot: process.platform === "darwin" ? derivedRoot : "",
|
|
98
|
+
worktreesRoot: WORKTREES_DIR,
|
|
99
|
+
liveIds: ids,
|
|
100
|
+
workspaceOf: workspaceFromPlist,
|
|
101
|
+
size,
|
|
102
|
+
});
|
|
103
|
+
const roots = [SPARE_ROOT, os.tmpdir(), derivedRoot];
|
|
104
|
+
return {
|
|
105
|
+
candidates: [...dbs.candidates, ...caches.candidates],
|
|
106
|
+
skipped: [...dbs.skipped, ...caches.skipped],
|
|
107
|
+
spares: {
|
|
108
|
+
count: dirCount(SPARE_ROOT),
|
|
109
|
+
bytes: await cachedRootSize(SPARE_ROOT, size, !!opts.fresh),
|
|
110
|
+
},
|
|
111
|
+
drop: async (c) => {
|
|
112
|
+
if (c.kind === "database") return dropTaskDatabase(c, froms);
|
|
113
|
+
const target = path.resolve(c.path);
|
|
114
|
+
if (!roots.some((r) => r && path.dirname(target) === path.resolve(r)))
|
|
115
|
+
return false;
|
|
116
|
+
await fs.promises.rm(target, { recursive: true, force: true });
|
|
117
|
+
return !fs.existsSync(target);
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Removes (or, off or dry, only lists) what the extras found.
|
|
123
|
+
export async function findExtras(
|
|
124
|
+
deps: SweepDeps,
|
|
125
|
+
summary: SweepSummary,
|
|
126
|
+
opts: { armed: boolean; dryRun: boolean }
|
|
127
|
+
): Promise<{ totals: SweepTotals }> {
|
|
128
|
+
const x = await deps.extras({ fresh: opts.dryRun });
|
|
129
|
+
summary.skipped.push(...x.skipped);
|
|
130
|
+
for (const c of x.candidates) {
|
|
131
|
+
const item: SweepRemoved = {
|
|
132
|
+
path: c.path,
|
|
133
|
+
kind: c.kind,
|
|
134
|
+
why: c.why ?? "",
|
|
135
|
+
bytes: c.bytes ?? (await deps.size(c.path)),
|
|
136
|
+
};
|
|
137
|
+
if (!opts.armed) {
|
|
138
|
+
summary.pending!.push(item);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
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
|
+
});
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (!opts.dryRun && !(await x.drop(c).catch(() => false))) {
|
|
150
|
+
summary.skipped.push({
|
|
151
|
+
path: c.path,
|
|
152
|
+
kind: c.kind,
|
|
153
|
+
reason: "removing it failed",
|
|
154
|
+
});
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
summary.removed.push(item);
|
|
158
|
+
summary.freedBytes += item.bytes;
|
|
159
|
+
}
|
|
160
|
+
const found = (kind: Candidate["kind"]) => {
|
|
161
|
+
const items = [...summary.removed, ...(summary.pending ?? [])].filter(
|
|
162
|
+
(i) => i.kind === kind
|
|
163
|
+
);
|
|
164
|
+
return {
|
|
165
|
+
count: items.length,
|
|
166
|
+
bytes: items.reduce((n, i) => n + i.bytes, 0),
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
return {
|
|
170
|
+
totals: {
|
|
171
|
+
worktrees: {
|
|
172
|
+
count: dirCount(deps.root),
|
|
173
|
+
bytes: await cachedRootSize(deps.root, deps.size, opts.dryRun),
|
|
174
|
+
},
|
|
175
|
+
databases: found("database"),
|
|
176
|
+
spares: x.spares,
|
|
177
|
+
temp: found("temp"),
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// For tests, and for a machine without the extras: nothing found.
|
|
183
|
+
export const noExtras = async (): Promise<Extras> => ({
|
|
184
|
+
candidates: [],
|
|
185
|
+
skipped: [],
|
|
186
|
+
drop: async () => false,
|
|
187
|
+
spares: { count: 0, bytes: 0 },
|
|
188
|
+
});
|
|
@@ -61,8 +61,16 @@ function gitdirOf(dir: string): string | null {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
// The server's own temp folder differs from a test run's (launchd, systemd),
|
|
65
|
+
// so the usual system ones count too.
|
|
66
|
+
const TEMP_ROOTS = [
|
|
67
|
+
/^\/(?:private\/)?tmp(?:\/|$)/,
|
|
68
|
+
/^\/(?:private\/)?var\/folders\/[^/]+\/[^/]+\/T(?:\/|$)/,
|
|
69
|
+
/^\/var\/tmp(?:\/|$)/,
|
|
70
|
+
];
|
|
64
71
|
const inTemp = (p: string) =>
|
|
65
|
-
[os.tmpdir(), real(os.tmpdir())].some((t) => inFolder(p, t))
|
|
72
|
+
[os.tmpdir(), real(os.tmpdir())].some((t) => inFolder(p, t)) ||
|
|
73
|
+
TEMP_ROOTS.some((r) => r.test(p));
|
|
66
74
|
|
|
67
75
|
// Nothing in it but what AgentOS placed or rebuilds: no work to lose.
|
|
68
76
|
export const holdsNothing = (dir: string) =>
|
|
@@ -103,7 +111,16 @@ export async function findOrphans(deps: OrphanDeps): Promise<{
|
|
|
103
111
|
if (mine.has(real(dir))) continue;
|
|
104
112
|
const skip = (reason: string) =>
|
|
105
113
|
skipped.push({ path: dir, kind: "orphan", reason });
|
|
106
|
-
|
|
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
|
+
// A test's folder whose repository is gone is nobody's, however young:
|
|
121
|
+
// new ones arrive with every run of an old checkout's tests.
|
|
122
|
+
const abandoned = leak && !registered;
|
|
123
|
+
if (!abandoned && deps.now - newestMs(dir) < days * DAY_MS) {
|
|
107
124
|
skip(`touched less than ${days} day${days === 1 ? "" : "s"} ago`);
|
|
108
125
|
continue;
|
|
109
126
|
}
|
|
@@ -111,12 +128,6 @@ export async function findOrphans(deps: OrphanDeps): Promise<{
|
|
|
111
128
|
skip("a terminal is open in it");
|
|
112
129
|
continue;
|
|
113
130
|
}
|
|
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
131
|
if (leak) {
|
|
121
132
|
candidates.push({
|
|
122
133
|
path: dir,
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The list a dry run writes before anything is removed: what would go, what
|
|
3
|
+
* stays and why, with sizes, for a person to read before turning the full
|
|
4
|
+
* clean-up on. One file per day under ~/.agent-os/audits/disk-cleanup.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import path from "path";
|
|
9
|
+
import { agentosHome } from "../agentos-home";
|
|
10
|
+
import type { SweepKind, SweepRemoved, SweepSummary } from "./types";
|
|
11
|
+
|
|
12
|
+
export const mb = (b: number) =>
|
|
13
|
+
b >= 1024 ** 3
|
|
14
|
+
? `${(b / 1024 ** 3).toFixed(2)} GB`
|
|
15
|
+
: `${(b / 1024 ** 2).toFixed(1)} MB`;
|
|
16
|
+
|
|
17
|
+
const TITLES: Record<SweepKind, string> = {
|
|
18
|
+
settled: "Worktrees of finished tasks",
|
|
19
|
+
orphan: "Worktree folders no session owns",
|
|
20
|
+
"test-leak": "Throwaway repositories left by tests",
|
|
21
|
+
database: "Task databases",
|
|
22
|
+
spare: "Dependency spares",
|
|
23
|
+
temp: "Temp folders",
|
|
24
|
+
"derived-data": "Xcode build folders of removed worktrees",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function section(kind: SweepKind, items: SweepRemoved[]): string {
|
|
28
|
+
const bytes = items.reduce((n, i) => n + i.bytes, 0);
|
|
29
|
+
const rows = items
|
|
30
|
+
.sort((a, b) => b.bytes - a.bytes)
|
|
31
|
+
.map((i) => `- \`${i.path}\` · ${mb(i.bytes)} · ${i.why}`);
|
|
32
|
+
return `## ${TITLES[kind]}: ${items.length} (${mb(bytes)})\n\n${rows.join("\n")}\n`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function renderDiskReport(summary: SweepSummary, at: Date): string {
|
|
36
|
+
const all = [...summary.removed, ...(summary.pending ?? [])];
|
|
37
|
+
const kinds = Object.keys(TITLES) as SweepKind[];
|
|
38
|
+
const total = all.reduce((n, i) => n + i.bytes, 0);
|
|
39
|
+
const why = new Map<string, number>();
|
|
40
|
+
for (const k of summary.skipped) {
|
|
41
|
+
const r = k.reason.replace(/\(.*\)/, "").trim();
|
|
42
|
+
why.set(r, (why.get(r) ?? 0) + 1);
|
|
43
|
+
}
|
|
44
|
+
return [
|
|
45
|
+
`# Disk clean-up dry run, ${at.toISOString()}`,
|
|
46
|
+
"",
|
|
47
|
+
`Would remove ${all.length} items, ${mb(total)}. Nothing was removed.`,
|
|
48
|
+
`Full clean-up is ${summary.armed ? "on" : "off"} on this machine.`,
|
|
49
|
+
"",
|
|
50
|
+
...kinds
|
|
51
|
+
.map((k) => all.filter((i) => i.kind === k))
|
|
52
|
+
.filter((items) => items.length)
|
|
53
|
+
.map((items) => section(items[0].kind, items)),
|
|
54
|
+
`## Kept: ${summary.skipped.length}`,
|
|
55
|
+
"",
|
|
56
|
+
...[...why].map(([r, n]) => `- ${n} × ${r}`),
|
|
57
|
+
"",
|
|
58
|
+
"### Kept, in detail",
|
|
59
|
+
"",
|
|
60
|
+
...summary.skipped
|
|
61
|
+
.filter((s) => !/less than \d+ day/.test(s.reason))
|
|
62
|
+
.slice(0, 300)
|
|
63
|
+
.map((s) => `- \`${s.path}\` · ${s.reason}`),
|
|
64
|
+
"",
|
|
65
|
+
].join("\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A second dry run the same day gets -2, -3, never overwrites the first.
|
|
69
|
+
export function writeDiskReport(
|
|
70
|
+
summary: SweepSummary,
|
|
71
|
+
at = new Date(),
|
|
72
|
+
dir = agentosHome("audits", "disk-cleanup")
|
|
73
|
+
): string {
|
|
74
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
75
|
+
const day = [
|
|
76
|
+
at.getFullYear(),
|
|
77
|
+
String(at.getMonth() + 1).padStart(2, "0"),
|
|
78
|
+
String(at.getDate()).padStart(2, "0"),
|
|
79
|
+
].join("-");
|
|
80
|
+
let file = path.join(dir, `${day}.md`);
|
|
81
|
+
for (let n = 2; fs.existsSync(file); n++)
|
|
82
|
+
file = path.join(dir, `${day}-${n}.md`);
|
|
83
|
+
fs.writeFileSync(file, renderDiskReport(summary, at), { mode: 0o600 });
|
|
84
|
+
return file;
|
|
85
|
+
}
|
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
|
|
7
7
|
import fs from "fs";
|
|
8
8
|
import path from "path";
|
|
9
|
-
import { execFile } from "child_process";
|
|
10
9
|
import { db, type Session } from "../db";
|
|
11
10
|
import { git as runGit } from "../git-exec";
|
|
12
11
|
import { run as runCmd } from "../tasks/gh";
|
|
@@ -19,10 +18,14 @@ import {
|
|
|
19
18
|
type SettledDeps,
|
|
20
19
|
} from "./findSettled";
|
|
21
20
|
import { findOrphans, holdsNothing } from "./findOrphans";
|
|
21
|
+
import duBytes from "./du";
|
|
22
|
+
import { findExtras, defaultExtras, type Extras } from "./extras";
|
|
23
|
+
import { reaperArmed } from "./settings";
|
|
24
|
+
import { writeDiskReport } from "./report";
|
|
22
25
|
import { judgeWorktree } from "./judge";
|
|
23
26
|
import { openPaneFolders } from "./panes";
|
|
24
27
|
import { finishRun, recordSwept, startRun } from "./store";
|
|
25
|
-
import type
|
|
28
|
+
import { ARMED_KINDS, type Candidate, type SweepSummary } from "./types";
|
|
26
29
|
|
|
27
30
|
export interface SweepDeps {
|
|
28
31
|
now: number;
|
|
@@ -33,19 +36,15 @@ export interface SweepDeps {
|
|
|
33
36
|
prHead: (repo: string, number: number) => Promise<string | null>;
|
|
34
37
|
// Stop early when the machine is busy.
|
|
35
38
|
busy: () => boolean;
|
|
39
|
+
// Task databases, spares, temp folders and build caches.
|
|
40
|
+
extras: (opts?: { fresh?: boolean }) => Promise<Extras>;
|
|
41
|
+
// Written by a dry run: where its list goes.
|
|
42
|
+
report: (summary: SweepSummary) => string | null;
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
const git = (cwd: string, ...args: string[]) =>
|
|
39
46
|
runGit(cwd, args, { timeout: 30_000 }).then((o) => o.trim());
|
|
40
47
|
|
|
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
48
|
async function prHeadOf(repo: string, number: number): Promise<string | null> {
|
|
50
49
|
try {
|
|
51
50
|
const out = await runCmd(
|
|
@@ -73,9 +72,18 @@ export const defaultSweepDeps = (): SweepDeps => ({
|
|
|
73
72
|
root: WORKTREES_DIR,
|
|
74
73
|
panes: openPaneFolders,
|
|
75
74
|
settled: defaultSettledDeps(),
|
|
76
|
-
size: duBytes,
|
|
75
|
+
size: (dir) => duBytes(dir, 120_000),
|
|
77
76
|
prHead: prHeadOf,
|
|
78
77
|
busy: () => currentLevel() === "red",
|
|
78
|
+
extras: defaultExtras,
|
|
79
|
+
report: (summary) => {
|
|
80
|
+
try {
|
|
81
|
+
return writeDiskReport(summary);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
console.error("[worktree-sweep] report not written:", error);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
},
|
|
79
87
|
});
|
|
80
88
|
|
|
81
89
|
// A path under the root, never the root itself or something outside it.
|
|
@@ -123,7 +131,14 @@ export async function sweep(
|
|
|
123
131
|
): Promise<{ runId: number; summary: SweepSummary }> {
|
|
124
132
|
const dryRun = !!opts.dryRun;
|
|
125
133
|
const runId = startRun(dryRun);
|
|
126
|
-
const
|
|
134
|
+
const armed = reaperArmed();
|
|
135
|
+
const summary: SweepSummary = {
|
|
136
|
+
removed: [],
|
|
137
|
+
skipped: [],
|
|
138
|
+
freedBytes: 0,
|
|
139
|
+
pending: [],
|
|
140
|
+
armed,
|
|
141
|
+
};
|
|
127
142
|
let error: string | null = null;
|
|
128
143
|
try {
|
|
129
144
|
const panes = await deps.panes();
|
|
@@ -155,6 +170,18 @@ export async function sweep(
|
|
|
155
170
|
reason,
|
|
156
171
|
sessionId: c.sessionId,
|
|
157
172
|
});
|
|
173
|
+
if (ARMED_KINDS.has(c.kind) && !armed) {
|
|
174
|
+
summary.pending!.push({
|
|
175
|
+
path: c.path,
|
|
176
|
+
kind: c.kind,
|
|
177
|
+
why:
|
|
178
|
+
c.kind === "test-leak"
|
|
179
|
+
? "a test's throwaway repository"
|
|
180
|
+
: "an orphan worktree",
|
|
181
|
+
bytes: await deps.size(c.path),
|
|
182
|
+
});
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
158
185
|
const repo = await repoOfSession(c);
|
|
159
186
|
const why =
|
|
160
187
|
c.kind === "test-leak"
|
|
@@ -203,9 +230,12 @@ export async function sweep(
|
|
|
203
230
|
summary.freedBytes += bytes;
|
|
204
231
|
}
|
|
205
232
|
if (!dryRun) await pruneProjects([...pruned]);
|
|
233
|
+
const extras = await findExtras(deps, summary, { armed, dryRun });
|
|
234
|
+
summary.totals = extras.totals;
|
|
206
235
|
} catch (e) {
|
|
207
236
|
error = e instanceof Error ? e.message : String(e);
|
|
208
237
|
}
|
|
238
|
+
if (dryRun) summary.report = deps.report(summary) ?? undefined;
|
|
209
239
|
finishRun(runId, summary, error);
|
|
210
240
|
console.log(describeRun(summary, dryRun, error));
|
|
211
241
|
return { runId, summary };
|
|
@@ -239,5 +269,8 @@ export function describeRun(
|
|
|
239
269
|
}
|
|
240
270
|
const why = [...reasons].map(([r, n]) => `${n} ${r}`).join("; ");
|
|
241
271
|
const leaks = s.removed.filter((r) => r.kind === "test-leak").length;
|
|
242
|
-
|
|
272
|
+
const pending = s.pending?.length
|
|
273
|
+
? `, ${s.pending.length} more (${gb(s.pending.reduce((n, p) => n + p.bytes, 0))}) wait for the full clean-up to be turned on`
|
|
274
|
+
: "";
|
|
275
|
+
return `[worktree-sweep] ${dryRun ? "would remove" : "removed"} ${s.removed.length} (${gb(s.freedBytes)}${leaks ? `, ${leaks} test leftovers` : ""})${pending}, skipped ${s.skipped.length}${why ? `: ${why}` : ""}${error ? `; failed: ${error}` : ""}`;
|
|
243
276
|
}
|
|
@@ -81,3 +81,27 @@ export function projectSweepOverrides(): Array<{
|
|
|
81
81
|
return days === null ? [] : [{ projectId, days }];
|
|
82
82
|
});
|
|
83
83
|
}
|
|
84
|
+
|
|
85
|
+
// The full clean-up (test leftovers, task databases, spares, temp folders,
|
|
86
|
+
// build caches) removes nothing until a person turns it on for this machine,
|
|
87
|
+
// after reading what a dry run found.
|
|
88
|
+
const ARMED_KEY = "worktree_sweep.reaper";
|
|
89
|
+
|
|
90
|
+
export function reaperArmed(): boolean {
|
|
91
|
+
const row = db
|
|
92
|
+
.prepare(`SELECT value FROM settings WHERE key = ?`)
|
|
93
|
+
.get(ARMED_KEY) as { value: string } | undefined;
|
|
94
|
+
if (!row) return false;
|
|
95
|
+
try {
|
|
96
|
+
return JSON.parse(row.value)?.armed === true;
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function setReaperArmed(armed: boolean): void {
|
|
103
|
+
db.prepare(
|
|
104
|
+
`INSERT INTO settings (key, value) VALUES (?, ?)
|
|
105
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
|
106
|
+
).run(ARMED_KEY, JSON.stringify({ armed, at: new Date().toISOString() }));
|
|
107
|
+
}
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
DEFAULT_SWEEP_DAYS,
|
|
4
4
|
globalSweepDays,
|
|
5
5
|
projectSweepOverrides,
|
|
6
|
+
reaperArmed,
|
|
6
7
|
} from "./settings";
|
|
7
8
|
import { recentRuns } from "./store";
|
|
8
9
|
import type { SweepRun } from "./types";
|
|
@@ -13,6 +14,9 @@ export interface SweepState {
|
|
|
13
14
|
defaultDays: number;
|
|
14
15
|
overrides: Array<{ projectId: string; name: string; days: number }>;
|
|
15
16
|
runs: SweepRun[];
|
|
17
|
+
// Whether the full clean-up (test leftovers, task databases, spares, temp
|
|
18
|
+
// folders, build caches) removes anything on this machine.
|
|
19
|
+
armed: boolean;
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
export function sweepState(): SweepState {
|
|
@@ -32,5 +36,6 @@ export function sweepState(): SweepState {
|
|
|
32
36
|
: []
|
|
33
37
|
),
|
|
34
38
|
runs: recentRuns(),
|
|
39
|
+
armed: reaperArmed(),
|
|
35
40
|
};
|
|
36
41
|
}
|
|
@@ -51,6 +51,17 @@ export function finishRun(
|
|
|
51
51
|
summary.skipped.length,
|
|
52
52
|
JSON.stringify({
|
|
53
53
|
...summary,
|
|
54
|
+
// The biggest first: they are what a person looks for.
|
|
55
|
+
removed: [...summary.removed]
|
|
56
|
+
.sort((a, b) => b.bytes - a.bytes)
|
|
57
|
+
.slice(0, DETAIL_CAP),
|
|
58
|
+
pending: [...(summary.pending ?? [])]
|
|
59
|
+
.sort((a, b) => b.bytes - a.bytes)
|
|
60
|
+
.slice(0, DETAIL_CAP),
|
|
61
|
+
pendingTotal: {
|
|
62
|
+
count: summary.pending?.length ?? 0,
|
|
63
|
+
bytes: (summary.pending ?? []).reduce((n, p) => n + p.bytes, 0),
|
|
64
|
+
},
|
|
54
65
|
skipped: summary.skipped.slice(0, DETAIL_CAP),
|
|
55
66
|
}),
|
|
56
67
|
error,
|
|
@@ -1,4 +1,21 @@
|
|
|
1
|
-
export type SweepKind =
|
|
1
|
+
export type SweepKind =
|
|
2
|
+
| "settled"
|
|
3
|
+
| "orphan"
|
|
4
|
+
| "test-leak"
|
|
5
|
+
// What a task left beside its checkout, found by the full clean-up.
|
|
6
|
+
| "database"
|
|
7
|
+
| "spare"
|
|
8
|
+
| "temp"
|
|
9
|
+
| "derived-data";
|
|
10
|
+
|
|
11
|
+
// Kinds that go only once the full clean-up is turned on.
|
|
12
|
+
export const ARMED_KINDS: ReadonlySet<SweepKind> = new Set([
|
|
13
|
+
"test-leak",
|
|
14
|
+
"database",
|
|
15
|
+
"spare",
|
|
16
|
+
"temp",
|
|
17
|
+
"derived-data",
|
|
18
|
+
]);
|
|
2
19
|
|
|
3
20
|
export interface SweepRemoved {
|
|
4
21
|
path: string;
|
|
@@ -15,10 +32,31 @@ export interface SweepSkipped {
|
|
|
15
32
|
sessionId?: string;
|
|
16
33
|
}
|
|
17
34
|
|
|
35
|
+
export interface CategoryTotal {
|
|
36
|
+
count: number;
|
|
37
|
+
bytes: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// What is on disk after the run, for the one-line summary.
|
|
41
|
+
export interface SweepTotals {
|
|
42
|
+
worktrees: CategoryTotal;
|
|
43
|
+
databases: CategoryTotal;
|
|
44
|
+
spares: CategoryTotal;
|
|
45
|
+
temp: CategoryTotal;
|
|
46
|
+
}
|
|
47
|
+
|
|
18
48
|
export interface SweepSummary {
|
|
19
49
|
removed: SweepRemoved[];
|
|
20
50
|
skipped: SweepSkipped[];
|
|
21
51
|
freedBytes: number;
|
|
52
|
+
// Found but left, because the full clean-up isn't turned on.
|
|
53
|
+
pending?: SweepRemoved[];
|
|
54
|
+
// The whole of pending, once a stored run keeps only its biggest.
|
|
55
|
+
pendingTotal?: CategoryTotal;
|
|
56
|
+
totals?: SweepTotals;
|
|
57
|
+
armed?: boolean;
|
|
58
|
+
// Where a dry run wrote its list.
|
|
59
|
+
report?: string;
|
|
22
60
|
}
|
|
23
61
|
|
|
24
62
|
export interface SweepRun {
|
|
@@ -50,4 +88,9 @@ export interface Candidate {
|
|
|
50
88
|
// The PR head a merge took, when the session has one.
|
|
51
89
|
prNumber?: number | null;
|
|
52
90
|
branch?: string | null;
|
|
91
|
+
// A database: the server it lives on, and why it's reapable.
|
|
92
|
+
server?: { host?: string; port?: number };
|
|
93
|
+
why?: string;
|
|
94
|
+
// Known without measuring (a database's size).
|
|
95
|
+
bytes?: number;
|
|
53
96
|
}
|
package/lib/worktrees.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Git Worktree management for isolated feature development
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { stopDevServer } from "./dev-instance/stopDevServer";
|
|
5
6
|
import * as path from "path";
|
|
6
7
|
import * as fs from "fs";
|
|
7
8
|
import * as os from "os";
|
|
@@ -301,6 +302,8 @@ export function deleteWorktree(
|
|
|
301
302
|
let marker: string | null = null;
|
|
302
303
|
const done = (async () => {
|
|
303
304
|
await branch;
|
|
305
|
+
// A dev server left running would outlive the folder it runs in.
|
|
306
|
+
await stopDevServer(key).catch(() => {});
|
|
304
307
|
// Before anything is removed, so no reader catches it half-deleted.
|
|
305
308
|
marker = markRemoving(key);
|
|
306
309
|
await removeWorktree(key, repo);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saadnvd1/agent-os",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.664",
|
|
4
4
|
"description": "Self-hosted web UI for managing Claude Code sessions",
|
|
5
5
|
"bin": {
|
|
6
6
|
"agent-os": "scripts/agent-os"
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"dev": "AGENTOS_DEV=1 tsx server.ts",
|
|
34
34
|
"dev:task": "tsx scripts/dev-task.ts",
|
|
35
35
|
"dev:stop": "tsx scripts/dev-stop.ts",
|
|
36
|
+
"disk:dry-run": "tsx scripts/disk-dry-run.ts",
|
|
36
37
|
"prebuild": "node scripts/gen-migrations-index.mjs",
|
|
37
38
|
"build": "next build",
|
|
38
39
|
"prestart": "node scripts/ensure-native.mjs && node scripts/gen-migrations-index.mjs",
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the disk clean-up would remove, without removing anything: runs a dry
|
|
3
|
+
* sweep against a copy of the live database and writes the list to
|
|
4
|
+
* ~/.agent-os/audits/disk-cleanup/<date>.md.
|
|
5
|
+
*
|
|
6
|
+
* npm run disk:dry-run -- --db <path to the live agent-os.db>
|
|
7
|
+
*
|
|
8
|
+
* The live database is opened read-only for the copy and never written; the
|
|
9
|
+
* copy is deleted afterwards. Nothing on disk or in Postgres is touched.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from "fs";
|
|
13
|
+
import os from "os";
|
|
14
|
+
import path from "path";
|
|
15
|
+
import Database from "better-sqlite3";
|
|
16
|
+
|
|
17
|
+
const arg = process.argv.indexOf("--db");
|
|
18
|
+
const live: string = (arg > 0 && process.argv[arg + 1]) || "";
|
|
19
|
+
if (!live || !fs.existsSync(live)) {
|
|
20
|
+
console.error("usage: npm run disk:dry-run -- --db <path to agent-os.db>");
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function main() {
|
|
25
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "aos-disk-dry-"));
|
|
26
|
+
const copy = path.join(dir, "agent-os.db");
|
|
27
|
+
try {
|
|
28
|
+
const src = new Database(live, { readonly: true });
|
|
29
|
+
await src.backup(copy);
|
|
30
|
+
src.close();
|
|
31
|
+
process.env.DB_PATH = copy;
|
|
32
|
+
|
|
33
|
+
const { sweep, defaultSweepDeps } =
|
|
34
|
+
await import("../lib/worktree-sweep/runSweep");
|
|
35
|
+
const { summary } = await sweep(
|
|
36
|
+
{ dryRun: true },
|
|
37
|
+
{ ...defaultSweepDeps(), busy: () => false }
|
|
38
|
+
);
|
|
39
|
+
const all = [...summary.removed, ...(summary.pending ?? [])];
|
|
40
|
+
const gb = (b: number) => `${(b / 1024 ** 3).toFixed(2)} GB`;
|
|
41
|
+
for (const kind of new Set(all.map((i) => i.kind))) {
|
|
42
|
+
const items = all.filter((i) => i.kind === kind);
|
|
43
|
+
console.log(
|
|
44
|
+
`${kind}: ${items.length} (${gb(items.reduce((n, i) => n + i.bytes, 0))})`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
console.log(`kept: ${summary.skipped.length}`);
|
|
48
|
+
console.log(`list: ${summary.report ?? "(not written)"}`);
|
|
49
|
+
} finally {
|
|
50
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
main().catch((error) => {
|
|
55
|
+
console.error(error);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
});
|