@saadnvd1/agent-os 0.3.673 → 0.3.680
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 +2 -1
- package/app/api/bus/read/route.ts +34 -0
- package/app/api/search/conversations/context/route.ts +26 -0
- package/app/api/search/conversations/route.test.ts +139 -0
- package/app/api/search/conversations/route.ts +25 -0
- package/app/globals.css +25 -0
- package/components/Bookmarks/BookmarksDialog.tsx +2 -8
- package/components/Chat/useGoToItem.ts +11 -0
- package/components/CommandPalette/CommandPalette.tsx +23 -156
- package/components/CommandPalette/CommandsBody.tsx +216 -0
- package/components/CommandPalette/conversations/ConversationSearch.tsx +246 -0
- package/components/CommandPalette/conversations/EmptyState.tsx +95 -0
- package/components/CommandPalette/conversations/Preview.tsx +129 -0
- package/components/CommandPalette/conversations/ResultList.tsx +91 -0
- package/components/CommandPalette/conversations/Snippet.tsx +24 -0
- package/components/CommandPalette/conversations/format.ts +30 -0
- package/components/CommandPalette/conversations/recent.test.ts +20 -0
- package/components/CommandPalette/conversations/recent.ts +35 -0
- package/components/CommandPalette/conversations/useOpenHit.ts +30 -0
- package/data/search/index.ts +65 -0
- package/data/search/keys.ts +7 -0
- package/hooks/useDebouncedValue.ts +11 -0
- package/lib/agents/brief.ts +8 -0
- package/lib/bus/condense.ts +242 -0
- package/lib/bus/read-scope.ts +109 -0
- package/lib/bus/read.ts +181 -0
- package/lib/chat/worker/host.ts +3 -1
- package/lib/db/migrations/20261019T1500_conversation_search.ts +12 -0
- package/lib/demo-reseed.ts +4 -0
- package/lib/health/facts.ts +13 -3
- package/lib/health/spin-tick.ts +293 -0
- package/lib/health/spin.ts +158 -0
- package/lib/health/tick.ts +10 -2
- package/lib/health/types.ts +2 -0
- package/lib/orchestrator/read.ts +18 -4
- package/lib/palette/registry.ts +4 -0
- package/lib/resurrect/index.ts +2 -3
- package/lib/search/conversations/backfill.ts +117 -0
- package/lib/search/conversations/buildMatchQuery.ts +31 -0
- package/lib/search/conversations/index.ts +9 -0
- package/lib/search/conversations/messageContext.ts +45 -0
- package/lib/search/conversations/schema.ts +71 -0
- package/lib/search/conversations/searchConversations.ts +151 -0
- package/lib/search/conversations/types.ts +45 -0
- package/lib/sidebar/row-actions.ts +19 -8
- package/lib/sidebar/row-menu.ts +1 -1
- package/lib/tasks/restorable.ts +16 -0
- package/package.json +1 -1
- package/scripts/bench-conversation-search.ts +73 -0
- package/server.ts +3 -0
- package/stores/chatJump.ts +10 -0
- package/stores/palette.ts +22 -4
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { db } from "../db";
|
|
2
|
+
import type { Session } from "../db";
|
|
3
|
+
import { getProject } from "../projects";
|
|
4
|
+
import { getWorkspace } from "../workspaces";
|
|
5
|
+
import { previousNames } from "../session-names";
|
|
6
|
+
import { resolveRef, type Candidate } from "./resolve";
|
|
7
|
+
|
|
8
|
+
export type ReadableSession = Session & { project_name: string | null };
|
|
9
|
+
|
|
10
|
+
export interface ReaderScope {
|
|
11
|
+
workspaceId: string | null;
|
|
12
|
+
projectId: string | null;
|
|
13
|
+
label: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// What the asking session may read: its workspace, or its project when it
|
|
17
|
+
// has none. The boundary the orchestrator's tools use.
|
|
18
|
+
export function readerScope(callerId: string): ReaderScope {
|
|
19
|
+
const caller = db
|
|
20
|
+
.prepare(`SELECT * FROM session_rows WHERE id = ?`)
|
|
21
|
+
.get(callerId) as Session | undefined;
|
|
22
|
+
if (!caller) throw new Error("aos read runs from inside an AgentOS session");
|
|
23
|
+
const project = caller.project_id ? getProject(caller.project_id) : null;
|
|
24
|
+
const workspaceId =
|
|
25
|
+
caller.workspace_id ?? (project && project.workspace_id) ?? null;
|
|
26
|
+
if (workspaceId)
|
|
27
|
+
return {
|
|
28
|
+
workspaceId,
|
|
29
|
+
projectId: null,
|
|
30
|
+
label: `workspace ${getWorkspace(workspaceId)?.name ?? workspaceId}`,
|
|
31
|
+
};
|
|
32
|
+
if (!project || project.is_uncategorized)
|
|
33
|
+
throw new Error(`${caller.name} isn't in a project`);
|
|
34
|
+
return {
|
|
35
|
+
workspaceId: null,
|
|
36
|
+
projectId: project.id,
|
|
37
|
+
label: `project ${project.name}`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const SELECT = `SELECT s.*, p.name AS project_name FROM session_rows s
|
|
42
|
+
LEFT JOIN projects p ON p.id = s.project_id
|
|
43
|
+
WHERE s.peer_mirror = 0 AND s.held_by_device IS NULL`;
|
|
44
|
+
|
|
45
|
+
const isLive = (s: Session) =>
|
|
46
|
+
!s.archived_at && (!s.task_status || s.task_status === "running");
|
|
47
|
+
|
|
48
|
+
function candidates(rows: ReadableSession[]): Candidate[] {
|
|
49
|
+
const old = previousNames();
|
|
50
|
+
return rows.map((s) => ({
|
|
51
|
+
id: s.id,
|
|
52
|
+
name: s.name,
|
|
53
|
+
projectName: s.project_name,
|
|
54
|
+
tmuxName: s.tmux_name,
|
|
55
|
+
previousNames: old.get(s.id) ?? [],
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function inScope(s: ReadableSession, scope: ReaderScope): boolean {
|
|
60
|
+
if (s.role) return false;
|
|
61
|
+
if (scope.workspaceId) {
|
|
62
|
+
const p = s.project_id ? getProject(s.project_id) : null;
|
|
63
|
+
return !!p && p.workspace_id === scope.workspaceId;
|
|
64
|
+
}
|
|
65
|
+
return s.project_id === scope.projectId;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type Readable =
|
|
69
|
+
| { ok: true; session: ReadableSession; note?: string }
|
|
70
|
+
| { ok: false; external: boolean; error: string };
|
|
71
|
+
|
|
72
|
+
// A session in the caller's scope by name, old name, project/name, id or id
|
|
73
|
+
// prefix. Done, dropped and archived ones count, but a live session wins a
|
|
74
|
+
// name over a finished one.
|
|
75
|
+
export function resolveReadable(callerId: string, ref: string): Readable {
|
|
76
|
+
const scope = readerScope(callerId);
|
|
77
|
+
const all = (db.prepare(SELECT).all() as ReadableSession[]).sort((a, b) =>
|
|
78
|
+
a.updated_at < b.updated_at ? 1 : -1
|
|
79
|
+
);
|
|
80
|
+
const mine = all.filter((s) => inScope(s, scope));
|
|
81
|
+
let none = "";
|
|
82
|
+
for (const group of [mine.filter(isLive), mine.filter((s) => !isLive(s))]) {
|
|
83
|
+
const r = resolveRef(ref, candidates(group));
|
|
84
|
+
if (r.ok) {
|
|
85
|
+
const session = group.find((s) => s.id === r.id)!;
|
|
86
|
+
if (session.id === callerId)
|
|
87
|
+
return { ok: false, external: false, error: "that's this session" };
|
|
88
|
+
return { ok: true, session, note: r.note };
|
|
89
|
+
}
|
|
90
|
+
if (r.reason === "ambiguous")
|
|
91
|
+
return { ok: false, external: false, error: r.error };
|
|
92
|
+
none = r.error;
|
|
93
|
+
}
|
|
94
|
+
const other = resolveRef(
|
|
95
|
+
ref,
|
|
96
|
+
candidates(all.filter((s) => !mine.includes(s)))
|
|
97
|
+
);
|
|
98
|
+
if (other.ok) {
|
|
99
|
+
const s = all.find((x) => x.id === other.id)!;
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
external: false,
|
|
103
|
+
error: s.role
|
|
104
|
+
? `${s.name} is an orchestrator: it isn't readable. Message it with aos send`
|
|
105
|
+
: `"${ref}" isn't in your ${scope.label}; aos read reaches only sessions there`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return { ok: false, external: true, error: `${none}. Run: aos peers` };
|
|
109
|
+
}
|
package/lib/bus/read.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { db } from "../db";
|
|
4
|
+
import type { Session } from "../db";
|
|
5
|
+
import { lastItems, listItems } from "../chat/store";
|
|
6
|
+
import { untrusted } from "../orchestrator/untrusted";
|
|
7
|
+
import { readExternal, readResolved } from "../orchestrator/read";
|
|
8
|
+
import { findExternalSession } from "../orchestrator/external-sessions";
|
|
9
|
+
import { pieceOfSession, planPathOf } from "../plans/features";
|
|
10
|
+
import { hostLink } from "../hosts/remote-api";
|
|
11
|
+
import {
|
|
12
|
+
readerScope,
|
|
13
|
+
resolveReadable,
|
|
14
|
+
type ReadableSession,
|
|
15
|
+
} from "./read-scope";
|
|
16
|
+
import {
|
|
17
|
+
READ_DEFAULT_TURNS,
|
|
18
|
+
TERMINAL_LINES,
|
|
19
|
+
TERMINAL_LINES_ALL,
|
|
20
|
+
condenseItems,
|
|
21
|
+
grepTurns,
|
|
22
|
+
renderTurns,
|
|
23
|
+
type Rendered,
|
|
24
|
+
} from "./condense";
|
|
25
|
+
|
|
26
|
+
export interface ReadOpts {
|
|
27
|
+
turns?: number;
|
|
28
|
+
lines?: number;
|
|
29
|
+
all?: boolean;
|
|
30
|
+
grep?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ReadResult {
|
|
34
|
+
// Ours, not another session's text: who, what state, how much.
|
|
35
|
+
header: string;
|
|
36
|
+
// Another session's text, fenced and redacted.
|
|
37
|
+
text: string;
|
|
38
|
+
session: { id: string | null; name: string; project: string | null };
|
|
39
|
+
task: { prompt: string | null; plan: string | null } | null;
|
|
40
|
+
turns: { from: number; to: number; total: number } | null;
|
|
41
|
+
truncated: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const PLAN_FILE = ".agentos/plan.md";
|
|
45
|
+
const PROMPT_CAP = 1500;
|
|
46
|
+
// Items fetched per turn asked for, when the whole chat isn't needed.
|
|
47
|
+
const ITEMS_PER_TURN = 400;
|
|
48
|
+
|
|
49
|
+
export class ReadError extends Error {
|
|
50
|
+
constructor(
|
|
51
|
+
message: string,
|
|
52
|
+
readonly status: number
|
|
53
|
+
) {
|
|
54
|
+
super(message);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function stateOf(s: Session): string {
|
|
59
|
+
if (s.archived_at) return "archived";
|
|
60
|
+
return s.task_status ?? "running";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function planOf(s: Session): string | null {
|
|
64
|
+
if (s.worktree_path) {
|
|
65
|
+
const own = path.join(s.worktree_path, PLAN_FILE);
|
|
66
|
+
if (fs.existsSync(own)) return own;
|
|
67
|
+
}
|
|
68
|
+
const piece = pieceOfSession(s.id);
|
|
69
|
+
const fromFeature = piece ? planPathOf(piece.feature_id) : null;
|
|
70
|
+
return fromFeature && fs.existsSync(fromFeature) ? fromFeature : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function taskHeader(s: Session, all: boolean) {
|
|
74
|
+
if (!s.task_prompt) return null;
|
|
75
|
+
const p = s.task_prompt.trim();
|
|
76
|
+
return {
|
|
77
|
+
prompt: !all && p.length > PROMPT_CAP ? `${p.slice(0, PROMPT_CAP)}…` : p,
|
|
78
|
+
plan: planOf(s),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function userCount(sessionId: string): number {
|
|
83
|
+
return (
|
|
84
|
+
db
|
|
85
|
+
.prepare(
|
|
86
|
+
`SELECT COUNT(*) AS n FROM chat_items WHERE session_id = ? AND kind = 'user'`
|
|
87
|
+
)
|
|
88
|
+
.get(sessionId) as { n: number }
|
|
89
|
+
).n;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function chatTurns(s: Session, o: ReadOpts) {
|
|
93
|
+
const total = userCount(s.id);
|
|
94
|
+
if (o.all || o.grep) return { turns: condenseItems(listItems(s.id)), total };
|
|
95
|
+
const asked = o.lines
|
|
96
|
+
? Math.ceil(o.lines / 4) + 1
|
|
97
|
+
: (o.turns ?? READ_DEFAULT_TURNS);
|
|
98
|
+
const items = lastItems(s.id, Math.min(asked * ITEMS_PER_TURN, 4000));
|
|
99
|
+
const inWindow = items.filter((i) => i.kind === "user").length;
|
|
100
|
+
return { turns: condenseItems(items, Math.max(total - inWindow, 0)), total };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// What a session in the caller's workspace said and did, condensed.
|
|
104
|
+
export async function readPeer(
|
|
105
|
+
callerId: string,
|
|
106
|
+
ref: string,
|
|
107
|
+
o: ReadOpts = {}
|
|
108
|
+
): Promise<ReadResult> {
|
|
109
|
+
let found;
|
|
110
|
+
try {
|
|
111
|
+
found = resolveReadable(callerId, ref);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
throw new ReadError(
|
|
114
|
+
error instanceof Error ? error.message : String(error),
|
|
115
|
+
403
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
if (!found.ok) {
|
|
119
|
+
const scope = readerScope(callerId);
|
|
120
|
+
const external =
|
|
121
|
+
found.external && scope.workspaceId
|
|
122
|
+
? await findExternalSession(scope.workspaceId, ref).catch(() => null)
|
|
123
|
+
: null;
|
|
124
|
+
if (external) {
|
|
125
|
+
const n = o.lines ?? (o.all ? TERMINAL_LINES_ALL : TERMINAL_LINES);
|
|
126
|
+
return {
|
|
127
|
+
header: `${external.ref} (external terminal)`,
|
|
128
|
+
text: await readExternal(external, n),
|
|
129
|
+
session: { id: null, name: external.ref, project: null },
|
|
130
|
+
task: null,
|
|
131
|
+
turns: null,
|
|
132
|
+
truncated: false,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
throw new ReadError(found.error, found.external ? 404 : 403);
|
|
136
|
+
}
|
|
137
|
+
const s: ReadableSession = found.session;
|
|
138
|
+
const who = { id: s.id, name: s.name, project: s.project_name };
|
|
139
|
+
const label = `${s.project_name ? `${s.project_name}/` : ""}${s.name}`;
|
|
140
|
+
const task = taskHeader(s, !!o.all);
|
|
141
|
+
const note = found.note ? ` (${found.note})` : "";
|
|
142
|
+
|
|
143
|
+
if (s.view !== "chat" || hostLink(s.host_id)) {
|
|
144
|
+
const n =
|
|
145
|
+
o.lines ?? (o.all || o.grep ? TERMINAL_LINES_ALL : TERMINAL_LINES);
|
|
146
|
+
return {
|
|
147
|
+
header: `${label}${note}: ${s.view}, ${stateOf(s)}`,
|
|
148
|
+
text: await readResolved(s, n, o.grep),
|
|
149
|
+
session: who,
|
|
150
|
+
task,
|
|
151
|
+
turns: null,
|
|
152
|
+
truncated: false,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const { turns, total } = chatTurns(s, o);
|
|
157
|
+
const r: Rendered = o.grep
|
|
158
|
+
? grepTurns(turns, o.grep, { all: o.all })
|
|
159
|
+
: renderTurns(turns, o);
|
|
160
|
+
const taskText = task
|
|
161
|
+
? `Task brief:\n${task.prompt}${task.plan ? `\nPlan: ${task.plan}` : ""}\n\n`
|
|
162
|
+
: "";
|
|
163
|
+
const body = r.text
|
|
164
|
+
? r.text
|
|
165
|
+
: o.grep
|
|
166
|
+
? `no turns match "${o.grep}"`
|
|
167
|
+
: "(no messages yet)";
|
|
168
|
+
const range = r.to
|
|
169
|
+
? `turns ${r.from === r.to ? r.to : `${r.from}-${r.to}`} of ${total}`
|
|
170
|
+
: `${total} turns`;
|
|
171
|
+
const more =
|
|
172
|
+
r.truncated && !o.all ? "; more with --turns N, --grep or --all" : "";
|
|
173
|
+
return {
|
|
174
|
+
header: `${label}${note} (chat, ${stateOf(s)}), ${o.grep ? `matches for "${o.grep}", ` : ""}${range}${more}`,
|
|
175
|
+
text: untrusted(s.name, `${taskText}${body}`),
|
|
176
|
+
session: who,
|
|
177
|
+
task,
|
|
178
|
+
turns: { from: r.from, to: r.to, total },
|
|
179
|
+
truncated: r.truncated,
|
|
180
|
+
};
|
|
181
|
+
}
|
package/lib/chat/worker/host.ts
CHANGED
|
@@ -596,7 +596,9 @@ export class ChatHost {
|
|
|
596
596
|
if (/^\/compact\b/.test(m.text.trimStart())) this.setCompacting(true);
|
|
597
597
|
const checkpoint = now
|
|
598
598
|
? this.conversation.send(text, m.images, { now, files: m.files })
|
|
599
|
-
:
|
|
599
|
+
: m.files?.length
|
|
600
|
+
? this.conversation.send(text, m.images, { files: m.files })
|
|
601
|
+
: this.conversation.send(text, m.images);
|
|
600
602
|
const user: ChatItem = {
|
|
601
603
|
id: m.id,
|
|
602
604
|
kind: "user",
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
import { createConversationSearch } from "../../search/conversations/schema";
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
key: "20261019T1500_conversation_search",
|
|
6
|
+
up: (db) => {
|
|
7
|
+
// The index and the triggers that keep it current. Existing chats are
|
|
8
|
+
// indexed in the background after startup (lib/search/conversations/backfill.ts),
|
|
9
|
+
// never in this migration, so a big database doesn't hold up the boot.
|
|
10
|
+
createConversationSearch(db);
|
|
11
|
+
},
|
|
12
|
+
} satisfies Migration;
|
package/lib/demo-reseed.ts
CHANGED
|
@@ -57,6 +57,10 @@ export function restoreDemo(db: Database.Database, file: string): void {
|
|
|
57
57
|
db.transaction(() => {
|
|
58
58
|
for (const { name } of tables) {
|
|
59
59
|
if (KEEP_TABLES.has(name)) continue;
|
|
60
|
+
// The search index (an FTS table, its shadow tables, its keys) is
|
|
61
|
+
// kept up to date by triggers on chat_items; copying it would
|
|
62
|
+
// fight them, and shadow tables can't be written at all.
|
|
63
|
+
if (name.startsWith("chat_search")) continue;
|
|
60
64
|
// Stored columns only: a generated one can't be written.
|
|
61
65
|
const cols = (
|
|
62
66
|
db.prepare(`SELECT * FROM pragma_table_xinfo(?)`).all(name) as {
|
package/lib/health/facts.ts
CHANGED
|
@@ -76,7 +76,12 @@ function queue(): QueueFact[] {
|
|
|
76
76
|
|
|
77
77
|
// Background jobs still running in a chat whose agent is alive, that have
|
|
78
78
|
// been going long enough to be worth a look at their output.
|
|
79
|
-
|
|
79
|
+
export interface BgTask extends BgFact {
|
|
80
|
+
outputFile?: string;
|
|
81
|
+
taskType?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function runningBgTasks(now: number, minAgeMs: number): BgTask[] {
|
|
80
85
|
const live = new Set(runningWorkers());
|
|
81
86
|
if (!live.size) return [];
|
|
82
87
|
const sessions = db
|
|
@@ -86,12 +91,12 @@ function backgroundJobs(now: number, c: HealthConfig): BgFact[] {
|
|
|
86
91
|
WHERE s.archived_at IS NULL AND s.view = 'chat'`
|
|
87
92
|
)
|
|
88
93
|
.all() as { id: string; name: string; workspace_id: string | null }[];
|
|
89
|
-
const out:
|
|
94
|
+
const out: BgTask[] = [];
|
|
90
95
|
for (const s of sessions) {
|
|
91
96
|
if (!live.has(s.id)) continue;
|
|
92
97
|
for (const t of itemsOfKind(s.id, "task")) {
|
|
93
98
|
if (t.status !== "running" || t.ambient) continue;
|
|
94
|
-
if (now - t.createdAt <
|
|
99
|
+
if (now - t.createdAt < minAgeMs) continue;
|
|
95
100
|
const tail = taskOutputTail(t.taskId, t.outputFile);
|
|
96
101
|
out.push({
|
|
97
102
|
sessionId: s.id,
|
|
@@ -101,12 +106,17 @@ function backgroundJobs(now: number, c: HealthConfig): BgFact[] {
|
|
|
101
106
|
description: t.description,
|
|
102
107
|
startedAt: t.createdAt,
|
|
103
108
|
outputAt: tail?.updatedAt ?? null,
|
|
109
|
+
outputFile: t.outputFile,
|
|
110
|
+
taskType: t.taskType,
|
|
104
111
|
});
|
|
105
112
|
}
|
|
106
113
|
}
|
|
107
114
|
return out;
|
|
108
115
|
}
|
|
109
116
|
|
|
117
|
+
const backgroundJobs = (now: number, c: HealthConfig): BgFact[] =>
|
|
118
|
+
runningBgTasks(now, c.bgQuietMinutes * MIN);
|
|
119
|
+
|
|
110
120
|
// Every running background job, for the heartbeat a restart reads back.
|
|
111
121
|
export function liveBackgroundJobs(): string[] {
|
|
112
122
|
const live = new Set(runningWorkers());
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The I/O half of bg-spin (spin.ts decides): once a minute, for each old
|
|
3
|
+
* enough background job of a live chat, find its process tree, sample the CPU
|
|
4
|
+
* of its shells, and stop a job that spins. A stop is by PID, the job's own
|
|
5
|
+
* tree only: SIGTERM, then SIGKILL for what is still the same process after a
|
|
6
|
+
* grace. Never a pattern, never a group, never AgentOS, tmux or the agent.
|
|
7
|
+
* With no old background job there is no process I/O at all.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execFile } from "child_process";
|
|
11
|
+
import fs from "fs";
|
|
12
|
+
import { sendMessage } from "../bus";
|
|
13
|
+
import { findOutputFile } from "../chat/task-output";
|
|
14
|
+
import { queueEventOnce } from "../orchestrator/events";
|
|
15
|
+
import { untrusted } from "../orchestrator/untrusted";
|
|
16
|
+
import { processTable, type ProcRow } from "../process-table";
|
|
17
|
+
import { runningBgTasks, type BgTask } from "./facts";
|
|
18
|
+
import { close, recordTrips } from "./store";
|
|
19
|
+
import {
|
|
20
|
+
KILL_GRACE_MS,
|
|
21
|
+
SPIN_KEEP_MS,
|
|
22
|
+
SPIN_MIN_AGE_MS,
|
|
23
|
+
baseOf,
|
|
24
|
+
classifySpin,
|
|
25
|
+
jobTree,
|
|
26
|
+
killPlan,
|
|
27
|
+
parseCpuTime,
|
|
28
|
+
pickRoot,
|
|
29
|
+
safeToKill,
|
|
30
|
+
shellPids,
|
|
31
|
+
spinEventLine,
|
|
32
|
+
spinNote,
|
|
33
|
+
type SpinSample,
|
|
34
|
+
} from "./spin";
|
|
35
|
+
import type { Trip } from "./types";
|
|
36
|
+
|
|
37
|
+
export interface SpinDeps {
|
|
38
|
+
tasks: (now: number) => BgTask[];
|
|
39
|
+
// Output file -> the PIDs holding it open.
|
|
40
|
+
holders: (files: string[]) => Promise<Map<string, number[]>>;
|
|
41
|
+
table: () => Promise<ProcRow[] | null>;
|
|
42
|
+
cpuSeconds: (pids: number[]) => Promise<Map<number, number>>;
|
|
43
|
+
kill: (pid: number, signal: "SIGTERM" | "SIGKILL") => void;
|
|
44
|
+
sleep: (ms: number) => Promise<void>;
|
|
45
|
+
tell: (sessionId: string, body: string) => Promise<void>;
|
|
46
|
+
event: (
|
|
47
|
+
workspaceId: string,
|
|
48
|
+
key: string,
|
|
49
|
+
subject: string,
|
|
50
|
+
line: string,
|
|
51
|
+
now: number
|
|
52
|
+
) => void;
|
|
53
|
+
record: (trip: Trip, now: number) => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const run = (cmd: string, args: string[]): Promise<string> =>
|
|
57
|
+
new Promise((resolve) =>
|
|
58
|
+
execFile(cmd, args, { timeout: 5000, maxBuffer: 4 << 20 }, (_err, out) =>
|
|
59
|
+
resolve(out ?? "")
|
|
60
|
+
)
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// `lsof -F pn`: a `p<pid>` line, then `n<name>` for each file it holds.
|
|
64
|
+
export function parseHolders(out: string): Map<string, number[]> {
|
|
65
|
+
const map = new Map<string, number[]>();
|
|
66
|
+
let pid = 0;
|
|
67
|
+
for (const line of out.split("\n")) {
|
|
68
|
+
if (line.startsWith("p")) pid = Number(line.slice(1)) || 0;
|
|
69
|
+
else if (line.startsWith("n") && pid) {
|
|
70
|
+
const list = map.get(line.slice(1)) ?? [];
|
|
71
|
+
if (!list.includes(pid)) list.push(pid);
|
|
72
|
+
map.set(line.slice(1), list);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return map;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Linux counts reaped children in /proc; macOS ps's TIME already includes them.
|
|
79
|
+
function procCpu(pid: number): number | null {
|
|
80
|
+
try {
|
|
81
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
82
|
+
const f = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
83
|
+
return (
|
|
84
|
+
(Number(f[11]) + Number(f[12]) + Number(f[13]) + Number(f[14])) / 100
|
|
85
|
+
);
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function cpuSeconds(pids: number[]): Promise<Map<number, number>> {
|
|
92
|
+
const out = new Map<number, number>();
|
|
93
|
+
if (!pids.length) return out;
|
|
94
|
+
if (process.platform === "linux") {
|
|
95
|
+
for (const pid of pids) {
|
|
96
|
+
const sec = procCpu(pid);
|
|
97
|
+
if (sec !== null) out.set(pid, sec);
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
const text = await run("ps", ["-o", "pid=,time=", "-p", pids.join(",")]);
|
|
102
|
+
for (const line of text.split("\n")) {
|
|
103
|
+
const m = /^\s*(\d+)\s+(\S+)\s*$/.exec(line);
|
|
104
|
+
const sec = m ? parseCpuTime(m[2]) : null;
|
|
105
|
+
if (m && sec !== null) out.set(Number(m[1]), sec);
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const realSpin: SpinDeps = {
|
|
111
|
+
tasks: (now) =>
|
|
112
|
+
runningBgTasks(now, SPIN_MIN_AGE_MS).map((t) => ({
|
|
113
|
+
...t,
|
|
114
|
+
outputFile: t.outputFile ?? findOutputFile(t.taskId) ?? undefined,
|
|
115
|
+
})),
|
|
116
|
+
holders: async (files) =>
|
|
117
|
+
parseHolders(await run("lsof", ["-F", "pn", "--", ...files])),
|
|
118
|
+
table: () => processTable(0),
|
|
119
|
+
cpuSeconds,
|
|
120
|
+
kill: (pid, signal) => process.kill(pid, signal),
|
|
121
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
122
|
+
tell: async (sessionId, body) => {
|
|
123
|
+
await sendMessage({
|
|
124
|
+
fromId: null,
|
|
125
|
+
to: sessionId,
|
|
126
|
+
body,
|
|
127
|
+
fromLabel: "AgentOS health",
|
|
128
|
+
origin: { kind: "system", label: "Health", body },
|
|
129
|
+
});
|
|
130
|
+
},
|
|
131
|
+
event: (ws, key, subject, line, now) => {
|
|
132
|
+
queueEventOnce(ws, key, subject, line, now);
|
|
133
|
+
},
|
|
134
|
+
record: (trip, now) => {
|
|
135
|
+
const { opened } = recordTrips([trip], [], now);
|
|
136
|
+
// History only: nothing to wake a watcher for.
|
|
137
|
+
for (const row of opened) close(row.id, "stopped", now);
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
interface Track {
|
|
142
|
+
root: number;
|
|
143
|
+
comm: string;
|
|
144
|
+
samples: SpinSample[];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const tracks = new Map<string, Track>();
|
|
148
|
+
let running = false;
|
|
149
|
+
|
|
150
|
+
export function resetSpin(): void {
|
|
151
|
+
tracks.clear();
|
|
152
|
+
running = false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const keyOf = (t: BgTask) => `${t.sessionId}:${t.taskId}`;
|
|
156
|
+
|
|
157
|
+
// SIGTERM the whole job, root first; SIGKILL what is still the same process.
|
|
158
|
+
async function stopTree(
|
|
159
|
+
root: number,
|
|
160
|
+
table: ProcRow[],
|
|
161
|
+
deps: SpinDeps
|
|
162
|
+
): Promise<number[]> {
|
|
163
|
+
const plan = killPlan(root, table);
|
|
164
|
+
const comm = new Map(table.map((r) => [r.pid, r.comm]));
|
|
165
|
+
const signal = (pid: number, sig: "SIGTERM" | "SIGKILL") => {
|
|
166
|
+
try {
|
|
167
|
+
deps.kill(pid, sig);
|
|
168
|
+
} catch {
|
|
169
|
+
// Already gone.
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
for (const pid of plan) signal(pid, "SIGTERM");
|
|
173
|
+
await deps.sleep(KILL_GRACE_MS);
|
|
174
|
+
const after = new Map((await deps.table())?.map((r) => [r.pid, r.comm]));
|
|
175
|
+
for (const pid of plan)
|
|
176
|
+
if (after.has(pid) && after.get(pid) === comm.get(pid))
|
|
177
|
+
signal(pid, "SIGKILL");
|
|
178
|
+
return plan;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function sampleAll(
|
|
182
|
+
jobs: BgTask[],
|
|
183
|
+
now: number,
|
|
184
|
+
deps: SpinDeps
|
|
185
|
+
): Promise<{ job: BgTask; track: Track; table: ProcRow[] }[]> {
|
|
186
|
+
const table = await deps.table();
|
|
187
|
+
if (!table) return [];
|
|
188
|
+
const need = jobs.filter((j) => !tracks.get(keyOf(j)) && j.outputFile);
|
|
189
|
+
if (need.length) {
|
|
190
|
+
const held = await deps.holders(need.map((j) => j.outputFile as string));
|
|
191
|
+
for (const j of need) {
|
|
192
|
+
const root = pickRoot(held.get(j.outputFile as string) ?? [], table);
|
|
193
|
+
const row = table.find((r) => r.pid === root);
|
|
194
|
+
if (row)
|
|
195
|
+
tracks.set(keyOf(j), { root: row.pid, comm: row.comm, samples: [] });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const live: { job: BgTask; track: Track; table: ProcRow[] }[] = [];
|
|
199
|
+
for (const j of jobs) {
|
|
200
|
+
const track = tracks.get(keyOf(j));
|
|
201
|
+
if (!track) continue;
|
|
202
|
+
const row = table.find((r) => r.pid === track.root);
|
|
203
|
+
// Gone, or the PID is someone else's now.
|
|
204
|
+
if (!row || row.comm !== track.comm) {
|
|
205
|
+
tracks.delete(keyOf(j));
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
live.push({ job: j, track, table });
|
|
209
|
+
}
|
|
210
|
+
const pids = live.flatMap((l) => shellPids(jobTree(l.track.root, table)));
|
|
211
|
+
const cpu = await deps.cpuSeconds(pids);
|
|
212
|
+
for (const { job, track } of live) {
|
|
213
|
+
const sec = shellPids(jobTree(track.root, table)).reduce(
|
|
214
|
+
(sum, pid) => sum + (cpu.get(pid) ?? 0),
|
|
215
|
+
0
|
|
216
|
+
);
|
|
217
|
+
track.samples.push({ at: now, cpuSec: sec, outputAt: job.outputAt });
|
|
218
|
+
track.samples = track.samples.filter((s) => now - s.at <= SPIN_KEEP_MS);
|
|
219
|
+
}
|
|
220
|
+
return live;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function spinTick(
|
|
224
|
+
now = Date.now(),
|
|
225
|
+
deps: SpinDeps = realSpin
|
|
226
|
+
): Promise<void> {
|
|
227
|
+
if (running) return;
|
|
228
|
+
running = true;
|
|
229
|
+
try {
|
|
230
|
+
// Shell jobs only: a subagent task has no shell to spin.
|
|
231
|
+
const jobs = deps
|
|
232
|
+
.tasks(now)
|
|
233
|
+
.filter((t) => !t.taskType || t.taskType === "local_bash");
|
|
234
|
+
const names = new Set(jobs.map(keyOf));
|
|
235
|
+
for (const k of tracks.keys()) if (!names.has(k)) tracks.delete(k);
|
|
236
|
+
if (!jobs.length) return;
|
|
237
|
+
for (const { job, track, table } of await sampleAll(jobs, now, deps)) {
|
|
238
|
+
if (classifySpin(track.samples) !== "spinning") continue;
|
|
239
|
+
if (!safeToKill(track.root, table)) {
|
|
240
|
+
console.warn(
|
|
241
|
+
`[health] bg-spin: not stopping pid ${track.root} (${baseOf(track.comm)}) in ${job.sessionName}: it is not a plain job of the agent`
|
|
242
|
+
);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const pids = await stopTree(track.root, table, deps);
|
|
246
|
+
tracks.delete(keyOf(job));
|
|
247
|
+
console.warn(
|
|
248
|
+
`[health] bg-spin: stopped "${job.description}" in ${job.sessionName} (session ${job.sessionId}): pid ${track.root}, tree ${pids.join(",")}`
|
|
249
|
+
);
|
|
250
|
+
await announce(job, track.root, now, deps);
|
|
251
|
+
}
|
|
252
|
+
} finally {
|
|
253
|
+
running = false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function announce(
|
|
258
|
+
job: BgTask,
|
|
259
|
+
pid: number,
|
|
260
|
+
now: number,
|
|
261
|
+
deps: SpinDeps
|
|
262
|
+
): Promise<void> {
|
|
263
|
+
const line = spinEventLine(job.sessionName, job.description, pid);
|
|
264
|
+
try {
|
|
265
|
+
deps.record(
|
|
266
|
+
{
|
|
267
|
+
check: "bg-spin",
|
|
268
|
+
subject: keyOf(job),
|
|
269
|
+
severity: "high",
|
|
270
|
+
line,
|
|
271
|
+
detail: { sessionId: job.sessionId, taskId: job.taskId, pid },
|
|
272
|
+
workspaceId: job.workspaceId,
|
|
273
|
+
},
|
|
274
|
+
now
|
|
275
|
+
);
|
|
276
|
+
} catch (error) {
|
|
277
|
+
console.error("[health] bg-spin record:", error);
|
|
278
|
+
}
|
|
279
|
+
if (job.workspaceId)
|
|
280
|
+
deps.event(
|
|
281
|
+
job.workspaceId,
|
|
282
|
+
`bg-spin:${keyOf(job)}`,
|
|
283
|
+
job.sessionId,
|
|
284
|
+
// The description is the agent's own text.
|
|
285
|
+
`Health (tasks): ${untrusted("health detector", line)}`,
|
|
286
|
+
now
|
|
287
|
+
);
|
|
288
|
+
try {
|
|
289
|
+
await deps.tell(job.sessionId, spinNote(job.description, pid));
|
|
290
|
+
} catch (error) {
|
|
291
|
+
console.error("[health] bg-spin note:", error);
|
|
292
|
+
}
|
|
293
|
+
}
|