@saadnvd1/agent-os 0.3.660 → 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/sessions/[id]/summarize/route.ts +3 -2
- 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 +6 -9
- package/lib/agents/server-env.ts +24 -0
- package/lib/artifacts/store.ts +2 -5
- package/lib/chat/drivers/claude.ts +3 -2
- package/lib/chat/drivers/codex.ts +3 -2
- package/lib/chat/drivers/copilot.ts +2 -1
- package/lib/chat/drivers/opencode.ts +3 -2
- package/lib/chat/drivers/pi-approvals.ts +2 -2
- package/lib/chat/drivers/pi.ts +3 -2
- package/lib/chat/worker/client.ts +18 -13
- 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/hosts/attach.ts +8 -1
- package/lib/orchestration.ts +3 -1
- 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/pr-generation.ts +2 -1
- 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/tmux/exec.ts +3 -1
- 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,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a task leaves outside its checkout and the database: dependency
|
|
3
|
+
* spares nobody took, its temp folder, a crashed preview browser's profile,
|
|
4
|
+
* and Xcode's build folder for a worktree that no longer exists. Each is
|
|
5
|
+
* rebuilt or recreated on demand, so the only rule is "old enough, and not
|
|
6
|
+
* in use".
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from "fs";
|
|
10
|
+
import path from "path";
|
|
11
|
+
import { execFile } from "child_process";
|
|
12
|
+
import { SESSION_TMP_PREFIX, tmpName } from "../sessions/leftovers";
|
|
13
|
+
import type { Candidate, SweepSkipped } from "./types";
|
|
14
|
+
|
|
15
|
+
const DAY_MS = 24 * 3600_000;
|
|
16
|
+
const SPARE_MIN_AGE_MS = 7 * DAY_MS;
|
|
17
|
+
const BUILDING_MIN_AGE_MS = 3600_000;
|
|
18
|
+
|
|
19
|
+
export interface CacheDeps {
|
|
20
|
+
now: number;
|
|
21
|
+
spareRoot: string;
|
|
22
|
+
tmpRoot: string;
|
|
23
|
+
derivedRoot: string;
|
|
24
|
+
worktreesRoot: string;
|
|
25
|
+
// Ids of the sessions still running on this machine.
|
|
26
|
+
liveIds: Set<string>;
|
|
27
|
+
workspaceOf: (derivedDir: string) => Promise<string | null>;
|
|
28
|
+
size: (dir: string) => Promise<number>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const workspaceFromPlist = (dir: string): Promise<string | null> =>
|
|
32
|
+
new Promise((resolve) =>
|
|
33
|
+
execFile(
|
|
34
|
+
"plutil",
|
|
35
|
+
[
|
|
36
|
+
"-extract",
|
|
37
|
+
"WorkspacePath",
|
|
38
|
+
"raw",
|
|
39
|
+
"-o",
|
|
40
|
+
"-",
|
|
41
|
+
path.join(dir, "info.plist"),
|
|
42
|
+
],
|
|
43
|
+
{ timeout: 10_000 },
|
|
44
|
+
(error, stdout) => resolve(error ? null : String(stdout).trim() || null)
|
|
45
|
+
)
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const subdirs = (root: string): string[] => {
|
|
49
|
+
try {
|
|
50
|
+
return fs
|
|
51
|
+
.readdirSync(root, { withFileTypes: true })
|
|
52
|
+
.filter((e) => e.isDirectory())
|
|
53
|
+
.map((e) => path.join(root, e.name));
|
|
54
|
+
} catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const mtime = (p: string) => {
|
|
60
|
+
try {
|
|
61
|
+
return fs.statSync(p).mtimeMs;
|
|
62
|
+
} catch {
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const inside = (root: string, p: string) => {
|
|
68
|
+
const rel = path.relative(path.resolve(root), path.resolve(p));
|
|
69
|
+
return !!rel && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export async function findCacheLeftovers(deps: CacheDeps): Promise<{
|
|
73
|
+
candidates: Candidate[];
|
|
74
|
+
skipped: SweepSkipped[];
|
|
75
|
+
}> {
|
|
76
|
+
const candidates: Candidate[] = [];
|
|
77
|
+
const skipped: SweepSkipped[] = [];
|
|
78
|
+
const add = async (dir: string, kind: Candidate["kind"], why: string) =>
|
|
79
|
+
candidates.push({ path: dir, kind, why, bytes: await deps.size(dir) });
|
|
80
|
+
|
|
81
|
+
for (const dir of subdirs(deps.spareRoot)) {
|
|
82
|
+
const building = path.basename(dir).includes(".");
|
|
83
|
+
const min = building ? BUILDING_MIN_AGE_MS : SPARE_MIN_AGE_MS;
|
|
84
|
+
if (deps.now - mtime(dir) >= min)
|
|
85
|
+
await add(
|
|
86
|
+
dir,
|
|
87
|
+
"spare",
|
|
88
|
+
building ? "an abandoned build" : "unused for a week"
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const livePrefixes = [...deps.liveIds].map(tmpName);
|
|
93
|
+
for (const dir of subdirs(deps.tmpRoot)) {
|
|
94
|
+
const name = path.basename(dir);
|
|
95
|
+
const isSession = name.startsWith(SESSION_TMP_PREFIX);
|
|
96
|
+
if (!isSession && !name.startsWith("agentos-preview-")) continue;
|
|
97
|
+
if (deps.now - mtime(dir) < DAY_MS) continue;
|
|
98
|
+
if (
|
|
99
|
+
isSession &&
|
|
100
|
+
livePrefixes.includes(name.slice(SESSION_TMP_PREFIX.length))
|
|
101
|
+
) {
|
|
102
|
+
skipped.push({
|
|
103
|
+
path: dir,
|
|
104
|
+
kind: "temp",
|
|
105
|
+
reason: "its session is running",
|
|
106
|
+
});
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
await add(
|
|
110
|
+
dir,
|
|
111
|
+
"temp",
|
|
112
|
+
isSession ? "its task is over" : "a preview browser's profile"
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const dir of subdirs(deps.derivedRoot)) {
|
|
117
|
+
const workspace = await deps.workspaceOf(dir);
|
|
118
|
+
if (!workspace || !inside(deps.worktreesRoot, workspace)) continue;
|
|
119
|
+
if (fs.existsSync(workspace)) continue;
|
|
120
|
+
await add(dir, "derived-data", "its worktree is gone");
|
|
121
|
+
}
|
|
122
|
+
return { candidates, skipped };
|
|
123
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task databases left behind: copies and what projects derived from them
|
|
3
|
+
* (`<copy>_test`), found by the `_aos_<12 hex>` token every session's name
|
|
4
|
+
* carries. A database goes only if its token belongs to no live session,
|
|
5
|
+
* nobody is connected, it isn't a project's declared source, and it is more
|
|
6
|
+
* than a day old. Anything without the token is never looked at.
|
|
7
|
+
*
|
|
8
|
+
* A session is over only when it is merged, dropped, done or moved away, or
|
|
9
|
+
* its row is gone: archiving and settling can be undone, and an interactive
|
|
10
|
+
* session has no end of its own, so neither keeps a database off the list.
|
|
11
|
+
*
|
|
12
|
+
* Proof of who made it: a copy must carry the `agentos session <id>` comment
|
|
13
|
+
* AgentOS gave it, and the id must hash to the token. Something a project
|
|
14
|
+
* derived from a copy (`<copy>_test`) has no such comment, so it also needs
|
|
15
|
+
* its token to belong to no session this machine has ever known, and the
|
|
16
|
+
* server to be local (a host that isn't localhost or a socket is never
|
|
17
|
+
* reaped on a name alone).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { db } from "../db";
|
|
21
|
+
import { loadProjectConfig } from "../project-config";
|
|
22
|
+
import { expandHome } from "../project-config/session";
|
|
23
|
+
import {
|
|
24
|
+
LOCAL_HOSTS,
|
|
25
|
+
MARK,
|
|
26
|
+
SOCKET_DIR,
|
|
27
|
+
pgEnv,
|
|
28
|
+
pgRun,
|
|
29
|
+
sessionHash,
|
|
30
|
+
taskHashOf,
|
|
31
|
+
type PgRun,
|
|
32
|
+
} from "../project-config/database";
|
|
33
|
+
import type { Candidate, SweepSkipped } from "./types";
|
|
34
|
+
|
|
35
|
+
const DAY_MS = 24 * 3600_000;
|
|
36
|
+
|
|
37
|
+
export interface PgServer {
|
|
38
|
+
host?: string;
|
|
39
|
+
port?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface DatabaseDeps {
|
|
43
|
+
now: number;
|
|
44
|
+
run: PgRun;
|
|
45
|
+
servers: PgServer[];
|
|
46
|
+
// Hashes of the sessions that still need their database.
|
|
47
|
+
live: Set<string>;
|
|
48
|
+
// Hashes of every session this machine has a row for, over or not.
|
|
49
|
+
known: Set<string>;
|
|
50
|
+
// Databases a project declares as its source, never reaped.
|
|
51
|
+
froms: Set<string>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const where = (s: PgServer) =>
|
|
55
|
+
`${s.host ?? "local server"}${s.port ? `:${s.port}` : ""}`;
|
|
56
|
+
|
|
57
|
+
// Sessions whose work isn't over: everything with a row except a task that
|
|
58
|
+
// merged, was dropped, finished or moved away.
|
|
59
|
+
export function liveSessionIds(): Set<string> {
|
|
60
|
+
const rows = db
|
|
61
|
+
.prepare(
|
|
62
|
+
`SELECT id FROM session_rows
|
|
63
|
+
WHERE task_status IS NULL
|
|
64
|
+
OR task_status NOT IN ('merged', 'dropped', 'done', 'moved')`
|
|
65
|
+
)
|
|
66
|
+
.all() as Array<{ id: string }>;
|
|
67
|
+
return new Set(rows.map((r) => r.id));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const knownSessionIds = (): Set<string> =>
|
|
71
|
+
new Set(
|
|
72
|
+
(
|
|
73
|
+
db.prepare(`SELECT id FROM session_rows`).all() as Array<{ id: string }>
|
|
74
|
+
).map((r) => r.id)
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
export const hashesOf = (ids: Iterable<string>): Set<string> =>
|
|
78
|
+
new Set([...ids].map(sessionHash));
|
|
79
|
+
|
|
80
|
+
export const liveHashes = (ids = liveSessionIds()): Set<string> =>
|
|
81
|
+
hashesOf(ids);
|
|
82
|
+
|
|
83
|
+
const isLocal = (s: PgServer) =>
|
|
84
|
+
!s.host || LOCAL_HOSTS.has(s.host) || SOCKET_DIR.test(s.host);
|
|
85
|
+
|
|
86
|
+
// Every project's declared database source and server.
|
|
87
|
+
export function declaredDatabases(): {
|
|
88
|
+
servers: PgServer[];
|
|
89
|
+
froms: Set<string>;
|
|
90
|
+
} {
|
|
91
|
+
const rows = db
|
|
92
|
+
.prepare(
|
|
93
|
+
`SELECT working_directory FROM projects WHERE working_directory IS NOT NULL`
|
|
94
|
+
)
|
|
95
|
+
.all() as Array<{ working_directory: string }>;
|
|
96
|
+
const servers = new Map<string, PgServer>([[":", {}]]);
|
|
97
|
+
const froms = new Set<string>();
|
|
98
|
+
for (const r of rows) {
|
|
99
|
+
try {
|
|
100
|
+
const d = loadProjectConfig(expandHome(r.working_directory)).config
|
|
101
|
+
.database;
|
|
102
|
+
if (!d) continue;
|
|
103
|
+
froms.add(d.from);
|
|
104
|
+
servers.set(`${d.host ?? ""}:${d.port ?? ""}`, {
|
|
105
|
+
host: d.host,
|
|
106
|
+
port: d.port,
|
|
107
|
+
});
|
|
108
|
+
} catch {
|
|
109
|
+
// A project whose config can't be read declares nothing.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { servers: [...servers.values()], froms };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const LIST = (age: boolean) =>
|
|
116
|
+
`select d.datname, pg_database_size(d.oid), coalesce(shobj_description(d.oid, 'pg_database'), ''),
|
|
117
|
+
(select count(*) from pg_stat_activity a where a.datname = d.datname),
|
|
118
|
+
${age ? `extract(epoch from (pg_stat_file('base/' || d.oid || '/PG_VERSION')).modification)::bigint` : "0"}
|
|
119
|
+
from pg_database d where d.datname ~ '_aos_[0-9a-f]{12}'`;
|
|
120
|
+
|
|
121
|
+
// Why this database can't be shown to be a session's, or null when it can.
|
|
122
|
+
function whoMade(
|
|
123
|
+
hash: string,
|
|
124
|
+
note: string,
|
|
125
|
+
known: Set<string>,
|
|
126
|
+
server: PgServer
|
|
127
|
+
): string | null {
|
|
128
|
+
const m = /^agentos session (\S+?)(?: \(copying\))?$/.exec(note);
|
|
129
|
+
if (m)
|
|
130
|
+
return sessionHash(m[1]) === hash
|
|
131
|
+
? null
|
|
132
|
+
: "its comment names another session";
|
|
133
|
+
if (note) return "it has a comment that isn't AgentOS's";
|
|
134
|
+
if (known.has(hash))
|
|
135
|
+
return "no AgentOS comment on it, and its session is known here";
|
|
136
|
+
return isLocal(server) ? null : "no AgentOS comment, and not a local server";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function list(deps: DatabaseDeps, server: PgServer) {
|
|
140
|
+
const env = pgEnv(server);
|
|
141
|
+
for (const age of [true, false]) {
|
|
142
|
+
const { code, out } = await deps.run(
|
|
143
|
+
"psql",
|
|
144
|
+
["-X", "-d", "postgres", "-tAq", "-F", "\t", "-c", LIST(age)],
|
|
145
|
+
env
|
|
146
|
+
);
|
|
147
|
+
if (code === 0)
|
|
148
|
+
return out
|
|
149
|
+
.split("\n")
|
|
150
|
+
.filter(Boolean)
|
|
151
|
+
.map((line) => {
|
|
152
|
+
const [name, size, note, conns, at] = line.split("\t");
|
|
153
|
+
return {
|
|
154
|
+
name,
|
|
155
|
+
bytes: Number(size) || 0,
|
|
156
|
+
note: note ?? "",
|
|
157
|
+
conns: Number(conns) || 0,
|
|
158
|
+
madeAt: Number(at) * 1000 || 0,
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
if (code === 127) return "psql isn't installed";
|
|
162
|
+
if (!age) return `Postgres isn't answering (${out.slice(0, 120)})`;
|
|
163
|
+
}
|
|
164
|
+
return "Postgres isn't answering";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function findTaskDatabases(deps: DatabaseDeps): Promise<{
|
|
168
|
+
candidates: Candidate[];
|
|
169
|
+
skipped: SweepSkipped[];
|
|
170
|
+
}> {
|
|
171
|
+
const candidates: Candidate[] = [];
|
|
172
|
+
const skipped: SweepSkipped[] = [];
|
|
173
|
+
for (const server of deps.servers) {
|
|
174
|
+
const found = await list(deps, server);
|
|
175
|
+
if (typeof found === "string") {
|
|
176
|
+
skipped.push({
|
|
177
|
+
path: where(server),
|
|
178
|
+
kind: "database",
|
|
179
|
+
reason: found,
|
|
180
|
+
});
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
for (const d of found) {
|
|
184
|
+
const hash = taskHashOf(d.name);
|
|
185
|
+
const skip = (reason: string) =>
|
|
186
|
+
skipped.push({ path: d.name, kind: "database", reason });
|
|
187
|
+
if (!hash) continue;
|
|
188
|
+
const proof = whoMade(hash, d.note, deps.known, server);
|
|
189
|
+
if (deps.froms.has(d.name)) skip("a project's declared source");
|
|
190
|
+
else if (proof) skip(proof);
|
|
191
|
+
else if (deps.live.has(hash)) skip("its session is still running");
|
|
192
|
+
else if (d.conns > 0) skip(`${d.conns} connection(s) open`);
|
|
193
|
+
else if (d.madeAt && deps.now - d.madeAt < DAY_MS)
|
|
194
|
+
skip("made less than a day ago");
|
|
195
|
+
else
|
|
196
|
+
candidates.push({
|
|
197
|
+
path: d.name,
|
|
198
|
+
kind: "database",
|
|
199
|
+
server,
|
|
200
|
+
bytes: d.bytes,
|
|
201
|
+
why: `no session needs it${d.madeAt ? "" : " (age unknown)"}`,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return { candidates, skipped };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Re-checked here as well: only a name carrying the token is ever dropped,
|
|
209
|
+
// and a plain drop, which Postgres refuses while anyone is connected.
|
|
210
|
+
export async function dropTaskDatabase(
|
|
211
|
+
c: Candidate,
|
|
212
|
+
froms: Set<string>,
|
|
213
|
+
run: PgRun = pgRun
|
|
214
|
+
): Promise<boolean> {
|
|
215
|
+
if (!taskHashOf(c.path) || froms.has(c.path)) return false;
|
|
216
|
+
const { code } = await run(
|
|
217
|
+
"dropdb",
|
|
218
|
+
["--if-exists", "--", c.path],
|
|
219
|
+
pgEnv(c.server ?? {})
|
|
220
|
+
);
|
|
221
|
+
return code === 0;
|
|
222
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
|
|
3
|
+
// Disk use of a folder in bytes; 0 when it can't be measured in time.
|
|
4
|
+
export default function duBytes(
|
|
5
|
+
dir: string,
|
|
6
|
+
timeout = 300_000
|
|
7
|
+
): Promise<number> {
|
|
8
|
+
return new Promise((resolve) =>
|
|
9
|
+
execFile("du", ["-sk", dir], { timeout }, (error, stdout) =>
|
|
10
|
+
resolve(error ? 0 : (parseInt(String(stdout), 10) || 0) * 1024)
|
|
11
|
+
)
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -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
|
+
}
|