@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,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
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export type SweepKind = "settled" | "orphan" | "test-leak";
|
|
2
|
+
|
|
3
|
+
export interface SweepRemoved {
|
|
4
|
+
path: string;
|
|
5
|
+
kind: SweepKind;
|
|
6
|
+
why: string;
|
|
7
|
+
bytes: number;
|
|
8
|
+
sessionId?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SweepSkipped {
|
|
12
|
+
path: string;
|
|
13
|
+
kind: SweepKind;
|
|
14
|
+
reason: string;
|
|
15
|
+
sessionId?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SweepSummary {
|
|
19
|
+
removed: SweepRemoved[];
|
|
20
|
+
skipped: SweepSkipped[];
|
|
21
|
+
freedBytes: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SweepRun {
|
|
25
|
+
id: number;
|
|
26
|
+
startedAt: string;
|
|
27
|
+
finishedAt: string | null;
|
|
28
|
+
dryRun: boolean;
|
|
29
|
+
removed: number;
|
|
30
|
+
freedBytes: number;
|
|
31
|
+
skipped: number;
|
|
32
|
+
details: SweepSummary | null;
|
|
33
|
+
error: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type DaysSource = "project" | "global" | "default";
|
|
37
|
+
|
|
38
|
+
export interface ResolvedDays {
|
|
39
|
+
days: number;
|
|
40
|
+
from: DaysSource;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A directory the sweep may remove, once it has been judged.
|
|
44
|
+
export interface Candidate {
|
|
45
|
+
path: string;
|
|
46
|
+
kind: SweepKind;
|
|
47
|
+
sessionId?: string;
|
|
48
|
+
// The main checkout git knows it by, when it is a registered worktree.
|
|
49
|
+
repo?: string;
|
|
50
|
+
// The PR head a merge took, when the session has one.
|
|
51
|
+
prNumber?: number | null;
|
|
52
|
+
branch?: string | null;
|
|
53
|
+
}
|
package/package.json
CHANGED
|
@@ -234,7 +234,7 @@ function tmux(args: string[]): void {
|
|
|
234
234
|
|
|
235
235
|
async function seedDb(): Promise<void> {
|
|
236
236
|
process.env.DB_PATH = DB_PATH;
|
|
237
|
-
const { getDb } = await import("../../lib/db");
|
|
237
|
+
const { getDb, insertSessionRow } = await import("../../lib/db");
|
|
238
238
|
const db = getDb();
|
|
239
239
|
const projectIds = new Map<string, string>();
|
|
240
240
|
const workspaceIds = new Map<string, string>();
|
|
@@ -261,24 +261,25 @@ async function seedDb(): Promise<void> {
|
|
|
261
261
|
sessionIds.set(s.key, id);
|
|
262
262
|
const tmuxName = `claude-${id}`;
|
|
263
263
|
const when = `-${s.minutesAgo} minutes`;
|
|
264
|
-
|
|
265
|
-
`
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
).run(
|
|
264
|
+
const at = (
|
|
265
|
+
db.prepare(`SELECT datetime('now', ?) AS t`).get(when) as { t: string }
|
|
266
|
+
).t;
|
|
267
|
+
insertSessionRow(db, {
|
|
269
268
|
id,
|
|
270
|
-
s.name,
|
|
271
|
-
tmuxName,
|
|
272
|
-
`~/code/${s.project}`,
|
|
273
|
-
projectIds.get(s.project),
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
s.
|
|
277
|
-
s.task?.
|
|
278
|
-
s.task
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
269
|
+
name: s.name,
|
|
270
|
+
tmux_name: tmuxName,
|
|
271
|
+
working_directory: `~/code/${s.project}`,
|
|
272
|
+
project_id: projectIds.get(s.project),
|
|
273
|
+
agent_type: "claude",
|
|
274
|
+
model: "opus",
|
|
275
|
+
view: s.view ?? "terminal",
|
|
276
|
+
task_prompt: s.task?.prompt ?? null,
|
|
277
|
+
task_status: s.task?.status ?? null,
|
|
278
|
+
branch_name: s.task?.branch ?? null,
|
|
279
|
+
base_branch: s.task ? "main" : null,
|
|
280
|
+
created_at: at,
|
|
281
|
+
updated_at: at,
|
|
282
|
+
});
|
|
282
283
|
if (s.chat) {
|
|
283
284
|
const insert = db.prepare(
|
|
284
285
|
`INSERT INTO chat_items (session_id, item_id, seq, data) VALUES (?, ?, ?, ?)`
|
package/server.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { recordBoot } from "./lib/health/boots";
|
|
|
18
18
|
import { realDeps, schedulesEnabled, startScheduler } from "./lib/schedules";
|
|
19
19
|
import { startSettling } from "./lib/recipes";
|
|
20
20
|
import { startAutoArchive } from "./lib/done/auto-archive";
|
|
21
|
+
import { startWorktreeSweep } from "./lib/worktree-sweep/start";
|
|
21
22
|
import { startUsageRollup, usageRollupEnabled } from "./lib/usage/nightly";
|
|
22
23
|
import { resumePhoneOutbox } from "./lib/notify";
|
|
23
24
|
import { resumePushOutbox } from "./lib/notify/push";
|
|
@@ -445,6 +446,8 @@ app.prepare().then(async () => {
|
|
|
445
446
|
startSettling();
|
|
446
447
|
// Sessions that sat in Done for 30 days are archived.
|
|
447
448
|
startAutoArchive();
|
|
449
|
+
// Settled worktrees older than the setting are removed; branches stay.
|
|
450
|
+
startWorktreeSweep();
|
|
448
451
|
resumePhoneOutbox();
|
|
449
452
|
resumePushOutbox();
|
|
450
453
|
// The usage ledger's daily rollup and its ratchet alerts.
|