@saadnvd1/agent-os 0.3.512 → 0.3.514
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/lib/chat/activity.ts +6 -12
- package/lib/chat/events.ts +1 -1
- package/lib/chat/registry.ts +8 -0
- package/lib/chat/runner.ts +4 -11
- package/lib/chat/socket.ts +2 -3
- package/lib/chat/store.ts +17 -7
- package/lib/db/migrations.ts +13 -0
- package/lib/demo-reseed.ts +2 -0
- package/lib/orchestrator/armed.ts +2 -0
- package/lib/orchestrator/checks.ts +3 -0
- package/lib/orchestrator/facts.ts +5 -1
- package/lib/orchestrator/gates.ts +3 -0
- package/lib/orchestrator/merge-approvals.ts +2 -0
- package/lib/status/collect.ts +39 -51
- package/lib/status/dirty.ts +42 -0
- package/lib/status/incremental.ts +151 -0
- package/lib/status/pr-stages.ts +34 -31
- package/lib/tasks/session.ts +3 -0
- package/package.json +1 -1
package/lib/chat/activity.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { formatTokens } from "./context";
|
|
|
3
3
|
import { formatElapsed } from "./elapsed";
|
|
4
4
|
import { registry, type Live } from "./registry";
|
|
5
5
|
import { stallThresholdMs } from "./stall";
|
|
6
|
-
import {
|
|
6
|
+
import { lastItems, runningItems } from "./store";
|
|
7
7
|
import type { WorkerEvent } from "./worker/protocol";
|
|
8
8
|
|
|
9
9
|
const lastWhat = (i: ChatItem): string =>
|
|
@@ -55,23 +55,17 @@ export function track(live: Live, e: WorkerEvent): void {
|
|
|
55
55
|
|
|
56
56
|
// After a reattach, what's still running comes from the saved items.
|
|
57
57
|
export function restoreActivity(sessionId: string, state: ChatState) {
|
|
58
|
-
const items = listItems(sessionId);
|
|
59
58
|
const activity: Live["activity"] = { tools: new Map(), tasks: new Set() };
|
|
60
|
-
if (state !== "idle")
|
|
61
|
-
activity.turnStartedAt = items.findLast(
|
|
62
|
-
(i) => i.kind === "user"
|
|
63
|
-
)?.createdAt;
|
|
64
59
|
if (state !== "idle") {
|
|
65
|
-
|
|
60
|
+
activity.turnStartedAt = lastItems(sessionId, 1, "user")[0]?.createdAt;
|
|
61
|
+
const last = lastItems(sessionId, 1)[0];
|
|
66
62
|
activity.lastAt = last?.createdAt ?? activity.turnStartedAt;
|
|
67
63
|
activity.lastWhat = last ? lastWhat(last) : undefined;
|
|
68
|
-
|
|
69
|
-
for (const i of items) {
|
|
70
|
-
if (i.kind === "tool" && i.status === "running" && state !== "idle")
|
|
64
|
+
for (const i of runningItems(sessionId, "tool"))
|
|
71
65
|
activity.tools.set(i.id, { label: i.title, since: i.createdAt });
|
|
72
|
-
if (i.kind === "task" && i.status === "running" && !i.ambient)
|
|
73
|
-
activity.tasks.add(i.taskId);
|
|
74
66
|
}
|
|
67
|
+
for (const i of runningItems(sessionId, "task"))
|
|
68
|
+
if (!i.ambient) activity.tasks.add(i.taskId);
|
|
75
69
|
return activity;
|
|
76
70
|
}
|
|
77
71
|
|
package/lib/chat/events.ts
CHANGED
|
@@ -311,7 +311,7 @@ export interface ToolBody {
|
|
|
311
311
|
export type ChatServerMessage =
|
|
312
312
|
| {
|
|
313
313
|
type: "snapshot";
|
|
314
|
-
//
|
|
314
|
+
// The latest page; older ones come with `history`.
|
|
315
315
|
items: ChatItem[];
|
|
316
316
|
// Where older items start, and whether there are any.
|
|
317
317
|
cursor: number | null;
|
package/lib/chat/registry.ts
CHANGED
|
@@ -9,6 +9,11 @@ import type {
|
|
|
9
9
|
ChatState,
|
|
10
10
|
Compacting,
|
|
11
11
|
} from "./events";
|
|
12
|
+
import { markStatusDirty } from "../status/dirty";
|
|
13
|
+
import { FLUSH_MS } from "./store";
|
|
14
|
+
|
|
15
|
+
// Long enough for the worker's buffered write of an item to land.
|
|
16
|
+
const EMIT_SETTLE_MS = 2 * FLUSH_MS;
|
|
12
17
|
|
|
13
18
|
// A conversation whose worker this server is connected to.
|
|
14
19
|
export interface Live {
|
|
@@ -82,6 +87,9 @@ registry.caps ??= new Map();
|
|
|
82
87
|
registry.connecting ??= new Map();
|
|
83
88
|
|
|
84
89
|
export function emit(sessionId: string, m: ChatServerMessage): void {
|
|
90
|
+
// Items come from the worker, which writes them a moment after saying so.
|
|
91
|
+
if (m.type === "state" || m.type === "compacting" || m.type === "item")
|
|
92
|
+
markStatusDirty(sessionId, EMIT_SETTLE_MS);
|
|
85
93
|
registry.listeners.get(sessionId)?.forEach((fn) => fn(m));
|
|
86
94
|
// Idle, running, waiting and back, and a finished turn (unread): the
|
|
87
95
|
// sidebar hears at once.
|
package/lib/chat/runner.ts
CHANGED
|
@@ -38,7 +38,6 @@ import {
|
|
|
38
38
|
hasItem,
|
|
39
39
|
itemsOfKind,
|
|
40
40
|
recentTasks,
|
|
41
|
-
listItems,
|
|
42
41
|
saveItem,
|
|
43
42
|
settle,
|
|
44
43
|
} from "./store";
|
|
@@ -851,17 +850,11 @@ function present(sessionId: string, items: ChatItem[]): ChatItem[] {
|
|
|
851
850
|
return runningWorkers().includes(sessionId) ? items : settle(items);
|
|
852
851
|
}
|
|
853
852
|
|
|
854
|
-
// Snapshot then live updates. Returns the unsubscribe function.
|
|
855
|
-
//
|
|
856
|
-
export function watchChat(
|
|
857
|
-
sessionId: string,
|
|
858
|
-
listener: Listener,
|
|
859
|
-
paged = false
|
|
860
|
-
): () => void {
|
|
853
|
+
// Snapshot then live updates. Returns the unsubscribe function. Every
|
|
854
|
+
// client gets the latest page and asks for older ones.
|
|
855
|
+
export function watchChat(sessionId: string, listener: Listener): () => void {
|
|
861
856
|
const live = registry.live.get(sessionId);
|
|
862
|
-
const page =
|
|
863
|
-
? readPage(sessionId)
|
|
864
|
-
: { items: listItems(sessionId), cursor: null, hasMore: false };
|
|
857
|
+
const page = readPage(sessionId);
|
|
865
858
|
const onPage = new Set(page.items.map((i) => i.id));
|
|
866
859
|
// The composer lists background work still going or just done, even when
|
|
867
860
|
// it started before the page.
|
package/lib/chat/socket.ts
CHANGED
|
@@ -102,17 +102,16 @@ export function serveChatSocket(
|
|
|
102
102
|
// A linked machine's session: its own AgentOS serves the chat.
|
|
103
103
|
const link = demo ? null : peerChatLink(sessionId);
|
|
104
104
|
if (link) {
|
|
105
|
-
const paged = params.get("paged");
|
|
106
105
|
return relayChatSocket(
|
|
107
106
|
ws,
|
|
108
107
|
link,
|
|
109
|
-
{ session: sessionId,
|
|
108
|
+
{ session: sessionId, paged: "1" },
|
|
110
109
|
send,
|
|
111
110
|
(message) => reply(errorItem(message))
|
|
112
111
|
);
|
|
113
112
|
}
|
|
114
113
|
try {
|
|
115
|
-
unwatch = watchChat(sessionId, reply
|
|
114
|
+
unwatch = watchChat(sessionId, reply);
|
|
116
115
|
} catch (err) {
|
|
117
116
|
fail(err);
|
|
118
117
|
if (!closed) ws.close();
|
package/lib/chat/store.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { db } from "../db";
|
|
2
2
|
import type { ChatItem } from "./events";
|
|
3
|
+
import { markStatusDirty } from "../status/dirty";
|
|
3
4
|
|
|
4
5
|
// Items keep the position they were first written at, so replacing one
|
|
5
6
|
// (a finished tool call, a streamed message's final text) never reorders.
|
|
@@ -11,6 +12,7 @@ export function saveItem(sessionId: string, item: ChatItem): void {
|
|
|
11
12
|
ON CONFLICT(session_id, item_id) DO UPDATE SET data = excluded.data`
|
|
12
13
|
).run(sessionId, item.id, sessionId, JSON.stringify(item));
|
|
13
14
|
seqs.delete(sessionId);
|
|
15
|
+
markStatusDirty(sessionId);
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
// The streaming path. Every update of an item is a write, so they're held
|
|
@@ -79,6 +81,7 @@ export function flushItems(): void {
|
|
|
79
81
|
for (const [itemId, p] of items)
|
|
80
82
|
insert.run(sessionId, itemId, p.seq, p.data);
|
|
81
83
|
})();
|
|
84
|
+
for (const sessionId of pending.keys()) markStatusDirty(sessionId);
|
|
82
85
|
pending.clear();
|
|
83
86
|
} catch (error) {
|
|
84
87
|
// Kept for the next try rather than lost.
|
|
@@ -96,6 +99,7 @@ export function moveToEnd(sessionId: string, itemId: string): void {
|
|
|
96
99
|
WHERE session_id = ? AND item_id = ?`
|
|
97
100
|
).run(sessionId, sessionId, itemId);
|
|
98
101
|
seqs.delete(sessionId);
|
|
102
|
+
markStatusDirty(sessionId);
|
|
99
103
|
}
|
|
100
104
|
|
|
101
105
|
export function listItems(sessionId: string): ChatItem[] {
|
|
@@ -198,26 +202,32 @@ export function recentTasks(
|
|
|
198
202
|
.map((r) => JSON.parse(r.data));
|
|
199
203
|
}
|
|
200
204
|
|
|
201
|
-
//
|
|
202
|
-
export function
|
|
203
|
-
sessionId: string
|
|
204
|
-
|
|
205
|
+
// Items of a kind saved as still running, read from the running index.
|
|
206
|
+
export function runningItems<K extends "tool" | "task">(
|
|
207
|
+
sessionId: string,
|
|
208
|
+
kind: K
|
|
209
|
+
): Extract<ChatItem, { kind: K }>[] {
|
|
205
210
|
flushItems();
|
|
206
211
|
return (
|
|
207
212
|
db
|
|
208
213
|
.prepare(
|
|
209
214
|
`SELECT data FROM chat_items WHERE session_id = ?
|
|
210
|
-
AND kind = '
|
|
211
|
-
|
|
215
|
+
AND kind = ? AND json_extract(data, '$.status') = 'running'
|
|
216
|
+
ORDER BY seq`
|
|
212
217
|
)
|
|
213
|
-
.all(sessionId) as { data: string }[]
|
|
218
|
+
.all(sessionId, kind) as { data: string }[]
|
|
214
219
|
).map((r) => JSON.parse(r.data));
|
|
215
220
|
}
|
|
216
221
|
|
|
222
|
+
// Tool calls saved as still running.
|
|
223
|
+
export const runningTools = (sessionId: string) =>
|
|
224
|
+
runningItems(sessionId, "tool");
|
|
225
|
+
|
|
217
226
|
export function deleteItems(sessionId: string): void {
|
|
218
227
|
pending.delete(sessionId);
|
|
219
228
|
seqs.delete(sessionId);
|
|
220
229
|
db.prepare(`DELETE FROM chat_items WHERE session_id = ?`).run(sessionId);
|
|
230
|
+
markStatusDirty(sessionId);
|
|
221
231
|
}
|
|
222
232
|
|
|
223
233
|
// With no live conversation, nothing is still streaming or running: those
|
package/lib/db/migrations.ts
CHANGED
|
@@ -1617,6 +1617,19 @@ const migrations: Migration[] = [
|
|
|
1617
1617
|
`);
|
|
1618
1618
|
},
|
|
1619
1619
|
},
|
|
1620
|
+
{
|
|
1621
|
+
id: 70,
|
|
1622
|
+
name: "chat_items_running",
|
|
1623
|
+
up: (db) => {
|
|
1624
|
+
// What is still running (tools, background tasks) is read on every
|
|
1625
|
+
// attach; the index holds only those rows, so it's a lookup, not a
|
|
1626
|
+
// scan that parses every tool output of a long conversation.
|
|
1627
|
+
db.exec(
|
|
1628
|
+
`CREATE INDEX IF NOT EXISTS idx_chat_items_running ON chat_items(session_id, kind, seq)
|
|
1629
|
+
WHERE json_extract(data, '$.status') = 'running'`
|
|
1630
|
+
);
|
|
1631
|
+
},
|
|
1632
|
+
},
|
|
1620
1633
|
];
|
|
1621
1634
|
|
|
1622
1635
|
// `upTo`: stop after this id (tests that start from an older database).
|
package/lib/demo-reseed.ts
CHANGED
|
@@ -9,6 +9,7 @@ import fs from "fs";
|
|
|
9
9
|
import type Database from "better-sqlite3";
|
|
10
10
|
import { notifyStatusChanged } from "./status/hub";
|
|
11
11
|
import { resetDemoChat } from "./chat/demo";
|
|
12
|
+
import { markAllStatusDirty } from "./status/dirty";
|
|
12
13
|
|
|
13
14
|
export const RESEED_MS = 10 * 60 * 1000;
|
|
14
15
|
|
|
@@ -92,6 +93,7 @@ export function startDemoReseed(
|
|
|
92
93
|
try {
|
|
93
94
|
restoreDemo(db, file);
|
|
94
95
|
resetDemoChat();
|
|
96
|
+
markAllStatusDirty();
|
|
95
97
|
notifyStatusChanged();
|
|
96
98
|
onRestored();
|
|
97
99
|
} catch (error) {
|
|
@@ -26,6 +26,7 @@ import { isPaused } from "./pause";
|
|
|
26
26
|
import { review } from "./review";
|
|
27
27
|
import { judge, mergeJudged } from "./signoff";
|
|
28
28
|
import { workspaceTask } from "./targets";
|
|
29
|
+
import { markStatusDirty } from "../status/dirty";
|
|
29
30
|
|
|
30
31
|
// Judged again at least this often, for what the fingerprint can't see.
|
|
31
32
|
export const ARMED_RECHECK_MS = 5 * 60 * 1000;
|
|
@@ -38,6 +39,7 @@ export function setArmed(sessionId: string, armed: boolean): void {
|
|
|
38
39
|
merge_armed_seen = NULL WHERE id = ?`
|
|
39
40
|
).run(sessionId);
|
|
40
41
|
looked.delete(sessionId);
|
|
42
|
+
markStatusDirty(sessionId);
|
|
41
43
|
notifyStatusChanged();
|
|
42
44
|
}
|
|
43
45
|
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { db } from "../db";
|
|
8
8
|
import { notifyStatusChanged } from "../status/hub";
|
|
9
|
+
import { markAllStatusDirty, markStatusDirty } from "../status/dirty";
|
|
9
10
|
|
|
10
11
|
// ci: when the check count on a commit last changed, for settling.
|
|
11
12
|
export type CheckKind = "review" | "scope" | "ci";
|
|
@@ -58,6 +59,7 @@ export function putCheck(row: {
|
|
|
58
59
|
row.status,
|
|
59
60
|
row.detail ?? null
|
|
60
61
|
);
|
|
62
|
+
markStatusDirty(row.sessionId);
|
|
61
63
|
notifyStatusChanged();
|
|
62
64
|
return getCheck(row.sessionId, row.sha, row.kind)!;
|
|
63
65
|
}
|
|
@@ -68,4 +70,5 @@ export function clearStaleRunning(maxAgeMinutes = 30): void {
|
|
|
68
70
|
`UPDATE orchestrator_checks SET status = 'error', detail = 'Interrupted: run it again'
|
|
69
71
|
WHERE status = 'running' AND created_at < datetime('now', ?)`
|
|
70
72
|
).run(`-${maxAgeMinutes} minutes`);
|
|
73
|
+
markAllStatusDirty();
|
|
71
74
|
}
|
|
@@ -20,6 +20,7 @@ import { storedPR } from "../tasks/session";
|
|
|
20
20
|
import { commitTime, repoOf } from "./repo";
|
|
21
21
|
import { CHECKLESS_MS, checklessFor, ciSettleIn } from "./task-state";
|
|
22
22
|
import { repoHasCI } from "./repo-ci";
|
|
23
|
+
import { markStatusDirty } from "../status/dirty";
|
|
23
24
|
|
|
24
25
|
export type FactStatus = "running" | "waiting" | "idle" | "dead";
|
|
25
26
|
|
|
@@ -211,7 +212,10 @@ export async function settleIn(
|
|
|
211
212
|
let at = committed.get(pr.head);
|
|
212
213
|
if (at === undefined) {
|
|
213
214
|
at = await commitTime(repoOf(s), pr.head).catch(() => 0);
|
|
214
|
-
if (at)
|
|
215
|
+
if (at) {
|
|
216
|
+
committed.set(pr.head, at);
|
|
217
|
+
markStatusDirty(s.id);
|
|
218
|
+
}
|
|
215
219
|
}
|
|
216
220
|
return ciSettleIn({
|
|
217
221
|
workspaceId,
|
|
@@ -18,6 +18,7 @@ import type { CheckRow } from "./checks";
|
|
|
18
18
|
import { APPROVAL_GATES, mergeApprovalsOn } from "./merge-approvals";
|
|
19
19
|
import type { RepoCI } from "./repo-ci";
|
|
20
20
|
import { untrusted } from "./untrusted";
|
|
21
|
+
import { markStatusDirty } from "../status/dirty";
|
|
21
22
|
|
|
22
23
|
export type GateName =
|
|
23
24
|
| "ci"
|
|
@@ -244,6 +245,7 @@ function countOnce(
|
|
|
244
245
|
last_reason = excluded.last_reason,
|
|
245
246
|
last_sha = excluded.last_sha`
|
|
246
247
|
).run(sessionId, gate, workspaceId, reason, sha, fresh ? 1 : 0);
|
|
248
|
+
markStatusDirty(sessionId);
|
|
247
249
|
return failureOf(sessionId, gate)!.count;
|
|
248
250
|
}
|
|
249
251
|
|
|
@@ -261,6 +263,7 @@ export function markEscalated(sessionId: string, gate: string): void {
|
|
|
261
263
|
db.prepare(
|
|
262
264
|
`UPDATE orchestrator_gate_failures SET escalated_at = datetime('now') WHERE session_id = ? AND gate = ?`
|
|
263
265
|
).run(sessionId, gate);
|
|
266
|
+
markStatusDirty(sessionId);
|
|
264
267
|
}
|
|
265
268
|
|
|
266
269
|
export function escalations(sessionId: string): FailureRow[] {
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { db } from "../db";
|
|
10
|
+
import { markAllStatusDirty } from "../status/dirty";
|
|
10
11
|
|
|
11
12
|
const KEY = "orchestrator.merge_approvals";
|
|
12
13
|
|
|
@@ -26,4 +27,5 @@ export function setMergeApprovals(on: boolean): void {
|
|
|
26
27
|
`INSERT INTO settings (key, value) VALUES (?, ?)
|
|
27
28
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
|
28
29
|
).run(KEY, on ? "1" : "0");
|
|
30
|
+
markAllStatusDirty();
|
|
29
31
|
}
|
package/lib/status/collect.ts
CHANGED
|
@@ -19,7 +19,8 @@ import {
|
|
|
19
19
|
getProviderIdFromSessionName,
|
|
20
20
|
getSessionIdFromName,
|
|
21
21
|
} from "../providers/registry";
|
|
22
|
-
import { getDb
|
|
22
|
+
import { getDb } from "../db";
|
|
23
|
+
import { beginPass, chatMemo } from "./incremental";
|
|
23
24
|
import { chatCompacting, chatState } from "../chat/runner";
|
|
24
25
|
import { chatActivityLine, chatStall } from "../chat/activity";
|
|
25
26
|
import {
|
|
@@ -243,32 +244,16 @@ function getAgentTypeFromSessionName(sessionName: string): AgentType {
|
|
|
243
244
|
return getProviderIdFromSessionName(sessionName) || "claude";
|
|
244
245
|
}
|
|
245
246
|
|
|
246
|
-
function
|
|
247
|
-
return db
|
|
248
|
-
.prepare(
|
|
249
|
-
`SELECT id, view, updated_at, last_seen_at FROM sessions WHERE id = ?`
|
|
250
|
-
)
|
|
251
|
-
.get(id) as
|
|
252
|
-
| Pick<Session, "id" | "view" | "updated_at" | "last_seen_at">
|
|
253
|
-
| undefined;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
async function collect(): Promise<StatusSnapshot> {
|
|
247
|
+
async function collect(full = false): Promise<StatusSnapshot> {
|
|
257
248
|
const sessions = await getTmuxSessions();
|
|
258
249
|
|
|
259
250
|
const db = getDb();
|
|
251
|
+
// Read once the sessions are listed, so what was kept is as new as it.
|
|
252
|
+
const pass = beginPass(Date.now(), full);
|
|
253
|
+
const rows = [...pass.rows.values()];
|
|
260
254
|
const panes = managedPanes(
|
|
261
255
|
sessions,
|
|
262
|
-
|
|
263
|
-
.prepare(
|
|
264
|
-
`SELECT id, tmux_name, agent_type, host_id FROM sessions WHERE archived_at IS NULL AND (view IS NULL OR view != 'chat')`
|
|
265
|
-
)
|
|
266
|
-
.all() as {
|
|
267
|
-
id: string;
|
|
268
|
-
tmux_name: string | null;
|
|
269
|
-
agent_type: string | null;
|
|
270
|
-
host_id: string | null;
|
|
271
|
-
}[],
|
|
256
|
+
rows.filter((s) => s.view !== "chat"),
|
|
272
257
|
(name) => UUID_PATTERN.test(name),
|
|
273
258
|
getSessionIdFromName
|
|
274
259
|
);
|
|
@@ -351,7 +336,7 @@ async function collect(): Promise<StatusSnapshot> {
|
|
|
351
336
|
lastLine,
|
|
352
337
|
agentType,
|
|
353
338
|
} of results) {
|
|
354
|
-
const row =
|
|
339
|
+
const row = pass.rows.get(id);
|
|
355
340
|
const shared = {
|
|
356
341
|
sessionName,
|
|
357
342
|
lastLine,
|
|
@@ -418,6 +403,8 @@ async function collect(): Promise<StatusSnapshot> {
|
|
|
418
403
|
// Update claude_session_id directly here instead of requiring separate API calls
|
|
419
404
|
for (const { id, claudeSessionId, agentType } of results) {
|
|
420
405
|
if (!claudeSessionId) continue;
|
|
406
|
+
// Already stored (as the rows above read it): nothing to write.
|
|
407
|
+
if (pass.rows.get(id)?.claude_session_id === claudeSessionId) continue;
|
|
421
408
|
if (agentType === "claude")
|
|
422
409
|
updateClaudeIdStmt.run(claudeSessionId, id, claudeSessionId);
|
|
423
410
|
else
|
|
@@ -432,14 +419,9 @@ async function collect(): Promise<StatusSnapshot> {
|
|
|
432
419
|
|
|
433
420
|
// A linked machine's sessions: its AgentOS says how they are.
|
|
434
421
|
const linked = linkedHostIds();
|
|
435
|
-
const mirrors = (
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
`SELECT id, tmux_name, agent_type, host_id FROM sessions
|
|
439
|
-
WHERE archived_at IS NULL AND host_id IS NOT NULL AND host_id != 'local'`
|
|
440
|
-
)
|
|
441
|
-
.all() as Pick<Session, "id" | "tmux_name" | "agent_type" | "host_id">[]
|
|
442
|
-
).filter((s) => linked.has(s.host_id));
|
|
422
|
+
const mirrors = rows.filter(
|
|
423
|
+
(s) => s.host_id && s.host_id !== "local" && linked.has(s.host_id)
|
|
424
|
+
);
|
|
443
425
|
for (const s of mirrors) {
|
|
444
426
|
const status = peerStatus(s.host_id, s.id);
|
|
445
427
|
if (status)
|
|
@@ -452,36 +434,39 @@ async function collect(): Promise<StatusSnapshot> {
|
|
|
452
434
|
|
|
453
435
|
// Chat sessions have no tmux pane: their live conversation is the status.
|
|
454
436
|
// A linked machine's chats are its own (above).
|
|
455
|
-
for (const session of
|
|
456
|
-
.
|
|
457
|
-
|
|
458
|
-
AND (host_id IS NULL OR host_id = 'local')`
|
|
459
|
-
)
|
|
460
|
-
.all() as Session[]) {
|
|
437
|
+
for (const session of rows.filter(
|
|
438
|
+
(s) => s.view === "chat" && (!s.host_id || s.host_id === "local")
|
|
439
|
+
)) {
|
|
461
440
|
const state = chatState(session.id);
|
|
462
|
-
const
|
|
463
|
-
|
|
441
|
+
const orchestrator = session.role === "orchestrator";
|
|
442
|
+
// What the database says; the rest below moves with time or the process.
|
|
443
|
+
const base = chatMemo(pass, session.id, orchestrator, state ?? "", () => {
|
|
444
|
+
const asks = orchestrator ? openAskCount(session.workspace_id) : 0;
|
|
445
|
+
return {
|
|
446
|
+
asks,
|
|
447
|
+
waiting: needsYou(session, state),
|
|
448
|
+
need: chatNeed(session, state, asks),
|
|
449
|
+
unread: isUnread(session, state),
|
|
450
|
+
lastTask: lastUserTask(session.id),
|
|
451
|
+
};
|
|
452
|
+
});
|
|
464
453
|
statusMap[session.id] = {
|
|
465
454
|
sessionName: session.tmux_name,
|
|
466
455
|
status:
|
|
467
|
-
state === "running"
|
|
468
|
-
|
|
469
|
-
: needsYou(session, state)
|
|
470
|
-
? "waiting"
|
|
471
|
-
: "idle",
|
|
472
|
-
task: chatActivityLine(session.id) ?? lastUserTask(session.id),
|
|
456
|
+
state === "running" ? "running" : base.waiting ? "waiting" : "idle",
|
|
457
|
+
task: chatActivityLine(session.id) ?? base.lastTask,
|
|
473
458
|
agentType: session.agent_type,
|
|
474
|
-
need:
|
|
475
|
-
unread:
|
|
459
|
+
need: base.need,
|
|
460
|
+
unread: base.unread,
|
|
476
461
|
...(chatStall(session.id) && { stalled: true }),
|
|
477
462
|
...(chatCompacting(session.id) && {
|
|
478
463
|
compacting: chatCompacting(session.id)!,
|
|
479
464
|
}),
|
|
480
|
-
...(
|
|
465
|
+
...(orchestrator && { asks: base.asks }),
|
|
481
466
|
};
|
|
482
467
|
}
|
|
483
468
|
|
|
484
|
-
addPrStages(statusMap);
|
|
469
|
+
addPrStages(statusMap, pass);
|
|
485
470
|
|
|
486
471
|
// Cleanup old trackers
|
|
487
472
|
statusDetector.cleanup();
|
|
@@ -492,8 +477,11 @@ async function collect(): Promise<StatusSnapshot> {
|
|
|
492
477
|
// Callers at the same moment share one pass over tmux.
|
|
493
478
|
let inflight: Promise<StatusSnapshot> | null = null;
|
|
494
479
|
|
|
495
|
-
|
|
496
|
-
|
|
480
|
+
/** `full`: recompute everything kept from earlier passes. */
|
|
481
|
+
export function collectStatuses({
|
|
482
|
+
full = false,
|
|
483
|
+
}: { full?: boolean } = {}): Promise<StatusSnapshot> {
|
|
484
|
+
inflight ??= collect(full).finally(() => {
|
|
497
485
|
inflight = null;
|
|
498
486
|
});
|
|
499
487
|
return inflight;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Which sessions' status inputs changed since the last pass, so a pass
|
|
2
|
+
// recomputes those and reuses the rest (lib/status/incremental.ts). Marked
|
|
3
|
+
// from the places that write them. No imports, so any module can call it.
|
|
4
|
+
//
|
|
5
|
+
// Shared by the custom server and the Next.js route bundles.
|
|
6
|
+
interface Dirty {
|
|
7
|
+
all: boolean;
|
|
8
|
+
// A session's id and the time before which it stays marked.
|
|
9
|
+
ids: Map<string, number>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const g = globalThis as unknown as { __agentosStatusDirty?: Dirty };
|
|
13
|
+
const dirty: Dirty = (g.__agentosStatusDirty ??= {
|
|
14
|
+
all: false,
|
|
15
|
+
ids: new Map(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `id`'s inputs changed. With `settleMs` it stays marked, so every pass
|
|
20
|
+
* recomputes it, until a pass at or after that time: a write still on its
|
|
21
|
+
* way (a worker's buffered chat items) can't be cached half-seen.
|
|
22
|
+
*/
|
|
23
|
+
export function markStatusDirty(id: string, settleMs = 0): void {
|
|
24
|
+
const until = Date.now() + settleMs;
|
|
25
|
+
dirty.ids.set(id, Math.max(dirty.ids.get(id) ?? 0, until));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Something every session's status may depend on changed. */
|
|
29
|
+
export function markAllStatusDirty(): void {
|
|
30
|
+
dirty.all = true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** What a pass starting at `now` recomputes; the marks it settles are cleared. */
|
|
34
|
+
export function takeDirty(now = Date.now()): {
|
|
35
|
+
all: boolean;
|
|
36
|
+
ids: Set<string>;
|
|
37
|
+
} {
|
|
38
|
+
const taken = { all: dirty.all, ids: new Set(dirty.ids.keys()) };
|
|
39
|
+
dirty.all = false;
|
|
40
|
+
for (const [id, until] of dirty.ids) if (until <= now) dirty.ids.delete(id);
|
|
41
|
+
return taken;
|
|
42
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// What a status pass keeps from the one before, so its cost follows what
|
|
2
|
+
// changed and not how many sessions there are:
|
|
3
|
+
// - the session rows, re-read only when `sessions` or `projects` moved
|
|
4
|
+
// (lib/db/changes.ts: every writer is seen) and compared to find the
|
|
5
|
+
// sessions that changed;
|
|
6
|
+
// - per chat and per task, what is worked out from the database, recomputed
|
|
7
|
+
// when its session is marked dirty (lib/status/dirty.ts).
|
|
8
|
+
// Every FULL_RECOMPUTE_MS a pass recomputes everything, so an invalidation
|
|
9
|
+
// nobody made self-heals.
|
|
10
|
+
import { getDb, type Session } from "../db";
|
|
11
|
+
import { changeWatcher } from "../db/changes";
|
|
12
|
+
import { takeDirty } from "./dirty";
|
|
13
|
+
|
|
14
|
+
export const FULL_RECOMPUTE_MS = 30_000;
|
|
15
|
+
|
|
16
|
+
// A session row, and the workspace of its project.
|
|
17
|
+
export type SessionRow = Session & { project_workspace_id: string | null };
|
|
18
|
+
|
|
19
|
+
export interface Pass {
|
|
20
|
+
// Everything is recomputed.
|
|
21
|
+
full: boolean;
|
|
22
|
+
// Sessions whose inputs changed (all of them on a full pass).
|
|
23
|
+
dirty: Set<string>;
|
|
24
|
+
// An orchestrator's asks changed: they and the tasks that read them.
|
|
25
|
+
asksMoved: boolean;
|
|
26
|
+
rows: Map<string, SessionRow>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Entry<T> = { key: string; value: T };
|
|
30
|
+
|
|
31
|
+
interface Cache {
|
|
32
|
+
rows: Map<string, { row: SessionRow; json: string }>;
|
|
33
|
+
watch: (() => string[]) | null;
|
|
34
|
+
lastFullAt: number;
|
|
35
|
+
loaded: boolean;
|
|
36
|
+
chats: Map<string, Entry<unknown>>;
|
|
37
|
+
prs: Map<string, Entry<unknown>>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const g = globalThis as unknown as { __agentosStatusCache?: Cache };
|
|
41
|
+
const cache: Cache = (g.__agentosStatusCache ??= {
|
|
42
|
+
rows: new Map(),
|
|
43
|
+
watch: null,
|
|
44
|
+
lastFullAt: 0,
|
|
45
|
+
loaded: false,
|
|
46
|
+
chats: new Map(),
|
|
47
|
+
prs: new Map(),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/** Forget everything kept: the next pass computes it all. */
|
|
51
|
+
export function resetStatusCaches(): void {
|
|
52
|
+
cache.rows.clear();
|
|
53
|
+
cache.chats.clear();
|
|
54
|
+
cache.prs.clear();
|
|
55
|
+
cache.watch = null;
|
|
56
|
+
cache.loaded = false;
|
|
57
|
+
cache.lastFullAt = 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function loadRows(dirty: Set<string>): void {
|
|
61
|
+
const rows = getDb()
|
|
62
|
+
.prepare(
|
|
63
|
+
`SELECT s.*, p.workspace_id AS project_workspace_id FROM sessions s
|
|
64
|
+
LEFT JOIN projects p ON p.id = s.project_id
|
|
65
|
+
WHERE s.archived_at IS NULL`
|
|
66
|
+
)
|
|
67
|
+
.all() as SessionRow[];
|
|
68
|
+
const before = cache.rows;
|
|
69
|
+
cache.rows = new Map();
|
|
70
|
+
for (const row of rows) {
|
|
71
|
+
const json = JSON.stringify(row);
|
|
72
|
+
if (before.get(row.id)?.json !== json) dirty.add(row.id);
|
|
73
|
+
cache.rows.set(row.id, { row, json });
|
|
74
|
+
}
|
|
75
|
+
for (const id of before.keys())
|
|
76
|
+
if (!cache.rows.has(id)) {
|
|
77
|
+
cache.chats.delete(id);
|
|
78
|
+
cache.prs.delete(id);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function beginPass(now = Date.now(), forceFull = false): Pass {
|
|
83
|
+
// Read before the rows, so a write between the two is seen next time.
|
|
84
|
+
const moved = (cache.watch ??= changeWatcher(getDb))();
|
|
85
|
+
const taken = takeDirty(now);
|
|
86
|
+
const full =
|
|
87
|
+
forceFull ||
|
|
88
|
+
!cache.loaded ||
|
|
89
|
+
taken.all ||
|
|
90
|
+
now - cache.lastFullAt >= FULL_RECOMPUTE_MS;
|
|
91
|
+
const dirty = taken.ids;
|
|
92
|
+
if (full || moved.includes("sessions") || moved.includes("projects")) {
|
|
93
|
+
loadRows(dirty);
|
|
94
|
+
cache.loaded = true;
|
|
95
|
+
}
|
|
96
|
+
if (full) {
|
|
97
|
+
cache.chats.clear();
|
|
98
|
+
cache.prs.clear();
|
|
99
|
+
cache.lastFullAt = now;
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
full,
|
|
103
|
+
dirty,
|
|
104
|
+
asksMoved: moved.includes("orchestrator_asks"),
|
|
105
|
+
rows: new Map([...cache.rows].map(([id, e]) => [id, e.row])),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function remembered<T>(
|
|
110
|
+
store: Map<string, Entry<unknown>>,
|
|
111
|
+
id: string,
|
|
112
|
+
stale: boolean,
|
|
113
|
+
key: string,
|
|
114
|
+
compute: () => { value: T; keep: boolean }
|
|
115
|
+
): T {
|
|
116
|
+
const kept = store.get(id);
|
|
117
|
+
if (!stale && kept && kept.key === key) return kept.value as T;
|
|
118
|
+
const { value, keep } = compute();
|
|
119
|
+
if (keep) store.set(id, { key, value });
|
|
120
|
+
else store.delete(id);
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A chat's database-derived status, from the last pass unless it changed.
|
|
126
|
+
* `key` is what else it was computed from (its state).
|
|
127
|
+
*/
|
|
128
|
+
export function chatMemo<T>(
|
|
129
|
+
pass: Pass,
|
|
130
|
+
id: string,
|
|
131
|
+
orchestrator: boolean,
|
|
132
|
+
key: string,
|
|
133
|
+
compute: () => T
|
|
134
|
+
): T {
|
|
135
|
+
const stale =
|
|
136
|
+
pass.full || pass.dirty.has(id) || (orchestrator && pass.asksMoved);
|
|
137
|
+
return remembered(cache.chats, id, stale, key, () => ({
|
|
138
|
+
value: compute(),
|
|
139
|
+
keep: true,
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A task's PR stage; `compute` says whether it can be kept (time moves it). */
|
|
144
|
+
export function prMemo<T>(
|
|
145
|
+
pass: Pass,
|
|
146
|
+
id: string,
|
|
147
|
+
compute: () => { value: T; keep: boolean }
|
|
148
|
+
): T {
|
|
149
|
+
const stale = pass.full || pass.dirty.has(id) || pass.asksMoved;
|
|
150
|
+
return remembered(cache.prs, id, stale, "", compute);
|
|
151
|
+
}
|
package/lib/status/pr-stages.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// A task's PR stage for the sidebar, from state AgentOS already holds: the
|
|
2
2
|
// PR as the last poll saw it, the stored review and CI checks, escalations,
|
|
3
3
|
// asks and the armed flag. Nothing here asks GitHub.
|
|
4
|
-
import { db, type Session } from "../db";
|
|
5
4
|
import { cachedPR, storedPR } from "../tasks/session";
|
|
6
5
|
import { derivePrStage } from "../tasks/pr-stage";
|
|
7
6
|
import { getCheck } from "../orchestrator/checks";
|
|
@@ -9,30 +8,23 @@ import { holdingEscalations } from "../orchestrator/gates";
|
|
|
9
8
|
import { openBySubject, workSubject } from "../orchestrator/asks";
|
|
10
9
|
import { ciSettleLeft } from "../orchestrator/task-state";
|
|
11
10
|
import { knownCommitTime } from "../orchestrator/facts";
|
|
11
|
+
import { prMemo, type Pass } from "./incremental";
|
|
12
12
|
import type { SessionStatusResponse } from "./collect";
|
|
13
13
|
|
|
14
|
-
type TaskRow = Session & { workspace_id: string };
|
|
15
|
-
|
|
16
14
|
export function addPrStages(
|
|
17
|
-
statuses: Record<string, SessionStatusResponse
|
|
15
|
+
statuses: Record<string, SessionStatusResponse>,
|
|
16
|
+
pass: Pass
|
|
18
17
|
): void {
|
|
19
|
-
const
|
|
20
|
-
.
|
|
21
|
-
`SELECT s.*, p.workspace_id FROM sessions s
|
|
22
|
-
JOIN projects p ON p.id = s.project_id
|
|
23
|
-
WHERE s.task_status = 'running' AND s.pr_number IS NOT NULL
|
|
24
|
-
AND s.archived_at IS NULL`
|
|
25
|
-
)
|
|
26
|
-
.all() as TaskRow[];
|
|
27
|
-
for (const t of tasks) {
|
|
18
|
+
for (const t of pass.rows.values()) {
|
|
19
|
+
if (t.task_status !== "running" || !t.pr_number || !t.project_id) continue;
|
|
28
20
|
const status = statuses[t.id];
|
|
29
|
-
if (!status) continue;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
pr
|
|
35
|
-
settleIn
|
|
21
|
+
if (!status || t.project_workspace_id === null) continue;
|
|
22
|
+
const workspaceId = t.project_workspace_id;
|
|
23
|
+
const stage = prMemo(pass, t.id, () => {
|
|
24
|
+
try {
|
|
25
|
+
const pr = cachedPR(t.id) ?? storedPR(t);
|
|
26
|
+
const head = pr?.head;
|
|
27
|
+
const settleIn =
|
|
36
28
|
pr?.checks === "pass" && head
|
|
37
29
|
? ciSettleLeft({
|
|
38
30
|
taskId: t.id,
|
|
@@ -40,16 +32,27 @@ export function addPrStages(
|
|
|
40
32
|
checkCount: pr.checkCount ?? 0,
|
|
41
33
|
committedAt: knownCommitTime(head),
|
|
42
34
|
})
|
|
43
|
-
: 0
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
35
|
+
: 0;
|
|
36
|
+
return {
|
|
37
|
+
value: derivePrStage({
|
|
38
|
+
pr,
|
|
39
|
+
settleIn,
|
|
40
|
+
review: head
|
|
41
|
+
? (getCheck(t.id, head, "review")?.status ?? null)
|
|
42
|
+
: null,
|
|
43
|
+
held:
|
|
44
|
+
holdingEscalations(t.id).length > 0 ||
|
|
45
|
+
!!openBySubject(workspaceId, workSubject(t.id)),
|
|
46
|
+
armed: !!t.merge_armed_at,
|
|
47
|
+
}),
|
|
48
|
+
// Counting down to the end of the settle wait: time moves it.
|
|
49
|
+
keep: settleIn <= 0,
|
|
50
|
+
};
|
|
51
|
+
} catch (error) {
|
|
52
|
+
console.error(`[status] PR stage of ${t.id.slice(0, 8)}:`, error);
|
|
53
|
+
return { value: null, keep: false };
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
if (stage) status.prStage = stage;
|
|
54
57
|
}
|
|
55
58
|
}
|
package/lib/tasks/session.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { lookupPR } from "./pr-poll";
|
|
|
18
18
|
import { captureMergedTask } from "../session-logs";
|
|
19
19
|
import { notifyStatusChanged } from "../status/hub";
|
|
20
20
|
import type { TaskPR } from "./state";
|
|
21
|
+
import { markStatusDirty } from "../status/dirty";
|
|
21
22
|
|
|
22
23
|
export const expandHome = (p: string) => p.replace(/^~/, os.homedir());
|
|
23
24
|
|
|
@@ -190,6 +191,7 @@ export async function prFor(
|
|
|
190
191
|
if (renamed && !refused) moveBranch(session, renamed);
|
|
191
192
|
const before = prCache.get(session.id)?.pr;
|
|
192
193
|
prCache.set(session.id, { at: Date.now(), pr });
|
|
194
|
+
markStatusDirty(session.id);
|
|
193
195
|
// A PR whose stage inputs moved is news for the sidebar's badge.
|
|
194
196
|
if (prSignature(before) !== prSignature(pr)) notifyStatusChanged();
|
|
195
197
|
if (pr) {
|
|
@@ -279,4 +281,5 @@ export function storedPR(s: Session): TaskPR | null {
|
|
|
279
281
|
|
|
280
282
|
export function forgetPR(sessionId: string): void {
|
|
281
283
|
prCache.delete(sessionId);
|
|
284
|
+
markStatusDirty(sessionId);
|
|
282
285
|
}
|