@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.
Files changed (70) hide show
  1. package/app/api/bus/branches/route.ts +25 -0
  2. package/app/api/bus/resurrect/route.test.ts +3 -6
  3. package/app/api/worktree-sweep/route.test.ts +40 -0
  4. package/app/api/worktree-sweep/route.ts +31 -0
  5. package/app/api/worktree-sweep/run/route.ts +26 -0
  6. package/components/Health/HealthPanel.tsx +4 -1
  7. package/components/Health/SweepRuns.tsx +125 -0
  8. package/components/Merge/GlobalMergeSection.tsx +2 -0
  9. package/components/Merge/ProjectMergeSection.tsx +2 -0
  10. package/components/Merge/WorktreeSweepSection.tsx +125 -0
  11. package/components/Settings/NoApprovalsSection.tsx +1 -1
  12. package/components/Settings/SettingsDialog.tsx +1 -1
  13. package/data/worktree-sweep/index.ts +56 -0
  14. package/lib/away/report.ts +1 -1
  15. package/lib/db/backup.ts +49 -0
  16. package/lib/db/migrations/20261015T1733_gate_loop_guard.ts +40 -0
  17. package/lib/db/migrations/20261015T1830_drop_moved_session_columns.ts +173 -0
  18. package/lib/db/migrations/20261015T1900_worktree_sweep.ts +30 -0
  19. package/lib/db/migrations/README.md +7 -3
  20. package/lib/db/session-parts/columns.ts +3 -3
  21. package/lib/db/session-parts/index.ts +1 -0
  22. package/lib/db/session-parts/rows.ts +69 -0
  23. package/lib/db/session-parts/view.ts +14 -7
  24. package/lib/db/session-parts/write.ts +4 -12
  25. package/lib/done/remote.ts +9 -20
  26. package/lib/orchestrator/armed.test-helpers.ts +228 -0
  27. package/lib/orchestrator/armed.ts +1 -1
  28. package/lib/orchestrator/ask-verdict.ts +111 -0
  29. package/lib/orchestrator/asks.ts +26 -0
  30. package/lib/orchestrator/brief.ts +2 -1
  31. package/lib/orchestrator/escalate.ts +25 -12
  32. package/lib/orchestrator/external-gates.ts +1 -0
  33. package/lib/orchestrator/gates.ts +94 -25
  34. package/lib/orchestrator/held-asks.ts +5 -8
  35. package/lib/orchestrator/held-review.ts +8 -4
  36. package/lib/orchestrator/loop-guard.ts +145 -0
  37. package/lib/orchestrator/merge-approvals.ts +2 -2
  38. package/lib/orchestrator/quick.ts +1 -1
  39. package/lib/orchestrator/review-prompt.ts +2 -0
  40. package/lib/orchestrator/review.ts +14 -4
  41. package/lib/orchestrator/signoff.test-helpers.ts +322 -0
  42. package/lib/orchestrator/signoff.ts +46 -10
  43. package/lib/orchestrator/testing.ts +11 -14
  44. package/lib/orchestrator/watcher.ts +4 -0
  45. package/lib/recipes/test-fixtures.ts +2 -3
  46. package/lib/resurrect/index.ts +20 -2
  47. package/lib/resurrect/swept.ts +61 -0
  48. package/lib/stacks/testing.ts +17 -16
  49. package/lib/tasks/finish.ts +20 -14
  50. package/lib/tasks/move-testing.ts +16 -7
  51. package/lib/tasks/pr-activity.ts +11 -2
  52. package/lib/tasks/prune-branches.ts +193 -0
  53. package/lib/tasks/prune-scope.ts +22 -0
  54. package/lib/tasks/remote-branch.ts +170 -0
  55. package/lib/tasks/testing.ts +13 -7
  56. package/lib/worktree-sweep/commits.ts +65 -0
  57. package/lib/worktree-sweep/findOrphans.ts +139 -0
  58. package/lib/worktree-sweep/findSettled.ts +112 -0
  59. package/lib/worktree-sweep/index.ts +10 -0
  60. package/lib/worktree-sweep/judge.ts +31 -0
  61. package/lib/worktree-sweep/panes.ts +18 -0
  62. package/lib/worktree-sweep/runSweep.ts +243 -0
  63. package/lib/worktree-sweep/settings.ts +83 -0
  64. package/lib/worktree-sweep/start.ts +64 -0
  65. package/lib/worktree-sweep/state.ts +36 -0
  66. package/lib/worktree-sweep/store.ts +96 -0
  67. package/lib/worktree-sweep/types.ts +53 -0
  68. package/package.json +1 -1
  69. package/scripts/screenshots/seed.ts +19 -18
  70. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.639",
3
+ "version": "0.3.644",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -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
- db.prepare(
265
- `INSERT INTO sessions (id, name, tmux_name, working_directory, project_id, agent_type, model, view,
266
- task_prompt, task_status, branch_name, base_branch, created_at, updated_at)
267
- VALUES (?, ?, ?, ?, ?, 'claude', 'opus', ?, ?, ?, ?, ?, datetime('now', ?), datetime('now', ?))`
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
- s.view ?? "terminal",
275
- s.task?.prompt ?? null,
276
- s.task?.status ?? null,
277
- s.task?.branch ?? null,
278
- s.task ? "main" : null,
279
- when,
280
- when
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.