@saadnvd1/agent-os 0.3.824 → 0.3.826

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 (68) hide show
  1. package/app/api/fork/cancel/route.ts +19 -0
  2. package/app/api/fork/children/route.ts +48 -0
  3. package/app/api/fork/result/route.ts +29 -0
  4. package/app/api/fork/return/route.ts +20 -0
  5. package/app/api/fork/route.ts +32 -0
  6. package/app/api/fork/wait/route.ts +27 -0
  7. package/app/api/load/heavy/lease/route.ts +26 -0
  8. package/app/api/sessions/[id]/archive/route.ts +17 -4
  9. package/app/api/sessions/[id]/route.ts +2 -3
  10. package/app/page.tsx +0 -5
  11. package/components/Chat/ChatPanel.tsx +33 -206
  12. package/components/Chat/Forks.tsx +278 -0
  13. package/components/Chat/ReplyingTo.tsx +24 -0
  14. package/components/Chat/TimelineRows.tsx +194 -0
  15. package/components/Pane/DesktopTabBar.tsx +0 -6
  16. package/components/Pane/MobileTabBar.tsx +0 -6
  17. package/components/Pane/MobileViewPicker.test.ts +1 -8
  18. package/components/Pane/MobileViewPicker.tsx +0 -11
  19. package/components/Pane/PaneViewToggle.test.ts +0 -2
  20. package/components/Pane/PaneViewToggle.tsx +0 -17
  21. package/components/Pane/index.tsx +0 -50
  22. package/components/SessionList/useDoneAction.ts +18 -3
  23. package/contexts/PaneContext.tsx +1 -1
  24. package/data/done/index.ts +26 -2
  25. package/data/fork/index.ts +83 -0
  26. package/lib/chat/commands.ts +15 -0
  27. package/lib/chat/runner.ts +8 -2
  28. package/lib/chat/worker/extras.ts +7 -1
  29. package/lib/chat/worker/host.ts +2 -0
  30. package/lib/config/client-config-types.ts +7 -0
  31. package/lib/config/client-config.ts +2 -0
  32. package/lib/db/migrations/20261102T0100_forks.ts +87 -0
  33. package/lib/done/archive.ts +21 -1
  34. package/lib/experiments/registry.ts +8 -0
  35. package/lib/fork/api.ts +10 -0
  36. package/lib/fork/brief.ts +63 -0
  37. package/lib/fork/conversation.ts +75 -0
  38. package/lib/fork/deliver.ts +91 -0
  39. package/lib/fork/extras.ts +53 -0
  40. package/lib/fork/guard.ts +323 -0
  41. package/lib/fork/heavy-lease.ts +104 -0
  42. package/lib/fork/held.ts +26 -0
  43. package/lib/fork/ledger.ts +324 -0
  44. package/lib/fork/options.ts +46 -0
  45. package/lib/fork/point.ts +41 -0
  46. package/lib/fork/scratch.ts +67 -0
  47. package/lib/fork/settle.ts +215 -0
  48. package/lib/fork/snapshot.ts +69 -0
  49. package/lib/fork/start.ts +273 -0
  50. package/lib/fork/tick.ts +54 -0
  51. package/lib/fork/types.ts +79 -0
  52. package/lib/fork/view.ts +116 -0
  53. package/lib/fork/waits.ts +83 -0
  54. package/lib/fork/worktree.ts +115 -0
  55. package/lib/orchestrator/shell-guard.ts +6 -3
  56. package/lib/orchestrator/write-guard.ts +1 -1
  57. package/lib/security/demo.ts +1 -2
  58. package/lib/sessions/end.ts +5 -0
  59. package/package.json +1 -1
  60. package/server.ts +2 -0
  61. package/app/api/orchestrate/spawn/route.ts +0 -54
  62. package/app/api/orchestrate/workers/[id]/route.ts +0 -89
  63. package/app/api/orchestrate/workers/route.ts +0 -31
  64. package/app/api/sessions/[id]/mcp-config/route.ts +0 -34
  65. package/components/ConductorPanel.tsx +0 -258
  66. package/lib/mcp-config.ts +0 -80
  67. package/lib/orchestration.ts +0 -463
  68. package/mcp/orchestration-server.ts +0 -441
