@saadnvd1/agent-os 0.3.698 → 0.3.700

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.
Files changed (65) hide show
  1. package/app/api/bus/send/route.ts +11 -1
  2. package/app/api/bus/wait/route.ts +22 -0
  3. package/app/api/peer/hello/reveal/route.ts +6 -0
  4. package/app/api/peer/hello/route.ts +9 -0
  5. package/app/api/peer/hello/status/route.ts +6 -0
  6. package/app/api/peers/account/route.ts +9 -0
  7. package/app/api/peers/gate.test.ts +111 -0
  8. package/app/api/peers/link/[id]/confirm/route.ts +27 -0
  9. package/app/api/peers/link/[id]/route.ts +20 -0
  10. package/app/api/peers/link/route.ts +23 -0
  11. package/app/api/peers/pending/[id]/accept/route.ts +31 -0
  12. package/app/api/peers/pending/[id]/decline/route.ts +13 -0
  13. package/app/api/peers/pending/route.ts +7 -0
  14. package/app/api/sessions/route.ts +18 -1
  15. package/app/page.tsx +2 -0
  16. package/components/Hosts/AccountMachines.tsx +180 -0
  17. package/components/Hosts/HostRow.tsx +44 -35
  18. package/components/Hosts/HostsDialog.tsx +49 -37
  19. package/components/Hosts/IncomingLinkSheet.tsx +228 -0
  20. package/components/Hosts/LinkCodeSheet.tsx +186 -0
  21. package/components/Hosts/sheet.ts +3 -0
  22. package/components/SessionList/RowSubtitle.tsx +11 -1
  23. package/components/Welcome/ConnectBlock.tsx +1 -1
  24. package/data/peers/index.ts +2 -0
  25. package/data/peers/keys.ts +6 -0
  26. package/data/peers/queries.ts +141 -0
  27. package/lib/agents/brief.ts +3 -1
  28. package/lib/bus/format.ts +25 -4
  29. package/lib/bus/index.ts +113 -106
  30. package/lib/bus/resolve.ts +16 -2
  31. package/lib/bus/targets.ts +122 -0
  32. package/lib/bus/waits/kick.ts +12 -0
  33. package/lib/bus/waits/lines.ts +69 -0
  34. package/lib/bus/waits/register.ts +83 -0
  35. package/lib/bus/waits/store.ts +196 -0
  36. package/lib/bus/waits/tick.ts +192 -0
  37. package/lib/connect/control-api.ts +108 -2
  38. package/lib/connect/heartbeat.ts +11 -3
  39. package/lib/connect/serve.ts +2 -0
  40. package/lib/db/migrations/20261020T1600_bus_waits.ts +35 -0
  41. package/lib/db/types.ts +4 -0
  42. package/lib/done/archive.ts +2 -0
  43. package/lib/orchestrator/describe.ts +13 -0
  44. package/lib/orchestrator/facts.ts +17 -0
  45. package/lib/peer/account.ts +198 -0
  46. package/lib/peer/cli.ts +24 -0
  47. package/lib/peer/code.ts +3 -0
  48. package/lib/peer/hello-route.ts +32 -0
  49. package/lib/peer/hello.ts +346 -0
  50. package/lib/peer/link-cli.ts +233 -0
  51. package/lib/peer/machines.ts +72 -0
  52. package/lib/peer/notify.ts +32 -0
  53. package/lib/peer/pairing-out.ts +382 -0
  54. package/lib/peer/pairing.ts +403 -0
  55. package/lib/peer/pin.ts +55 -0
  56. package/lib/peer/rpc-intake.ts +37 -8
  57. package/lib/peer/rpc-server.ts +19 -1
  58. package/lib/schedules/when.ts +1 -1
  59. package/lib/security/auth.ts +10 -2
  60. package/lib/security/gate.ts +3 -0
  61. package/lib/sessions/end.ts +3 -0
  62. package/package.json +1 -1
  63. package/scripts/agent-os +2 -0
  64. package/scripts/lib/commands.sh +2 -0
  65. package/server.ts +3 -0
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Which live session a name someone typed reaches, across workspaces.
3
+ */
4
+
5
+ import { db, type Session } from "../db";
6
+ import { getProject } from "../projects";
7
+ import { getWorkspace } from "../workspaces";
8
+ import { previousNames } from "../session-names";
9
+ import { sessionWorkspace } from "../schedules/store";
10
+ import { heldTarget } from "./held-reply";
11
+ import { resolveRef, type Candidate } from "./resolve";
12
+
13
+ export function liveSessions(): Session[] {
14
+ return db
15
+ .prepare(
16
+ `SELECT * FROM session_rows WHERE (task_status IS NULL OR task_status = 'running')
17
+ AND archived_at IS NULL
18
+ ORDER BY updated_at DESC`
19
+ )
20
+ .all() as Session[];
21
+ }
22
+
23
+ export function candidates(sessions: Session[]): Candidate[] {
24
+ const old = previousNames();
25
+ const workspaces = new Map<string | null, string | null>();
26
+ const workspaceName = (s: Session) => {
27
+ const id = sessionWorkspace(s);
28
+ if (!workspaces.has(id))
29
+ workspaces.set(id, id ? (getWorkspace(id)?.name ?? null) : null);
30
+ return workspaces.get(id) ?? null;
31
+ };
32
+ return sessions.map((s) => ({
33
+ id: s.id,
34
+ name: s.name,
35
+ projectName: s.project_id ? (getProject(s.project_id)?.name ?? null) : null,
36
+ tmuxName: s.tmux_name,
37
+ previousNames: old.get(s.id) ?? [],
38
+ workspaceName: workspaceName(s),
39
+ orchestrator: s.role === "orchestrator",
40
+ }));
41
+ }
42
+
43
+ export const sessionById = (id: string | null | undefined) =>
44
+ id
45
+ ? (db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
46
+ Session | undefined)
47
+ : undefined;
48
+
49
+ // The orchestrator a held caller's ref names, if it names it.
50
+ export function resolveHeldOrchestrator(callerId: string, ref: string) {
51
+ return heldTarget(sessionById(callerId), ref);
52
+ }
53
+
54
+ // A live session by name, old name, project/name, id or id prefix. The note
55
+ // says when the name meant something other than its current owner.
56
+ export function resolveTarget(
57
+ ref: string,
58
+ callerId?: string | null
59
+ ): {
60
+ session: Session;
61
+ note?: string;
62
+ } {
63
+ const found = tryResolveTarget(ref, callerId);
64
+ if (!found.ok) throw new Error(found.error);
65
+ return found;
66
+ }
67
+
68
+ // "orchestrator" / "my orchestrator": the caller's own workspace orchestrator.
69
+ function ownOrchestrator(
70
+ ref: string,
71
+ sessions: Session[],
72
+ callerId?: string | null
73
+ ): Session | undefined {
74
+ if (!/^(my )?orchestrator$/i.test(ref.trim())) return undefined;
75
+ const workspace = sessionById(callerId)?.workspace_id;
76
+ if (!workspace) return undefined;
77
+ return sessions.find(
78
+ (s) => s.role === "orchestrator" && s.workspace_id === workspace
79
+ );
80
+ }
81
+
82
+ export function tryResolveTarget(
83
+ ref: string,
84
+ callerId?: string | null
85
+ ):
86
+ | { ok: true; session: Session; note?: string }
87
+ | { ok: false; none: boolean; error: string } {
88
+ const sessions = liveSessions();
89
+ const own = ownOrchestrator(ref, sessions, callerId);
90
+ if (own) return { ok: true, session: own };
91
+ const r = resolveRef(ref, candidates(sessions));
92
+ if (!r.ok) {
93
+ const orchestrators = /^(my )?orchestrator$/i.test(ref.trim())
94
+ ? sessions.filter((s) => s.role === "orchestrator")
95
+ : [];
96
+ if (orchestrators.length)
97
+ return {
98
+ ok: false,
99
+ none: true,
100
+ error: `You are in no workspace, so "${ref}" is ambiguous. Use <workspace>/orchestrator: ${orchestrators
101
+ .map(
102
+ (s) =>
103
+ `${getWorkspace(s.workspace_id ?? "")?.name ?? "?"}/orchestrator (${s.name}, id ${s.id.slice(0, 8)})`
104
+ )
105
+ .join(", ")}`,
106
+ };
107
+ return {
108
+ ok: false,
109
+ none: r.reason === "none",
110
+ error: r.reason === "none" ? `${r.error}. Run: aos peers` : r.error,
111
+ };
112
+ }
113
+ return {
114
+ ok: true,
115
+ session: sessions.find((s) => s.id === r.id)!,
116
+ note: r.note,
117
+ };
118
+ }
119
+
120
+ export function resolveSession(ref: string): Session {
121
+ return resolveTarget(ref).session;
122
+ }
@@ -0,0 +1,12 @@
1
+ // Lets the places where a session ends ask for a look at open waits now,
2
+ // without importing the bus. The watcher registers itself here.
3
+
4
+ let kick: (() => void) | null = null;
5
+
6
+ export function onKick(fn: () => void): void {
7
+ kick = fn;
8
+ }
9
+
10
+ export function kickWaits(): void {
11
+ kick?.();
12
+ }
@@ -0,0 +1,69 @@
1
+ // Every text an agent reads about a wait. Each starts the way a bus message
2
+ // does, so a line left unsent in a terminal's input is recognised as ours.
3
+
4
+ import { shortId } from "../format";
5
+
6
+ const from = "[AgentOS message from AgentOS]";
7
+ const who = (name: string, id: string) => `"${name}" (${shortId(id)})`;
8
+
9
+ export const minutes = (ms: number) =>
10
+ ms >= 3_600_000
11
+ ? `${Math.round(ms / 360_000) / 10}h`
12
+ : `${Math.max(1, Math.round(ms / 60_000))}m`;
13
+
14
+ export function nudgeLine(
15
+ waiter: { id: string; name: string },
16
+ request: string | null,
17
+ messageId: number | null
18
+ ): string {
19
+ const reply = messageId ? ` --reply ${messageId}` : "";
20
+ return `${from} ${who(waiter.name, waiter.id)} is waiting on your reply${request ? ` to: "${request}"` : ""}. Reply with: aos send ${shortId(waiter.id)}${reply} "<message>"`;
21
+ }
22
+
23
+ export function timeoutLine(
24
+ target: { id: string; name: string },
25
+ kind: "reply" | "idle" | "merged",
26
+ waitedMs: number,
27
+ request: string | null,
28
+ targetIs: string
29
+ ): string {
30
+ const what =
31
+ kind === "reply"
32
+ ? `No reply from ${who(target.name, target.id)}${request ? ` to: "${request}"` : ""}`
33
+ : kind === "idle"
34
+ ? `${who(target.name, target.id)} didn't go idle`
35
+ : `${who(target.name, target.id)}'s PR didn't merge`;
36
+ return `${from} ${what} within ${minutes(waitedMs)}. The wait is closed; it is ${targetIs}. See what it did: aos read ${shortId(target.id)}`;
37
+ }
38
+
39
+ export const satisfiedLine = (
40
+ target: { id: string; name: string },
41
+ kind: "idle" | "merged",
42
+ pr?: number | null
43
+ ): string =>
44
+ `${from} ${
45
+ kind === "idle"
46
+ ? `${who(target.name, target.id)} is idle now`
47
+ : `${who(target.name, target.id)}'s PR${pr ? ` #${pr}` : ""} merged`
48
+ } (you waited for that). See what it did: aos read ${shortId(target.id)}`;
49
+
50
+ export const endedLine = (
51
+ target: { id: string; name: string },
52
+ how: string,
53
+ request: string | null
54
+ ): string =>
55
+ `${from} ${who(target.name, target.id)} ended (${how}) with your wait still open${request ? ` on: "${request}"` : ""}. It won't answer.`;
56
+
57
+ export const closedPrLine = (
58
+ target: { id: string; name: string },
59
+ pr: number | null
60
+ ): string =>
61
+ `${from} ${who(target.name, target.id)}'s PR${pr ? ` #${pr}` : ""} was closed without merging. The wait is closed.`;
62
+
63
+ export const orchestratorTimeoutLine = (
64
+ waiter: string,
65
+ target: string,
66
+ waitedMs: number,
67
+ request: string | null
68
+ ): string =>
69
+ `session ${waiter} waited ${minutes(waitedMs)} on ${target} with no ${request ? `reply to "${request}"` : "result"}; it has been told`;
@@ -0,0 +1,83 @@
1
+ // Registering a wait: `aos send --await` (in sendMessage) and `aos wait`.
2
+
3
+ import { statusDetector } from "../../status-detector";
4
+ import { statusOf } from "../../orchestrator/facts";
5
+ import { duration } from "../../schedules/when";
6
+ import { sessionById, tryResolveTarget } from "../targets";
7
+ import {
8
+ DEFAULT_TIMEOUT_MS,
9
+ MAX_TIMEOUT_MS,
10
+ MIN_TIMEOUT_MS,
11
+ cantWaitOn,
12
+ openWait,
13
+ type WaitKind,
14
+ } from "./store";
15
+
16
+ // "30m", "2h", "1h30m"; empty is the default.
17
+ export function parseTimeout(text: string | null | undefined): number {
18
+ const t = String(text ?? "").trim();
19
+ if (!t) return DEFAULT_TIMEOUT_MS;
20
+ const ms = duration(t.toLowerCase().replace(/([a-z])(?=\d)/g, "$1 "));
21
+ if (ms === null)
22
+ throw new Error(`Can't read the timeout "${t}": try 30m, 2h or 1h30m`);
23
+ if (ms < MIN_TIMEOUT_MS || ms > MAX_TIMEOUT_MS)
24
+ throw new Error("The timeout is between 1m and 24h");
25
+ return ms;
26
+ }
27
+
28
+ export const UNTILS = ["idle", "merged", "reply"] as const;
29
+ export type Until = (typeof UNTILS)[number];
30
+
31
+ export async function registerWait(opts: {
32
+ fromId: string;
33
+ to: string;
34
+ until: string;
35
+ timeout?: string | null;
36
+ }): Promise<{
37
+ id?: number;
38
+ deadline?: number;
39
+ already?: string;
40
+ target: string;
41
+ }> {
42
+ if (!(UNTILS as readonly string[]).includes(opts.until))
43
+ throw new Error("--until is idle, merged or reply");
44
+ const kind: WaitKind = opts.until as Until;
45
+ const timeoutMs = parseTimeout(opts.timeout);
46
+ const from = sessionById(opts.fromId);
47
+ if (!from) throw new Error("Unknown sender session");
48
+ const found = tryResolveTarget(opts.to, opts.fromId);
49
+ if (!found.ok) throw new Error(found.error);
50
+ const target = found.session;
51
+ if (target.id === from.id) throw new Error("A session can't wait on itself");
52
+ const why = cantWaitOn(target);
53
+ if (why) throw new Error(`Can't wait on ${target.name}: ${why}`);
54
+
55
+ if (kind === "merged") {
56
+ if (target.pr_status === "merged" || target.task_status === "merged")
57
+ return {
58
+ already: `${target.name}'s PR is already merged`,
59
+ target: target.name,
60
+ };
61
+ if (target.pr_status === "closed")
62
+ throw new Error(`${target.name}'s PR was closed without merging`);
63
+ if (!target.task_status && !target.pr_number && !target.branch_name)
64
+ throw new Error(
65
+ `${target.name} isn't a task and has no PR, so there is nothing to merge`
66
+ );
67
+ }
68
+ if (kind === "idle") {
69
+ await statusDetector.refreshCache();
70
+ if ((await statusOf(target)).status === "idle")
71
+ return { already: `${target.name} is idle now`, target: target.name };
72
+ }
73
+ const now = Date.now();
74
+ const deadline = now + timeoutMs;
75
+ const id = openWait({
76
+ waiterId: from.id,
77
+ targetId: target.id,
78
+ kind,
79
+ deadline,
80
+ now,
81
+ });
82
+ return { id, deadline, target: target.name };
83
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Waits: session A waiting on session B, kept in `bus_waits`. A change of
3
+ * state is claimed with a conditional UPDATE and acted on only by the caller
4
+ * whose update changed a row, so overlapping sweeps and restarts never
5
+ * wake anyone twice.
6
+ */
7
+
8
+ import { db, type Session } from "../../db";
9
+ import { isHeldRemote } from "../../tasks/contract/held-calls";
10
+ import { notifySessionsChanged } from "../../status/hub";
11
+
12
+ export type WaitKind = "reply" | "idle" | "merged";
13
+ export type WaitOutcome =
14
+ "replied" | "idle" | "merged" | "timeout" | "ended" | "cancelled";
15
+
16
+ export interface WaitRow {
17
+ id: number;
18
+ waiter_id: string;
19
+ target_id: string;
20
+ kind: WaitKind;
21
+ message_id: number | null;
22
+ request: string | null;
23
+ deadline: number;
24
+ created_at: number;
25
+ seen_busy_at: number | null;
26
+ nudged_at: number | null;
27
+ closed_at: number | null;
28
+ outcome: WaitOutcome | null;
29
+ reply_id: number | null;
30
+ }
31
+
32
+ export const MAX_OPEN_WAITS = 10;
33
+ export const DEFAULT_TIMEOUT_MS = 30 * 60_000;
34
+ export const MIN_TIMEOUT_MS = 60_000;
35
+ export const MAX_TIMEOUT_MS = 24 * 60 * 60_000;
36
+
37
+ // A request shown in a line: one line, cut.
38
+ export function excerpt(body: string, max = 100): string {
39
+ const one = body.replace(/\s+/g, " ").trim().replace(/"/g, "'");
40
+ return one.length <= max ? one : `${one.slice(0, max - 1)}…`;
41
+ }
42
+
43
+ export function openWait(w: {
44
+ waiterId: string;
45
+ targetId: string;
46
+ kind: WaitKind;
47
+ messageId?: number | null;
48
+ request?: string | null;
49
+ deadline: number;
50
+ now?: number;
51
+ }): number {
52
+ const now = w.now ?? Date.now();
53
+ const open = db
54
+ .prepare(
55
+ `SELECT id FROM bus_waits WHERE waiter_id = ? AND target_id = ? AND kind = ? AND closed_at IS NULL`
56
+ )
57
+ .get(w.waiterId, w.targetId, w.kind) as { id: number } | undefined;
58
+ let id: number;
59
+ if (open) {
60
+ // The same wait again: the newer request and deadline stand.
61
+ db.prepare(
62
+ `UPDATE bus_waits SET message_id = ?, request = ?, deadline = ?, nudged_at = NULL WHERE id = ?`
63
+ ).run(w.messageId ?? null, w.request ?? null, w.deadline, open.id);
64
+ id = open.id;
65
+ } else {
66
+ const count = (
67
+ db
68
+ .prepare(
69
+ `SELECT COUNT(*) AS n FROM bus_waits WHERE waiter_id = ? AND closed_at IS NULL`
70
+ )
71
+ .get(w.waiterId) as { n: number }
72
+ ).n;
73
+ if (count >= MAX_OPEN_WAITS)
74
+ throw new Error(
75
+ `You already have ${MAX_OPEN_WAITS} waits open; wait for one to finish`
76
+ );
77
+ id = Number(
78
+ db
79
+ .prepare(
80
+ `INSERT INTO bus_waits (waiter_id, target_id, kind, message_id, request, deadline, created_at)
81
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
82
+ )
83
+ .run(
84
+ w.waiterId,
85
+ w.targetId,
86
+ w.kind,
87
+ w.messageId ?? null,
88
+ w.request ?? null,
89
+ w.deadline,
90
+ now
91
+ ).lastInsertRowid
92
+ );
93
+ }
94
+ notifySessionsChanged();
95
+ return id;
96
+ }
97
+
98
+ // True for the one caller that closed it.
99
+ export function claimClose(
100
+ id: number,
101
+ outcome: WaitOutcome,
102
+ replyId: number | null = null,
103
+ now = Date.now()
104
+ ): boolean {
105
+ const changed =
106
+ db
107
+ .prepare(
108
+ `UPDATE bus_waits SET closed_at = ?, outcome = ?, reply_id = ? WHERE id = ? AND closed_at IS NULL`
109
+ )
110
+ .run(now, outcome, replyId, id).changes === 1;
111
+ if (changed) notifySessionsChanged();
112
+ return changed;
113
+ }
114
+
115
+ export const claimNudge = (id: number, now = Date.now()): boolean =>
116
+ db
117
+ .prepare(
118
+ `UPDATE bus_waits SET nudged_at = ? WHERE id = ? AND closed_at IS NULL AND nudged_at IS NULL`
119
+ )
120
+ .run(now, id).changes === 1;
121
+
122
+ export function markSeenBusy(id: number, now = Date.now()): void {
123
+ db.prepare(
124
+ `UPDATE bus_waits SET seen_busy_at = ? WHERE id = ? AND seen_busy_at IS NULL`
125
+ ).run(now, id);
126
+ }
127
+
128
+ export const openWaits = (): WaitRow[] =>
129
+ db
130
+ .prepare(`SELECT * FROM bus_waits WHERE closed_at IS NULL ORDER BY id`)
131
+ .all() as WaitRow[];
132
+
133
+ // A reply from `targetId` to `waiterId` settles these.
134
+ export const replyWaitsFor = (waiterId: string, targetId: string): WaitRow[] =>
135
+ db
136
+ .prepare(
137
+ `SELECT * FROM bus_waits WHERE waiter_id = ? AND target_id = ? AND kind = 'reply' AND closed_at IS NULL ORDER BY id`
138
+ )
139
+ .all(waiterId, targetId) as WaitRow[];
140
+
141
+ export interface WaitBadges {
142
+ on: { id: string; name: string; kind: WaitKind; deadline: number }[];
143
+ by: { id: string; name: string }[];
144
+ }
145
+
146
+ // Who each session waits on, and who waits on it, for the open waits.
147
+ export function waitBadges(): Map<string, WaitBadges> {
148
+ const rows = db
149
+ .prepare(
150
+ `SELECT w.waiter_id, w.target_id, w.kind, w.deadline,
151
+ COALESCE(a.name, 'a session') AS waiter_name,
152
+ COALESCE(b.name, 'a session') AS target_name
153
+ FROM bus_waits w
154
+ LEFT JOIN sessions a ON a.id = w.waiter_id
155
+ LEFT JOIN sessions b ON b.id = w.target_id
156
+ WHERE w.closed_at IS NULL ORDER BY w.id`
157
+ )
158
+ .all() as {
159
+ waiter_id: string;
160
+ target_id: string;
161
+ kind: WaitKind;
162
+ deadline: number;
163
+ waiter_name: string;
164
+ target_name: string;
165
+ }[];
166
+ const out = new Map<string, WaitBadges>();
167
+ const get = (id: string) => {
168
+ let b = out.get(id);
169
+ if (!b) out.set(id, (b = { on: [], by: [] }));
170
+ return b;
171
+ };
172
+ for (const r of rows) {
173
+ get(r.waiter_id).on.push({
174
+ id: r.target_id,
175
+ name: r.target_name,
176
+ kind: r.kind,
177
+ deadline: r.deadline,
178
+ });
179
+ get(r.target_id).by.push({ id: r.waiter_id, name: r.waiter_name });
180
+ }
181
+ return out;
182
+ }
183
+
184
+ // "B", "B and C", "B, C +2": for a sidebar chip.
185
+ export function namesLine(names: string[]): string {
186
+ const unique = [...new Set(names)];
187
+ if (unique.length <= 2) return unique.join(" and ");
188
+ return `${unique.slice(0, 2).join(", ")} +${unique.length - 2}`;
189
+ }
190
+
191
+ // Why a wait on this session can't be kept, or null.
192
+ export function cantWaitOn(to: Session): string | null {
193
+ if (isHeldRemote(to) || (to.peer_mirror && !to.task_prompt))
194
+ return "it runs on a linked machine, which can't tell this one when it answers";
195
+ return null;
196
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Settles open waits: nudges a target that went idle without answering,
3
+ * wakes a waiter whose condition holds, whose deadline passed or whose
4
+ * target ended. Replies settle in sendMessage; this covers the rest. It
5
+ * reads status through statusOf, the same idle test the orchestrator uses.
6
+ */
7
+
8
+ import type { Session } from "../../db";
9
+ import { db } from "../../db";
10
+ import { statusDetector } from "../../status-detector";
11
+ import { statusOf } from "../../orchestrator/facts";
12
+ import { queueEventOnce } from "../../orchestrator/events";
13
+ import { sessionWorkspace } from "../../schedules/store";
14
+ import { sendMessage } from "../index";
15
+ import { onKick } from "./kick";
16
+ import {
17
+ claimClose,
18
+ claimNudge,
19
+ markSeenBusy,
20
+ openWaits,
21
+ type WaitRow,
22
+ } from "./store";
23
+ import {
24
+ closedPrLine,
25
+ endedLine,
26
+ nudgeLine,
27
+ orchestratorTimeoutLine,
28
+ satisfiedLine,
29
+ timeoutLine,
30
+ } from "./lines";
31
+
32
+ // A target that was never seen working is idle only this long after the
33
+ // wait opened: a chat turn can start and end between two looks, and a
34
+ // message may not have been picked up yet.
35
+ export const IDLE_GRACE_MS = 20_000;
36
+ // A terminal reads dead for a moment when tmux hiccups: twice in a row is gone.
37
+ const DEAD_LOOKS = 2;
38
+ const EVERY_MS = 5000;
39
+
40
+ const dead = new Map<string, number>();
41
+
42
+ const row = (id: string) =>
43
+ db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
44
+ Session | undefined;
45
+
46
+ // Why this session can no longer act on a wait, or null.
47
+ function endedHow(s: Session | undefined): string | null {
48
+ if (!s) return "deleted";
49
+ if (s.archived_at) return "archived";
50
+ if (s.task_status === "moved" || s.task_status === "moving") return "moved";
51
+ if (s.task_status && s.task_status !== "running") return s.task_status;
52
+ return null;
53
+ }
54
+
55
+ async function tell(to: Session, line: string): Promise<void> {
56
+ try {
57
+ await sendMessage({
58
+ fromId: null,
59
+ fromLabel: "AgentOS",
60
+ to: to.id,
61
+ body: line,
62
+ line,
63
+ });
64
+ } catch (error) {
65
+ console.error(`[waits] couldn't tell ${to.name}:`, error);
66
+ }
67
+ }
68
+
69
+ async function settle(w: WaitRow, waiter: Session, now: number): Promise<void> {
70
+ const target = row(w.target_id);
71
+ const end = endedHow(target);
72
+ const prMerged =
73
+ target?.pr_status === "merged" || target?.task_status === "merged";
74
+
75
+ if (w.kind === "merged" && prMerged) {
76
+ if (claimClose(w.id, "merged", null, now))
77
+ await tell(waiter, satisfiedLine(target!, "merged", target!.pr_number));
78
+ return;
79
+ }
80
+ if (w.kind === "merged" && target?.pr_status === "closed") {
81
+ if (claimClose(w.id, "ended", null, now))
82
+ await tell(waiter, closedPrLine(target, target.pr_number));
83
+ return;
84
+ }
85
+ if (end || !target) {
86
+ if (claimClose(w.id, "ended", null, now))
87
+ await tell(
88
+ waiter,
89
+ endedLine(
90
+ target ?? { id: w.target_id, name: "the session" },
91
+ end ?? "deleted",
92
+ w.request
93
+ )
94
+ );
95
+ return;
96
+ }
97
+
98
+ const { status } = await statusOf(target);
99
+ if (status === "dead") {
100
+ const n = (dead.get(target.id) ?? 0) + 1;
101
+ dead.set(target.id, n);
102
+ if (n >= DEAD_LOOKS) {
103
+ if (claimClose(w.id, "ended", null, now))
104
+ await tell(
105
+ waiter,
106
+ endedLine(target, "its terminal is gone", w.request)
107
+ );
108
+ return;
109
+ }
110
+ } else dead.delete(target.id);
111
+
112
+ if (status === "running") markSeenBusy(w.id, now);
113
+ const idle =
114
+ status === "idle" &&
115
+ (w.seen_busy_at != null || now - w.created_at >= IDLE_GRACE_MS);
116
+
117
+ if (w.kind === "idle" && idle) {
118
+ if (claimClose(w.id, "idle", null, now))
119
+ await tell(waiter, satisfiedLine(target, "idle"));
120
+ return;
121
+ }
122
+
123
+ if (now >= w.deadline) {
124
+ if (!claimClose(w.id, "timeout", null, now)) return;
125
+ const is = status === "running" ? "still working" : status;
126
+ await tell(
127
+ waiter,
128
+ timeoutLine(target, w.kind, w.deadline - w.created_at, w.request, is)
129
+ );
130
+ const workspace = sessionWorkspace(waiter);
131
+ if (workspace && waiter.role !== "orchestrator")
132
+ queueEventOnce(
133
+ workspace,
134
+ `wait-timeout:${w.id}`,
135
+ waiter.id,
136
+ orchestratorTimeoutLine(
137
+ waiter.name,
138
+ target.name,
139
+ w.deadline - w.created_at,
140
+ w.request
141
+ )
142
+ );
143
+ return;
144
+ }
145
+
146
+ if (w.kind === "reply" && idle && !w.nudged_at && claimNudge(w.id, now))
147
+ await tell(target, nudgeLine(waiter, w.request, w.message_id));
148
+ }
149
+
150
+ let running = false;
151
+
152
+ export async function waitsTick(now = Date.now()): Promise<void> {
153
+ if (running) return;
154
+ running = true;
155
+ try {
156
+ const waits = openWaits();
157
+ if (!waits.length) return;
158
+ await statusDetector.refreshCache();
159
+ for (const w of waits) {
160
+ try {
161
+ const waiter = row(w.waiter_id);
162
+ // A waiter that is gone has no one to tell.
163
+ if (!waiter || endedHow(waiter)) {
164
+ claimClose(w.id, "cancelled", null, now);
165
+ continue;
166
+ }
167
+ await settle(w, waiter, now);
168
+ } catch (error) {
169
+ console.error(`[waits] wait ${w.id}:`, error);
170
+ }
171
+ }
172
+ } finally {
173
+ running = false;
174
+ }
175
+ }
176
+
177
+ let timer: NodeJS.Timeout | null = null;
178
+ let kicked = false;
179
+
180
+ export function startWaitWatcher(): void {
181
+ if (timer) return;
182
+ timer = setInterval(() => void waitsTick(), EVERY_MS);
183
+ timer.unref();
184
+ onKick(() => {
185
+ if (kicked) return;
186
+ kicked = true;
187
+ setImmediate(() => {
188
+ kicked = false;
189
+ void waitsTick();
190
+ });
191
+ });
192
+ }