@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,25 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { getDoneTarget } from "@/lib/done";
3
+ import { scopeOf } from "@/lib/done/bulk";
4
+ import { prunableRepos } from "@/lib/tasks/prune-scope";
5
+ import { pruneRepo, pruneText } from "@/lib/tasks/prune-branches";
6
+
7
+ // `aos branches prune`, from inside a session: reaches only the caller's
8
+ // workspace (or its project when it has none). Lists unless `yes` is true.
9
+ export async function POST(request: NextRequest) {
10
+ try {
11
+ const { from = null, yes = false } = await request.json();
12
+ if (typeof from !== "string" || !from)
13
+ throw new Error("aos branches runs from inside an AgentOS session");
14
+ const scope = scopeOf(getDoneTarget(from));
15
+ const repos = await prunableRepos(scope);
16
+ if (!repos.length) throw new Error("No GitHub projects in scope");
17
+ const results = [];
18
+ for (const repo of repos)
19
+ results.push(await pruneRepo(repo, { yes: yes === true }));
20
+ return NextResponse.json({ summary: pruneText(results, yes === true) });
21
+ } catch (error) {
22
+ const message = error instanceof Error ? error.message : String(error);
23
+ return NextResponse.json({ error: message }, { status: 409 });
24
+ }
25
+ }
@@ -1,4 +1,5 @@
1
1
  import { NextRequest } from "next/server";
2
+ import { updateSessionRow } from "@/lib/db";
2
3
  import { describe, expect, it, vi } from "vitest";
3
4
 
4
5
  const resurrect = vi.hoisted(() => vi.fn());
