@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
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
mainCheckoutOf,
|
|
8
8
|
} from "@/lib/worktrees";
|
|
9
9
|
import { releasePort } from "@/lib/ports";
|
|
10
|
-
import {
|
|
10
|
+
import { releaseSessionLeftovers } from "@/lib/sessions/leftovers";
|
|
11
11
|
import { killWorker } from "@/lib/orchestration";
|
|
12
12
|
import { endSessionAgent } from "@/lib/sessions/end";
|
|
13
13
|
import { endTmuxSession } from "@/lib/tmux/end";
|
|
@@ -261,7 +261,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
|
|
261
261
|
} catch (error) {
|
|
262
262
|
console.error(`Failed to kill worker ${worker.id}:`, error);
|
|
263
263
|
}
|
|
264
|
-
void
|
|
264
|
+
void releaseSessionLeftovers(worker);
|
|
265
265
|
queries.deleteSession(db).run(worker.id);
|
|
266
266
|
}
|
|
267
267
|
|
|
@@ -271,7 +271,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
|
|
271
271
|
}
|
|
272
272
|
|
|
273
273
|
// Its private Postgres copy: the row is read now, the drop runs on.
|
|
274
|
-
void
|
|
274
|
+
void releaseSessionLeftovers(existing);
|
|
275
275
|
|
|
276
276
|
// Delete from database immediately for instant UI feedback
|
|
277
277
|
// The agent's tmux and processes go on in the background; the worktree
|
|
@@ -5,10 +5,11 @@ import { NextRequest } from "next/server";
|
|
|
5
5
|
const { GET, PUT } = await import("./route");
|
|
6
6
|
const { createProject } = await import("@/lib/projects");
|
|
7
7
|
|
|
8
|
-
const put = (body: unknown) =>
|
|
8
|
+
const put = (body: unknown, trust: string | null = "loopback") =>
|
|
9
9
|
PUT(
|
|
10
10
|
new NextRequest("http://localhost/api/worktree-sweep", {
|
|
11
11
|
method: "PUT",
|
|
12
|
+
headers: trust ? { "x-agentos-trust": trust } : {},
|
|
12
13
|
body: JSON.stringify(body),
|
|
13
14
|
})
|
|
14
15
|
);
|
|
@@ -30,6 +31,23 @@ describe("/api/worktree-sweep", () => {
|
|
|
30
31
|
expect((await (await put({ days: null })).json()).days).toBeNull();
|
|
31
32
|
});
|
|
32
33
|
|
|
34
|
+
it("turns the full clean-up on and off, off by default", async () => {
|
|
35
|
+
expect((await (await GET()).json()).armed).toBe(false);
|
|
36
|
+
expect((await (await put({ armed: true })).json()).armed).toBe(true);
|
|
37
|
+
expect((await (await GET()).json()).armed).toBe(true);
|
|
38
|
+
expect((await (await put({ armed: false })).json()).armed).toBe(false);
|
|
39
|
+
expect((await put({ armed: "yes" })).status).toBe(400);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("turns the full clean-up on only from this machine or the tailnet, off from anywhere", async () => {
|
|
43
|
+
expect((await put({ armed: true }, null)).status).toBe(403);
|
|
44
|
+
expect((await put({ armed: true }, "device")).status).toBe(403);
|
|
45
|
+
expect((await (await GET()).json()).armed).toBe(false);
|
|
46
|
+
expect((await put({ armed: true }, "tailnet")).status).toBe(200);
|
|
47
|
+
expect((await put({ armed: false }, null)).status).toBe(200);
|
|
48
|
+
expect((await (await GET()).json()).armed).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
33
51
|
it("refuses a number that isn't a whole 0-365", async () => {
|
|
34
52
|
for (const days of [-1, 1.5, 400, "7", undefined]) {
|
|
35
53
|
const res = await put({ days });
|
|
@@ -2,16 +2,21 @@ import { NextRequest, NextResponse } from "next/server";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import {
|
|
4
4
|
setGlobalSweepDays,
|
|
5
|
+
setReaperArmed,
|
|
5
6
|
setProjectSweepDays,
|
|
6
7
|
sweepDays,
|
|
7
8
|
} from "@/lib/worktree-sweep/settings";
|
|
9
|
+
import { requireLocalTrust } from "@/lib/security/route-guard";
|
|
8
10
|
import { sweepState } from "@/lib/worktree-sweep/state";
|
|
9
11
|
|
|
10
|
-
const body = z.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
const body = z.union([
|
|
13
|
+
z.object({
|
|
14
|
+
// Null clears the setting: the global one, then the default, applies.
|
|
15
|
+
days: sweepDays.nullable(),
|
|
16
|
+
projectId: z.string().min(1).optional(),
|
|
17
|
+
}),
|
|
18
|
+
z.object({ armed: z.boolean() }),
|
|
19
|
+
]);
|
|
15
20
|
|
|
16
21
|
export async function GET() {
|
|
17
22
|
return NextResponse.json(sweepState());
|
|
@@ -21,9 +26,23 @@ export async function PUT(request: NextRequest) {
|
|
|
21
26
|
const got = body.safeParse(await request.json().catch(() => null));
|
|
22
27
|
if (!got.success)
|
|
23
28
|
return NextResponse.json(
|
|
24
|
-
{
|
|
29
|
+
{
|
|
30
|
+
error:
|
|
31
|
+
"days must be a whole number from 0 to 365, or null; or armed true or false",
|
|
32
|
+
},
|
|
25
33
|
{ status: 400 }
|
|
26
34
|
);
|
|
35
|
+
if ("armed" in got.data) {
|
|
36
|
+
// Turning the removal on is a person's call, made on this machine or
|
|
37
|
+
// over Tailscale: a stolen device token must not be able to. Turning it
|
|
38
|
+
// off stays on the ordinary gate.
|
|
39
|
+
if (got.data.armed) {
|
|
40
|
+
const refused = requireLocalTrust(request);
|
|
41
|
+
if (refused) return refused;
|
|
42
|
+
}
|
|
43
|
+
setReaperArmed(got.data.armed);
|
|
44
|
+
return NextResponse.json(sweepState());
|
|
45
|
+
}
|
|
27
46
|
const { days, projectId } = got.data;
|
|
28
47
|
if (projectId) setProjectSweepDays(projectId, days);
|
|
29
48
|
else setGlobalSweepDays(days);
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { Button } from "@/components/ui/button";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
useRunSweep,
|
|
6
|
+
useSetReaperArmed,
|
|
7
|
+
useWorktreeSweep,
|
|
8
|
+
} from "@/data/worktree-sweep";
|
|
5
9
|
import { compactTimeAgo, fromSqliteTime } from "@/lib/session-meta";
|
|
6
10
|
import { formatBytes } from "@/lib/share/view";
|
|
7
11
|
import type { SweepRun } from "@/lib/worktree-sweep/types";
|
|
@@ -10,8 +14,34 @@ const KIND: Record<string, string> = {
|
|
|
10
14
|
settled: "settled session",
|
|
11
15
|
orphan: "orphan folder",
|
|
12
16
|
"test-leak": "test leftover",
|
|
17
|
+
database: "task database",
|
|
18
|
+
spare: "dependency spare",
|
|
19
|
+
temp: "temp folder",
|
|
20
|
+
"derived-data": "Xcode build folder",
|
|
13
21
|
};
|
|
14
22
|
|
|
23
|
+
// "18.2 GB in 3,876 worktrees · 12 task databases (270 MB) · ..." from the
|
|
24
|
+
// latest run that measured, and what the last real run freed.
|
|
25
|
+
export function diskSummary(runs: SweepRun[]): string | null {
|
|
26
|
+
const totals = runs.find((r) => r.details?.totals)?.details?.totals;
|
|
27
|
+
if (!totals) return null;
|
|
28
|
+
const part = (n: number, bytes: number, what: string) =>
|
|
29
|
+
`${n.toLocaleString()} ${what}${bytes ? ` (${formatBytes(bytes)})` : ""}`;
|
|
30
|
+
const last = runs.find((r) => !r.dryRun && r.finishedAt);
|
|
31
|
+
return [
|
|
32
|
+
`Task leftovers: ${part(totals.worktrees.count, totals.worktrees.bytes, "worktrees")}`,
|
|
33
|
+
part(
|
|
34
|
+
totals.databases.count,
|
|
35
|
+
totals.databases.bytes,
|
|
36
|
+
"stale task databases"
|
|
37
|
+
),
|
|
38
|
+
part(totals.spares.count, totals.spares.bytes, "dependency spares"),
|
|
39
|
+
last
|
|
40
|
+
? `last clean-up freed ${formatBytes(last.freedBytes)}, ${compactTimeAgo(fromSqliteTime(last.finishedAt!))}`
|
|
41
|
+
: "no clean-up has run yet",
|
|
42
|
+
].join(" · ");
|
|
43
|
+
}
|
|
44
|
+
|
|
15
45
|
const name = (p: string) => p.split("/").pop();
|
|
16
46
|
|
|
17
47
|
function Run({ run }: { run: SweepRun }) {
|
|
@@ -51,6 +81,22 @@ function Run({ run }: { run: SweepRun }) {
|
|
|
51
81
|
</ul>
|
|
52
82
|
</div>
|
|
53
83
|
)}
|
|
84
|
+
{(run.details?.pending?.length ?? 0) > 0 && (
|
|
85
|
+
<div className="mt-2 space-y-1 pb-2">
|
|
86
|
+
<p className="text-sm font-medium">
|
|
87
|
+
Waiting for the full clean-up to be turned on (
|
|
88
|
+
{run.details?.pendingTotal?.count ?? 0},{" "}
|
|
89
|
+
{formatBytes(run.details?.pendingTotal?.bytes ?? 0)})
|
|
90
|
+
</p>
|
|
91
|
+
<ul className="text-muted-foreground space-y-1 text-xs">
|
|
92
|
+
{run.details?.pending?.slice(0, 100).map((p) => (
|
|
93
|
+
<li key={p.path} className="break-all">
|
|
94
|
+
{name(p.path)} ({KIND[p.kind]}, {formatBytes(p.bytes)})
|
|
95
|
+
</li>
|
|
96
|
+
))}
|
|
97
|
+
</ul>
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
54
100
|
{removed.length > 0 && (
|
|
55
101
|
<div className="mt-2 space-y-1 pb-2">
|
|
56
102
|
<p className="text-sm font-medium">Removed</p>
|
|
@@ -72,8 +118,37 @@ function Run({ run }: { run: SweepRun }) {
|
|
|
72
118
|
export function SweepRuns() {
|
|
73
119
|
const { data, isPending, error } = useWorktreeSweep();
|
|
74
120
|
const run = useRunSweep();
|
|
121
|
+
const arm = useSetReaperArmed();
|
|
122
|
+
const summary = data ? diskSummary(data.runs) : null;
|
|
75
123
|
return (
|
|
76
124
|
<div className="space-y-3">
|
|
125
|
+
{summary && (
|
|
126
|
+
<p
|
|
127
|
+
className="text-sm font-medium tabular-nums"
|
|
128
|
+
data-testid="disk-summary"
|
|
129
|
+
>
|
|
130
|
+
{summary}
|
|
131
|
+
</p>
|
|
132
|
+
)}
|
|
133
|
+
{data && (
|
|
134
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
135
|
+
<p className="text-muted-foreground min-w-0 flex-1 text-sm">
|
|
136
|
+
Full clean-up is {data.armed ? "on" : "off"}: test leftovers, stale
|
|
137
|
+
task databases, spares, temp folders and build caches{" "}
|
|
138
|
+
{data.armed
|
|
139
|
+
? "are removed with each run."
|
|
140
|
+
: "are only listed until you turn it on. Read a dry run first."}
|
|
141
|
+
</p>
|
|
142
|
+
<Button
|
|
143
|
+
variant="outline"
|
|
144
|
+
className="min-h-11 md:min-h-8"
|
|
145
|
+
disabled={arm.isPending}
|
|
146
|
+
onClick={() => arm.mutate(!data.armed)}
|
|
147
|
+
>
|
|
148
|
+
{data.armed ? "Turn off" : "Turn on"}
|
|
149
|
+
</Button>
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
77
152
|
<div className="flex flex-wrap items-center gap-2">
|
|
78
153
|
<p className="text-muted-foreground min-w-0 flex-1 text-sm">
|
|
79
154
|
Settled worktrees older than {data?.days ?? data?.defaultDays ?? 7}{" "}
|
|
@@ -33,6 +33,21 @@ export function useSetSweepDays() {
|
|
|
33
33
|
});
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
export function useSetReaperArmed() {
|
|
37
|
+
const queryClient = useQueryClient();
|
|
38
|
+
return useMutation({
|
|
39
|
+
mutationFn: async (armed: boolean) =>
|
|
40
|
+
json<SweepState>(
|
|
41
|
+
await fetch("/api/worktree-sweep", {
|
|
42
|
+
method: "PUT",
|
|
43
|
+
headers: { "Content-Type": "application/json" },
|
|
44
|
+
body: JSON.stringify({ armed }),
|
|
45
|
+
})
|
|
46
|
+
),
|
|
47
|
+
onSuccess: (data) => queryClient.setQueryData(sweepKeys.all, data),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
36
51
|
export function useRunSweep() {
|
|
37
52
|
const queryClient = useQueryClient();
|
|
38
53
|
return useMutation({
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import os from "os";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { refuseRealStateUnderTest } from "./test-home-guard";
|
|
4
|
+
|
|
5
|
+
// Every path under ~/.agent-os is built here, so a test run that resolves one
|
|
6
|
+
// to the real home throws instead of leaving files in the owner's state.
|
|
7
|
+
export function agentosHome(...parts: string[]): string {
|
|
8
|
+
return refuseRealStateUnderTest(
|
|
9
|
+
path.join(os.homedir(), ".agent-os", ...parts)
|
|
10
|
+
);
|
|
11
|
+
}
|
package/lib/agents/launch.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { agentosHome } from "../agentos-home";
|
|
1
2
|
import fs from "fs";
|
|
2
|
-
import os from "os";
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { execFile } from "child_process";
|
|
5
5
|
import { promisify } from "util";
|
|
@@ -22,12 +22,8 @@ import {
|
|
|
22
22
|
} from "../project-config/session";
|
|
23
23
|
|
|
24
24
|
const execFileAsync = promisify(execFile);
|
|
25
|
-
const PROMPTS_DIR =
|
|
26
|
-
export const BUS_BRIEF_FILE =
|
|
27
|
-
os.homedir(),
|
|
28
|
-
".agent-os",
|
|
29
|
-
"bus-brief.md"
|
|
30
|
-
);
|
|
25
|
+
const PROMPTS_DIR = agentosHome("prompts");
|
|
26
|
+
export const BUS_BRIEF_FILE = agentosHome("bus-brief.md");
|
|
31
27
|
|
|
32
28
|
export function ensureBusBrief(): void {
|
|
33
29
|
fs.mkdirSync(path.dirname(BUS_BRIEF_FILE), { recursive: true });
|
package/lib/artifacts/store.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* ~/.agent-os/artifacts/<session id>/, and a row saying whose it is.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { agentosHome } from "../agentos-home";
|
|
6
7
|
import fs from "fs";
|
|
7
|
-
import os from "os";
|
|
8
8
|
import path from "path";
|
|
9
9
|
import { randomUUID } from "crypto";
|
|
10
10
|
import { db } from "../db";
|
|
@@ -23,10 +23,7 @@ export const MAX_TITLE_CHARS = 120;
|
|
|
23
23
|
const ID = /^[0-9a-f-]{36}$/;
|
|
24
24
|
|
|
25
25
|
export function artifactsDir(): string {
|
|
26
|
-
return (
|
|
27
|
-
process.env.AGENTOS_ARTIFACTS_DIR ||
|
|
28
|
-
path.join(os.homedir(), ".agent-os", "artifacts")
|
|
29
|
-
);
|
|
26
|
+
return process.env.AGENTOS_ARTIFACTS_DIR || agentosHome("artifacts");
|
|
30
27
|
}
|
|
31
28
|
|
|
32
29
|
// Session ids are UUIDs; anything else must not become a path segment.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { agentosHome } from "../../agentos-home";
|
|
1
2
|
import fs from "fs";
|
|
2
|
-
import os from "os";
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { randomUUID } from "crypto";
|
|
5
5
|
import type { ChatAccess, DriverEvent } from "../events";
|
|
@@ -9,7 +9,7 @@ import { piTool } from "./pi-mapper";
|
|
|
9
9
|
type P = Record<string, unknown>;
|
|
10
10
|
const str = (v: unknown) => (typeof v === "string" ? v : "");
|
|
11
11
|
|
|
12
|
-
const DIR =
|
|
12
|
+
const DIR = agentosHome("pi");
|
|
13
13
|
const MARK = "agentos:";
|
|
14
14
|
|
|
15
15
|
// Pi runs every tool without asking. This extension asks the chat first,
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* worker's Unix socket: one JSON message per line.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { agentosHome } from "../../agentos-home";
|
|
6
7
|
import crypto from "crypto";
|
|
7
|
-
import os from "os";
|
|
8
8
|
import path from "path";
|
|
9
9
|
import type {
|
|
10
10
|
ApprovalDecision,
|
|
@@ -116,7 +116,7 @@ export function workerDir(): string {
|
|
|
116
116
|
process.env.DB_PATH || path.join(process.cwd(), "agent-os.db")
|
|
117
117
|
);
|
|
118
118
|
const key = crypto.createHash("sha1").update(db).digest("hex").slice(0, 8);
|
|
119
|
-
return
|
|
119
|
+
return agentosHome("chat", key);
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
export const socketPath = (sessionId: string) =>
|
package/lib/connect/config.ts
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
* each time the tunnel starts.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { agentosHome } from "../agentos-home";
|
|
14
15
|
import fs from "fs";
|
|
15
|
-
import os from "os";
|
|
16
16
|
import path from "path";
|
|
17
17
|
|
|
18
18
|
export interface ConnectConfig {
|
|
@@ -32,8 +32,7 @@ export interface ConnectFiles {
|
|
|
32
32
|
export const MACHINE_HOST = /^m-[0-9a-f]{16}\.runagentos\.com$/;
|
|
33
33
|
|
|
34
34
|
export const connectDir = () =>
|
|
35
|
-
process.env.AGENTOS_CONNECT_DIR ||
|
|
36
|
-
path.join(os.homedir(), ".agent-os", "connect");
|
|
35
|
+
process.env.AGENTOS_CONNECT_DIR || agentosHome("connect");
|
|
37
36
|
|
|
38
37
|
export type ConnectRead =
|
|
39
38
|
| { ok: true; files: ConnectFiles; enabled: boolean }
|
package/lib/db/backup.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
+
import { agentosHome } from "../agentos-home";
|
|
1
2
|
import type Database from "better-sqlite3";
|
|
2
3
|
import Sqlite from "better-sqlite3";
|
|
3
4
|
import { randomBytes } from "crypto";
|
|
4
5
|
import fs from "fs";
|
|
5
|
-
import os from "os";
|
|
6
6
|
import path from "path";
|
|
7
7
|
import { refuseRealStateUnderTest } from "../test-home-guard";
|
|
8
8
|
|
|
9
9
|
export const backupsDir = (): string =>
|
|
10
|
-
refuseRealStateUnderTest(
|
|
10
|
+
refuseRealStateUnderTest(agentosHome("backups"));
|
|
11
11
|
|
|
12
12
|
// A full copy of the database (VACUUM INTO) under ~/.agent-os/backups, taken
|
|
13
13
|
// before a destructive migration. Throws, and the migration must stop, unless
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
key: "20261018T1600_health_boot_deploys",
|
|
5
|
+
up: (db) => {
|
|
6
|
+
const cols = (
|
|
7
|
+
db.prepare(`PRAGMA table_info(health_boots)`).all() as { name: string }[]
|
|
8
|
+
).map((c) => c.name);
|
|
9
|
+
// 1 when autodeploy planned the restart that started this boot.
|
|
10
|
+
if (!cols.includes("deploy"))
|
|
11
|
+
db.exec(`ALTER TABLE health_boots ADD COLUMN deploy INTEGER`);
|
|
12
|
+
},
|
|
13
|
+
} satisfies Migration;
|
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
|
-
import {
|
|
2
|
+
import { execFile } from "child_process";
|
|
3
|
+
import { promisify } from "util";
|
|
4
|
+
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
|
|
7
|
+
// These run inside the server (task end, the sweep), so none may block the
|
|
8
|
+
// event loop or hang: a timeout reads as "could not verify".
|
|
9
|
+
export const PROBE_TIMEOUT_MS = 3000;
|
|
10
|
+
|
|
11
|
+
export class ProbeTimeout extends Error {}
|
|
12
|
+
|
|
13
|
+
export async function probe(cmd: string, args: string[]): Promise<string> {
|
|
14
|
+
try {
|
|
15
|
+
const { stdout } = await execFileAsync(cmd, args, {
|
|
16
|
+
encoding: "utf8",
|
|
17
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
18
|
+
});
|
|
19
|
+
return stdout;
|
|
20
|
+
} catch (e) {
|
|
21
|
+
if ((e as { killed?: boolean }).killed) throw new ProbeTimeout(cmd);
|
|
22
|
+
throw e;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
3
25
|
|
|
4
26
|
export function pidAlive(pid: number): boolean {
|
|
5
27
|
try {
|
|
@@ -18,18 +40,18 @@ const real = (p: string): string => {
|
|
|
18
40
|
}
|
|
19
41
|
};
|
|
20
42
|
|
|
21
|
-
export function processCwd(pid: number): string | null {
|
|
43
|
+
export async function processCwd(pid: number): Promise<string | null> {
|
|
22
44
|
try {
|
|
23
45
|
if (process.platform === "linux")
|
|
24
46
|
return real(fs.readlinkSync(`/proc/${pid}/cwd`));
|
|
25
|
-
const out =
|
|
26
|
-
"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
);
|
|
47
|
+
const out = await probe("lsof", [
|
|
48
|
+
"-a",
|
|
49
|
+
"-p",
|
|
50
|
+
String(pid),
|
|
51
|
+
"-d",
|
|
52
|
+
"cwd",
|
|
53
|
+
"-Fn",
|
|
54
|
+
]);
|
|
33
55
|
const line = out.split("\n").find((l) => l.startsWith("n"));
|
|
34
56
|
return line ? real(line.slice(1)) : null;
|
|
35
57
|
} catch {
|
|
@@ -38,30 +60,29 @@ export function processCwd(pid: number): string | null {
|
|
|
38
60
|
}
|
|
39
61
|
|
|
40
62
|
// Whether the process was launched as the live server: its environment
|
|
41
|
-
// carries the marker, or its title is the server's.
|
|
42
|
-
|
|
63
|
+
// carries the marker, or its title is the server's. A probe that times out
|
|
64
|
+
// answers true: not being able to tell is a reason to leave it alone.
|
|
65
|
+
export async function looksLive(pid: number): Promise<boolean> {
|
|
43
66
|
try {
|
|
44
67
|
if (process.platform === "linux") {
|
|
45
68
|
const env = fs.readFileSync(`/proc/${pid}/environ`, "utf8").split("\0");
|
|
46
69
|
if (env.includes("AGENTOS_SERVER=live")) return true;
|
|
47
70
|
} else {
|
|
48
|
-
const out =
|
|
49
|
-
"
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
);
|
|
71
|
+
const out = await probe("ps", [
|
|
72
|
+
"eww",
|
|
73
|
+
"-p",
|
|
74
|
+
String(pid),
|
|
75
|
+
"-o",
|
|
76
|
+
"command=",
|
|
77
|
+
]);
|
|
56
78
|
if (/\bAGENTOS_SERVER=live\b/.test(out)) return true;
|
|
57
79
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
return false;
|
|
80
|
+
return (
|
|
81
|
+
(await probe("ps", ["-p", String(pid), "-o", "comm="])).trim() ===
|
|
82
|
+
"agentos-server"
|
|
83
|
+
);
|
|
84
|
+
} catch (e) {
|
|
85
|
+
return e instanceof ProbeTimeout;
|
|
65
86
|
}
|
|
66
87
|
}
|
|
67
88
|
|
|
@@ -35,7 +35,7 @@ export async function startDevServer(opts: {
|
|
|
35
35
|
if (
|
|
36
36
|
existing &&
|
|
37
37
|
pidAlive(existing.pid) &&
|
|
38
|
-
samePath(processCwd(existing.pid) ?? "", worktree)
|
|
38
|
+
samePath((await processCwd(existing.pid)) ?? "", worktree)
|
|
39
39
|
)
|
|
40
40
|
return { ...existing, reused: true };
|
|
41
41
|
|
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import { execFileSync } from "child_process";
|
|
4
3
|
import { readLiveRecord } from "./liveServer";
|
|
5
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
pidAlive,
|
|
6
|
+
looksLive,
|
|
7
|
+
processCwd,
|
|
8
|
+
samePath,
|
|
9
|
+
probe,
|
|
10
|
+
ProbeTimeout,
|
|
11
|
+
} from "./processInfo";
|
|
6
12
|
import { readDevRecord, recordFile } from "./startDevServer";
|
|
7
13
|
|
|
8
14
|
export interface StopDeps {
|
|
9
15
|
kill: (pid: number, signal: NodeJS.Signals) => void;
|
|
10
|
-
cwdOf: (pid: number) => string | null
|
|
11
|
-
looksLive: (pid: number) => boolean
|
|
16
|
+
cwdOf: (pid: number) => string | null | Promise<string | null>;
|
|
17
|
+
looksLive: (pid: number) => boolean | Promise<boolean>;
|
|
12
18
|
livePid: () => number | null;
|
|
13
19
|
alive: (pid: number) => boolean;
|
|
14
20
|
sleep: (ms: number) => Promise<void>;
|
|
15
|
-
killTmux: (socket: string) => void
|
|
21
|
+
killTmux: (socket: string) => void | Promise<void>;
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
const realDeps: StopDeps = {
|
|
@@ -22,16 +28,13 @@ const realDeps: StopDeps = {
|
|
|
22
28
|
livePid: () => readLiveRecord()?.pid ?? null,
|
|
23
29
|
alive: pidAlive,
|
|
24
30
|
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
25
|
-
killTmux: (socket) => {
|
|
26
|
-
const env = { ...process.env };
|
|
27
|
-
delete env.TMUX;
|
|
31
|
+
killTmux: async (socket) => {
|
|
28
32
|
try {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
// No server on it.
|
|
33
|
+
await probe("env", ["-u", "TMUX", "tmux", "-S", socket, "kill-server"]);
|
|
34
|
+
} catch (e) {
|
|
35
|
+
// No server on it, or tmux did not answer in time.
|
|
36
|
+
if (e instanceof ProbeTimeout)
|
|
37
|
+
console.warn(`[dev-server] tmux on ${socket} did not answer; left`);
|
|
35
38
|
}
|
|
36
39
|
},
|
|
37
40
|
};
|
|
@@ -59,14 +62,14 @@ export async function stopDevServer(
|
|
|
59
62
|
ok: false,
|
|
60
63
|
message: `Refusing to stop pid ${rec.pid}: ${why}. Nothing was killed.`,
|
|
61
64
|
});
|
|
62
|
-
const cwd = d.cwdOf(rec.pid);
|
|
65
|
+
const cwd = await d.cwdOf(rec.pid);
|
|
63
66
|
if (cwd === null || !samePath(cwd, worktree))
|
|
64
67
|
return refuse(
|
|
65
68
|
`its working directory (${cwd ?? "unknown"}) is not ${worktree}`
|
|
66
69
|
);
|
|
67
70
|
if (rec.pid === process.pid || rec.pid === process.ppid)
|
|
68
71
|
return refuse("that is this command's own process");
|
|
69
|
-
if (rec.pid === d.livePid() || d.looksLive(rec.pid))
|
|
72
|
+
if (rec.pid === d.livePid() || (await d.looksLive(rec.pid)))
|
|
70
73
|
return refuse("it is the live AgentOS server");
|
|
71
74
|
|
|
72
75
|
const signal = (s: NodeJS.Signals) => {
|
|
@@ -89,7 +92,7 @@ export async function stopDevServer(
|
|
|
89
92
|
`tmux-${process.getuid?.() ?? 0}`,
|
|
90
93
|
"default"
|
|
91
94
|
);
|
|
92
|
-
if (fs.existsSync(sock)) d.killTmux(sock);
|
|
95
|
+
if (fs.existsSync(sock)) await d.killTmux(sock);
|
|
93
96
|
drop();
|
|
94
97
|
return {
|
|
95
98
|
ok: true,
|
package/lib/dev-servers.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { agentosHome } from "./agentos-home";
|
|
1
2
|
import { spawn, execFile } from "child_process";
|
|
2
3
|
import { promisify } from "util";
|
|
3
4
|
import fs from "fs";
|
|
@@ -12,7 +13,7 @@ const run = async (file: string, args: string[], cwd?: string) =>
|
|
|
12
13
|
// A compose service is a name, never an option or more than one word.
|
|
13
14
|
const COMPOSE_SERVICE = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
14
15
|
|
|
15
|
-
const LOGS_DIR =
|
|
16
|
+
const LOGS_DIR = agentosHome("logs");
|
|
16
17
|
|
|
17
18
|
// Ensure logs directory exists
|
|
18
19
|
if (!fs.existsSync(LOGS_DIR)) {
|
package/lib/done/index.ts
CHANGED
|
@@ -28,7 +28,7 @@ import { deleteMergedRemote } from "./remote";
|
|
|
28
28
|
import { settleWorktree, type WorktreeFate } from "./worktree";
|
|
29
29
|
import { sessionMergePolicy } from "../tasks/finish";
|
|
30
30
|
import { releasePorts } from "../ports";
|
|
31
|
-
import {
|
|
31
|
+
import { releaseSessionLeftovers } from "../sessions/leftovers";
|
|
32
32
|
import { doneOnPeer, peerSessionLink } from "../hosts/peer-actions";
|
|
33
33
|
import { hostLink } from "../hosts/remote-api";
|
|
34
34
|
import { doneHeldTask, isHeldRemote } from "../tasks/contract/held-calls";
|
|
@@ -70,7 +70,7 @@ async function stopAgent(s: Session): Promise<void> {
|
|
|
70
70
|
clearQueue(s.id);
|
|
71
71
|
releasePorts(s.id);
|
|
72
72
|
// Never waited on: the row is read now, and Postgres can't hold this up.
|
|
73
|
-
void
|
|
73
|
+
void releaseSessionLeftovers(s);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
function worktreeLine(f: WorktreeFate): string {
|