@saadnvd1/agent-os 0.3.476 → 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/done/worktree.ts +9 -2
- package/lib/orchestration.ts +4 -5
- package/lib/orchestrator/act.ts +3 -6
- package/lib/orchestrator/ask-approvals.ts +11 -0
- package/lib/orchestrator/brief.ts +7 -4
- 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 +18 -2
- package/lib/orchestrator/repo.ts +78 -13
- package/lib/orchestrator/review.ts +21 -0
- package/lib/orchestrator/serve.ts +17 -0
- package/lib/orchestrator/signoff.ts +37 -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/stacks/guard.ts +11 -0
- package/lib/tasks/finish.ts +9 -8
- package/lib/tasks/gh.ts +3 -1
- 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/pr-poll.ts +1 -1
- package/lib/tasks/queue.ts +3 -2
- package/lib/tasks/session.ts +63 -2
- package/lib/tasks/start.ts +2 -4
- package/lib/tasks/state.ts +3 -0
- package/lib/tmux/end.ts +33 -0
- package/lib/tmux/orphans.ts +146 -0
- package/package.json +1 -1
- package/server.ts +9 -0
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/pr-poll.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { TaskPR } from "./state";
|
|
|
9
9
|
export const OPEN_TTL_MS = 60_000;
|
|
10
10
|
export const BRANCH_TTL_MS = 5 * 60_000;
|
|
11
11
|
const OPEN_FIELDS =
|
|
12
|
-
"number,url,state,headRefOid,statusCheckRollup,body,headRefName,isCrossRepository,createdAt,mergeable";
|
|
12
|
+
"number,url,state,headRefOid,statusCheckRollup,body,headRefName,isCrossRepository,createdAt,mergeable,baseRefName";
|
|
13
13
|
|
|
14
14
|
interface OpenList {
|
|
15
15
|
at: number;
|
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/session.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import os from "os";
|
|
3
|
-
import { db, queries, type Session } from "../db";
|
|
4
|
-
import { isBranchName } from "../git";
|
|
3
|
+
import { db, queries, stackQueries, type Session } from "../db";
|
|
4
|
+
import { getDefaultBranch, isBranchName } from "../git";
|
|
5
|
+
import { parentBranchOf } from "../stacks/guard";
|
|
6
|
+
import { voidApprovalsOn } from "../orchestrator/ask-approvals";
|
|
7
|
+
import { taskSubject } from "../orchestrator/asks";
|
|
5
8
|
import { getProject } from "../projects";
|
|
6
9
|
import { findPRStrict } from "./gh";
|
|
7
10
|
import { currentBranch, git } from "../git-exec";
|
|
@@ -170,9 +173,67 @@ export async function prFor(
|
|
|
170
173
|
`UPDATE sessions SET pr_url = ?, pr_number = ?, pr_status = ? WHERE id = ?`
|
|
171
174
|
).run(pr.url, pr.number, pr.state.toLowerCase(), session.id);
|
|
172
175
|
}
|
|
176
|
+
// Only an answer just read from GitHub: a cached one may predate a
|
|
177
|
+
// retarget AgentOS made itself (a restack).
|
|
178
|
+
if (fresh) await recordBase(session, repo, pr);
|
|
173
179
|
return pr;
|
|
174
180
|
}
|
|
175
181
|
|
|
182
|
+
// An open PR's base as GitHub has it now becomes the task's, in the row and
|
|
183
|
+
// in `session`, so every diff, the stack logic and the UI read the truth:
|
|
184
|
+
// the base recorded at the start goes stale when the PR is retargeted (a
|
|
185
|
+
// stacked parent's branch deleted, so GitHub moved it to main). Only the
|
|
186
|
+
// default branch or the stack parent's branch is taken: a base the task
|
|
187
|
+
// picked itself would choose what its diff is judged against, so it stays
|
|
188
|
+
// unrecorded and sign_off refuses it. Reviews and scope checks judged
|
|
189
|
+
// against the old base no longer count. A stack item on a merged parent
|
|
190
|
+
// keeps its base until the restack moves it, which is also what takes the
|
|
191
|
+
// parent's commits off it.
|
|
192
|
+
// The bases a task may be judged against: the default branch or its stack
|
|
193
|
+
// parent's branch (and what it already has).
|
|
194
|
+
export async function isAllowedBase(
|
|
195
|
+
session: Session,
|
|
196
|
+
repo: string,
|
|
197
|
+
base: string
|
|
198
|
+
): Promise<boolean> {
|
|
199
|
+
if (base === session.base_branch) return true;
|
|
200
|
+
const item = stackQueries.itemForSession(db, session.id);
|
|
201
|
+
const parent = item?.parent_item_id
|
|
202
|
+
? stackQueries.item(db, item.parent_item_id)
|
|
203
|
+
: null;
|
|
204
|
+
return (
|
|
205
|
+
base === parentBranchOf(parent) || base === (await getDefaultBranch(repo))
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function recordBase(
|
|
210
|
+
session: Session,
|
|
211
|
+
repo: string,
|
|
212
|
+
pr: TaskPR | null
|
|
213
|
+
): Promise<void> {
|
|
214
|
+
const base = pr?.state === "OPEN" ? pr.base : undefined;
|
|
215
|
+
if (!base || base === session.base_branch || !isBranchName(base)) return;
|
|
216
|
+
if (!(await isAllowedBase(session, repo, base))) return;
|
|
217
|
+
const item = stackQueries.itemForSession(db, session.id);
|
|
218
|
+
const parent = item?.parent_item_id
|
|
219
|
+
? stackQueries.item(db, item.parent_item_id)
|
|
220
|
+
: null;
|
|
221
|
+
db.transaction(() => {
|
|
222
|
+
db.prepare(`UPDATE sessions SET base_branch = ? WHERE id = ?`).run(
|
|
223
|
+
base,
|
|
224
|
+
session.id
|
|
225
|
+
);
|
|
226
|
+
db.prepare(
|
|
227
|
+
`DELETE FROM orchestrator_checks WHERE session_id = ? AND kind IN ('review', 'scope')`
|
|
228
|
+
).run(session.id);
|
|
229
|
+
// An approval was for the diff against the old base.
|
|
230
|
+
voidApprovalsOn(taskSubject(session.id));
|
|
231
|
+
if (item && item.base_branch !== base && parent?.status !== "merged")
|
|
232
|
+
stackQueries.updateItem(db, item.id, { base_branch: base });
|
|
233
|
+
})();
|
|
234
|
+
session.base_branch = base;
|
|
235
|
+
}
|
|
236
|
+
|
|
176
237
|
// A finished task's PR as the database last saw it: no gh call.
|
|
177
238
|
export function storedPR(s: Session): TaskPR | null {
|
|
178
239
|
if (!s.pr_number) return null;
|
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/tasks/state.ts
CHANGED
|
@@ -30,6 +30,9 @@ export interface TaskPR {
|
|
|
30
30
|
// GitHub's word on merging it into its base: CONFLICTING once the base
|
|
31
31
|
// moved under it (UNKNOWN while GitHub works it out).
|
|
32
32
|
mergeable?: "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
|
|
33
|
+
// The branch it merges into, as GitHub has it now: it moves when the
|
|
34
|
+
// PR is retargeted (a stacked parent's branch deleted, say).
|
|
35
|
+
base?: string;
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
export type TaskState =
|
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.
|