@@ -0,0 +1,69 @@
1
+ /**
2
+ * What a child that can't have a copy of the conversation is told about
3
+ * where its parent stands: built from AgentOS's own records, the same pieces
4
+ * as the note after a compaction (lib/chat/compact-context).
5
+ */
6
+
7
+ import os from "node:os";
8
+ import { db, type Session } from "../db";
9
+ import { statusPorcelain } from "../git-exec";
10
+ import { lastItems } from "../chat/store";
11
+ import { editedFiles } from "../chat/working-state";
12
+
13
+ const EXCHANGES = 6;
14
+ const CLIP = 700;
15
+ const MAX = 10_000;
16
+
17
+ const clip = (text: string, max = CLIP) =>
18
+ text.length > max ? `${text.slice(0, max)}…` : text;
19
+
20
+ export async function parentSnapshot(parentId: string): Promise<string> {
21
+ const session = db
22
+ .prepare(
23
+ `SELECT name, working_directory, task_prompt FROM session_rows WHERE id = ?`
24
+ )
25
+ .get(parentId) as
26
+ Pick<Session, "name" | "working_directory" | "task_prompt"> | undefined;
27
+ if (!session) return "";
28
+ const parts: string[] = [`Parent session: ${session.name}`];
29
+ if (session.task_prompt?.trim())
30
+ parts.push(`## Its task\n${clip(session.task_prompt, 4_000)}`);
31
+ const said = lastItems(parentId, 60)
32
+ .filter((i) => i.kind === "user" || i.kind === "assistant")
33
+ .slice(-EXCHANGES * 2)
34
+ .map((i) =>
35
+ i.kind === "user"
36
+ ? `User: ${clip(i.text)}`
37
+ : i.kind === "assistant"
38
+ ? `Agent: ${clip(i.text)}`
39
+ : ""
40
+ )
41
+ .filter(Boolean);
42
+ if (said.length) parts.push(`## The latest exchanges\n${said.join("\n\n")}`);
43
+ const todos = lastItems(parentId, 1, "todos").find((i) => i.kind === "todos");
44
+ if (todos?.kind === "todos" && todos.todos.length)
45
+ parts.push(
46
+ `## Its todo list\n${todos.todos.map((t) => `- [${t.status}] ${t.text}`).join("\n")}`
47
+ );
48
+ const files = editedFiles(
49
+ lastItems(parentId, 400, "tool").filter((i) => i.kind === "tool")
50
+ );
51
+ if (files.length)
52
+ parts.push(
53
+ `## Files it was editing\n${files.map((f) => `- ${f}`).join("\n")}`
54
+ );
55
+ try {
56
+ const status = await statusPorcelain(
57
+ session.working_directory.replace(/^~/, os.homedir()),
58
+ { timeout: 3_000, maxBuffer: 64 * 1024 }
59
+ );
60
+ if (status.trim())
61
+ parts.push(
62
+ `## git status\n${status.split("\n").slice(0, 40).join("\n")}`
63
+ );
64
+ } catch {
65
+ // Not a repository, or git is slow: the rest stands.
66
+ }
67
+ const text = parts.join("\n\n");
68
+ return text.length > MAX ? `${text.slice(0, MAX)}\n… (cut)` : text;
69
+ }
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Starting forks: the ledger rows first (so a crash or a repeated request
3
+ * never doubles them), then each child's session in the background as the
4
+ * limits allow. A parent's first open child always starts, so a parent that
5
+ * holds a slot never waits on its own children.
6
+ */
7
+
8
+ import os from "os";
9
+ import { randomUUID } from "crypto";
10
+ import { db, queries, type Session } from "../db";
11
+ import type { AgentType } from "../providers";
12
+ import { launchSession } from "../sessions/launch";
13
+ import { sendChat } from "../chat/runner";
14
+ import { saveItem } from "../chat/store";
15
+ import { emit } from "../chat/registry";
16
+ import { runInBackground } from "../async-operations";
17
+ import { getProject } from "../projects";
18
+ import { limitOf, runningIn, startingIn } from "../tasks/queue-rows";
19
+ import { currentLevel, loadEnabled } from "../load/monitor";
20
+ import { childFirstMessage } from "./brief";
21
+ import { ForkError, forkConversation } from "./conversation";
22
+ import {
23
+ childrenOf,
24
+ getChild,
25
+ groupByRequest,
26
+ groupChildren,
27
+ insertGroup,
28
+ markFellBack,
29
+ newGroupId,
30
+ numberOf,
31
+ openChildren,
32
+ setCut,
33
+ setState,
34
+ } from "./ledger";
35
+ import { FORK_LIMITS, FORKS_OFF, forksOn, type DeliveryMode } from "./options";
36
+ import { ensureScratch, removeFork } from "./scratch";
37
+ import { parentSnapshot } from "./snapshot";
38
+ import { createForkWorktree } from "./worktree";
39
+ import type { ChildRow } from "./types";
40
+
41
+ const expand = (p: string) => p.replace(/^~(?=$|\/)/, os.homedir());
42
+
43
+ const sessionOf = (id: string) =>
44
+ queries.getSession(db).get(id) as Session | undefined;
45
+
46
+ export interface ForkRequest {
47
+ parentId: string;
48
+ // The caller's key for this call; a repeat returns the same group.
49
+ requestId: string;
50
+ instructions: string[];
51
+ labels?: string[];
52
+ worktree?: boolean;
53
+ startedBy: "agent" | "user";
54
+ delivery?: DeliveryMode;
55
+ }
56
+
57
+ export interface ForkStarted {
58
+ groupId: string;
59
+ repeat: boolean;
60
+ children: { id: string; n: number; label: string }[];
61
+ }
62
+
63
+ // Why this session can't fork, or null.
64
+ export function refusal(parent: Session | undefined): string | null {
65
+ if (!forksOn()) return FORKS_OFF;
66
+ if (!parent) return "Unknown session";
67
+ if (getChild(parent.id)) return "A fork child can't fork";
68
+ if (parent.role === "orchestrator")
69
+ return "An orchestrator doesn't fork: start tasks or sessions instead";
70
+ if (parent.archived_at) return "That session is archived";
71
+ if (parent.view !== "chat") return "Forks work from a chat session";
72
+ if (parent.host_id && parent.host_id !== "local")
73
+ return "Forks run on this machine only";
74
+ return null;
75
+ }
76
+
77
+ const labelFor = (instruction: string, i: number) => {
78
+ const words = instruction.trim().split(/\s+/).slice(0, 5).join(" ");
79
+ return `${i + 1}. ${words.length > 40 ? `${words.slice(0, 39)}…` : words}`;
80
+ };
81
+
82
+ export function startFork(req: ForkRequest): ForkStarted {
83
+ const parent = sessionOf(req.parentId);
84
+ const refused = refusal(parent);
85
+ if (refused || !parent) throw new Error(refused ?? "Unknown session");
86
+ const again = groupByRequest(req.requestId);
87
+ if (again)
88
+ return {
89
+ groupId: again.id,
90
+ repeat: true,
91
+ children: groupChildren(again.id).map((c) => ({
92
+ id: c.child_session_id,
93
+ n: numberOf(parent.id, c.child_session_id),
94
+ label: c.label,
95
+ })),
96
+ };
97
+ const texts = req.instructions.map((t) => t.trim()).filter(Boolean);
98
+ if (!texts.length) throw new Error("Give each fork an instruction");
99
+ if (texts.length > FORK_LIMITS.maxPerFork)
100
+ throw new Error(`At most ${FORK_LIMITS.maxPerFork} forks at a time`);
101
+ if (texts.some((t) => t.length > FORK_LIMITS.instructionChars))
102
+ throw new Error(
103
+ `An instruction is over ${FORK_LIMITS.instructionChars} characters: put the detail in a file and point at it`
104
+ );
105
+ if (
106
+ openChildren(parent.id).length + texts.length >
107
+ FORK_LIMITS.maxOpenPerParent
108
+ )
109
+ throw new Error(
110
+ `${FORK_LIMITS.maxOpenPerParent} forks open at once is the most: wait for some to finish`
111
+ );
112
+ const claude = parent.agent_type === "claude";
113
+ // Only the Claude driver runs a guard, so any other agent's children get
114
+ // their own checkout instead.
115
+ const worktree = !claude || !!req.worktree;
116
+ const groupId = newGroupId();
117
+ const children = texts.map((instruction, i) => ({
118
+ childId: randomUUID(),
119
+ label: req.labels?.[i]?.trim() || labelFor(instruction, i),
120
+ instruction,
121
+ worktree,
122
+ }));
123
+ const noteId = `fork-${groupId}`;
124
+ insertGroup({
125
+ id: groupId,
126
+ parentId: parent.id,
127
+ requestId: req.requestId,
128
+ startedBy: req.startedBy,
129
+ context: claude ? "fork" : "brief",
130
+ delivery: req.delivery ?? (req.startedBy === "agent" ? "wake" : "next"),
131
+ forkItemId: noteId,
132
+ children,
133
+ });
134
+ const item = {
135
+ id: noteId,
136
+ kind: "note" as const,
137
+ tone: "note" as const,
138
+ createdAt: Date.now(),
139
+ text: `Forked ${children.length === 1 ? "1 copy" : `${children.length} copies`}: ${children.map((c) => c.label).join(" · ")}`,
140
+ };
141
+ saveItem(parent.id, item);
142
+ emit(parent.id, { type: "item", item });
143
+ const out = children.map((c) => ({
144
+ id: c.childId,
145
+ n: numberOf(parent.id, c.childId),
146
+ label: c.label,
147
+ }));
148
+ runInBackground(() => admitQueued(parent.id), `fork-admit-${groupId}`);
149
+ return { groupId, repeat: false, children: out };
150
+ }
151
+
152
+ const red = () => loadEnabled() && currentLevel() === "red";
153
+
154
+ // Whether one more child of this parent may start, when it has none open.
155
+ function mayStart(parent: Session, running: number): boolean {
156
+ if (running === 0) return true;
157
+ if (running >= FORK_LIMITS.maxRunningPerParent || red()) return false;
158
+ const project = getProject(parent.project_id ?? "");
159
+ const ws = project?.workspace_id ?? null;
160
+ const limit = limitOf(ws);
161
+ if (limit == null) return true;
162
+ const busy =
163
+ runningIn({ workspaceId: ws, projectId: parent.project_id ?? "" }, true)
164
+ .length + (ws ? startingIn(ws) : 0);
165
+ return busy < limit;
166
+ }
167
+
168
+ const STARTED = new Set<ChildRow["state"]>([
169
+ "starting",
170
+ "running",
171
+ "needs_input",
172
+ ]);
173
+
174
+ // Starts what the limits allow, oldest first. Called when forks are made,
175
+ // when one settles, and by the tick.
176
+ export async function admitQueued(parentId: string): Promise<void> {
177
+ const parent = sessionOf(parentId);
178
+ if (!parent || parent.archived_at) return;
179
+ const all = childrenOf(parentId);
180
+ let running = all.filter((c) => STARTED.has(c.state)).length;
181
+ for (const c of all) {
182
+ if (c.state !== "queued") continue;
183
+ if (!mayStart(parent, running)) break;
184
+ setState(c.child_session_id, "starting");
185
+ running++;
186
+ runInBackground(
187
+ () => startChild(c.child_session_id),
188
+ `fork-child-${c.child_session_id}`
189
+ );
190
+ }
191
+ }
192
+
193
+ async function startChild(childId: string): Promise<void> {
194
+ const row = getChild(childId);
195
+ if (!row) return;
196
+ const parent = sessionOf(row.parent_session_id);
197
+ if (!parent) return;
198
+ try {
199
+ const parentCwd = expand(parent.working_directory);
200
+ await ensureScratch(childId);
201
+ const checkout = row.worktree
202
+ ? await createForkWorktree({ childId, parentCwd })
203
+ : null;
204
+ const cwd = checkout?.cwd ?? parentCwd;
205
+ const n = numberOf(parent.id, childId);
206
+ await launchSession({
207
+ id: childId,
208
+ projectId: parent.project_id,
209
+ workingDirectory: checkout?.cwd ?? parent.working_directory,
210
+ agentType: parent.agent_type as AgentType,
211
+ model: parent.model,
212
+ access: parent.chat_access,
213
+ autoApprove: !!parent.auto_approve,
214
+ name: `${parent.name} · fork ${n}`,
215
+ view: "chat",
216
+ parentSessionId: parent.id,
217
+ startedVia: "session",
218
+ });
219
+ db.prepare(
220
+ `UPDATE sessions SET conductor_session_id = ?, model_provider = ? WHERE id = ?`
221
+ ).run(parent.id, parent.model_provider, childId);
222
+ let snapshot: string | undefined;
223
+ if (parent.agent_type === "claude" && parent.claude_session_id) {
224
+ try {
225
+ const forked = await forkConversation({
226
+ claudeSessionId: parent.claude_session_id,
227
+ parentCwd,
228
+ childCwd: cwd,
229
+ title: `fork ${n} of ${parent.name}`,
230
+ });
231
+ db.prepare(
232
+ `UPDATE sessions SET claude_session_id = ?, chat_resume_at = NULL WHERE id = ?`
233
+ ).run(forked.claudeSessionId, childId);
234
+ setCut(childId, forked.cut);
235
+ } catch (error) {
236
+ if (!(error instanceof ForkError)) throw error;
237
+ markFellBack(childId);
238
+ snapshot = await parentSnapshot(parent.id);
239
+ }
240
+ } else snapshot = await parentSnapshot(parent.id);
241
+ const instruction = row.instruction;
242
+ await sendChat(childId, {
243
+ id: `fork-start-${childId}`,
244
+ text: childFirstMessage({
245
+ parentName: parent.name,
246
+ n,
247
+ instruction,
248
+ worktree: !!row.worktree,
249
+ scratch: await ensureScratch(childId),
250
+ snapshot,
251
+ checkout: checkout?.root,
252
+ }),
253
+ from: "AgentOS",
254
+ origin: {
255
+ kind: "event",
256
+ label: `Fork ${n} of ${parent.name}`,
257
+ sessionId: parent.id,
258
+ body: instruction,
259
+ },
260
+ });
261
+ setState(childId, "running");
262
+ } catch (error) {
263
+ const why = error instanceof Error ? error.message : String(error);
264
+ const { settleAndDeliver } = await import("./settle");
265
+ await removeFork(childId).catch(() => undefined);
266
+ await settleAndDeliver(childId, {
267
+ outcome: "failed",
268
+ summary: `couldn't start: ${why.split("\n")[0]}`,
269
+ result: `Fork didn't start: ${why}`,
270
+ kind: "implicit",
271
+ });
272
+ }
273
+ }
@@ -0,0 +1,54 @@
1
+ /** The fork watcher: looks at open children, delivers results, runs waits. */
2
+
3
+ import { db } from "../db";
4
+ import { admitQueued } from "./start";
5
+ import { childrenOf } from "./ledger";
6
+ import { deliverDue } from "./deliver";
7
+ import { lookAtChild } from "./settle";
8
+ import { tickForkWaits } from "./waits";
9
+ import { forksOn } from "./options";
10
+ import type { ChildRow } from "./types";
11
+
12
+ const EVERY_MS = 5000;
13
+ let running = false;
14
+ let timer: NodeJS.Timeout | null = null;
15
+ let kicked = false;
16
+
17
+ export async function forksTick(now = Date.now()): Promise<void> {
18
+ if (running || !forksOn()) return;
19
+ running = true;
20
+ try {
21
+ const open = db
22
+ .prepare(`SELECT * FROM session_children WHERE state != 'settled'`)
23
+ .all() as ChildRow[];
24
+ for (const row of open) {
25
+ try {
26
+ await lookAtChild(row, now);
27
+ } catch (error) {
28
+ console.error(`[forks] child ${row.child_session_id}:`, error);
29
+ }
30
+ }
31
+ for (const parent of new Set(open.map((c) => c.parent_session_id)))
32
+ if (childrenOf(parent).some((c) => c.state === "queued"))
33
+ await admitQueued(parent);
34
+ await tickForkWaits(now);
35
+ await deliverDue(now);
36
+ } finally {
37
+ running = false;
38
+ }
39
+ }
40
+
41
+ export function kickForks(): void {
42
+ if (kicked) return;
43
+ kicked = true;
44
+ setImmediate(() => {
45
+ kicked = false;
46
+ void forksTick();
47
+ });
48
+ }
49
+
50
+ export function startForkWatcher(): void {
51
+ if (timer) return;
52
+ timer = setInterval(() => void forksTick(), EVERY_MS);
53
+ timer.unref();
54
+ }
@@ -0,0 +1,79 @@
1
+ export type ChildState =
2
+ "queued" | "starting" | "running" | "needs_input" | "settled";
3
+
4
+ export type ChildOutcome = "ok" | "failed" | "cancelled";
5
+
6
+ // `implicit`: the child's turn ended without `aos return`, so its final
7
+ // answer stands in (kept apart so stats can tell them).
8
+ export type ResultKind = "explicit" | "implicit";
9
+
10
+ export interface GroupRow {
11
+ id: string;
12
+ parent_session_id: string;
13
+ request_id: string;
14
+ started_by: "agent" | "user";
15
+ context: "fork" | "brief";
16
+ delivery: "wake" | "next";
17
+ fork_item_id: string | null;
18
+ created_at: number;
19
+ last_settled_at: number | null;
20
+ }
21
+
22
+ export interface ChildRow {
23
+ child_session_id: string;
24
+ parent_session_id: string;
25
+ group_id: string;
26
+ position: number;
27
+ label: string;
28
+ instruction: string;
29
+ worktree: number;
30
+ state: ChildState;
31
+ hold: string | null;
32
+ outcome: ChildOutcome | null;
33
+ summary: string | null;
34
+ result: string | null;
35
+ result_kind: ResultKind | null;
36
+ fell_back: number;
37
+ cut_message_id: string | null;
38
+ idle_since: number | null;
39
+ started_at: number | null;
40
+ settled_at: number | null;
41
+ tokens_in: number | null;
42
+ tokens_out: number | null;
43
+ cache_read: number | null;
44
+ heavy_runs: number;
45
+ delivered_at: number | null;
46
+ delivery_id: string | null;
47
+ used: "opened" | "mentioned" | null;
48
+ created_at: number;
49
+ }
50
+
51
+ // What a client shows for one child.
52
+ export interface ChildView {
53
+ id: string;
54
+ n: number;
55
+ group: string;
56
+ label: string;
57
+ instruction: string;
58
+ state: ChildState;
59
+ hold: string | null;
60
+ outcome: ChildOutcome | null;
61
+ summary: string | null;
62
+ resultKind: ResultKind | null;
63
+ worktree: boolean;
64
+ fellBack: boolean;
65
+ startedAt: number | null;
66
+ settledAt: number | null;
67
+ delivered: boolean;
68
+ // A question or approval it's waiting on.
69
+ asks: string | null;
70
+ }
71
+
72
+ export interface GroupView {
73
+ id: string;
74
+ delivery: "wake" | "next";
75
+ context: "fork" | "brief";
76
+ startedBy: "agent" | "user";
77
+ createdAt: number;
78
+ stats: string | null;
79
+ }
@@ -0,0 +1,116 @@
1
+ /** What clients and `aos children` show, and the numbers behind them. */
2
+
3
+ import { childrenOf, getGroup } from "./ledger";
4
+ import type { ChildRow, ChildView, GroupView } from "./types";
5
+
6
+ export const childView = (c: ChildRow): ChildView => ({
7
+ id: c.child_session_id,
8
+ n: 0,
9
+ group: c.group_id,
10
+ label: c.label,
11
+ instruction: c.instruction,
12
+ state: c.state,
13
+ hold: c.hold,
14
+ outcome: c.outcome,
15
+ summary: c.summary,
16
+ resultKind: c.result_kind,
17
+ worktree: !!c.worktree,
18
+ fellBack: !!c.fell_back,
19
+ startedAt: c.started_at,
20
+ settledAt: c.settled_at,
21
+ delivered: c.delivered_at != null,
22
+ asks: c.state === "needs_input" ? c.hold : null,
23
+ });
24
+
25
+ export function childrenView(parentId: string): {
26
+ children: ChildView[];
27
+ groups: GroupView[];
28
+ } {
29
+ const rows = childrenOf(parentId);
30
+ const seen = new Set<string>();
31
+ const groups: GroupView[] = [];
32
+ for (const c of rows) {
33
+ if (seen.has(c.group_id)) continue;
34
+ seen.add(c.group_id);
35
+ const g = getGroup(c.group_id);
36
+ if (g)
37
+ groups.push({
38
+ id: g.id,
39
+ delivery: g.delivery,
40
+ context: g.context,
41
+ startedBy: g.started_by,
42
+ createdAt: g.created_at,
43
+ stats: null,
44
+ });
45
+ }
46
+ return {
47
+ children: rows.map((c, i) => ({ ...childView(c), n: i + 1 })),
48
+ groups,
49
+ };
50
+ }
51
+
52
+ const median = (xs: number[]): number | null => {
53
+ if (!xs.length) return null;
54
+ const s = [...xs].sort((a, b) => a - b);
55
+ return s[Math.floor(s.length / 2)];
56
+ };
57
+
58
+ export interface ForkStats {
59
+ children: number;
60
+ settled: number;
61
+ ok: number;
62
+ failed: number;
63
+ cancelled: number;
64
+ implicit: number;
65
+ fellBack: number;
66
+ medianSeconds: number | null;
67
+ tokensIn: number;
68
+ tokensOut: number;
69
+ cacheRead: number;
70
+ heavyRuns: number;
71
+ delivered: number;
72
+ opened: number;
73
+ mentioned: number;
74
+ }
75
+
76
+ export function statsOf(rows: ChildRow[]): ForkStats {
77
+ const done = rows.filter((c) => c.state === "settled");
78
+ const sum = (f: (c: ChildRow) => number | null) =>
79
+ rows.reduce((n, c) => n + (f(c) ?? 0), 0);
80
+ return {
81
+ children: rows.length,
82
+ settled: done.length,
83
+ ok: done.filter((c) => c.outcome === "ok").length,
84
+ failed: done.filter((c) => c.outcome === "failed").length,
85
+ cancelled: done.filter((c) => c.outcome === "cancelled").length,
86
+ implicit: done.filter(
87
+ (c) => c.result_kind === "implicit" && c.outcome === "ok"
88
+ ).length,
89
+ fellBack: rows.filter((c) => c.fell_back).length,
90
+ medianSeconds: median(
91
+ done
92
+ .filter((c) => c.started_at && c.settled_at)
93
+ .map((c) => Math.round((c.settled_at! - c.started_at!) / 1000))
94
+ ),
95
+ tokensIn: sum((c) => c.tokens_in),
96
+ tokensOut: sum((c) => c.tokens_out),
97
+ cacheRead: sum((c) => c.cache_read),
98
+ heavyRuns: sum((c) => c.heavy_runs),
99
+ delivered: rows.filter((c) => c.delivered_at != null).length,
100
+ opened: rows.filter((c) => c.used === "opened").length,
101
+ mentioned: rows.filter((c) => c.used === "mentioned").length,
102
+ };
103
+ }
104
+
105
+ export function statsText(s: ForkStats): string {
106
+ const pct = (n: number) =>
107
+ s.settled ? `${Math.round((n / s.settled) * 100)}%` : "-";
108
+ return [
109
+ `${s.children} children, ${s.settled} settled (${s.ok} ok, ${s.failed} failed, ${s.cancelled} cancelled)`,
110
+ `returned without aos return: ${s.implicit} (${pct(s.implicit)}); started from a brief, not a fork: ${s.fellBack}`,
111
+ `median time to settle: ${s.medianSeconds ?? "-"}s`,
112
+ `tokens: ${s.tokensIn} in, ${s.tokensOut} out, ${s.cacheRead} cache read`,
113
+ `heavy runs queued: ${s.heavyRuns}`,
114
+ `results delivered: ${s.delivered}; opened in full: ${s.opened}; mentioned later: ${s.mentioned}`,
115
+ ].join("\n");
116
+ }
@@ -0,0 +1,83 @@
1
+ /** `aos wait children`: the parent ends its turn and is woken with results. */
2
+
3
+ import { db } from "../db";
4
+ import { numberOf, openChildren, childrenOf, undelivered } from "./ledger";
5
+ import { deliverNow } from "./deliver";
6
+
7
+ interface WaitRow {
8
+ id: number;
9
+ parent_session_id: string;
10
+ mode: "all" | "any";
11
+ created_at: number;
12
+ deadline: number;
13
+ }
14
+
15
+ export const MAX_WAIT_MS = 24 * 60 * 60_000;
16
+ export const DEFAULT_WAIT_MS = 30 * 60_000;
17
+
18
+ export const openForkWait = (parentId: string): WaitRow | undefined =>
19
+ db
20
+ .prepare(
21
+ `SELECT * FROM fork_waits WHERE parent_session_id = ? AND closed_at IS NULL`
22
+ )
23
+ .get(parentId) as WaitRow | undefined;
24
+
25
+ export function registerForkWait(
26
+ parentId: string,
27
+ mode: "all" | "any",
28
+ timeoutMs = DEFAULT_WAIT_MS,
29
+ now = Date.now()
30
+ ): { id: number; deadline: number } | { already: string } {
31
+ const open = childrenOf(parentId).filter((c) => c.state !== "settled");
32
+ const waiting = undelivered(parentId);
33
+ if (!open.length && !waiting.length)
34
+ return { already: "you have no running forks and no unread results" };
35
+ const deadline = now + Math.min(Math.max(timeoutMs, 60_000), MAX_WAIT_MS);
36
+ // One wait per parent: a new one replaces it.
37
+ db.prepare(
38
+ `UPDATE fork_waits SET closed_at = ? WHERE parent_session_id = ? AND closed_at IS NULL`
39
+ ).run(now, parentId);
40
+ const { lastInsertRowid } = db
41
+ .prepare(
42
+ `INSERT INTO fork_waits (parent_session_id, mode, created_at, deadline) VALUES (?, ?, ?, ?)`
43
+ )
44
+ .run(parentId, mode, now, deadline);
45
+ return { id: Number(lastInsertRowid), deadline };
46
+ }
47
+
48
+ const close = (id: number, now: number): boolean =>
49
+ db
50
+ .prepare(
51
+ `UPDATE fork_waits SET closed_at = ? WHERE id = ? AND closed_at IS NULL`
52
+ )
53
+ .run(now, id).changes === 1;
54
+
55
+ export async function tickForkWaits(now = Date.now()): Promise<void> {
56
+ const waits = db
57
+ .prepare(`SELECT * FROM fork_waits WHERE closed_at IS NULL`)
58
+ .all() as WaitRow[];
59
+ for (const w of waits) {
60
+ const ready = undelivered(w.parent_session_id);
61
+ const open = openChildren(w.parent_session_id);
62
+ const holds =
63
+ w.mode === "all"
64
+ ? open.length === 0
65
+ : ready.length > 0 || open.length === 0;
66
+ const timedOut = now >= w.deadline;
67
+ if (!holds && !timedOut) continue;
68
+ if (!close(w.id, now)) continue;
69
+ if (!ready.length && !timedOut) continue;
70
+ if (!ready.length) {
71
+ const { sendChat } = await import("../chat/runner");
72
+ await sendChat(w.parent_session_id, {
73
+ id: `fork-w-${w.id}`,
74
+ text: `[AgentOS: your wait on forks timed out; ${open.map((c) => numberOf(w.parent_session_id, c.child_session_id)).join(", ")} still running. \`aos children\` shows them.]`,
75
+ queue: true,
76
+ from: "AgentOS",
77
+ origin: { kind: "event", label: "Forks" },
78
+ });
79
+ continue;
80
+ }
81
+ await deliverNow(w.parent_session_id, ready);
82
+ }
83
+ }