@@ -26,12 +27,8 @@ describe("POST /api/bus/resurrect (aos resurrect)", () => {
26
27
  it("reaches only the caller's workspace, and lists what ended with no session", async () => {
27
28
  const mine = seedWorkspace("Bus mine");
28
29
  const other = seedWorkspace("Bus other");
29
- db.prepare(`UPDATE sessions SET task_status = 'dropped' WHERE id = ?`).run(
30
- mine.task
31
- );
32
- db.prepare(`UPDATE sessions SET task_status = 'dropped' WHERE id = ?`).run(
33
- other.task
34
- );
30
+ updateSessionRow(db, mine.task, { task_status: "dropped" });
31
+ updateSessionRow(db, other.task, { task_status: "dropped" });
35
32
  const list = await (await call({ from: mine.chat })).json();
36
33
  expect(list.sessions.map((s: { id: string }) => s.id)).toEqual([mine.task]);
37
34
 
@@ -0,0 +1,40 @@
1
+ import { randomUUID } from "crypto";
2
+ import { describe, expect, it } from "vitest";
3
+ import { NextRequest } from "next/server";
4
+
5
+ const { GET, PUT } = await import("./route");
6
+ const { createProject } = await import("@/lib/projects");
7
+
8
+ const put = (body: unknown) =>
9
+ PUT(
10
+ new NextRequest("http://localhost/api/worktree-sweep", {
11
+ method: "PUT",
12
+ body: JSON.stringify(body),
13
+ })
14
+ );
15
+
16
+ describe("/api/worktree-sweep", () => {
17
+ it("round-trips the global number and a project's own", async () => {
18
+ const p = createProject({
19
+ name: `app-${randomUUID().slice(0, 8)}`,
20
+ workingDirectory: "/tmp/app",
21
+ });
22
+ expect((await (await GET()).json()).days).toBeNull();
23
+ expect((await (await put({ days: 3 })).json()).days).toBe(3);
24
+ const state = await (await put({ days: 0, projectId: p.id })).json();
25
+ expect(state.overrides).toEqual([
26
+ { projectId: p.id, name: p.name, days: 0 },
27
+ ]);
28
+ const cleared = await (await put({ days: null, projectId: p.id })).json();
29
+ expect(cleared.overrides).toEqual([]);
30
+ expect((await (await put({ days: null })).json()).days).toBeNull();
31
+ });
32
+
33
+ it("refuses a number that isn't a whole 0-365", async () => {
34
+ for (const days of [-1, 1.5, 400, "7", undefined]) {
35
+ const res = await put({ days });
36
+ expect(res.status).toBe(400);
37
+ }
38
+ expect((await put("nope")).status).toBe(400);
39
+ });
40
+ });
@@ -0,0 +1,31 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { z } from "zod";
3
+ import {
4
+ setGlobalSweepDays,
5
+ setProjectSweepDays,
6
+ sweepDays,
7
+ } from "@/lib/worktree-sweep/settings";
8
+ import { sweepState } from "@/lib/worktree-sweep/state";
9
+
10
+ const body = z.object({
11
+ // Null clears the setting: the global one, then the default, applies.
12
+ days: sweepDays.nullable(),
13
+ projectId: z.string().min(1).optional(),
14
+ });
15
+
16
+ export async function GET() {
17
+ return NextResponse.json(sweepState());
18
+ }
19
+
20
+ export async function PUT(request: NextRequest) {
21
+ const got = body.safeParse(await request.json().catch(() => null));
22
+ if (!got.success)
23
+ return NextResponse.json(
24
+ { error: "days must be a whole number from 0 to 365, or null" },
25
+ { status: 400 }
26
+ );
27
+ const { days, projectId } = got.data;
28
+ if (projectId) setProjectSweepDays(projectId, days);
29
+ else setGlobalSweepDays(days);
30
+ return NextResponse.json(sweepState());
31
+ }
@@ -0,0 +1,26 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { z } from "zod";
3
+ import { sweepNow } from "@/lib/worktree-sweep/start";
4
+
5
+ const body = z.object({ dryRun: z.boolean().optional() });
6
+
7
+ // Starts a run and answers at once; its result is in the run list.
8
+ export async function POST(request: NextRequest) {
9
+ const got = body.safeParse(await request.json().catch(() => ({})));
10
+ if (!got.success)
11
+ return NextResponse.json(
12
+ { error: "dryRun must be a boolean" },
13
+ { status: 400 }
14
+ );
15
+ try {
16
+ const started = new Promise<void>((resolve, reject) => {
17
+ sweepNow(got.data.dryRun).then(() => resolve(), reject);
18
+ setTimeout(resolve, 250);
19
+ });
20
+ await started;
21
+ return NextResponse.json({ started: true }, { status: 202 });
22
+ } catch (error) {
23
+ const message = error instanceof Error ? error.message : String(error);
24
+ return NextResponse.json({ error: message }, { status: 409 });
25
+ }
26
+ }
@@ -19,14 +19,16 @@ import {
19
19
  type CheckSummary,
20
20
  } from "@/lib/health/view";
21
21
  import { cn } from "@/lib/utils";
22
+ import { SweepRuns } from "./SweepRuns";
22
23
 
23
- const TABS = ["open", "history", "notes", "thresholds"] as const;
24
+ const TABS = ["open", "history", "notes", "thresholds", "cleanup"] as const;
24
25
  type Tab = (typeof TABS)[number];
25
26
  const TAB_LABEL: Record<Tab, string> = {
26
27
  open: "Open",
27
28
  history: "History",
28
29
  notes: "Notes",
29
30
  thresholds: "Thresholds",
31
+ cleanup: "Clean-up",
30
32
  };
31
33
 
32
34
  const tokens = (n: number) =>
@@ -407,6 +409,7 @@ export function HealthPanel() {
407
409
  )}
408
410
  {tab === "notes" && <Notes />}
409
411
  {tab === "thresholds" && <Thresholds />}
412
+ {tab === "cleanup" && <SweepRuns />}
410
413
  </div>
411
414
  );
412
415
  }
@@ -0,0 +1,125 @@
1
+ "use client";
2
+
3
+ import { Button } from "@/components/ui/button";
4
+ import { useRunSweep, useWorktreeSweep } from "@/data/worktree-sweep";
5
+ import { compactTimeAgo, fromSqliteTime } from "@/lib/session-meta";
6
+ import { formatBytes } from "@/lib/share/view";
7
+ import type { SweepRun } from "@/lib/worktree-sweep/types";
8
+
9
+ const KIND: Record<string, string> = {
10
+ settled: "settled session",
11
+ orphan: "orphan folder",
12
+ "test-leak": "test leftover",
13
+ };
14
+
15
+ const name = (p: string) => p.split("/").pop();
16
+
17
+ function Run({ run }: { run: SweepRun }) {
18
+ const skipped = run.details?.skipped ?? [];
19
+ const removed = run.details?.removed ?? [];
20
+ return (
21
+ <li className="border-border/60 border-b px-3 py-1 last:border-b-0">
22
+ <details>
23
+ <summary className="flex min-h-11 cursor-pointer flex-wrap items-center gap-x-3 gap-y-1 text-sm">
24
+ <span className="font-medium">
25
+ {run.dryRun ? "Dry run, would remove" : "Removed"} {run.removed}
26
+ </span>
27
+ <span className="text-muted-foreground tabular-nums">
28
+ {formatBytes(run.freedBytes)}
29
+ </span>
30
+ <span className="text-muted-foreground">{run.skipped} skipped</span>
31
+ <span className="text-muted-foreground ml-auto text-xs">
32
+ {run.finishedAt
33
+ ? compactTimeAgo(fromSqliteTime(run.finishedAt))
34
+ : "running"}
35
+ </span>
36
+ </summary>
37
+ {run.error && (
38
+ <p role="alert" className="text-destructive mt-2 text-sm">
39
+ {run.error}
40
+ </p>
41
+ )}
42
+ {skipped.length > 0 && (
43
+ <div className="mt-2 space-y-1 pb-2">
44
+ <p className="text-sm font-medium">Skipped</p>
45
+ <ul className="text-muted-foreground space-y-1 text-xs">
46
+ {skipped.slice(0, 200).map((s) => (
47
+ <li key={s.path} className="break-all">
48
+ {name(s.path)}: {s.reason}
49
+ </li>
50
+ ))}
51
+ </ul>
52
+ </div>
53
+ )}
54
+ {removed.length > 0 && (
55
+ <div className="mt-2 space-y-1 pb-2">
56
+ <p className="text-sm font-medium">Removed</p>
57
+ <ul className="text-muted-foreground space-y-1 text-xs">
58
+ {removed.slice(0, 200).map((r) => (
59
+ <li key={r.path} className="break-all">
60
+ {name(r.path)} ({KIND[r.kind]}, {formatBytes(r.bytes)})
61
+ </li>
62
+ ))}
63
+ </ul>
64
+ </div>
65
+ )}
66
+ </details>
67
+ </li>
68
+ );
69
+ }
70
+
71
+ // Each worktree clean-up run: what it removed, freed and skipped, and why.
72
+ export function SweepRuns() {
73
+ const { data, isPending, error } = useWorktreeSweep();
74
+ const run = useRunSweep();
75
+ return (
76
+ <div className="space-y-3">
77
+ <div className="flex flex-wrap items-center gap-2">
78
+ <p className="text-muted-foreground min-w-0 flex-1 text-sm">
79
+ Settled worktrees older than {data?.days ?? data?.defaultDays ?? 7}{" "}
80
+ days are removed every 6 hours. Branches and conversations stay.
81
+ </p>
82
+ <Button
83
+ variant="outline"
84
+ className="min-h-11 md:min-h-8"
85
+ disabled={run.isPending}
86
+ onClick={() => run.mutate(true)}
87
+ >
88
+ Dry run
89
+ </Button>
90
+ <Button
91
+ className="min-h-11 md:min-h-8"
92
+ disabled={run.isPending}
93
+ onClick={() => run.mutate(false)}
94
+ >
95
+ Run now
96
+ </Button>
97
+ </div>
98
+ {run.error && (
99
+ <p role="alert" className="text-destructive text-sm">
100
+ {run.error.message}
101
+ </p>
102
+ )}
103
+ {error && (
104
+ <p role="alert" className="text-destructive text-sm">
105
+ {error.message}
106
+ </p>
107
+ )}
108
+ {isPending && (
109
+ <div className="bg-muted/40 h-24 animate-pulse rounded-xl" />
110
+ )}
111
+ {data && data.runs.length === 0 && (
112
+ <p className="text-muted-foreground text-sm">
113
+ No run yet. The first one starts 10 minutes after the server does.
114
+ </p>
115
+ )}
116
+ {data && data.runs.length > 0 && (
117
+ <ul className="border-border/60 overflow-hidden rounded-xl border">
118
+ {data.runs.map((r) => (
119
+ <Run key={r.id} run={r} />
120
+ ))}
121
+ </ul>
122
+ )}
123
+ </div>
124
+ );
125
+ }
@@ -11,6 +11,7 @@ import {
11
11
  } from "@/lib/tasks/merge-methods";
12
12
  import { MergeApprovalToggle } from "./MergeApprovalToggle";
13
13
  import { MergeSettingsFields } from "./MergeSettingsFields";
14
+ import { GlobalSweepSection } from "./WorktreeSweepSection";
14
15
 
15
16
  // "rebase, keeps worktree"
16
17
  function describe(s: MergeSettings): string {
@@ -85,6 +86,7 @@ export function GlobalMergeSection() {
85
86
  )}
86
87
  </div>
87
88
  {data && <Overrides overrides={data.overrides} />}
89
+ <GlobalSweepSection />
88
90
  </div>
89
91
  );
