@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
|
@@ -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 {
|
package/lib/health/boots.ts
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
* next boot reads it back.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import fs from "fs";
|
|
10
|
+
import os from "os";
|
|
11
|
+
import path from "path";
|
|
9
12
|
import { db } from "../db";
|
|
10
13
|
import { gitSync } from "../git-exec";
|
|
11
14
|
import { getSetting, putSetting } from "../settings-store";
|
|
@@ -62,6 +65,7 @@ export interface BootRow {
|
|
|
62
65
|
head: string | null;
|
|
63
66
|
prev_seen_at: number | null;
|
|
64
67
|
prev_bg: string | null;
|
|
68
|
+
deploy: number | null;
|
|
65
69
|
}
|
|
66
70
|
|
|
67
71
|
export const bootsSince = (since: number) =>
|
|
@@ -76,4 +80,37 @@ export const recentBoots = (n = 6) =>
|
|
|
76
80
|
.prepare(`SELECT * FROM health_boots ORDER BY started_at DESC LIMIT ?`)
|
|
77
81
|
.all(n) as BootRow[];
|
|
78
82
|
|
|
83
|
+
export const DEPLOY_RESTART_FILE = path.join(
|
|
84
|
+
os.homedir(),
|
|
85
|
+
".agent-os",
|
|
86
|
+
"autodeploy",
|
|
87
|
+
"restarted"
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
// When autodeploy last restarted the server (it writes epoch seconds once the
|
|
91
|
+
// server is back up), or null.
|
|
92
|
+
export function lastDeployRestart(file = DEPLOY_RESTART_FILE): number | null {
|
|
93
|
+
try {
|
|
94
|
+
const s = Number.parseInt(fs.readFileSync(file, "utf8").trim(), 10);
|
|
95
|
+
return Number.isFinite(s) && s > 0 ? s * 1000 : null;
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Autodeploy writes its file up to the startup wait after the restart.
|
|
102
|
+
const DEPLOY_LOOKBACK_MS = 15 * MIN;
|
|
103
|
+
|
|
104
|
+
// Stamps the latest boot that lines up with a planned restart, so it isn't
|
|
105
|
+
// counted as an unexpected one. A crash loop during a deploy keeps its extra
|
|
106
|
+
// boots.
|
|
107
|
+
export function markDeployBoot(restartedAt: number | null): void {
|
|
108
|
+
if (restartedAt === null) return;
|
|
109
|
+
db.prepare(
|
|
110
|
+
`UPDATE health_boots SET deploy = 1 WHERE id = (
|
|
111
|
+
SELECT id FROM health_boots WHERE started_at BETWEEN ? AND ?
|
|
112
|
+
ORDER BY started_at DESC LIMIT 1)`
|
|
113
|
+
).run(restartedAt - DEPLOY_LOOKBACK_MS, restartedAt + MIN);
|
|
114
|
+
}
|
|
115
|
+
|
|
79
116
|
export const BOOT_WINDOW_MS = 24 * 60 * MIN;
|
package/lib/health/ci.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* How long CI takes, learned from what the orchestrator already sees. Each
|
|
3
3
|
* diff it reads every open PR's verdict; the first time a head is pending is
|
|
4
4
|
* when its run started (to within a diff), and the first time it isn't is
|
|
5
|
-
* when it settled. No extra gh call.
|
|
5
|
+
* when it settled. No extra gh call. A row lives only while its PR is open.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { db } from "../db";
|
|
@@ -19,6 +19,7 @@ export function observeCi(
|
|
|
19
19
|
now: number
|
|
20
20
|
): void {
|
|
21
21
|
db.transaction(() => {
|
|
22
|
+
const open = new Set<string>();
|
|
22
23
|
for (const f of facts) {
|
|
23
24
|
const pr = f.task?.pr;
|
|
24
25
|
const project = f.project;
|
|
@@ -31,6 +32,7 @@ export function observeCi(
|
|
|
31
32
|
).run(project, pr.number);
|
|
32
33
|
continue;
|
|
33
34
|
}
|
|
35
|
+
open.add(`${project}#${pr.number}`);
|
|
34
36
|
if (pr.checks === "pending") {
|
|
35
37
|
// A newer head replaces the run still pending on an older one.
|
|
36
38
|
db.prepare(
|
|
@@ -50,13 +52,32 @@ export function observeCi(
|
|
|
50
52
|
).run(now, project, pr.number, head);
|
|
51
53
|
}
|
|
52
54
|
}
|
|
55
|
+
// A PR that left the facts (done, archived) can't be seen merging.
|
|
56
|
+
const rows = db
|
|
57
|
+
.prepare(
|
|
58
|
+
`SELECT project_id, pr_number FROM ci_runs
|
|
59
|
+
WHERE workspace_id = ? AND settled_at IS NULL`
|
|
60
|
+
)
|
|
61
|
+
.all(workspaceId) as { project_id: string; pr_number: number }[];
|
|
62
|
+
for (const r of rows)
|
|
63
|
+
if (!open.has(`${r.project_id}#${r.pr_number}`))
|
|
64
|
+
db.prepare(
|
|
65
|
+
`DELETE FROM ci_runs WHERE project_id = ? AND pr_number = ?
|
|
66
|
+
AND settled_at IS NULL`
|
|
67
|
+
).run(r.project_id, r.pr_number);
|
|
53
68
|
})();
|
|
54
69
|
}
|
|
55
70
|
|
|
56
71
|
export function pendingCi(now: number): CiFact[] {
|
|
57
72
|
const rows = db
|
|
58
73
|
.prepare(
|
|
59
|
-
`SELECT
|
|
74
|
+
`SELECT r.* FROM ci_runs r
|
|
75
|
+
WHERE r.settled_at IS NULL AND r.pending_since >= ?
|
|
76
|
+
AND NOT EXISTS (
|
|
77
|
+
SELECT 1 FROM session_rows s JOIN projects p ON p.id = s.project_id
|
|
78
|
+
WHERE p.name = r.project_id AND s.pr_number = r.pr_number
|
|
79
|
+
AND (s.pr_status IN ('merged', 'closed')
|
|
80
|
+
OR s.task_status = 'merged'))`
|
|
60
81
|
)
|
|
61
82
|
.all(now - LOST_AFTER_MS) as {
|
|
62
83
|
project_id: string;
|
package/lib/health/detectors.ts
CHANGED
|
@@ -90,12 +90,13 @@ export function restarts(f: HealthFacts, c: HealthConfig): Trip[] {
|
|
|
90
90
|
(t) => f.now - t <= c.restartsWindowMinutes * MIN
|
|
91
91
|
);
|
|
92
92
|
if (recent.length <= c.restartsMax) return [];
|
|
93
|
+
const deploys = f.deploys.length;
|
|
93
94
|
return [
|
|
94
95
|
trip({
|
|
95
96
|
check: "restarts",
|
|
96
97
|
subject: "server",
|
|
97
98
|
severity: "high",
|
|
98
|
-
line: `AgentOS restarted ${recent.length} times in the last ${c.restartsWindowMinutes} minutes.`,
|
|
99
|
+
line: `AgentOS restarted ${recent.length} times in the last ${c.restartsWindowMinutes} minutes${deploys ? ` (besides ${deploys} autodeploy ${deploys === 1 ? "restart" : "restarts"})` : ""}.`,
|
|
99
100
|
detail: { boots: recent },
|
|
100
101
|
}),
|
|
101
102
|
];
|
package/lib/health/evidence.ts
CHANGED
|
@@ -96,7 +96,7 @@ function pipelineSnapshot(f: HealthFacts): string {
|
|
|
96
96
|
lines.push("Recent server starts:");
|
|
97
97
|
for (const b of recentBoots(6))
|
|
98
98
|
lines.push(
|
|
99
|
-
`- ${ago(f.now, b.started_at)}${b.head ? ` at ${b.head}` : ""}${b.prev_seen_at ? `; the previous run was last seen ${ago(f.now, b.prev_seen_at)}` : ""}`
|
|
99
|
+
`- ${ago(f.now, b.started_at)}${b.deploy ? " (autodeploy restart)" : ""}${b.head ? ` at ${b.head}` : ""}${b.prev_seen_at ? `; the previous run was last seen ${ago(f.now, b.prev_seen_at)}` : ""}`
|
|
100
100
|
);
|
|
101
101
|
lines.push(
|
|
102
102
|
`Server errors in the last 10 minutes: ${f.errors.recent} (usual ${Math.round(f.errors.baselineMean)}).`
|
package/lib/health/facts.ts
CHANGED
|
@@ -144,6 +144,7 @@ function armed(): ArmedFact[] {
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
export function gatherFacts(now: number, c: HealthConfig): HealthFacts {
|
|
147
|
+
const recent = bootsSince(now - c.restartsWindowMinutes * MIN);
|
|
147
148
|
return {
|
|
148
149
|
now,
|
|
149
150
|
machines: machines(now),
|
|
@@ -151,9 +152,8 @@ export function gatherFacts(now: number, c: HealthConfig): HealthFacts {
|
|
|
151
152
|
bg: backgroundJobs(now, c),
|
|
152
153
|
armed: armed(),
|
|
153
154
|
ci: pendingCi(now),
|
|
154
|
-
boots:
|
|
155
|
-
|
|
156
|
-
),
|
|
155
|
+
boots: recent.filter((b) => !b.deploy).map((b) => b.started_at),
|
|
156
|
+
deploys: recent.filter((b) => b.deploy).map((b) => b.started_at),
|
|
157
157
|
errors: errorRate(now),
|
|
158
158
|
};
|
|
159
159
|
}
|
package/lib/health/fixtures.ts
CHANGED
package/lib/health/tick.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { runClaude } from "../orchestrator/claude-cli";
|
|
9
|
-
import { leaveHeartbeat } from "./boots";
|
|
9
|
+
import { lastDeployRestart, leaveHeartbeat, markDeployBoot } from "./boots";
|
|
10
10
|
import { healthConfig, type HealthConfig } from "./config";
|
|
11
11
|
import { runDetectors } from "./detectors";
|
|
12
12
|
import { gatherFacts, liveBackgroundJobs } from "./facts";
|
|
@@ -134,6 +134,11 @@ export function healthTick(
|
|
|
134
134
|
if (now - last < EVERY_MS) return [];
|
|
135
135
|
last = now;
|
|
136
136
|
const c = deps.config();
|
|
137
|
+
try {
|
|
138
|
+
markDeployBoot(lastDeployRestart());
|
|
139
|
+
} catch (error) {
|
|
140
|
+
console.error("[health] deploy restarts:", error);
|
|
141
|
+
}
|
|
137
142
|
const facts = deps.facts(now, c);
|
|
138
143
|
const found = recordTrips(runDetectors(facts, c), TICK_CHECKS, now);
|
|
139
144
|
for (const row of found.closed) deliverClosed(row, now, deps.route);
|
package/lib/health/types.ts
CHANGED
|
@@ -105,6 +105,8 @@ export interface HealthFacts {
|
|
|
105
105
|
ci: CiFact[];
|
|
106
106
|
// Server start times inside the restart window.
|
|
107
107
|
boots: number[];
|
|
108
|
+
// Autodeploy's planned restarts inside the same window.
|
|
109
|
+
deploys: number[];
|
|
108
110
|
errors: { recent: number; baselineMean: number };
|
|
109
111
|
}
|
|
110
112
|
|
package/lib/hosts/attach.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { SSH_OPTIONS, loginShellCommand, shellQuote } from "./ssh";
|
|
2
2
|
import { isValidTmuxName } from "./tmux-name";
|
|
3
|
+
import { SERVER_ONLY_ENV } from "../agents/server-env";
|
|
4
|
+
|
|
5
|
+
// For a shared tmux server that already holds them: attach is the one path
|
|
6
|
+
// that cannot prefix the pane command.
|
|
7
|
+
const UNSET_GLOBAL_ENV = SERVER_ONLY_ENV.map(
|
|
8
|
+
(k) => `set-environment -gu ${k} \\;`
|
|
9
|
+
).join(" ");
|
|
3
10
|
|
|
4
11
|
export interface AttachSpec {
|
|
5
12
|
sessionName: string;
|
|
@@ -28,7 +35,7 @@ export function buildTmuxAttachCommand(spec: AttachSpec): string {
|
|
|
28
35
|
}
|
|
29
36
|
// Commands chained after an attaching command only run once the client
|
|
30
37
|
// detaches, so options go first; start-server keeps a fresh server alive.
|
|
31
|
-
const prefix =
|
|
38
|
+
const prefix = `tmux start-server \\; set -g mouse on \\; ${UNSET_GLOBAL_ENV}`;
|
|
32
39
|
if (spec.attachOnly) {
|
|
33
40
|
// Quoted: zsh expands a bare "=word" to a command path.
|
|
34
41
|
return `${prefix} attach-session -t ${shellQuote(`=${spec.sessionName}`)}`;
|
package/lib/orchestration.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { resolveModelForAgent } from "./model-catalog";
|
|
|
15
15
|
import { type AgentType, getProvider } from "./providers";
|
|
16
16
|
import { statusDetector } from "./status-detector";
|
|
17
17
|
import { wrapWithBanner } from "./banner";
|
|
18
|
+
import { UNSET_SERVER_ENV } from "./agents/server-env";
|
|
18
19
|
import { runInBackground } from "./async-operations";
|
|
19
20
|
import { tmux } from "./tmux/exec";
|
|
20
21
|
import { endTmuxSession } from "./tmux/end";
|
|
@@ -168,7 +169,8 @@ export async function spawnWorker(
|
|
|
168
169
|
|
|
169
170
|
// Create tmux session with the agent and banner
|
|
170
171
|
const agentCmd = `${provider.command} ${flagsStr}`;
|
|
171
|
-
const newSessionCmd =
|
|
172
|
+
const newSessionCmd =
|
|
173
|
+
UNSET_SERVER_ENV + wrapWithBanner(agentCmd, provider.preamble);
|
|
172
174
|
try {
|
|
173
175
|
await tmux(["set", "-g", "mouse", "on"]).catch(() => {});
|
|
174
176
|
// The last argument is the agent's command line, which tmux runs.
|
package/lib/orchestrator/home.ts
CHANGED
|
@@ -5,10 +5,9 @@
|
|
|
5
5
|
* sessions.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { agentosHome } from "../agentos-home";
|
|
8
9
|
import { resolveRoleOrDefault } from "../models/resolve";
|
|
9
10
|
import fs from "fs";
|
|
10
|
-
import os from "os";
|
|
11
|
-
import path from "path";
|
|
12
11
|
import { randomBytes, randomUUID, timingSafeEqual } from "crypto";
|
|
13
12
|
import { db, type Session } from "../db";
|
|
14
13
|
import { getWorkspace } from "../workspaces";
|
|
@@ -16,9 +15,7 @@ import { refuseRealStateUnderTest } from "../test-home-guard";
|
|
|
16
15
|
|
|
17
16
|
// It works through tools, not in a repo: a scratch folder of its own.
|
|
18
17
|
export const orchestratorDir = (workspaceId: string) =>
|
|
19
|
-
refuseRealStateUnderTest(
|
|
20
|
-
path.join(os.homedir(), ".agent-os", "orchestrators", workspaceId)
|
|
21
|
-
);
|
|
18
|
+
refuseRealStateUnderTest(agentosHome("orchestrators", workspaceId));
|
|
22
19
|
|
|
23
20
|
export function getOrchestrator(workspaceId: string): Session | null {
|
|
24
21
|
return (
|
|
@@ -229,6 +229,8 @@ function aosRefusal(args: string[]): string | null {
|
|
|
229
229
|
resurrect: T.resurrect,
|
|
230
230
|
stop: T.stop,
|
|
231
231
|
};
|
|
232
|
+
if (sub === "sweep" && args.includes("--arm"))
|
|
233
|
+
return "Refused: `aos sweep --arm` turns on removing files and databases, which a person decides: Health, Clean-up, Turn on. `--dry-run` and `--disarm` are fine.";
|
|
232
234
|
if (sub && tool[sub])
|
|
233
235
|
return `Refused: \`aos ${sub}\` isn't for you; use ${tool[sub]}, which is scoped to this workspace and held by Pause.`;
|
|
234
236
|
return null;
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* `..` resolved the way the system will resolve it.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { agentosHome } from "../agentos-home";
|
|
15
16
|
import { execFile } from "child_process";
|
|
16
17
|
import fs from "fs";
|
|
17
18
|
import os from "os";
|
|
@@ -296,9 +297,9 @@ export async function orchestratorWriteBounds(
|
|
|
296
297
|
memory: [...new Set([cwd, await physicalPath(cwd)])].map((c) =>
|
|
297
298
|
path.join(os.homedir(), ".claude", "projects", mangleCwd(c), "memory")
|
|
298
299
|
),
|
|
299
|
-
audits:
|
|
300
|
+
audits: agentosHome("audits"),
|
|
300
301
|
quick: quickWorktrees(session.id),
|
|
301
|
-
state:
|
|
302
|
+
state: agentosHome(),
|
|
302
303
|
repos: [...repos, WORKTREES_DIR, process.cwd(), path.dirname(dbPath)],
|
|
303
304
|
};
|
|
304
305
|
}
|
package/lib/plans/features.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// A plan that split into pieces: the feature, its pieces, and the one-time
|
|
2
2
|
// checks that turn a plan file into rows. A one-piece plan makes none.
|
|
3
3
|
|
|
4
|
+
import { agentosHome } from "../agentos-home";
|
|
4
5
|
import fs from "fs";
|
|
5
6
|
import os from "os";
|
|
6
7
|
import path from "path";
|
|
@@ -61,8 +62,7 @@ export interface FeaturePiece {
|
|
|
61
62
|
const expandHome = (p: string) => p.replace(/^~/, os.homedir());
|
|
62
63
|
|
|
63
64
|
export const plansDir = () =>
|
|
64
|
-
process.env.AGENTOS_PLANS_DIR ||
|
|
65
|
-
path.join(os.homedir(), ".agent-os", "plans");
|
|
65
|
+
process.env.AGENTOS_PLANS_DIR || agentosHome("plans");
|
|
66
66
|
export const planPathOf = (featureId: string) =>
|
|
67
67
|
path.join(plansDir(), `${featureId}.md`);
|
|
68
68
|
|
package/lib/pr-generation.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sessionBaseEnv } from "./agents/server-env";
|
|
1
2
|
import { routedRun } from "./models/launch";
|
|
2
3
|
import { execFile } from "child_process";
|
|
3
4
|
import { git as runGit } from "./git-exec";
|
|
@@ -163,7 +164,7 @@ async function claudePrint(cwd: string, prompt: string): Promise<string> {
|
|
|
163
164
|
["--print", "--model", routed.model],
|
|
164
165
|
{
|
|
165
166
|
cwd,
|
|
166
|
-
env: { ...
|
|
167
|
+
env: { ...sessionBaseEnv(), ...routed.env },
|
|
167
168
|
encoding: "utf-8",
|
|
168
169
|
timeout: 30_000,
|
|
169
170
|
killSignal: "SIGKILL",
|
|
@@ -1,9 +1,9 @@
|
|
|
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 { NO_ATTRIBUTION } from "../agents/attribution";
|
|
5
5
|
|
|
6
|
-
const DIR =
|
|
6
|
+
const DIR = agentosHome();
|
|
7
7
|
const HOOK = path.join(DIR, "bin", "agentos-status");
|
|
8
8
|
const SETTINGS = path.join(DIR, "claude-status-hooks.json");
|
|
9
9
|
const SUBAGENT_HOOK = path.join(DIR, "bin", "agentos-subagent-model.mjs");
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
* Remote-host sessions are not tapped: their output never passes through
|
|
10
10
|
* this machine except while attached. They keep reading the screen.
|
|
11
11
|
*/
|
|
12
|
+
import { agentosHome } from "../agentos-home";
|
|
12
13
|
import fs from "fs";
|
|
13
14
|
import net from "net";
|
|
14
|
-
import os from "os";
|
|
15
15
|
import path from "path";
|
|
16
16
|
import { execFile } from "child_process";
|
|
17
17
|
import { promisify } from "util";
|
|
@@ -50,7 +50,7 @@ const replied = new Map<string, number>();
|
|
|
50
50
|
|
|
51
51
|
// In a directory only this user can enter: only they can write reports.
|
|
52
52
|
export function tapSocketPath(port: number): string {
|
|
53
|
-
return
|
|
53
|
+
return agentosHome("run", `osc-${port}.sock`);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
async function foreground(name: string): Promise<string | undefined> {
|