@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
|
@@ -41,7 +41,7 @@ export type PgRun = (
|
|
|
41
41
|
env: NodeJS.ProcessEnv
|
|
42
42
|
) => Promise<{ code: number; out: string }>;
|
|
43
43
|
|
|
44
|
-
const pgRun: PgRun = (cmd, args, env) =>
|
|
44
|
+
export const pgRun: PgRun = (cmd, args, env) =>
|
|
45
45
|
new Promise((resolve) => {
|
|
46
46
|
execFile(
|
|
47
47
|
cmd,
|
|
@@ -66,10 +66,10 @@ const pgRun: PgRun = (cmd, args, env) =>
|
|
|
66
66
|
// A plain database name: never a connection string (`host=…`,
|
|
67
67
|
// `postgres://…`) or an option, which pg_dump would take as one.
|
|
68
68
|
const DB_NAME = /^[A-Za-z0-9_][A-Za-z0-9_$.-]{0,62}$/;
|
|
69
|
-
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
|
69
|
+
export const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
|
70
70
|
// One socket folder: libpq reads a comma as a list of hosts to try in turn,
|
|
71
71
|
// so `/nope,db.example.com` would reach the second.
|
|
72
|
-
const SOCKET_DIR = /^\/[^,=\s'"\\]*$/;
|
|
72
|
+
export const SOCKET_DIR = /^\/[^,=\s'"\\]*$/;
|
|
73
73
|
|
|
74
74
|
// Why `declared` can't be used, or null. A socket folder is local too.
|
|
75
75
|
export function refusal(declared: DatabaseDecl): string | null {
|
|
@@ -86,13 +86,22 @@ export function refusal(declared: DatabaseDecl): string | null {
|
|
|
86
86
|
return null;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
const MARK = (sessionId: string) => `agentos session ${sessionId}`;
|
|
89
|
+
export const MARK = (sessionId: string) => `agentos session ${sessionId}`;
|
|
90
90
|
const PENDING = (sessionId: string) => `${MARK(sessionId)} (copying)`;
|
|
91
91
|
|
|
92
|
+
export const sessionHash = (sessionId: string) =>
|
|
93
|
+
createHash("sha256").update(sessionId).digest("hex").slice(0, 12);
|
|
94
|
+
|
|
95
|
+
// The token a session's copy carries, and so does anything a project derives
|
|
96
|
+
// from it (`<copy>_test`, `<from>_test_aos_<hash>`): the one convention that
|
|
97
|
+
// says AgentOS's session made a database.
|
|
98
|
+
const TASK_DB = /_aos_([0-9a-f]{12})(?:_[A-Za-z0-9]+)*$/;
|
|
99
|
+
export const taskHashOf = (name: string): string | null =>
|
|
100
|
+
TASK_DB.exec(name)?.[1] ?? null;
|
|
101
|
+
|
|
92
102
|
// `<from>_aos_<12 hex of the session id>`, within Postgres's 63 bytes.
|
|
93
103
|
export function databaseName(from: string, sessionId: string): string {
|
|
94
|
-
const
|
|
95
|
-
const suffix = `_aos_${hash.slice(0, 12)}`;
|
|
104
|
+
const suffix = `_aos_${sessionHash(sessionId)}`;
|
|
96
105
|
return `${from.slice(0, 63 - suffix.length)}${suffix}`;
|
|
97
106
|
}
|
|
98
107
|
|
|
@@ -122,7 +131,7 @@ const oneLine = (s: string, n = 200) => {
|
|
|
122
131
|
};
|
|
123
132
|
|
|
124
133
|
// Every database on the server with its comment, or why there's no answer.
|
|
125
|
-
async function listDatabases(
|
|
134
|
+
export async function listDatabases(
|
|
126
135
|
run: PgRun,
|
|
127
136
|
env: NodeJS.ProcessEnv
|
|
128
137
|
): Promise<Map<string, string> | string> {
|
|
@@ -165,7 +174,7 @@ async function copy(
|
|
|
165
174
|
// recognised and remade rather than left or reused half-filled.
|
|
166
175
|
const created = await run(
|
|
167
176
|
"createdb",
|
|
168
|
-
["-T", "template0", target, PENDING(sessionId)],
|
|
177
|
+
["-T", "template0", "--", target, PENDING(sessionId)],
|
|
169
178
|
env
|
|
170
179
|
);
|
|
171
180
|
if (created.code !== 0)
|
|
@@ -175,7 +184,7 @@ async function copy(
|
|
|
175
184
|
try {
|
|
176
185
|
let step = await run(
|
|
177
186
|
"pg_dump",
|
|
178
|
-
["--no-owner", "--no-privileges", "-f", dump, from],
|
|
187
|
+
["--no-owner", "--no-privileges", "-f", dump, "--", from],
|
|
179
188
|
env
|
|
180
189
|
);
|
|
181
190
|
if (step.code === 0) {
|
|
@@ -191,7 +200,7 @@ async function copy(
|
|
|
191
200
|
);
|
|
192
201
|
}
|
|
193
202
|
if (step.code !== 0) {
|
|
194
|
-
await run("dropdb", ["--if-exists", target], env);
|
|
203
|
+
await run("dropdb", ["--if-exists", "--", target], env);
|
|
195
204
|
return `could not copy ${from} into ${target} (${oneLine(step.out, 300)}); ${target} was dropped again`;
|
|
196
205
|
}
|
|
197
206
|
return null;
|
|
@@ -243,7 +252,7 @@ async function makeDatabase(
|
|
|
243
252
|
// An interrupted copy of ours: made again from the start.
|
|
244
253
|
if (!record({ ...state, name: target, pending: true }))
|
|
245
254
|
return { ...state, error: ENDED };
|
|
246
|
-
const dropped = await run("dropdb", ["--if-exists", target], env);
|
|
255
|
+
const dropped = await run("dropdb", ["--if-exists", "--", target], env);
|
|
247
256
|
if (dropped.code !== 0)
|
|
248
257
|
return {
|
|
249
258
|
...state,
|
|
@@ -399,7 +408,10 @@ async function dropDatabase(
|
|
|
399
408
|
console.warn(`[database] ${name} left: ${listed}`);
|
|
400
409
|
return;
|
|
401
410
|
}
|
|
402
|
-
if (!listed.has(name))
|
|
411
|
+
if (!listed.has(name)) {
|
|
412
|
+
await dropDerived(sessionId, state, listed, run, env);
|
|
413
|
+
return void forget();
|
|
414
|
+
}
|
|
403
415
|
const comment = listed.get(name);
|
|
404
416
|
if (comment !== MARK(sessionId) && comment !== PENDING(sessionId)) {
|
|
405
417
|
console.warn(`[database] ${name} left: AgentOS didn't make it`);
|
|
@@ -408,9 +420,52 @@ async function dropDatabase(
|
|
|
408
420
|
// The session is over: a dev server left running in its worktree and
|
|
409
421
|
// still connected is a straggler, ended by --force (Postgres 13+; a plain
|
|
410
422
|
// drop for an older server).
|
|
411
|
-
let dropped = await run(
|
|
423
|
+
let dropped = await run(
|
|
424
|
+
"dropdb",
|
|
425
|
+
["--if-exists", "--force", "--", name],
|
|
426
|
+
env
|
|
427
|
+
);
|
|
412
428
|
if (dropped.code !== 0)
|
|
413
|
-
dropped = await run("dropdb", ["--if-exists", name], env);
|
|
414
|
-
if (dropped.code === 0)
|
|
415
|
-
|
|
429
|
+
dropped = await run("dropdb", ["--if-exists", "--", name], env);
|
|
430
|
+
if (dropped.code === 0) {
|
|
431
|
+
await dropDerived(sessionId, state, listed, run, env);
|
|
432
|
+
forget();
|
|
433
|
+
} else console.warn(`[database] ${name} left: ${oneLine(dropped.out)}`);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// What the project's own tooling made from the copy (a Rails `_test`
|
|
437
|
+
// database): same session token, never the declared source.
|
|
438
|
+
async function dropDerived(
|
|
439
|
+
sessionId: string,
|
|
440
|
+
state: SessionDatabase,
|
|
441
|
+
listed: Map<string, string>,
|
|
442
|
+
run: PgRun,
|
|
443
|
+
env: NodeJS.ProcessEnv
|
|
444
|
+
): Promise<void> {
|
|
445
|
+
const hash = sessionHash(sessionId);
|
|
446
|
+
for (const other of listed.keys()) {
|
|
447
|
+
if (other === state.from || other === state.name) continue;
|
|
448
|
+
if (taskHashOf(other) !== hash) continue;
|
|
449
|
+
// A plain drop, and not while anyone is connected: it is another
|
|
450
|
+
// task's or a person's session if the token matches by accident.
|
|
451
|
+
const busy = await run(
|
|
452
|
+
"psql",
|
|
453
|
+
[
|
|
454
|
+
"-X",
|
|
455
|
+
"-d",
|
|
456
|
+
"postgres",
|
|
457
|
+
"-tAq",
|
|
458
|
+
"-c",
|
|
459
|
+
`select count(*) from pg_stat_activity where datname = ${quoteLiteral(other)}`,
|
|
460
|
+
],
|
|
461
|
+
env
|
|
462
|
+
);
|
|
463
|
+
if (busy.code !== 0 || Number(busy.out.trim()) > 0) {
|
|
464
|
+
console.warn(`[database] ${other} left: connected or unreadable`);
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
const done = await run("dropdb", ["--if-exists", "--", other], env);
|
|
468
|
+
if (done.code !== 0)
|
|
469
|
+
console.warn(`[database] ${other} left: ${oneLine(done.out)}`);
|
|
470
|
+
}
|
|
416
471
|
}
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
* an agent editing agentos.json on its branch can't change its own launch.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import fs from "fs";
|
|
7
8
|
import os from "os";
|
|
9
|
+
import { sessionTmpDir } from "../sessions/leftovers";
|
|
8
10
|
import { db } from "../db";
|
|
9
11
|
import { getProject } from "../projects";
|
|
10
12
|
import { sessionPorts } from "../ports";
|
|
@@ -35,6 +37,21 @@ export function sessionConfig(
|
|
|
35
37
|
return { ...loaded, ports: sessionPorts(sessionId) };
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
// A task's own temp folder, so what it leaves there is removed with it.
|
|
41
|
+
// Only a session in a worktree: an orchestrator or a terminal in a checkout
|
|
42
|
+
// is long-lived and keeps the system's.
|
|
43
|
+
function taskTmpEnv(sessionId: string): Record<string, string> {
|
|
44
|
+
const row = db
|
|
45
|
+
.prepare(`SELECT worktree_path FROM sessions WHERE id = ?`)
|
|
46
|
+
.get(sessionId) as { worktree_path: string | null } | undefined;
|
|
47
|
+
if (!row?.worktree_path) return {};
|
|
48
|
+
const dir = sessionTmpDir(sessionId);
|
|
49
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
50
|
+
// A folder left by an earlier run keeps its old mode otherwise.
|
|
51
|
+
fs.chmodSync(dir, 0o700);
|
|
52
|
+
return { TMPDIR: dir };
|
|
53
|
+
}
|
|
54
|
+
|
|
38
55
|
// The project's env and the session's ports. Never throws: a terminal or a
|
|
39
56
|
// chat must still open when the config can't be read.
|
|
40
57
|
export function sessionProjectEnv(sessionId: string): Record<string, string> {
|
|
@@ -43,6 +60,7 @@ export function sessionProjectEnv(sessionId: string): Record<string, string> {
|
|
|
43
60
|
return {
|
|
44
61
|
...projectEnv(config, ports),
|
|
45
62
|
...(config.database ? databaseEnv(sessionDatabase(sessionId)) : {}),
|
|
63
|
+
...taskTmpEnv(sessionId),
|
|
46
64
|
};
|
|
47
65
|
} catch (error) {
|
|
48
66
|
console.error(`[project-config] env for ${sessionId}:`, error);
|
|
@@ -10,10 +10,10 @@
|
|
|
10
10
|
* address. AgentOS never runs `tailscale serve` or funnel.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { agentosHome } from "../agentos-home";
|
|
13
14
|
import { execFile } from "child_process";
|
|
14
15
|
import fs from "fs";
|
|
15
16
|
import https from "https";
|
|
16
|
-
import os from "os";
|
|
17
17
|
import path from "path";
|
|
18
18
|
import type { IncomingMessage, ServerResponse } from "http";
|
|
19
19
|
import type { Socket } from "net";
|
|
@@ -76,7 +76,7 @@ function runCert(
|
|
|
76
76
|
/** Gets (or refreshes) the certificate; null when the tailnet can't issue one. */
|
|
77
77
|
export async function tailnetCert(
|
|
78
78
|
log: (line: string) => void = console.log,
|
|
79
|
-
dir =
|
|
79
|
+
dir = agentosHome("tls")
|
|
80
80
|
) {
|
|
81
81
|
const ts = await tailscaleStatus();
|
|
82
82
|
const bin = tailscaleBinary();
|
|
@@ -14,6 +14,7 @@ import { git } from "../git-exec";
|
|
|
14
14
|
import { PLAN_FILE } from "../models/plan-step";
|
|
15
15
|
import { listArtifacts } from "../artifacts/store";
|
|
16
16
|
import { allowedRoots, within } from "./access";
|
|
17
|
+
import { SCREENSHOT_DIR } from "../tasks/ui-brief";
|
|
17
18
|
import {
|
|
18
19
|
fromItems,
|
|
19
20
|
fromPr,
|
|
@@ -293,6 +294,20 @@ export async function collectArtifacts(
|
|
|
293
294
|
|
|
294
295
|
export const cachedCount = () => cache.size;
|
|
295
296
|
|
|
297
|
+
// What the UI brief has a terminal session save, whether or not a chat tool
|
|
298
|
+
// call named it: the PR names these files and the worktree is about to go.
|
|
299
|
+
export function screenshotsIn(worktree: string): string[] {
|
|
300
|
+
const dir = path.join(worktree, SCREENSHOT_DIR);
|
|
301
|
+
try {
|
|
302
|
+
return fs
|
|
303
|
+
.readdirSync(dir)
|
|
304
|
+
.filter((f) => /\.(png|jpe?g|webp)$/i.test(f))
|
|
305
|
+
.map((f) => path.join(dir, f));
|
|
306
|
+
} catch {
|
|
307
|
+
return [];
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
296
311
|
/**
|
|
297
312
|
* Before a session's worktree is removed: copy what it made there that git
|
|
298
313
|
* doesn't hold. Never throws, and never holds up the removal for long.
|
|
@@ -309,7 +324,9 @@ export async function keepSessionFiles(sessionId: string): Promise<void> {
|
|
|
309
324
|
const files = entries
|
|
310
325
|
.map((e) => e.read)
|
|
311
326
|
.filter((f): f is string => !!f && !!relInside(worktree, f));
|
|
312
|
-
await keepFiles(sessionId, worktree,
|
|
327
|
+
await keepFiles(sessionId, worktree, [
|
|
328
|
+
...new Set([...files, ...screenshotsIn(worktree)]),
|
|
329
|
+
]);
|
|
313
330
|
} catch (error) {
|
|
314
331
|
console.error(`[session-artifacts] keeping ${sessionId}:`, error);
|
|
315
332
|
} finally {
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* copy; the manifest records them so the view can say "in the PR".
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { agentosHome } from "../agentos-home";
|
|
8
9
|
import fs from "fs";
|
|
9
|
-
import os from "os";
|
|
10
10
|
import path from "path";
|
|
11
11
|
import { git } from "../git-exec";
|
|
12
12
|
import { isSecretPath } from "../secret-paths";
|
|
@@ -23,9 +23,7 @@ export interface Manifest {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export function keptDir(): string {
|
|
26
|
-
return (
|
|
27
|
-
process.env.AGENTOS_KEPT_DIR || path.join(os.homedir(), ".agent-os", "kept")
|
|
28
|
-
);
|
|
26
|
+
return process.env.AGENTOS_KEPT_DIR || agentosHome("kept");
|
|
29
27
|
}
|
|
30
28
|
|
|
31
29
|
const safeId = (sessionId: string) => sessionId.replace(/[^\w-]/g, "_");
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* one before.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { agentosHome } from "../agentos-home";
|
|
11
12
|
import fs from "fs";
|
|
12
|
-
import os from "os";
|
|
13
13
|
import path from "path";
|
|
14
14
|
import type { Summary } from "./aggregate";
|
|
15
15
|
|
|
@@ -19,10 +19,7 @@ export const AUDIT_NAME = /^[a-z0-9][a-z0-9-]{0,80}$/;
|
|
|
19
19
|
export const SESSIONS_KIND = "sessions";
|
|
20
20
|
|
|
21
21
|
export function auditsRoot(): string {
|
|
22
|
-
return (
|
|
23
|
-
process.env.AGENTOS_AUDITS_DIR ||
|
|
24
|
-
path.join(os.homedir(), ".agent-os", "audits")
|
|
25
|
-
);
|
|
22
|
+
return process.env.AGENTOS_AUDITS_DIR || agentosHome("audits");
|
|
26
23
|
}
|
|
27
24
|
|
|
28
25
|
export function auditsDir(): string {
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* can be public, and a log holds briefs and reviews.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { agentosHome } from "../agentos-home";
|
|
9
10
|
import fs from "fs";
|
|
10
|
-
import os from "os";
|
|
11
11
|
import path from "path";
|
|
12
12
|
import { db } from "../db";
|
|
13
13
|
import { slugify } from "../git";
|
|
@@ -17,8 +17,7 @@ import { expand, repoPaths, resolved, within } from "./paths";
|
|
|
17
17
|
|
|
18
18
|
// AGENTOS_SESSION_LOGS_DIR moves the default (tests keep it off the real one).
|
|
19
19
|
export const DEFAULT_ROOT = () =>
|
|
20
|
-
process.env.AGENTOS_SESSION_LOGS_DIR ||
|
|
21
|
-
path.join(os.homedir(), ".agent-os", "session-logs");
|
|
20
|
+
process.env.AGENTOS_SESSION_LOGS_DIR || agentosHome("session-logs");
|
|
22
21
|
|
|
23
22
|
const key = (projectId: string) => `session-logs.folder.${projectId}`;
|
|
24
23
|
|
package/lib/sessions/launch.ts
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
* then (for a worktree) the setup in the background, then the first message.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { agentosHome } from "../agentos-home";
|
|
6
7
|
import type { StartVia } from "../tasks/starter";
|
|
7
8
|
import fs from "fs";
|
|
8
9
|
import os from "os";
|
|
9
|
-
import path from "path";
|
|
10
10
|
import { randomUUID } from "crypto";
|
|
11
11
|
import { db, queries, type Session } from "../db";
|
|
12
12
|
import type { AgentType } from "../providers";
|
|
@@ -45,7 +45,7 @@ export const DEFAULT_START_VIEW: StartView = "chat";
|
|
|
45
45
|
export const startView = (v: unknown): StartView | undefined =>
|
|
46
46
|
v === "chat" || v === "terminal" ? v : undefined;
|
|
47
47
|
|
|
48
|
-
export const SCRATCH_DIR =
|
|
48
|
+
export const SCRATCH_DIR = agentosHome("scratch");
|
|
49
49
|
|
|
50
50
|
export interface LaunchInput {
|
|
51
51
|
// The client's key for this start (a draft's id): it becomes the
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a task leaves outside its checkout, released when it ends (merge,
|
|
3
|
+
* drop, done, move, delete): its dev server, its private database and what
|
|
4
|
+
* the project derived from it, and its temp folder. The worktree itself is
|
|
5
|
+
* removed by the caller, which knows whether anything in it would be lost.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import os from "os";
|
|
10
|
+
import path from "path";
|
|
11
|
+
import { stopDevServer } from "../dev-instance/stopDevServer";
|
|
12
|
+
import { dropSessionDatabase } from "../project-config/database";
|
|
13
|
+
|
|
14
|
+
export const SESSION_TMP_PREFIX = "agentos-s-";
|
|
15
|
+
|
|
16
|
+
// The whole id: a shortened one could name two sessions' folders alike.
|
|
17
|
+
export const tmpName = (sessionId: string) => sessionId.replace(/[^\w-]/g, "");
|
|
18
|
+
|
|
19
|
+
export const sessionTmpDir = (sessionId: string) =>
|
|
20
|
+
path.join(os.tmpdir(), `${SESSION_TMP_PREFIX}${tmpName(sessionId)}`);
|
|
21
|
+
|
|
22
|
+
export interface LeftoverDeps {
|
|
23
|
+
stopDev: (worktree: string) => Promise<unknown>;
|
|
24
|
+
dropDatabase: (sessionId: string) => Promise<void>;
|
|
25
|
+
removeDir: (dir: string) => Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const realDeps: LeftoverDeps = {
|
|
29
|
+
stopDev: (worktree) => stopDevServer(worktree),
|
|
30
|
+
dropDatabase: dropSessionDatabase,
|
|
31
|
+
removeDir: (dir) => fs.promises.rm(dir, { recursive: true, force: true }),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Never throws and never waits on a step that can't be reached: each is
|
|
36
|
+
* tried on its own. Callers read the session row first and don't await this.
|
|
37
|
+
*/
|
|
38
|
+
export async function releaseSessionLeftovers(
|
|
39
|
+
session: { id: string; worktree_path?: string | null },
|
|
40
|
+
deps: Partial<LeftoverDeps> = {}
|
|
41
|
+
): Promise<void> {
|
|
42
|
+
const d = { ...realDeps, ...deps };
|
|
43
|
+
const steps: Array<[string, () => Promise<unknown>]> = [
|
|
44
|
+
// First: its row is read before the caller deletes it.
|
|
45
|
+
["database", () => d.dropDatabase(session.id)],
|
|
46
|
+
[
|
|
47
|
+
"dev server",
|
|
48
|
+
async () => {
|
|
49
|
+
if (session.worktree_path) await d.stopDev(session.worktree_path);
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
["temp folder", () => d.removeDir(sessionTmpDir(session.id))],
|
|
53
|
+
];
|
|
54
|
+
for (const [what, step] of steps) {
|
|
55
|
+
try {
|
|
56
|
+
await step();
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error(`[leftovers] ${what} for ${session.id}:`, error);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
* here, so it can only drive its own simulator.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { agentosHome } from "../agentos-home";
|
|
8
9
|
import { execFile } from "child_process";
|
|
9
10
|
import fs from "fs";
|
|
10
|
-
import os from "os";
|
|
11
11
|
import path from "path";
|
|
12
12
|
import { promisify } from "util";
|
|
13
13
|
|
|
@@ -15,7 +15,7 @@ const execFileAsync = promisify(execFile);
|
|
|
15
15
|
|
|
16
16
|
export const AGENT_DEVICE_VERSION = "0.21.27";
|
|
17
17
|
|
|
18
|
-
const root = () =>
|
|
18
|
+
const root = () => agentosHome();
|
|
19
19
|
export const toolDir = () =>
|
|
20
20
|
path.join(root(), "tools", `agent-device-${AGENT_DEVICE_VERSION}`);
|
|
21
21
|
export const toolBin = () =>
|
package/lib/tasks/finish.ts
CHANGED
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
prFor,
|
|
35
35
|
projectPathFor,
|
|
36
36
|
} from "./session";
|
|
37
|
-
import {
|
|
37
|
+
import { releaseSessionLeftovers } from "../sessions/leftovers";
|
|
38
38
|
import { captureMergedTask } from "../session-logs";
|
|
39
39
|
import { sessionEnded } from "../experiments/hooks";
|
|
40
40
|
import { snapshotWorktree } from "../resurrect/snapshot";
|
|
@@ -62,7 +62,7 @@ async function cleanup(
|
|
|
62
62
|
const keepRemoteBranch = opts.keepRemoteBranch ?? false;
|
|
63
63
|
await endSessionAgent(session);
|
|
64
64
|
// Never waited on: the row is read now, and Postgres can't hold this up.
|
|
65
|
-
void
|
|
65
|
+
void releaseSessionLeftovers(session);
|
|
66
66
|
if (session.worktree_path && opts.merged) {
|
|
67
67
|
const fate = await settleWorktree(session, opts.merged).catch(() => null);
|
|
68
68
|
if (fate?.action === "kept")
|
package/lib/tasks/import.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* again returns the task it made the first time.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { releaseSessionLeftovers } from "../sessions/leftovers";
|
|
8
9
|
import fs from "fs";
|
|
9
10
|
import os from "os";
|
|
10
11
|
import path from "path";
|
|
@@ -13,10 +14,7 @@ import { db, queries, type Session } from "../db";
|
|
|
13
14
|
import { setupWorktree } from "../env-setup";
|
|
14
15
|
import { allocatePorts } from "../ports";
|
|
15
16
|
import { loadProjectConfig, portBases } from "../project-config";
|
|
16
|
-
import {
|
|
17
|
-
dropSessionDatabase,
|
|
18
|
-
ensureSessionDatabase,
|
|
19
|
-
} from "../project-config/database";
|
|
17
|
+
import { ensureSessionDatabase } from "../project-config/database";
|
|
20
18
|
import { runInBackground } from "../async-operations";
|
|
21
19
|
import { launchClaude } from "../agents/launch";
|
|
22
20
|
import { resolveModelForAgent } from "../model-catalog";
|
|
@@ -238,7 +236,7 @@ async function arrive(
|
|
|
238
236
|
} catch (err) {
|
|
239
237
|
// Nothing runs here, so the source may resume it: leave no row behind,
|
|
240
238
|
// and no database (the drop reads the row before it goes).
|
|
241
|
-
void
|
|
239
|
+
void releaseSessionLeftovers({ id });
|
|
242
240
|
if (bundle.chat) {
|
|
243
241
|
stopChat(id);
|
|
244
242
|
dropChat(id);
|
package/lib/tasks/move.ts
CHANGED
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
} from "./move-bundle";
|
|
27
27
|
import { stepProgress } from "./move-progress";
|
|
28
28
|
import { PLAN_FILE } from "../models/plan-step";
|
|
29
|
-
import {
|
|
29
|
+
import { releaseSessionLeftovers } from "../sessions/leftovers";
|
|
30
30
|
import { releaseChat } from "../chat/runner";
|
|
31
31
|
import { collectWhileMoving } from "../chat/hold";
|
|
32
32
|
import { packChat, stopChatForMove } from "./move-chat";
|
|
@@ -258,7 +258,7 @@ export function markMoved(id: string, to: string): void {
|
|
|
258
258
|
{ task_status: ["running", "moving"] }
|
|
259
259
|
);
|
|
260
260
|
// It goes on there with a copy of its own.
|
|
261
|
-
if (moved) void
|
|
261
|
+
if (moved) void releaseSessionLeftovers({ id });
|
|
262
262
|
}
|
|
263
263
|
|
|
264
264
|
/**
|
package/lib/tasks/moved-away.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* no commit a remote lacks). The row stays, archived, as history.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { releaseSessionLeftovers } from "../sessions/leftovers";
|
|
8
9
|
import { db, type Session } from "../db";
|
|
9
10
|
import { endSessionAgent } from "../sessions/end";
|
|
10
11
|
import { releasePorts } from "../ports";
|
|
@@ -28,6 +29,7 @@ export async function cleanUpMovedAway(id: string): Promise<WorktreeFate> {
|
|
|
28
29
|
path: s.worktree_path ?? "",
|
|
29
30
|
})
|
|
30
31
|
);
|
|
32
|
+
void releaseSessionLeftovers(s);
|
|
31
33
|
archiveSession(id);
|
|
32
34
|
if (fate.action === "kept")
|
|
33
35
|
console.warn(
|
package/lib/tasks/state.ts
CHANGED
|
@@ -137,6 +137,10 @@ const UNFINISHED = [
|
|
|
137
137
|
const outcome = (c: RollupEntry) =>
|
|
138
138
|
(c.conclusion || c.state || c.status || "").toUpperCase();
|
|
139
139
|
|
|
140
|
+
// A skipped or neutral run is settled and says nothing about a rerun, so it
|
|
141
|
+
// never makes a check look unsettled.
|
|
142
|
+
const counts = (c: RollupEntry) => !["SKIPPED", "NEUTRAL"].includes(outcome(c));
|
|
143
|
+
|
|
140
144
|
// Job names repeat across workflows, and a status context can share a check
|
|
141
145
|
// run's name, so a check is its kind, workflow and name together.
|
|
142
146
|
const checkKey = (c: RollupEntry) => {
|
|
@@ -198,7 +202,7 @@ const groupByCheck = <T extends RollupEntry>(rollup: T[]) => {
|
|
|
198
202
|
// rollup can't tell one attempt from the next.
|
|
199
203
|
function shownAttempts(rollup: RollupEntry[]): Map<string, number | null> {
|
|
200
204
|
const shown = new Map<string, number | null>();
|
|
201
|
-
for (const c of rollup) {
|
|
205
|
+
for (const c of rollup.filter(counts)) {
|
|
202
206
|
const run = actionsRunOf(c);
|
|
203
207
|
if (!run) continue;
|
|
204
208
|
const key = `${run.repo}#${run.id}`;
|
|
@@ -226,7 +230,7 @@ export function rerunsToLookUp(
|
|
|
226
230
|
string,
|
|
227
231
|
{ repo: string; id: string; attempt: string | null }
|
|
228
232
|
>();
|
|
229
|
-
for (const group of groupByCheck(rollup).values()) {
|
|
233
|
+
for (const group of groupByCheck(rollup.filter(counts)).values()) {
|
|
230
234
|
if (group.length < 2) continue;
|
|
231
235
|
for (const c of group) {
|
|
232
236
|
const run = actionsRunOf(c);
|
|
@@ -259,7 +263,7 @@ export function settleReruns<T extends RollupEntry>(
|
|
|
259
263
|
const shown = shownAttempts(rollup);
|
|
260
264
|
const drop = new Set<RollupEntry>();
|
|
261
265
|
const unsettled: RollupEntry[] = [];
|
|
262
|
-
for (const group of groupByCheck(rollup).values()) {
|
|
266
|
+
for (const group of groupByCheck(rollup.filter(counts)).values()) {
|
|
263
267
|
if (group.length < 2) continue;
|
|
264
268
|
const located = group.map((c) => ({ c, run: actionsRunOf(c) }));
|
|
265
269
|
// Not all GitHub Actions: nothing says which run is stale, so all count.
|
package/lib/tasks/ui-brief.ts
CHANGED
|
@@ -80,6 +80,6 @@ If your diff changes what a user sees, run the app and look at every changed scr
|
|
|
80
80
|
- Log in and go to pages by URL, from the login, pages and map under "Running this project" when they are there. Exploring the UI to find a page is the most expensive thing a session does.
|
|
81
81
|
- One snapshot to find a uid, then act. Verify with a screenshot of the one element or a small \`evaluate_script\` returning the one value you need, not another full snapshot.
|
|
82
82
|
- Verify the thing that would be wrong, not the thing that is easy to see: a page that rendered proves it rendered. Fix anything visibly off (gaps, clipping, overlap, horizontal scroll, stray bands, misalignment; measure rects with \`evaluate_script\` and report numbers) and read the console for errors.
|
|
83
|
-
- Save screenshots of the final changed screens with \`filePath\` under \`${SCREENSHOT_DIR}/\`, after \`mkdir -p ${SCREENSHOT_DIR} && printf '*\\n' > .agentos/.gitignore\`. That folder is inside the browser's allowed root and git ignores it; a path in /tmp is refused.
|
|
84
|
-
- Add a **Browser check** section to the PR body: the pages and widths you checked, what you fixed, and the screenshot file names. If you didn't check (no visible change, or no browser), say why in one line.`;
|
|
83
|
+
- Save screenshots of the final changed screens with \`filePath\` under \`${SCREENSHOT_DIR}/\`, after \`mkdir -p ${SCREENSHOT_DIR} && printf '*\\n' > .agentos/.gitignore\`. That folder is inside the browser's allowed root and git ignores it; a path in /tmp is refused. AgentOS keeps these files after the worktree is removed.
|
|
84
|
+
- Add a **Browser check** section to the PR body: the pages and widths you checked, what you fixed, and the screenshot file names (names only, never a worktree path: the worktree is removed when the task ends). If you didn't check (no visible change, or no browser), say why in one line.`;
|
|
85
85
|
}
|
package/lib/tmux/exec.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFile } from "child_process";
|
|
2
2
|
import { promisify } from "util";
|
|
3
|
+
import { sessionBaseEnv } from "../agents/server-env";
|
|
3
4
|
|
|
4
5
|
const execFileAsync = promisify(execFile);
|
|
5
6
|
|
|
@@ -9,6 +10,7 @@ export async function tmux(args: string[], timeout = 10_000): Promise<string> {
|
|
|
9
10
|
const { stdout } = await execFileAsync("tmux", args, {
|
|
10
11
|
timeout,
|
|
11
12
|
maxBuffer: 8 << 20,
|
|
13
|
+
env: sessionBaseEnv(),
|
|
12
14
|
});
|
|
13
15
|
return stdout;
|
|
14
16
|
}
|
|
@@ -24,7 +26,7 @@ export async function pasteText(
|
|
|
24
26
|
const child = execFile(
|
|
25
27
|
"tmux",
|
|
26
28
|
["load-buffer", "-b", buffer, "-"],
|
|
27
|
-
{ timeout: 10_000 },
|
|
29
|
+
{ timeout: 10_000, env: sessionBaseEnv() },
|
|
28
30
|
(err) => (err ? reject(err) : resolve())
|
|
29
31
|
);
|
|
30
32
|
// tmux gone before it read everything is a failed paste, not a crash.
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
* none works, and the caller installs or copies plainly instead.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { agentosHome } from "../agentos-home";
|
|
8
9
|
import { execFile } from "child_process";
|
|
9
10
|
import * as fs from "fs";
|
|
10
|
-
import * as os from "os";
|
|
11
11
|
import * as path from "path";
|
|
12
12
|
import { promisify } from "util";
|
|
13
13
|
import type { CopyMode } from "./spare";
|
|
@@ -18,7 +18,7 @@ export type FastMode = Exclude<CopyMode, "copy">;
|
|
|
18
18
|
|
|
19
19
|
// Where hardlinked dependencies are kept: one real copy per lockfile, so a
|
|
20
20
|
// write through a link can only ever reach this, never the main checkout.
|
|
21
|
-
export const STORE_ROOT =
|
|
21
|
+
export const STORE_ROOT = agentosHome("deps-store");
|
|
22
22
|
|
|
23
23
|
// Files tools rewrite in place. A hardlink would carry the write back into
|
|
24
24
|
// the store, so these are real copies in every worktree.
|
|
@@ -8,13 +8,13 @@
|
|
|
8
8
|
* one made before the dependencies changed is never taken, and is pruned.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { agentosHome } from "../agentos-home";
|
|
11
12
|
import { createHash } from "crypto";
|
|
12
13
|
import { spawn } from "child_process";
|
|
13
14
|
import * as fs from "fs";
|
|
14
|
-
import * as os from "os";
|
|
15
15
|
import * as path from "path";
|
|
16
16
|
|
|
17
|
-
export const SPARE_ROOT =
|
|
17
|
+
export const SPARE_ROOT = agentosHome("spare");
|
|
18
18
|
|
|
19
19
|
// A half-built spare left by a restart is removed after this long.
|
|
20
20
|
const BUILDING_TTL_MS = 30 * 60 * 1000;
|