90
92
  }
@@ -5,6 +5,7 @@ import {
5
5
  useUpdateProjectMergeSettings,
6
6
  } from "@/data/merge";
7
7
  import { MergeSettingsFields } from "./MergeSettingsFields";
8
+ import { ProjectSweepSection } from "./WorktreeSweepSection";
8
9
 
9
10
  // A project's own merge settings, saved as they change. Unset ones inherit
10
11
  // from agentos.json, then the global settings.
@@ -42,6 +43,7 @@ export function ProjectMergeSection({
42
43
  <p className="text-destructive text-sm">{update.error.message}</p>
43
44
  )}
44
45
  <p className="text-muted-foreground text-xs">Saved as you change them.</p>
46
+ <ProjectSweepSection projectId={projectId} />
45
47
  </div>
46
48
  );
47
49
  }
@@ -0,0 +1,125 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Input } from "@/components/ui/input";
5
+ import { useSetSweepDays, useWorktreeSweep } from "@/data/worktree-sweep";
6
+ import { compactTimeAgo, fromSqliteTime } from "@/lib/session-meta";
7
+ import { formatBytes } from "@/lib/share/view";
8
+
9
+ // Days as the box shows them; empty means "inherit".
10
+ function DaysInput({
11
+ value,
12
+ placeholder,
13
+ disabled,
14
+ onSave,
15
+ }: {
16
+ value: number | null;
17
+ placeholder: string;
18
+ disabled: boolean;
19
+ onSave: (days: number | null) => void;
20
+ }) {
21
+ const [text, setText] = useState(value === null ? "" : String(value));
22
+ const commit = () => {
23
+ const raw = text.trim();
24
+ const next = raw === "" ? null : Number(raw);
25
+ if (next === value || (next !== null && !Number.isInteger(next))) return;
26
+ onSave(next);
27
+ };
28
+ return (
29
+ <Input
30
+ aria-label="Days before a settled worktree is removed"
31
+ inputMode="numeric"
32
+ value={text}
33
+ placeholder={placeholder}
34
+ disabled={disabled}
35
+ onChange={(e) => setText(e.target.value)}
36
+ onBlur={commit}
37
+ onKeyDown={(e) => e.key === "Enter" && e.currentTarget.blur()}
38
+ className="h-11 w-24 text-right tabular-nums md:h-9"
39
+ />
40
+ );
41
+ }
42
+
43
+ function LastRun() {
44
+ const { data } = useWorktreeSweep();
45
+ const last = data?.runs.find((r) => r.finishedAt && !r.dryRun);
46
+ if (!last) return null;
47
+ return (
48
+ <p className="text-muted-foreground text-xs">
49
+ Last run {compactTimeAgo(fromSqliteTime(last.finishedAt!))}: removed{" "}
50
+ {last.removed}, {formatBytes(last.freedBytes)} freed, {last.skipped}{" "}
51
+ skipped. Details in Health &gt; Clean-up.
52
+ </p>
53
+ );
54
+ }
55
+
56
+ // How long a settled worktree stays on disk. The branch and the conversation
57
+ // stay; restoring the session rebuilds it.
58
+ export function GlobalSweepSection() {
59
+ const { data, isPending, isError, error } = useWorktreeSweep();
60
+ const save = useSetSweepDays();
61
+ return (
62
+ <div className="space-y-2">
63
+ <h3 className="text-sm font-medium">Worktree clean-up</h3>
64
+ <p className="text-muted-foreground text-sm">
65
+ Remove the worktree of a merged, dropped, done or archived session this
66
+ many days after it settled, when nothing in it would be lost. 0 turns it
67
+ off.
68
+ </p>
69
+ {isPending && (
70
+ <div className="bg-muted/40 h-11 animate-pulse rounded-lg" />
71
+ )}
72
+ {isError && <p className="text-destructive text-sm">{error.message}</p>}
73
+ {data && (
74
+ <div className="flex items-center gap-3">
75
+ <DaysInput
76
+ key={data.days ?? "none"}
77
+ value={data.days}
78
+ placeholder={String(data.defaultDays)}
79
+ disabled={save.isPending}
80
+ onSave={(days) => save.mutate({ days })}
81
+ />
82
+ <span className="text-muted-foreground text-sm">days</span>
83
+ </div>
84
+ )}
85
+ {data && data.overrides.length > 0 && (
86
+ <p className="text-muted-foreground text-xs">
87
+ Own number:{" "}
88
+ {data.overrides.map((o) => `${o.name} (${o.days})`).join(", ")}
89
+ </p>
90
+ )}
91
+ {save.isError && (
92
+ <p className="text-destructive text-sm">{save.error.message}</p>
93
+ )}
94
+ <LastRun />
95
+ </div>
96
+ );
97
+ }
98
+
99
+ export function ProjectSweepSection({ projectId }: { projectId: string }) {
100
+ const { data } = useWorktreeSweep();
101
+ const save = useSetSweepDays();
102
+ const own =
103
+ data?.overrides.find((o) => o.projectId === projectId)?.days ?? null;
104
+ const inherited = data?.days ?? data?.defaultDays ?? 7;
105
+ return (
106
+ <div className="space-y-2">
107
+ <h3 className="text-sm font-medium">Worktree clean-up</h3>
108
+ <div className="flex items-center gap-3">
109
+ <DaysInput
110
+ key={own ?? "none"}
111
+ value={own}
112
+ placeholder={String(inherited)}
113
+ disabled={!data || save.isPending}
114
+ onSave={(days) => save.mutate({ days, projectId })}
115
+ />
116
+ <span className="text-muted-foreground text-sm">
117
+ days after a session settles (empty: {inherited}, 0: off)
118
+ </span>
119
+ </div>
120
+ {save.isError && (
121
+ <p className="text-destructive text-sm">{save.error.message}</p>
122
+ )}
123
+ </div>
124
+ );
125
+ }
@@ -8,7 +8,7 @@ import { useApprovalsModeQuery, useSetApprovalsMode } from "@/data/approvals";
8
8
  // What it switches off, said plainly before it can be switched on.
