@saadnvd1/agent-os 0.3.477 → 0.3.479
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/README.md +9 -0
- package/app/api/sessions/[id]/route.test.ts +66 -0
- package/app/api/sessions/[id]/route.ts +10 -7
- package/app/api/sessions/[id]/summarize/route.ts +2 -0
- package/app/api/tmux/kill-all/route.ts +2 -1
- package/components/SessionList/OrchestratorPinRow.tsx +12 -6
- package/components/SessionList/QuickChangeRow.tsx +58 -0
- package/components/SessionList/SessionRow.tsx +4 -0
- package/components/Tasks/TaskRow.tsx +2 -17
- package/components/Tasks/task-state-labels.ts +18 -0
- package/lib/away/sweep.ts +2 -0
- package/lib/chat/hold.ts +6 -2
- package/lib/chat/runner.ts +5 -0
- package/lib/chat/worker/client.ts +2 -3
- package/lib/db/migrations.ts +17 -0
- package/lib/db/types.ts +3 -0
- package/lib/done/index.ts +2 -8
- package/lib/orchestration.ts +4 -5
- package/lib/orchestrator/act.ts +3 -6
- package/lib/orchestrator/brief.ts +5 -3
- package/lib/orchestrator/conditions.ts +2 -1
- package/lib/orchestrator/describe.ts +1 -1
- package/lib/orchestrator/facts.ts +13 -0
- package/lib/orchestrator/git-guard.ts +201 -0
- package/lib/orchestrator/pr-wake.ts +9 -1
- package/lib/orchestrator/quick-store.ts +18 -0
- package/lib/orchestrator/quick.ts +307 -0
- package/lib/orchestrator/rebase.ts +11 -1
- package/lib/orchestrator/review.ts +3 -0
- package/lib/orchestrator/serve.ts +17 -0
- package/lib/orchestrator/signoff.ts +28 -6
- package/lib/orchestrator/task-state.ts +2 -0
- package/lib/orchestrator/tool-names.ts +9 -2
- package/lib/orchestrator/tool-schemas.ts +26 -0
- package/lib/orchestrator/tools.ts +4 -0
- package/lib/orchestrator/write-guard.ts +80 -3
- package/lib/sessions/end.ts +15 -0
- package/lib/sidebar/shelves.ts +19 -1
- package/lib/tasks/finish.ts +2 -8
- package/lib/tasks/index.ts +18 -6
- package/lib/tasks/move-guard.ts +2 -0
- package/lib/tasks/move.ts +2 -6
- package/lib/tasks/queue.ts +3 -2
- package/lib/tasks/start.ts +2 -4
- package/lib/tmux/end.ts +33 -0
- package/lib/tmux/orphans.ts +146 -0
- package/package.json +1 -1
- package/server.ts +9 -0
|
@@ -17,6 +17,8 @@ import { untrusted } from "./untrusted";
|
|
|
17
17
|
export async function waitingState(
|
|
18
18
|
task: Session
|
|
19
19
|
): Promise<{ blocked: string | null; waitingOn: string | null }> {
|
|
20
|
+
// A quick change has no agent to block or ask: the orchestrator makes it.
|
|
21
|
+
if (task.quick_of) return { blocked: null, waitingOn: null };
|
|
20
22
|
// A linked machine's chat lives in that machine's store, not this one's:
|
|
21
23
|
// its own reading of the task is asked for, fresh.
|
|
22
24
|
const link = hostLink(task.host_id);
|
|
@@ -16,6 +16,8 @@ export const TOOL_NAMES = {
|
|
|
16
16
|
start_task: name("start_task"),
|
|
17
17
|
set_task_model: name("set_task_model"),
|
|
18
18
|
start_session: name("start_session"),
|
|
19
|
+
quick_change: name("quick_change"),
|
|
20
|
+
quick_pr: name("quick_pr"),
|
|
19
21
|
stack: name("stack"),
|
|
20
22
|
stack_status: name("stack_status"),
|
|
21
23
|
land: name("land"),
|
|
@@ -39,8 +41,11 @@ export const TOOL_NAMES = {
|
|
|
39
41
|
// braked; the shell keeps only `aos` commands that read, and notify, which
|
|
40
42
|
// reaches only Saad, so `aos send`, `task`, `spawn` and `stack` can't go
|
|
41
43
|
// around them. It writes files, but never in a project repository, a task
|
|
42
|
-
// worktree, AgentOS's own state or a secret file
|
|
43
|
-
//
|
|
44
|
+
// worktree, AgentOS's own state or a secret file, except inside its own
|
|
45
|
+
// quick changes' worktrees (quick.ts), where it may also run git that
|
|
46
|
+
// looks, stages or undoes, never commits with hooks or pushes: its worker
|
|
47
|
+
// checks each write and each shell command first (write-guard.ts,
|
|
48
|
+
// git-guard.ts).
|
|
44
49
|
export const ORCHESTRATOR_PERMISSIONS: {
|
|
45
50
|
permissionMode: PermissionMode;
|
|
46
51
|
allowedTools: string[];
|
|
@@ -67,6 +72,8 @@ export const ORCHESTRATOR_PERMISSIONS: {
|
|
|
67
72
|
"Bash(aos notify:*)",
|
|
68
73
|
"Bash(aos docs:*)",
|
|
69
74
|
"Bash(aos doc:*)",
|
|
75
|
+
// Only `git -C <its own quick worktree>`, refused anywhere else.
|
|
76
|
+
"Bash(git:*)",
|
|
70
77
|
],
|
|
71
78
|
disallowedTools: [],
|
|
72
79
|
};
|
|
@@ -135,6 +135,32 @@ export const TOOL_SHAPES = {
|
|
|
135
135
|
.describe("A short name, 2-6 words (default: one made from the prompt)"),
|
|
136
136
|
view,
|
|
137
137
|
},
|
|
138
|
+
quick_change: {
|
|
139
|
+
project,
|
|
140
|
+
name: z
|
|
141
|
+
.string()
|
|
142
|
+
.trim()
|
|
143
|
+
.min(1)
|
|
144
|
+
.max(80)
|
|
145
|
+
.describe("A short name for the change, 2-6 words"),
|
|
146
|
+
},
|
|
147
|
+
quick_pr: {
|
|
148
|
+
change: ref("Your quick change, by name or id"),
|
|
149
|
+
title: z
|
|
150
|
+
.string()
|
|
151
|
+
.trim()
|
|
152
|
+
.min(1)
|
|
153
|
+
.max(200)
|
|
154
|
+
.describe("The PR's title, also its commit message"),
|
|
155
|
+
body: z
|
|
156
|
+
.string()
|
|
157
|
+
.trim()
|
|
158
|
+
.min(1)
|
|
159
|
+
.max(20000)
|
|
160
|
+
.describe(
|
|
161
|
+
"What changed and why; the review judges the change against it"
|
|
162
|
+
),
|
|
163
|
+
},
|
|
138
164
|
stack: {
|
|
139
165
|
target: ref("Board or project name"),
|
|
140
166
|
plan_only: z.boolean().optional(),
|
|
@@ -66,6 +66,10 @@ const DESCRIPTIONS: Record<ToolName, string> = {
|
|
|
66
66
|
'Resume a paused task on another model ("provider/model", or a map of role to one), keeping its conversation and worktree; it\'s told to carry on (or message). Refused mid-turn.',
|
|
67
67
|
start_session:
|
|
68
68
|
"Start an interactive agent session in a project with a prompt. It runs as a chat unless view is terminal (only for a job that needs a TUI). Refused while a brake holds.",
|
|
69
|
+
quick_change:
|
|
70
|
+
"Make a small, low-risk edit yourself (a typo, a config value, a one-file fix): cuts a worktree and branch from the project's default branch and returns its path. You may write there (Write, Edit) and run git there as `git -C <path> ...` (status, diff, add, rm, mv, restore, reset, log, show); nowhere else in a project. Anything needing real work, tests or an investigation is a task (start_task).",
|
|
71
|
+
quick_pr:
|
|
72
|
+
"Commit everything in a quick change's worktree, push its branch (never the default branch) and open its PR, or push to the PR it already has. The PR goes through the same gates as a task's; with no task agent to run /do-code-review, its code-review gate is the independent review of its exact head, which starts now.",
|
|
69
73
|
stack:
|
|
70
74
|
"Run a LumifyHub board's open cards as stacked tasks (each start braked), or with plan_only just show the plan.",
|
|
71
75
|
stack_status: "One stack's items, PRs and progress.",
|
|
@@ -21,6 +21,8 @@ import { isSecretPath } from "../secret-paths";
|
|
|
21
21
|
import { WORKTREES_DIR } from "../worktrees";
|
|
22
22
|
import { orchestratorDir } from "./home";
|
|
23
23
|
import { LIMITS_FILE } from "./usage";
|
|
24
|
+
import { GIT_LAYOUT_FILE, readCommand } from "./git-guard";
|
|
25
|
+
import { quickWorktrees } from "./quick-store";
|
|
24
26
|
|
|
25
27
|
// The tools that write a file, and the argument naming it.
|
|
26
28
|
export const WRITE_TOOLS: Record<string, string> = {
|
|
@@ -43,6 +45,9 @@ export interface WriteBounds {
|
|
|
43
45
|
home: string;
|
|
44
46
|
// Folders whose files run (~/Library, PATH, the usage brake's input...).
|
|
45
47
|
runs: string[];
|
|
48
|
+
// Its own quick changes' worktrees (quick.ts): the one place in a
|
|
49
|
+
// project it may write, everything else in `repos` still refused.
|
|
50
|
+
quick?: string[];
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
// Wherever they are, files that configure what an agent, git or a
|
|
@@ -129,6 +134,7 @@ export async function writeRefusal(
|
|
|
129
134
|
let ownDir: string[];
|
|
130
135
|
let home: string[];
|
|
131
136
|
let runs: string[];
|
|
137
|
+
let quick: string[][];
|
|
132
138
|
try {
|
|
133
139
|
// Where it lands as written, and with `..` taken by name first, as a
|
|
134
140
|
// tool that normalises the path before opening it would; and where it
|
|
@@ -151,16 +157,27 @@ export async function writeRefusal(
|
|
|
151
157
|
state = await both(bounds.state);
|
|
152
158
|
ownDir = await both(bounds.ownDir);
|
|
153
159
|
home = await both(bounds.home);
|
|
160
|
+
quick = await Promise.all((bounds.quick ?? []).map(both));
|
|
154
161
|
} catch (e) {
|
|
155
162
|
return `Couldn't resolve ${filePath}: ${(e as Error).message}.`;
|
|
156
163
|
}
|
|
157
164
|
for (const target of targets) {
|
|
158
|
-
|
|
165
|
+
// Inside one of its quick worktrees, the roots that hold that worktree
|
|
166
|
+
// (the worktrees folder, a project above it) don't count; any other
|
|
167
|
+
// root, one nested inside it included, still refuses.
|
|
168
|
+
const ownQuick = quick.find((q) => q.some((d) => within(target, d)));
|
|
169
|
+
const holdsQuick = (r: string) =>
|
|
170
|
+
!!ownQuick && ownQuick.some((d) => within(d, r));
|
|
171
|
+
const repo = repos.find((r) => within(target, r) && !holdsQuick(r));
|
|
159
172
|
if (repo)
|
|
160
|
-
return `${filePath} is inside ${repo}, a project repository, task worktree or AgentOS itself. Change code there through a task (start_task), so its PR goes through the merge gates.`;
|
|
173
|
+
return `${filePath} is inside ${repo}, a project repository, task worktree or AgentOS itself. Change code there through a task (start_task), or for a small edit, in a quick change's worktree (quick_change), so its PR goes through the merge gates.`;
|
|
174
|
+
if (ownQuick && GIT_LAYOUT_FILE.test(target))
|
|
175
|
+
return `${filePath} is part of a git repository's layout (HEAD, .git/, .gitattributes); git config can run programs, so orchestrators never write those.`;
|
|
161
176
|
if (AGENTOS_TEMP.test(target))
|
|
162
177
|
return `${filePath} is in a checkout AgentOS is reviewing or rebasing; orchestrators never write there.`;
|
|
163
|
-
|
|
178
|
+
// Its folder, or a quick worktree (which sits in ~/.agent-os/worktrees):
|
|
179
|
+
// AgentOS's state and the home's dot-folders hold it, but it's its own.
|
|
180
|
+
const own = ownDir.some((d) => within(target, d)) || !!ownQuick;
|
|
164
181
|
if (state.some((d) => within(target, d)) && !own)
|
|
165
182
|
return `${filePath} is in AgentOS's own state (${bounds.state}); you may write only in your folder, ${bounds.ownDir}.`;
|
|
166
183
|
if (isSecretPath(target) || isSecretPath(filePath))
|
|
@@ -219,6 +236,7 @@ export async function orchestratorWriteBounds(
|
|
|
219
236
|
...(process.env.PATH ?? "").split(":").filter((d) => d.startsWith("/")),
|
|
220
237
|
],
|
|
221
238
|
ownDir: orchestratorDir(session.workspace_id ?? ""),
|
|
239
|
+
quick: quickWorktrees(session.id),
|
|
222
240
|
state: path.join(os.homedir(), ".agent-os"),
|
|
223
241
|
repos: [...repos, WORKTREES_DIR, process.cwd(), path.dirname(dbPath)],
|
|
224
242
|
};
|
|
@@ -287,9 +305,68 @@ export async function orchestratorToolRefusal(
|
|
|
287
305
|
tool: string,
|
|
288
306
|
input: Record<string, unknown>
|
|
289
307
|
): Promise<string | null> {
|
|
308
|
+
if (tool === "Bash") return commandRefusal(session, input.command);
|
|
290
309
|
const arg = WRITE_TOOLS[tool];
|
|
291
310
|
if (!arg) return null;
|
|
292
311
|
const filePath = input[arg];
|
|
293
312
|
if (typeof filePath !== "string") return "No file path to check.";
|
|
294
313
|
return writeRefusal(filePath, await orchestratorWriteBounds(session));
|
|
295
314
|
}
|
|
315
|
+
|
|
316
|
+
// A shell command: one plain command, and git only in its own quick
|
|
317
|
+
// worktrees (git-guard.ts), the folder it names resolved like a write's.
|
|
318
|
+
async function commandRefusal(
|
|
319
|
+
session: Session,
|
|
320
|
+
command: unknown
|
|
321
|
+
): Promise<string | null> {
|
|
322
|
+
const read = readCommand(command);
|
|
323
|
+
if (read.refusal !== null || read.dir === null) return read.refusal;
|
|
324
|
+
const quick = quickWorktrees(session.id);
|
|
325
|
+
const dir = read.dir;
|
|
326
|
+
if (!dir.startsWith("/") || !quick.length)
|
|
327
|
+
return `Refused: git runs only in one of your quick changes' worktrees, named by its full path (${quick.length ? quick.join(", ") : "you have none open; start one with quick_change"}).`;
|
|
328
|
+
return (await isQuickRoot(dir, quick))
|
|
329
|
+
? null
|
|
330
|
+
: `Refused: ${read.dir} isn't the root of one of your quick changes' worktrees (${quick.join(", ")}). Git runs at the root only, never in a subfolder, a project checkout or another session's worktree.`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Whether a folder is inside one of these worktrees by every way the
|
|
334
|
+
// system could read it: as named, with `..` taken by name, and with links
|
|
335
|
+
// followed.
|
|
336
|
+
export async function insideQuick(
|
|
337
|
+
dir: string,
|
|
338
|
+
quick: string[]
|
|
339
|
+
): Promise<boolean> {
|
|
340
|
+
try {
|
|
341
|
+
const forms = [
|
|
342
|
+
path.resolve(dir),
|
|
343
|
+
await physicalPath(dir),
|
|
344
|
+
await physicalPath(path.resolve(dir)),
|
|
345
|
+
];
|
|
346
|
+
const roots = (
|
|
347
|
+
await Promise.all(
|
|
348
|
+
quick.map(async (q) => [path.resolve(q), await physicalPath(q)])
|
|
349
|
+
)
|
|
350
|
+
).flat();
|
|
351
|
+
return forms.every((f) => roots.some((r) => within(f, r)));
|
|
352
|
+
} catch {
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Whether a folder is exactly the root of one of these worktrees (as named,
|
|
358
|
+
// and with links followed), where git finds the real git dir.
|
|
359
|
+
async function isQuickRoot(dir: string, quick: string[]): Promise<boolean> {
|
|
360
|
+
if (!(await insideQuick(dir, quick))) return false;
|
|
361
|
+
try {
|
|
362
|
+
const forms = [path.resolve(dir), await physicalPath(dir)];
|
|
363
|
+
const roots = (
|
|
364
|
+
await Promise.all(
|
|
365
|
+
quick.map(async (q) => [path.resolve(q), await physicalPath(q)])
|
|
366
|
+
)
|
|
367
|
+
).flat();
|
|
368
|
+
return forms.every((f) => roots.includes(f));
|
|
369
|
+
} catch {
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Session } from "../db";
|
|
2
|
+
import { stopChat } from "../chat/runner";
|
|
3
|
+
import { waitForExit } from "../chat/worker/client";
|
|
4
|
+
import { endTmuxSession } from "../tmux/end";
|
|
5
|
+
|
|
6
|
+
type Agent = Pick<Session, "id" | "host_id" | "tmux_name">;
|
|
7
|
+
|
|
8
|
+
// A session's agent, ended: its chat worker, and its own tmux session (a
|
|
9
|
+
// chat session can still own one). The one call that delete, done, drop and
|
|
10
|
+
// finish share.
|
|
11
|
+
export async function endSessionAgent(s: Agent): Promise<void> {
|
|
12
|
+
stopChat(s.id);
|
|
13
|
+
void waitForExit(s.id).catch(() => {});
|
|
14
|
+
await endTmuxSession(s.host_id, s.tmux_name);
|
|
15
|
+
}
|
package/lib/sidebar/shelves.ts
CHANGED
|
@@ -41,6 +41,10 @@ export interface SidebarRow {
|
|
|
41
41
|
working: boolean;
|
|
42
42
|
// Orchestration workers, nested under their conductor.
|
|
43
43
|
workers: SidebarRow[];
|
|
44
|
+
// Its task's state, when it's a task.
|
|
45
|
+
task?: TaskState;
|
|
46
|
+
// An orchestrator's quick changes (orchestrator/quick.ts), nested under it.
|
|
47
|
+
quick?: SidebarRow[];
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
export interface Shelves {
|
|
@@ -113,6 +117,8 @@ function toRow(input: ShelfInput, session: Session): SidebarRow {
|
|
|
113
117
|
unread: !!status?.unread,
|
|
114
118
|
working: status?.status === "running",
|
|
115
119
|
workers: [],
|
|
120
|
+
task: input.tasks[session.id],
|
|
121
|
+
quick: [],
|
|
116
122
|
};
|
|
117
123
|
}
|
|
118
124
|
|
|
@@ -129,6 +135,15 @@ export function buildShelves(input: ShelfInput): Shelves {
|
|
|
129
135
|
|
|
130
136
|
const top: SidebarRow[] = [];
|
|
131
137
|
for (const row of rows) {
|
|
138
|
+
// A quick change always sits under the orchestrator that makes it: it's
|
|
139
|
+
// that orchestrator's to carry, never one that needs you.
|
|
140
|
+
const owner = row.session.quick_of
|
|
141
|
+
? byId.get(row.session.quick_of)
|
|
142
|
+
: undefined;
|
|
143
|
+
if (owner) {
|
|
144
|
+
(owner.quick ??= []).push(row);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
132
147
|
const conductor = row.session.conductor_session_id
|
|
133
148
|
? byId.get(row.session.conductor_session_id)
|
|
134
149
|
: undefined;
|
|
@@ -200,8 +215,11 @@ export function sameRow(a: SidebarRow, b: SidebarRow): boolean {
|
|
|
200
215
|
a.unread === b.unread &&
|
|
201
216
|
a.working === b.working &&
|
|
202
217
|
sameStatus(a.status, b.status) &&
|
|
218
|
+
a.task === b.task &&
|
|
203
219
|
a.workers.length === b.workers.length &&
|
|
204
|
-
a.workers.every((w, i) => sameRow(w, b.workers[i]))
|
|
220
|
+
a.workers.every((w, i) => sameRow(w, b.workers[i])) &&
|
|
221
|
+
(a.quick?.length ?? 0) === (b.quick?.length ?? 0) &&
|
|
222
|
+
(a.quick ?? []).every((q, i) => sameRow(q, b.quick![i]))
|
|
205
223
|
);
|
|
206
224
|
}
|
|
207
225
|
|
package/lib/tasks/finish.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { republishAfterMerge } from "../lumifyhub/publish";
|
|
|
10
10
|
import { restackAfterMerge } from "../stacks/restack";
|
|
11
11
|
import { itemName, liveChildren, signOffRefusal } from "../stacks/guard";
|
|
12
12
|
import { resolveMergedTaskAsks } from "../orchestrator/ask-settle";
|
|
13
|
-
import {
|
|
13
|
+
import { endSessionAgent } from "../sessions/end";
|
|
14
14
|
import { run } from "./gh";
|
|
15
15
|
import { gitFetch, gitPush } from "../git-exec";
|
|
16
16
|
import {
|
|
@@ -39,13 +39,7 @@ async function cleanup(
|
|
|
39
39
|
opts: { keepRemoteBranch?: boolean; merged?: MergedAs } = {}
|
|
40
40
|
): Promise<void> {
|
|
41
41
|
const keepRemoteBranch = opts.keepRemoteBranch ?? false;
|
|
42
|
-
|
|
43
|
-
if (session.view === "chat") stopChat(session.id);
|
|
44
|
-
await run(
|
|
45
|
-
"tmux",
|
|
46
|
-
["kill-session", "-t", `=${session.tmux_name}`],
|
|
47
|
-
repo
|
|
48
|
-
).catch(() => {});
|
|
42
|
+
await endSessionAgent(session);
|
|
49
43
|
// Never waited on: the row is read now, and Postgres can't hold this up.
|
|
50
44
|
void dropSessionDatabase(session.id);
|
|
51
45
|
if (session.worktree_path && opts.merged) {
|
package/lib/tasks/index.ts
CHANGED
|
@@ -96,6 +96,8 @@ export interface TaskView {
|
|
|
96
96
|
// plan step (lib/models).
|
|
97
97
|
model?: string;
|
|
98
98
|
planning?: boolean;
|
|
99
|
+
// A quick change: the orchestrator session that makes it (no agent).
|
|
100
|
+
quickOf?: string | null;
|
|
99
101
|
}
|
|
100
102
|
|
|
101
103
|
export async function createTask(opts: {
|
|
@@ -304,22 +306,31 @@ export async function taskView(session: Session): Promise<TaskView> {
|
|
|
304
306
|
const setup = taskSetupOf(session);
|
|
305
307
|
// A finished task's PR is in the database: only a running one asks gh.
|
|
306
308
|
const chat = session.view === "chat";
|
|
309
|
+
// A quick change has no agent: the orchestrator is working on it until
|
|
310
|
+
// its PR is up, and nothing in it can be blocked.
|
|
311
|
+
const quick = !!session.quick_of;
|
|
307
312
|
const [pr, chatTurn, screen] = await Promise.all([
|
|
308
313
|
live ? prFor(session) : storedPR(session),
|
|
309
|
-
live && chat
|
|
314
|
+
live && chat && !quick
|
|
310
315
|
? chatStateNow(session.id).catch((): ChatTurn => "unknown")
|
|
311
316
|
: Promise.resolve(null),
|
|
312
|
-
live && !chat
|
|
317
|
+
live && !chat && !quick
|
|
313
318
|
? statusDetector.getStatus(session.tmux_name)
|
|
314
319
|
: Promise.resolve(undefined),
|
|
315
320
|
]);
|
|
316
321
|
const sessionStatus = !live
|
|
317
322
|
? undefined
|
|
318
|
-
:
|
|
319
|
-
?
|
|
320
|
-
:
|
|
323
|
+
: quick
|
|
324
|
+
? "running"
|
|
325
|
+
: chat
|
|
326
|
+
? chatStatus(chatTurn)
|
|
327
|
+
: screen;
|
|
321
328
|
const agentGone =
|
|
322
|
-
!chat &&
|
|
329
|
+
!chat &&
|
|
330
|
+
!quick &&
|
|
331
|
+
live &&
|
|
332
|
+
sessionStatus !== "dead" &&
|
|
333
|
+
sessionStatus !== undefined
|
|
323
334
|
? await shellOnly(session.tmux_name)
|
|
324
335
|
: false;
|
|
325
336
|
const blocked =
|
|
@@ -365,6 +376,7 @@ export async function taskView(session: Session): Promise<TaskView> {
|
|
|
365
376
|
armed: !!session.merge_armed_at,
|
|
366
377
|
model: sessionModelRef(session),
|
|
367
378
|
planning: session.plan_phase === "planning",
|
|
379
|
+
quickOf: session.quick_of ?? null,
|
|
368
380
|
};
|
|
369
381
|
}
|
|
370
382
|
|
package/lib/tasks/move-guard.ts
CHANGED
|
@@ -10,6 +10,8 @@ import { db, type Session } from "../db";
|
|
|
10
10
|
import { escalations } from "../orchestrator/gates";
|
|
11
11
|
|
|
12
12
|
export function moveRefusal(session: Session): string | null {
|
|
13
|
+
if (session.quick_of)
|
|
14
|
+
return "It's a quick change its orchestrator makes on this machine, so it stays here";
|
|
13
15
|
if (escalations(session.id).length)
|
|
14
16
|
return "It's waiting on you (an escalated gate), so it stays here";
|
|
15
17
|
const ask = db
|
package/lib/tasks/move.ts
CHANGED
|
@@ -7,10 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import os from "os";
|
|
10
|
-
import { execFile } from "child_process";
|
|
11
|
-
import { promisify } from "util";
|
|
12
10
|
import { db, type Session } from "../db";
|
|
13
11
|
import { getProject } from "../projects";
|
|
12
|
+
import { endTmuxSession } from "../tmux/end";
|
|
14
13
|
import { launchClaude } from "../agents/launch";
|
|
15
14
|
import { buildTaskBrief } from "./brief";
|
|
16
15
|
import { git as runGit } from "../git-exec";
|
|
@@ -31,7 +30,6 @@ import {
|
|
|
31
30
|
|
|
32
31
|
export * from "./move-bundle";
|
|
33
32
|
|
|
34
|
-
const execFileAsync = promisify(execFile);
|
|
35
33
|
const git = (cwd: string, ...args: string[]) =>
|
|
36
34
|
runGit(cwd, args, { timeout: 120000 });
|
|
37
35
|
|
|
@@ -40,9 +38,7 @@ const g = globalThis as unknown as { __agentosExporting?: Set<string> };
|
|
|
40
38
|
const exporting = (g.__agentosExporting ??= new Set());
|
|
41
39
|
|
|
42
40
|
async function stopAgent(tmuxName: string): Promise<void> {
|
|
43
|
-
await
|
|
44
|
-
() => undefined
|
|
45
|
-
);
|
|
41
|
+
await endTmuxSession(null, tmuxName);
|
|
46
42
|
}
|
|
47
43
|
|
|
48
44
|
export async function claudeIdFor(session: Session): Promise<string | null> {
|
package/lib/tasks/queue.ts
CHANGED
|
@@ -110,7 +110,8 @@ const workspaceOf = (projectId: string) =>
|
|
|
110
110
|
getProject(projectId)?.workspace_id ?? null;
|
|
111
111
|
|
|
112
112
|
// Running tasks hold their slot until they finish (merged, dropped, done),
|
|
113
|
-
// PR open or not; a queued task being started counts too.
|
|
113
|
+
// PR open or not; a queued task being started counts too. A quick change
|
|
114
|
+
// runs no agent, so it holds none. Without a
|
|
114
115
|
// workspace, the project is the scope.
|
|
115
116
|
function runningIn(scope: { workspaceId: string | null; projectId: string }) {
|
|
116
117
|
const where = scope.workspaceId
|
|
@@ -120,7 +121,7 @@ function runningIn(scope: { workspaceId: string | null; projectId: string }) {
|
|
|
120
121
|
db
|
|
121
122
|
.prepare(
|
|
122
123
|
`SELECT s.id FROM sessions s JOIN projects p ON p.id = s.project_id
|
|
123
|
-
WHERE ${where} AND s.archived_at IS NULL
|
|
124
|
+
WHERE ${where} AND s.archived_at IS NULL AND s.quick_of IS NULL
|
|
124
125
|
AND s.task_status IN (${IN_FLIGHT.map((s) => `'${s}'`).join(", ")})`
|
|
125
126
|
)
|
|
126
127
|
.all({ ws: scope.workspaceId, project: scope.projectId }) as {
|
package/lib/tasks/start.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { execFile } from "child_process";
|
|
10
10
|
import { promisify } from "util";
|
|
11
|
+
import { endTmuxSession } from "../tmux/end";
|
|
11
12
|
import { db, type Session } from "../db";
|
|
12
13
|
import { getProject } from "../projects";
|
|
13
14
|
import { setupWorktree } from "../env-setup";
|
|
@@ -195,10 +196,7 @@ function earlierSetup(row: SetupColumns): TaskSetup | undefined {
|
|
|
195
196
|
return undefined;
|
|
196
197
|
}
|
|
197
198
|
|
|
198
|
-
export const killTmux = (name: string) =>
|
|
199
|
-
execFileAsync("tmux", ["kill-session", "-t", `=${name}`], {
|
|
200
|
-
timeout: 5000,
|
|
201
|
-
}).catch(() => undefined);
|
|
199
|
+
export const killTmux = (name: string) => endTmuxSession(null, name);
|
|
202
200
|
|
|
203
201
|
const tmuxAlive = (name: string) =>
|
|
204
202
|
execFileAsync("tmux", ["has-session", "-t", `=${name}`], {
|
package/lib/tmux/end.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { hostExec } from "../hosts";
|
|
2
|
+
import { shellQuote } from "../hosts/ssh";
|
|
3
|
+
import { isValidTmuxName } from "../hosts/tmux-name";
|
|
4
|
+
|
|
5
|
+
// Run by sh on the machine the session lives on. The pane processes and
|
|
6
|
+
// everything under them are read before tmux ends the session (the kill only
|
|
7
|
+
// hangs up the panes' own shells), then asked to stop and, if any is still
|
|
8
|
+
// there a second later, killed. Only pids found under this session's panes
|
|
9
|
+
// are ever signalled.
|
|
10
|
+
export function endTmuxScript(name: string): string {
|
|
11
|
+
return [
|
|
12
|
+
`t=${shellQuote(`=${name}`)}`,
|
|
13
|
+
`roots=$(tmux list-panes -s -t "$t" -F '#{pane_pid}' 2>/dev/null)`,
|
|
14
|
+
`tree=$(ps -Ao pid=,ppid= | awk -v roots="$roots" 'BEGIN{n=split(roots,r,/[ \\n]+/);for(i=1;i<=n;i++)if(r[i]!="")s[r[i]]=1} {p[$1]=$2;a[NR]=$1} END{do{c=0;for(i=1;i<=NR;i++){k=a[i];if(!(k in s)&&(p[k] in s)){s[k]=1;c=1}}}while(c);for(k in s)print k}')`,
|
|
15
|
+
`tmux kill-session -t "$t" 2>/dev/null`,
|
|
16
|
+
`if [ -n "$tree" ]; then`,
|
|
17
|
+
` kill -TERM $tree 2>/dev/null`,
|
|
18
|
+
` alive=; for p in $tree; do kill -0 "$p" 2>/dev/null && alive="$alive $p"; done`,
|
|
19
|
+
` if [ -n "$alive" ]; then sleep 1; kill -KILL $alive 2>/dev/null; fi`,
|
|
20
|
+
`fi`,
|
|
21
|
+
`exit 0`,
|
|
22
|
+
].join("\n");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Ends a tmux session and what its panes started, on the machine it runs on.
|
|
26
|
+
// A session that isn't there is nothing to do; this never throws.
|
|
27
|
+
export async function endTmuxSession(
|
|
28
|
+
hostId: string | null | undefined,
|
|
29
|
+
name: string | null | undefined
|
|
30
|
+
): Promise<void> {
|
|
31
|
+
if (!name || !isValidTmuxName(name)) return;
|
|
32
|
+
await hostExec(hostId, endTmuxScript(name), 15_000).catch(() => {});
|
|
33
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { db } from "../db";
|
|
2
|
+
import { getManagedSessionPattern } from "../providers/registry";
|
|
3
|
+
import { WORKER_TMUX_PREFIX } from "../chat/worker/protocol";
|
|
4
|
+
import { isValidTmuxName } from "../hosts/tmux-name";
|
|
5
|
+
import { endTmuxSession } from "./end";
|
|
6
|
+
import { tmux } from "./exec";
|
|
7
|
+
|
|
8
|
+
// tmux sessions AgentOS made whose session row is gone: deleted before every
|
|
9
|
+
// end-of-life path ended the agent. Swept once at start. Only sessions that
|
|
10
|
+
// carry AgentOS's own marks are ever touched: its names (<agent>-<uuid>,
|
|
11
|
+
// aos-chat-<uuid>) or the AGENTOS_SESSION_ID it sets on the session at
|
|
12
|
+
// launch. Dispatch's sessions and anyone's own terminals have neither.
|
|
13
|
+
|
|
14
|
+
const UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
|
|
15
|
+
const WORKER_NAME = new RegExp(`^${WORKER_TMUX_PREFIX}(${UUID})$`, "i");
|
|
16
|
+
const AGENT_NAME = new RegExp(`(${UUID})$`, "i");
|
|
17
|
+
|
|
18
|
+
export interface TmuxListing {
|
|
19
|
+
name: string;
|
|
20
|
+
// AGENTOS_SESSION_ID on the session itself; null when absent or when the
|
|
21
|
+
// whole tmux server carries one (every session would then).
|
|
22
|
+
taggedId: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SessionRow {
|
|
26
|
+
id: string;
|
|
27
|
+
tmux_name: string | null;
|
|
28
|
+
host_id: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const isLocal = (hostId: string | null) => !hostId || hostId === "local";
|
|
32
|
+
|
|
33
|
+
// The AgentOS session id a tmux session belongs to, or null if it isn't one
|
|
34
|
+
// of AgentOS's.
|
|
35
|
+
export function agentOsIdOf(
|
|
36
|
+
l: TmuxListing,
|
|
37
|
+
managed = getManagedSessionPattern()
|
|
38
|
+
): string | null {
|
|
39
|
+
const worker = WORKER_NAME.exec(l.name);
|
|
40
|
+
if (worker) return worker[1].toLowerCase();
|
|
41
|
+
if (managed.test(l.name)) return AGENT_NAME.exec(l.name)![1].toLowerCase();
|
|
42
|
+
return l.taggedId && new RegExp(`^${UUID}$`, "i").test(l.taggedId)
|
|
43
|
+
? l.taggedId.toLowerCase()
|
|
44
|
+
: null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function findOrphans(
|
|
48
|
+
listing: TmuxListing[],
|
|
49
|
+
rows: SessionRow[]
|
|
50
|
+
): { name: string; id: string }[] {
|
|
51
|
+
const ids = new Set(rows.map((r) => r.id.toLowerCase()));
|
|
52
|
+
const names = new Set(
|
|
53
|
+
rows.filter((r) => isLocal(r.host_id)).map((r) => r.tmux_name)
|
|
54
|
+
);
|
|
55
|
+
const orphans: { name: string; id: string }[] = [];
|
|
56
|
+
for (const l of listing) {
|
|
57
|
+
const id = agentOsIdOf(l);
|
|
58
|
+
if (id && !ids.has(id) && !names.has(l.name))
|
|
59
|
+
orphans.push({ name: l.name, id });
|
|
60
|
+
}
|
|
61
|
+
return orphans;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function taggedId(
|
|
65
|
+
name: string,
|
|
66
|
+
serverWide: boolean
|
|
67
|
+
): Promise<string | null> {
|
|
68
|
+
if (serverWide) return null;
|
|
69
|
+
const out = await tmux(
|
|
70
|
+
["show-environment", "-t", `=${name}`, "AGENTOS_SESSION_ID"],
|
|
71
|
+
5000
|
|
72
|
+
).catch(() => "");
|
|
73
|
+
return /^AGENTOS_SESSION_ID=(.+)$/m.exec(out)?.[1].trim() ?? null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function listLocal(createdBefore: number): Promise<TmuxListing[]> {
|
|
77
|
+
const out = await tmux(
|
|
78
|
+
["list-sessions", "-F", "#{session_created} #{session_name}"],
|
|
79
|
+
5000
|
|
80
|
+
).catch(() => "");
|
|
81
|
+
// Sessions made after the cutoff belong to work this process started: their
|
|
82
|
+
// rows may not be committed yet, so they are never candidates.
|
|
83
|
+
const names = out
|
|
84
|
+
.split("\n")
|
|
85
|
+
.map((l) => /^(\d+) (.+)$/.exec(l))
|
|
86
|
+
.filter((m): m is RegExpExecArray => !!m && Number(m[1]) <= createdBefore)
|
|
87
|
+
.map((m) => m[2])
|
|
88
|
+
.filter(isValidTmuxName);
|
|
89
|
+
// A server started from inside an agent's shell has the variable in its
|
|
90
|
+
// own environment, which every session on it then shows.
|
|
91
|
+
const global = await tmux(
|
|
92
|
+
["show-environment", "-g", "AGENTOS_SESSION_ID"],
|
|
93
|
+
5000
|
|
94
|
+
).catch(() => "");
|
|
95
|
+
const serverWide = /^AGENTOS_SESSION_ID=/m.test(global);
|
|
96
|
+
const managed = getManagedSessionPattern();
|
|
97
|
+
const listing: TmuxListing[] = [];
|
|
98
|
+
for (const name of names) {
|
|
99
|
+
const byName = WORKER_NAME.test(name) || managed.test(name);
|
|
100
|
+
listing.push({
|
|
101
|
+
name,
|
|
102
|
+
taggedId: byName ? null : await taggedId(name, serverWide),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return listing;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const hasRow = (id: string, tmuxName: string) =>
|
|
109
|
+
!!db
|
|
110
|
+
.prepare(`SELECT 1 FROM sessions WHERE id = ? OR tmux_name = ?`)
|
|
111
|
+
.get(id, tmuxName);
|
|
112
|
+
|
|
113
|
+
export async function sweepOrphanTmux(
|
|
114
|
+
opts: {
|
|
115
|
+
dryRun?: boolean;
|
|
116
|
+
rows?: SessionRow[];
|
|
117
|
+
// Epoch seconds; only tmux sessions created at or before it are swept.
|
|
118
|
+
// Defaults to when this process started.
|
|
119
|
+
createdBefore?: number;
|
|
120
|
+
} = {}
|
|
121
|
+
): Promise<{ found: string[]; killed: string[] }> {
|
|
122
|
+
const none = { found: [], killed: [] };
|
|
123
|
+
const rows =
|
|
124
|
+
opts.rows ??
|
|
125
|
+
(db
|
|
126
|
+
.prepare(`SELECT id, tmux_name, host_id FROM sessions`)
|
|
127
|
+
.all() as SessionRow[]);
|
|
128
|
+
// An empty table is a fresh or wrong database, not proof that nothing is
|
|
129
|
+
// alive: never sweep against it.
|
|
130
|
+
if (!rows.length) return none;
|
|
131
|
+
const cutoff =
|
|
132
|
+
opts.createdBefore ?? Math.floor(Date.now() / 1000 - process.uptime());
|
|
133
|
+
const orphans = findOrphans(await listLocal(cutoff), rows);
|
|
134
|
+
const killed: string[] = [];
|
|
135
|
+
for (const o of orphans) {
|
|
136
|
+
if (opts.dryRun) continue;
|
|
137
|
+
// The snapshot is old by now: a row inserted since keeps its session.
|
|
138
|
+
if (!opts.rows && hasRow(o.id, o.name)) continue;
|
|
139
|
+
await endTmuxSession(null, o.name);
|
|
140
|
+
killed.push(o.name);
|
|
141
|
+
console.log(`[tmux-sweep] ended ${o.name}: session ${o.id} has no row`);
|
|
142
|
+
}
|
|
143
|
+
if (killed.length)
|
|
144
|
+
console.log(`[tmux-sweep] ended ${killed.length} orphaned tmux sessions`);
|
|
145
|
+
return { found: orphans.map((o) => o.name), killed };
|
|
146
|
+
}
|
package/package.json
CHANGED
package/server.ts
CHANGED
|
@@ -61,6 +61,7 @@ import { installClaudeStatusHooks } from "./lib/program-status/claude-hooks";
|
|
|
61
61
|
import os from "os";
|
|
62
62
|
import { resumeHeldStarts, resumeTaskStarts } from "./lib/tasks/start";
|
|
63
63
|
import { failInterruptedSetups } from "./lib/sessions/worktree-setup";
|
|
64
|
+
import { sweepOrphanTmux } from "./lib/tmux/orphans";
|
|
64
65
|
import { releaseInterruptedClaims } from "./lib/orchestrator/ask-approvals";
|
|
65
66
|
import { resumeReviews } from "./lib/orchestrator/review";
|
|
66
67
|
import { claimAgentosEnv } from "./lib/agents/self-env";
|
|
@@ -357,6 +358,14 @@ app.prepare().then(async () => {
|
|
|
357
358
|
}
|
|
358
359
|
// New sessions whose worktree setup a restart cut off.
|
|
359
360
|
failInterruptedSetups();
|
|
361
|
+
// tmux sessions of sessions deleted before agents were ended with them.
|
|
362
|
+
// Production only: a dev or test instance has its own database but shares
|
|
363
|
+
// the default tmux server, so it would see the real instance's sessions as
|
|
364
|
+
// orphans.
|
|
365
|
+
if (!dev && process.env.AGENTOS_TMUX_SWEEP !== "off")
|
|
366
|
+
sweepOrphanTmux().catch((error) =>
|
|
367
|
+
console.error("Sweeping orphaned tmux sessions failed:", error)
|
|
368
|
+
);
|
|
360
369
|
// Chat turns that kept running through a restart.
|
|
361
370
|
void reattachChats();
|
|
362
371
|
// Reviews this restart cut off run again and deliver their verdicts.
|