9
9
  export const NO_APPROVALS_WARNING = [
10
10
  "Every ask is approved the moment it is raised, hard lines included: public posts and messages, money, irreversible or destructive changes, credentials.",
11
- "A PR that failed its gates twice, has no CI, or is too big to review still merges.",
11
+ "A PR stuck failing its gates, with no CI, or too big to review still merges.",
12
12
  "Brakes stop holding new starts, and plan-only tasks go on to implement without waiting for a plan approval.",
13
13
  "Every agent AgentOS starts runs with its permission prompts off, and prompts already waiting are allowed.",
14
14
  ];
@@ -59,7 +59,7 @@ export const SECTION_INFO: Record<
59
59
  > = {
60
60
  merging: {
61
61
  label: "Merging",
62
- hint: "Approval, merge method, branch clean-up",
62
+ hint: "Approval, merge method, branch and worktree clean-up",
63
63
  icon: GitMerge,
64
64
  },
65
65
  approvals: {
@@ -0,0 +1,56 @@
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import type { SweepState } from "@/lib/worktree-sweep/state";
3
+
4
+ export const sweepKeys = { all: ["worktree-sweep"] as const };
5
+
6
+ async function json<T>(res: Response): Promise<T> {
7
+ const data = await res.json();
8
+ if (!res.ok) throw new Error(data.error || "Request failed");
9
+ return data;
10
+ }
11
+
12
+ export function useWorktreeSweep(enabled = true) {
13
+ return useQuery({
14
+ queryKey: sweepKeys.all,
15
+ queryFn: async () => json<SweepState>(await fetch("/api/worktree-sweep")),
16
+ enabled,
17
+ refetchInterval: 60_000,
18
+ });
19
+ }
20
+
21
+ export function useSetSweepDays() {
22
+ const queryClient = useQueryClient();
23
+ return useMutation({
24
+ mutationFn: async (v: { days: number | null; projectId?: string }) =>
25
+ json<SweepState>(
26
+ await fetch("/api/worktree-sweep", {
27
+ method: "PUT",
28
+ headers: { "Content-Type": "application/json" },
29
+ body: JSON.stringify(v),
30
+ })
31
+ ),
32
+ onSuccess: (data) => queryClient.setQueryData(sweepKeys.all, data),
33
+ });
34
+ }
35
+
36
+ export function useRunSweep() {
37
+ const queryClient = useQueryClient();
38
+ return useMutation({
39
+ mutationFn: async (dryRun: boolean) =>
40
+ json<{ started: boolean }>(
41
+ await fetch("/api/worktree-sweep/run", {
42
+ method: "POST",
43
+ headers: { "Content-Type": "application/json" },
44
+ body: JSON.stringify({ dryRun }),
45
+ })
46
+ ),
47
+ onSuccess: () => {
48
+ // The run is in the background; look again as it goes.
49
+ for (const ms of [1500, 8000, 30000])
50
+ setTimeout(
51
+ () => queryClient.invalidateQueries({ queryKey: sweepKeys.all }),
52
+ ms
53
+ );
54
+ },
55
+ });
56
+ }
@@ -92,7 +92,7 @@ export function inFlightWhy(t: {
92
92
  }): string {
93
93
  const escalated = t.failures.filter((f) => f.escalated_at);
94
94
  if (escalated.length)
95
- return `with you: the ${escalated.map((f) => f.gate).join(" and ")} gate failed twice`;
95
+ return `with you: the ${escalated.map((f) => f.gate).join(" and ")} gate kept failing (the loop guard)`;
96
96
  if (t.askKinds.length) return `waiting on your answer (${t.askKinds[0]})`;
97
97
  const flagged = t.flagged
98
98
  ? `; flagged to the orchestrator ${t.flagged}x while you were away`
@@ -0,0 +1,49 @@
1
+ import type Database from "better-sqlite3";
2
+ import Sqlite from "better-sqlite3";
3
+ import { randomBytes } from "crypto";
4
+ import fs from "fs";
5
+ import os from "os";
6
+ import path from "path";
7
+ import { refuseRealStateUnderTest } from "../test-home-guard";
8
+
9
+ export const backupsDir = (): string =>
10
+ refuseRealStateUnderTest(path.join(os.homedir(), ".agent-os", "backups"));
11
+
12
+ // A full copy of the database (VACUUM INTO) under ~/.agent-os/backups, taken
13
+ // before a destructive migration. Throws, and the migration must stop, unless
14
+ // the copy exists and holds every session. The message never says "already
15
+ // exists": the migration runner reads that as applied.
16
+ export function backupDatabase(
17
+ db: Database.Database,
18
+ label: string,
19
+ dir: string = backupsDir()
20
+ ): string {
21
+ const stamp = new Date().toISOString().replace(/[-:]/g, "").slice(0, 15);
22
+ const file = path.join(
23
+ dir,
24
+ `agent-os-${stamp}-${process.pid}-${randomBytes(3).toString("hex")}-${label}.db`
25
+ );
26
+ try {
27
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
28
+ db.prepare(`VACUUM INTO ?`).run(file);
29
+ fs.chmodSync(file, 0o600);
30
+ const copy = new Sqlite(file, { readonly: true });
31
+ try {
32
+ const count = (sql: Database.Database) =>
33
+ (
34
+ sql.prepare(`SELECT COUNT(*) AS n FROM sessions`).get() as {
35
+ n: number;
36
+ }
37
+ ).n;
38
+ if (count(copy) !== count(db)) throw new Error("session count differs");
39
+ } finally {
40
+ copy.close();
41
+ }
42
+ } catch (error) {
43
+ const reason = error instanceof Error ? error.message : String(error);
44
+ throw new Error(
45
+ `Backup before ${label} failed (${file}): ${reason.replace(/already exists/gi, "is present")}. Nothing was changed.`
46
+ );
47
+ }
48
+ return file;
49
+ }
@@ -0,0 +1,40 @@
1
+ import type { Migration } from "../migration-types";
2
+
3
+ export default {
4
+ key: "20261015T1733_gate_loop_guard",
5
+ up: (db) => {
6
+ // The loop guard (orchestrator/loop-guard.ts): how many commits in a row
7
+ // a gate has failed on, the last failure's signature, whether it repeated
8
+ // the one before, and the reason the task went to Saad. Additive.
9
+ const columns = (table: string) =>
10
+ new Set(
11
+ (
12
+ db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]
13
+ ).map((c) => c.name)
14
+ );
15
+ const failures = columns("orchestrator_gate_failures");
16
+ if (!failures.has("streak")) {
17
+ db.exec(
18
+ `ALTER TABLE orchestrator_gate_failures ADD COLUMN streak INTEGER NOT NULL DEFAULT 0`
19
+ );
20
+ // A pass never reset the old count, so it's the most the streak can be.
21
+ db.exec(`UPDATE orchestrator_gate_failures SET streak = count`);
22
+ }
23
+ if (!failures.has("last_signature"))
24
+ db.exec(
25
+ `ALTER TABLE orchestrator_gate_failures ADD COLUMN last_signature TEXT`
26
+ );
27
+ if (!failures.has("repeated"))
28
+ db.exec(
29
+ `ALTER TABLE orchestrator_gate_failures ADD COLUMN repeated INTEGER NOT NULL DEFAULT 0`
30
+ );
31
+ if (!failures.has("escalated_why"))
32
+ db.exec(
33
+ `ALTER TABLE orchestrator_gate_failures ADD COLUMN escalated_why TEXT`
34
+ );
35
+ if (!columns("orchestrator_gate_failure_commits").has("signature"))
36
+ db.exec(
37
+ `ALTER TABLE orchestrator_gate_failure_commits ADD COLUMN signature TEXT`
38
+ );
39
+ },
40
+ } satisfies Migration;