@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.
- package/app/api/fork/cancel/route.ts +19 -0
- package/app/api/fork/children/route.ts +48 -0
- package/app/api/fork/result/route.ts +29 -0
- package/app/api/fork/return/route.ts +20 -0
- package/app/api/fork/route.ts +32 -0
- package/app/api/fork/wait/route.ts +27 -0
- package/app/api/load/heavy/lease/route.ts +26 -0
- package/app/api/sessions/[id]/archive/route.ts +17 -4
- package/app/api/sessions/[id]/route.ts +2 -3
- package/app/page.tsx +0 -5
- package/components/Chat/ChatPanel.tsx +33 -206
- package/components/Chat/Forks.tsx +278 -0
- package/components/Chat/ReplyingTo.tsx +24 -0
- package/components/Chat/TimelineRows.tsx +194 -0
- package/components/Pane/DesktopTabBar.tsx +0 -6
- package/components/Pane/MobileTabBar.tsx +0 -6
- package/components/Pane/MobileViewPicker.test.ts +1 -8
- package/components/Pane/MobileViewPicker.tsx +0 -11
- package/components/Pane/PaneViewToggle.test.ts +0 -2
- package/components/Pane/PaneViewToggle.tsx +0 -17
- package/components/Pane/index.tsx +0 -50
- package/components/SessionList/useDoneAction.ts +18 -3
- package/contexts/PaneContext.tsx +1 -1
- package/data/done/index.ts +26 -2
- package/data/fork/index.ts +83 -0
- package/lib/chat/commands.ts +15 -0
- package/lib/chat/runner.ts +8 -2
- package/lib/chat/worker/extras.ts +7 -1
- package/lib/chat/worker/host.ts +2 -0
- package/lib/config/client-config-types.ts +7 -0
- package/lib/config/client-config.ts +2 -0
- package/lib/db/migrations/20261102T0100_forks.ts +87 -0
- package/lib/done/archive.ts +21 -1
- package/lib/experiments/registry.ts +8 -0
- package/lib/fork/api.ts +10 -0
- package/lib/fork/brief.ts +63 -0
- package/lib/fork/conversation.ts +75 -0
- package/lib/fork/deliver.ts +91 -0
- package/lib/fork/extras.ts +53 -0
- package/lib/fork/guard.ts +323 -0
- package/lib/fork/heavy-lease.ts +104 -0
- package/lib/fork/held.ts +26 -0
- package/lib/fork/ledger.ts +324 -0
- package/lib/fork/options.ts +46 -0
- package/lib/fork/point.ts +41 -0
- package/lib/fork/scratch.ts +67 -0
- package/lib/fork/settle.ts +215 -0
- package/lib/fork/snapshot.ts +69 -0
- package/lib/fork/start.ts +273 -0
- package/lib/fork/tick.ts +54 -0
- package/lib/fork/types.ts +79 -0
- package/lib/fork/view.ts +116 -0
- package/lib/fork/waits.ts +83 -0
- package/lib/fork/worktree.ts +115 -0
- package/lib/orchestrator/shell-guard.ts +6 -3
- package/lib/orchestrator/write-guard.ts +1 -1
- package/lib/security/demo.ts +1 -2
- package/lib/sessions/end.ts +5 -0
- package/package.json +1 -1
- package/server.ts +2 -0
- package/app/api/orchestrate/spawn/route.ts +0 -54
- package/app/api/orchestrate/workers/[id]/route.ts +0 -89
- package/app/api/orchestrate/workers/route.ts +0 -31
- package/app/api/sessions/[id]/mcp-config/route.ts +0 -34
- package/components/ConductorPanel.tsx +0 -258
- package/lib/mcp-config.ts +0 -80
- package/lib/orchestration.ts +0 -463
- package/mcp/orchestration-server.ts +0 -441
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The children ledger: what a session has forked, kept in the database so
|
|
3
|
+
* it survives compaction, restarts and fresh conversations. A child settles
|
|
4
|
+
* once, by a conditional UPDATE; whichever caller's update changed a row
|
|
5
|
+
* acts on it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { randomUUID } from "crypto";
|
|
9
|
+
import { db } from "../db";
|
|
10
|
+
import { FORK_LIMITS } from "./options";
|
|
11
|
+
import type { ChildOutcome, ChildRow, GroupRow, ResultKind } from "./types";
|
|
12
|
+
|
|
13
|
+
const OPEN = `state != 'settled'`;
|
|
14
|
+
|
|
15
|
+
export const newGroupId = () => `g${randomUUID().slice(0, 6)}`;
|
|
16
|
+
|
|
17
|
+
export interface NewChild {
|
|
18
|
+
childId: string;
|
|
19
|
+
label: string;
|
|
20
|
+
instruction: string;
|
|
21
|
+
worktree: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function groupByRequest(requestId: string): GroupRow | undefined {
|
|
25
|
+
return db
|
|
26
|
+
.prepare(`SELECT * FROM fork_groups WHERE request_id = ?`)
|
|
27
|
+
.get(requestId) as GroupRow | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const getGroup = (id: string): GroupRow | undefined =>
|
|
31
|
+
db.prepare(`SELECT * FROM fork_groups WHERE id = ?`).get(id) as
|
|
32
|
+
GroupRow | undefined;
|
|
33
|
+
|
|
34
|
+
// The group and its children in one transaction, so a crash leaves both or
|
|
35
|
+
// neither. A repeat of the same request id is the caller's check, made first
|
|
36
|
+
// (the column is UNIQUE, so a race fails here rather than doubling).
|
|
37
|
+
export function insertGroup(input: {
|
|
38
|
+
id: string;
|
|
39
|
+
parentId: string;
|
|
40
|
+
requestId: string;
|
|
41
|
+
startedBy: "agent" | "user";
|
|
42
|
+
context: "fork" | "brief";
|
|
43
|
+
delivery: "wake" | "next";
|
|
44
|
+
forkItemId?: string | null;
|
|
45
|
+
children: NewChild[];
|
|
46
|
+
now?: number;
|
|
47
|
+
}): void {
|
|
48
|
+
const now = input.now ?? Date.now();
|
|
49
|
+
db.transaction(() => {
|
|
50
|
+
db.prepare(
|
|
51
|
+
`INSERT INTO fork_groups (id, parent_session_id, request_id, started_by, context, delivery, fork_item_id, created_at)
|
|
52
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
53
|
+
).run(
|
|
54
|
+
input.id,
|
|
55
|
+
input.parentId,
|
|
56
|
+
input.requestId,
|
|
57
|
+
input.startedBy,
|
|
58
|
+
input.context,
|
|
59
|
+
input.delivery,
|
|
60
|
+
input.forkItemId ?? null,
|
|
61
|
+
now
|
|
62
|
+
);
|
|
63
|
+
const add = db.prepare(
|
|
64
|
+
`INSERT INTO session_children (child_session_id, parent_session_id, group_id, position, label, instruction, worktree, state, created_at)
|
|
65
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', ?)`
|
|
66
|
+
);
|
|
67
|
+
input.children.forEach((c, i) =>
|
|
68
|
+
add.run(
|
|
69
|
+
c.childId,
|
|
70
|
+
input.parentId,
|
|
71
|
+
input.id,
|
|
72
|
+
i + 1,
|
|
73
|
+
c.label,
|
|
74
|
+
c.instruction,
|
|
75
|
+
c.worktree ? 1 : 0,
|
|
76
|
+
now
|
|
77
|
+
)
|
|
78
|
+
);
|
|
79
|
+
})();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const getChild = (id: string): ChildRow | undefined =>
|
|
83
|
+
db
|
|
84
|
+
.prepare(`SELECT * FROM session_children WHERE child_session_id = ?`)
|
|
85
|
+
.get(id) as ChildRow | undefined;
|
|
86
|
+
|
|
87
|
+
export const isChild = (sessionId: string): boolean => !!getChild(sessionId);
|
|
88
|
+
|
|
89
|
+
// A parent's children, oldest first: the order the numbers in `aos
|
|
90
|
+
// children` follow, across groups.
|
|
91
|
+
export function childrenOf(parentId: string): ChildRow[] {
|
|
92
|
+
return db
|
|
93
|
+
.prepare(
|
|
94
|
+
`SELECT * FROM session_children WHERE parent_session_id = ?
|
|
95
|
+
ORDER BY created_at, group_id, position`
|
|
96
|
+
)
|
|
97
|
+
.all(parentId) as ChildRow[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const numberOf = (parentId: string, childId: string): number =>
|
|
101
|
+
childrenOf(parentId).findIndex((c) => c.child_session_id === childId) + 1;
|
|
102
|
+
|
|
103
|
+
export const openChildren = (parentId: string): ChildRow[] =>
|
|
104
|
+
db
|
|
105
|
+
.prepare(
|
|
106
|
+
`SELECT * FROM session_children WHERE parent_session_id = ? AND ${OPEN}
|
|
107
|
+
ORDER BY created_at, group_id, position`
|
|
108
|
+
)
|
|
109
|
+
.all(parentId) as ChildRow[];
|
|
110
|
+
|
|
111
|
+
export const groupChildren = (groupId: string): ChildRow[] =>
|
|
112
|
+
db
|
|
113
|
+
.prepare(
|
|
114
|
+
`SELECT * FROM session_children WHERE group_id = ? ORDER BY position`
|
|
115
|
+
)
|
|
116
|
+
.all(groupId) as ChildRow[];
|
|
117
|
+
|
|
118
|
+
// A child named the way an agent or a person would: its number in `aos
|
|
119
|
+
// children`, its label, or its session id (or a prefix of it).
|
|
120
|
+
export function findChild(parentId: string, ref: string): ChildRow {
|
|
121
|
+
const all = childrenOf(parentId);
|
|
122
|
+
const text = ref.trim();
|
|
123
|
+
if (/^\d+$/.test(text)) {
|
|
124
|
+
const c = all[Number(text) - 1];
|
|
125
|
+
if (c) return c;
|
|
126
|
+
throw new Error(`No child ${text}: you have ${all.length}`);
|
|
127
|
+
}
|
|
128
|
+
const byId = all.filter((c) => c.child_session_id.startsWith(text));
|
|
129
|
+
if (byId.length === 1) return byId[0];
|
|
130
|
+
const byLabel = all.filter(
|
|
131
|
+
(c) => c.label.toLowerCase() === text.toLowerCase()
|
|
132
|
+
);
|
|
133
|
+
if (byLabel.length === 1) return byLabel[0];
|
|
134
|
+
if (byLabel.length > 1)
|
|
135
|
+
throw new Error(
|
|
136
|
+
`${byLabel.length} children are named ${text}: use their numbers`
|
|
137
|
+
);
|
|
138
|
+
throw new Error(`No child "${text}" of this session`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function setState(
|
|
142
|
+
childId: string,
|
|
143
|
+
state: ChildRow["state"],
|
|
144
|
+
hold: string | null = null,
|
|
145
|
+
now = Date.now()
|
|
146
|
+
): void {
|
|
147
|
+
if (state === "settled") throw new Error("Settle through settleChild");
|
|
148
|
+
db.prepare(
|
|
149
|
+
`UPDATE session_children SET state = ?, hold = ?,
|
|
150
|
+
started_at = CASE WHEN ? = 'running' AND started_at IS NULL THEN ? ELSE started_at END
|
|
151
|
+
WHERE child_session_id = ? AND state != 'settled'`
|
|
152
|
+
).run(state, hold, state, now, childId);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export const markFellBack = (childId: string): void => {
|
|
156
|
+
db.prepare(
|
|
157
|
+
`UPDATE session_children SET fell_back = 1 WHERE child_session_id = ?`
|
|
158
|
+
).run(childId);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export const setCut = (childId: string, cut: string | null): void => {
|
|
162
|
+
db.prepare(
|
|
163
|
+
`UPDATE session_children SET cut_message_id = ? WHERE child_session_id = ?`
|
|
164
|
+
).run(cut, childId);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// First line, cut: what the parent's agent sees.
|
|
168
|
+
export function summaryOf(text: string): string {
|
|
169
|
+
const line =
|
|
170
|
+
text
|
|
171
|
+
.trim()
|
|
172
|
+
.split("\n")
|
|
173
|
+
.find((l) => l.trim()) ?? "";
|
|
174
|
+
const one = line.replace(/\s+/g, " ").trim();
|
|
175
|
+
const max = FORK_LIMITS.summaryChars;
|
|
176
|
+
return one.length <= max ? one : `${one.slice(0, max - 1)}…`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export const capResult = (text: string): string =>
|
|
180
|
+
text.length <= FORK_LIMITS.resultChars
|
|
181
|
+
? text
|
|
182
|
+
: `${text.slice(0, FORK_LIMITS.resultChars)}\n… (cut)`;
|
|
183
|
+
|
|
184
|
+
// True for the one caller that settled it.
|
|
185
|
+
export function settleChild(
|
|
186
|
+
childId: string,
|
|
187
|
+
s: {
|
|
188
|
+
outcome: ChildOutcome;
|
|
189
|
+
summary: string;
|
|
190
|
+
result?: string | null;
|
|
191
|
+
kind: ResultKind;
|
|
192
|
+
tokens?: { in: number; out: number; cacheRead: number } | null;
|
|
193
|
+
now?: number;
|
|
194
|
+
}
|
|
195
|
+
): boolean {
|
|
196
|
+
const now = s.now ?? Date.now();
|
|
197
|
+
const changed =
|
|
198
|
+
db
|
|
199
|
+
.prepare(
|
|
200
|
+
`UPDATE session_children SET state = 'settled', hold = NULL, idle_since = NULL,
|
|
201
|
+
outcome = ?, summary = ?, result = ?, result_kind = ?, settled_at = ?,
|
|
202
|
+
started_at = COALESCE(started_at, ?),
|
|
203
|
+
tokens_in = ?, tokens_out = ?, cache_read = ?
|
|
204
|
+
WHERE child_session_id = ? AND state != 'settled'`
|
|
205
|
+
)
|
|
206
|
+
.run(
|
|
207
|
+
s.outcome,
|
|
208
|
+
s.summary,
|
|
209
|
+
s.result ? capResult(s.result) : null,
|
|
210
|
+
s.kind,
|
|
211
|
+
now,
|
|
212
|
+
now,
|
|
213
|
+
s.tokens?.in ?? null,
|
|
214
|
+
s.tokens?.out ?? null,
|
|
215
|
+
s.tokens?.cacheRead ?? null,
|
|
216
|
+
childId
|
|
217
|
+
).changes === 1;
|
|
218
|
+
if (changed)
|
|
219
|
+
db.prepare(
|
|
220
|
+
`UPDATE fork_groups SET last_settled_at = ? WHERE id =
|
|
221
|
+
(SELECT group_id FROM session_children WHERE child_session_id = ?)`
|
|
222
|
+
).run(now, childId);
|
|
223
|
+
return changed;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// A return after settling: the new text stands, shown as updated, and is
|
|
227
|
+
// sent again only if the first hadn't been (a delivered one stays delivered).
|
|
228
|
+
export function updateResult(
|
|
229
|
+
childId: string,
|
|
230
|
+
summary: string,
|
|
231
|
+
result: string | null,
|
|
232
|
+
outcome: ChildOutcome
|
|
233
|
+
): void {
|
|
234
|
+
db.prepare(
|
|
235
|
+
`UPDATE session_children SET summary = ?, result = ?, outcome = ?, result_kind = 'explicit'
|
|
236
|
+
WHERE child_session_id = ? AND state = 'settled' AND outcome != 'cancelled'`
|
|
237
|
+
).run(summary, result ? capResult(result) : null, outcome, childId);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export const markIdle = (childId: string, since: number | null): void => {
|
|
241
|
+
db.prepare(
|
|
242
|
+
`UPDATE session_children SET idle_since = ? WHERE child_session_id = ?`
|
|
243
|
+
).run(since, childId);
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
export const bumpHeavy = (childId: string): void => {
|
|
247
|
+
db.prepare(
|
|
248
|
+
`UPDATE session_children SET heavy_runs = heavy_runs + 1 WHERE child_session_id = ?`
|
|
249
|
+
).run(childId);
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
export const markUsed = (
|
|
253
|
+
childId: string,
|
|
254
|
+
how: "opened" | "mentioned"
|
|
255
|
+
): void => {
|
|
256
|
+
db.prepare(
|
|
257
|
+
`UPDATE session_children SET used = ?
|
|
258
|
+
WHERE child_session_id = ? AND (used IS NULL OR (used = 'mentioned' AND ? = 'opened'))`
|
|
259
|
+
).run(how, childId, how);
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// Settled and not yet handed to the parent's agent, oldest first.
|
|
263
|
+
export const undelivered = (parentId: string): ChildRow[] =>
|
|
264
|
+
db
|
|
265
|
+
.prepare(
|
|
266
|
+
`SELECT * FROM session_children
|
|
267
|
+
WHERE parent_session_id = ? AND settled_at IS NOT NULL AND delivered_at IS NULL
|
|
268
|
+
ORDER BY settled_at, position`
|
|
269
|
+
)
|
|
270
|
+
.all(parentId) as ChildRow[];
|
|
271
|
+
|
|
272
|
+
export const parentsWithUndelivered = (): string[] =>
|
|
273
|
+
(
|
|
274
|
+
db
|
|
275
|
+
.prepare(
|
|
276
|
+
`SELECT DISTINCT parent_session_id AS id FROM session_children
|
|
277
|
+
WHERE settled_at IS NOT NULL AND delivered_at IS NULL`
|
|
278
|
+
)
|
|
279
|
+
.all() as { id: string }[]
|
|
280
|
+
).map((r) => r.id);
|
|
281
|
+
|
|
282
|
+
// Marks these rows as one delivery; only rows nobody claimed change. The
|
|
283
|
+
// rows that now carry the id are returned, so a crash between the claim and
|
|
284
|
+
// the send just sends the same id again.
|
|
285
|
+
export function claimDelivery(ids: string[], deliveryId: string): ChildRow[] {
|
|
286
|
+
const claim = db.prepare(
|
|
287
|
+
`UPDATE session_children SET delivery_id = ?
|
|
288
|
+
WHERE child_session_id = ? AND delivery_id IS NULL AND delivered_at IS NULL`
|
|
289
|
+
);
|
|
290
|
+
db.transaction(() => ids.forEach((id) => claim.run(deliveryId, id)))();
|
|
291
|
+
return db
|
|
292
|
+
.prepare(
|
|
293
|
+
`SELECT * FROM session_children WHERE delivery_id = ? ORDER BY settled_at, position`
|
|
294
|
+
)
|
|
295
|
+
.all(deliveryId) as ChildRow[];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export const markDelivered = (deliveryId: string, now = Date.now()): number =>
|
|
299
|
+
db
|
|
300
|
+
.prepare(
|
|
301
|
+
`UPDATE session_children SET delivered_at = ? WHERE delivery_id = ? AND delivered_at IS NULL`
|
|
302
|
+
)
|
|
303
|
+
.run(now, deliveryId).changes;
|
|
304
|
+
|
|
305
|
+
// A result already in a wait's wake message, so it isn't sent again.
|
|
306
|
+
export const markDeliveredIds = (ids: string[], now = Date.now()): void => {
|
|
307
|
+
const mark = db.prepare(
|
|
308
|
+
`UPDATE session_children SET delivered_at = ? WHERE child_session_id = ? AND delivered_at IS NULL`
|
|
309
|
+
);
|
|
310
|
+
db.transaction(() => ids.forEach((id) => mark.run(now, id)))();
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
export const unsettledWithDelivery = (parentId: string): string | null =>
|
|
314
|
+
(
|
|
315
|
+
db
|
|
316
|
+
.prepare(
|
|
317
|
+
`SELECT delivery_id AS d FROM session_children
|
|
318
|
+
WHERE parent_session_id = ? AND delivery_id IS NOT NULL AND delivered_at IS NULL LIMIT 1`
|
|
319
|
+
)
|
|
320
|
+
.get(parentId) as { d: string } | undefined
|
|
321
|
+
)?.d ?? null;
|
|
322
|
+
|
|
323
|
+
export const allChildren = (): ChildRow[] =>
|
|
324
|
+
db.prepare(`SELECT * FROM session_children`).all() as ChildRow[];
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { experimentOn } from "../experiments";
|
|
2
|
+
|
|
3
|
+
// The server's limits for forks. Clients read them from /api/client-config
|
|
4
|
+
// and never carry their own (lib/config/client-config.ts).
|
|
5
|
+
export const FORK_LIMITS = {
|
|
6
|
+
// Children one `aos fork` call may start.
|
|
7
|
+
maxPerFork: 8,
|
|
8
|
+
// Children of one parent not yet settled, all calls together.
|
|
9
|
+
maxOpenPerParent: 16,
|
|
10
|
+
// Children of one parent running at once; the rest wait their turn.
|
|
11
|
+
maxRunningPerParent: 4,
|
|
12
|
+
// Characters of a result's first line the parent's agent sees.
|
|
13
|
+
summaryChars: 300,
|
|
14
|
+
// Characters of a full result kept.
|
|
15
|
+
resultChars: 64_000,
|
|
16
|
+
// Characters of one instruction.
|
|
17
|
+
instructionChars: 4_000,
|
|
18
|
+
// Results that settle within this long of the first go in one delivery,
|
|
19
|
+
// and a delivery never waits longer than the cap after the first.
|
|
20
|
+
batchWindowMs: 20_000,
|
|
21
|
+
batchCapMs: 2 * 60_000,
|
|
22
|
+
// A child idle this long with no `aos return` settles with its final
|
|
23
|
+
// answer, so one about to return gets there first.
|
|
24
|
+
implicitGraceMs: 45_000,
|
|
25
|
+
// Fork children's builds and test runs at once on this machine
|
|
26
|
+
// (lib/fork/heavy-lease); 2 while the machine reads green.
|
|
27
|
+
heavySlots: 1,
|
|
28
|
+
heavySlotsCalm: 2,
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
export const DELIVERY_MODES = ["wake", "next"] as const;
|
|
32
|
+
export type DeliveryMode = (typeof DELIVERY_MODES)[number];
|
|
33
|
+
|
|
34
|
+
export const forksOn = (): boolean => experimentOn("forks");
|
|
35
|
+
|
|
36
|
+
export const FORKS_OFF = "Parallel forks are off (Settings → Experimental)";
|
|
37
|
+
|
|
38
|
+
// What /api/client-config serves; no client carries these.
|
|
39
|
+
export function forkConfig() {
|
|
40
|
+
return {
|
|
41
|
+
enabled: forksOn(),
|
|
42
|
+
maxPerFork: FORK_LIMITS.maxPerFork,
|
|
43
|
+
summaryChars: FORK_LIMITS.summaryChars,
|
|
44
|
+
deliveryModes: [...DELIVERY_MODES],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a conversation is cut for a fork. A parent that runs `aos fork` is
|
|
3
|
+
* inside a turn whose own tool call has no result yet, so the cut is the
|
|
4
|
+
* last entry after which every tool call so far has its result: nothing
|
|
5
|
+
* dangling, and every earlier step of the turn kept.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface TranscriptEntry {
|
|
9
|
+
type: string;
|
|
10
|
+
uuid: string;
|
|
11
|
+
message?: unknown;
|
|
12
|
+
parent_tool_use_id?: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Block {
|
|
16
|
+
type?: string;
|
|
17
|
+
id?: string;
|
|
18
|
+
tool_use_id?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const blocks = (entry: TranscriptEntry): Block[] => {
|
|
22
|
+
const content = (entry.message as { content?: unknown } | undefined)?.content;
|
|
23
|
+
return Array.isArray(content) ? (content as Block[]) : [];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// The uuid of the entry to cut at, or null when nothing is safe to keep.
|
|
27
|
+
export function cutPoint(entries: TranscriptEntry[]): string | null {
|
|
28
|
+
const open = new Set<string>();
|
|
29
|
+
let cut: string | null = null;
|
|
30
|
+
for (const entry of entries) {
|
|
31
|
+
if (entry.parent_tool_use_id) continue;
|
|
32
|
+
if (entry.type !== "user" && entry.type !== "assistant") continue;
|
|
33
|
+
for (const block of blocks(entry)) {
|
|
34
|
+
if (block.type === "tool_use" && block.id) open.add(block.id);
|
|
35
|
+
if (block.type === "tool_result" && block.tool_use_id)
|
|
36
|
+
open.delete(block.tool_use_id);
|
|
37
|
+
}
|
|
38
|
+
if (open.size === 0) cut = entry.uuid;
|
|
39
|
+
}
|
|
40
|
+
return cut;
|
|
41
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fork child's own folders under the data root: a scratch folder for temp
|
|
3
|
+
* work (scripts, repros, notes), and, when it was asked for, a throwaway
|
|
4
|
+
* checkout. Both are removed when the child settles or is dropped, and a
|
|
5
|
+
* sweep removes what a crash left.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import path from "path";
|
|
10
|
+
import { agentosHome } from "../agentos-home";
|
|
11
|
+
import { git } from "../git-exec";
|
|
12
|
+
|
|
13
|
+
export const forksRoot = () => agentosHome("forks");
|
|
14
|
+
export const forkHome = (childId: string) => agentosHome("forks", childId);
|
|
15
|
+
export const scratchOf = (childId: string) =>
|
|
16
|
+
path.join(forkHome(childId), "scratch");
|
|
17
|
+
export const worktreeOf = (childId: string) =>
|
|
18
|
+
path.join(forkHome(childId), "wt");
|
|
19
|
+
|
|
20
|
+
// Made on demand, so a settled child that is talked to again has one.
|
|
21
|
+
export async function ensureScratch(childId: string): Promise<string> {
|
|
22
|
+
const dir = scratchOf(childId);
|
|
23
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
24
|
+
return dir;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The folder's real repository, for taking a worktree out of its list.
|
|
28
|
+
async function repoOf(wt: string): Promise<string | null> {
|
|
29
|
+
try {
|
|
30
|
+
const common = (
|
|
31
|
+
await git(wt, ["rev-parse", "--path-format=absolute", "--git-common-dir"])
|
|
32
|
+
).trim();
|
|
33
|
+
return path.dirname(common);
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Everything of the child's, the worktree first so git forgets it.
|
|
40
|
+
export async function removeFork(childId: string): Promise<void> {
|
|
41
|
+
const home = forkHome(childId);
|
|
42
|
+
const wt = worktreeOf(childId);
|
|
43
|
+
if (fs.existsSync(wt)) {
|
|
44
|
+
const repo = await repoOf(wt);
|
|
45
|
+
if (repo)
|
|
46
|
+
await git(repo, ["worktree", "remove", "--force", "--", wt]).catch(
|
|
47
|
+
() => undefined
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
await fs.promises.rm(home, { recursive: true, force: true, maxRetries: 3 });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Folders of children that are settled or gone: what a crash between a
|
|
54
|
+
// settle and its cleanup left. `keep` says whose folders are still in use.
|
|
55
|
+
export async function sweepForks(
|
|
56
|
+
keep: (childId: string) => boolean
|
|
57
|
+
): Promise<number> {
|
|
58
|
+
const root = forksRoot();
|
|
59
|
+
const names = await fs.promises.readdir(root).catch(() => [] as string[]);
|
|
60
|
+
let removed = 0;
|
|
61
|
+
for (const name of names) {
|
|
62
|
+
if (keep(name)) continue;
|
|
63
|
+
await removeFork(name);
|
|
64
|
+
removed++;
|
|
65
|
+
}
|
|
66
|
+
return removed;
|
|
67
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a child ends: it returns (`aos return`), its turn ends without one and
|
|
3
|
+
* its last reply stands in after a grace, it is cancelled, or it can't be
|
|
4
|
+
* run. A child settles once; the settle that changed the row does the rest.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import path from "path";
|
|
9
|
+
import { db, queries, type Session } from "../db";
|
|
10
|
+
import { git } from "../git-exec";
|
|
11
|
+
import { statusOf } from "../orchestrator/facts";
|
|
12
|
+
import { openWaits } from "../bus/waits/store";
|
|
13
|
+
import { lastItems } from "../chat/store";
|
|
14
|
+
import { stopChat } from "../chat/runner";
|
|
15
|
+
import type { UsageTotals } from "../chat/events";
|
|
16
|
+
import {
|
|
17
|
+
getChild,
|
|
18
|
+
markIdle,
|
|
19
|
+
setState,
|
|
20
|
+
settleChild,
|
|
21
|
+
summaryOf,
|
|
22
|
+
updateResult,
|
|
23
|
+
childrenOf,
|
|
24
|
+
findChild,
|
|
25
|
+
markDeliveredIds,
|
|
26
|
+
} from "./ledger";
|
|
27
|
+
import { FORK_LIMITS } from "./options";
|
|
28
|
+
import { forksRoot, removeFork, worktreeOf } from "./scratch";
|
|
29
|
+
import type { ChildOutcome, ChildRow, ResultKind } from "./types";
|
|
30
|
+
|
|
31
|
+
const sessionOf = (id: string) =>
|
|
32
|
+
queries.getSession(db).get(id) as Session | undefined;
|
|
33
|
+
|
|
34
|
+
function tokensOf(childId: string) {
|
|
35
|
+
const raw = (
|
|
36
|
+
db.prepare(`SELECT chat_usage FROM sessions WHERE id = ?`).get(childId) as
|
|
37
|
+
{ chat_usage: string | null } | undefined
|
|
38
|
+
)?.chat_usage;
|
|
39
|
+
if (!raw) return null;
|
|
40
|
+
try {
|
|
41
|
+
const u = JSON.parse(raw) as UsageTotals;
|
|
42
|
+
return {
|
|
43
|
+
in: u.inputTokens,
|
|
44
|
+
out: u.outputTokens,
|
|
45
|
+
cacheRead: u.cacheReadTokens,
|
|
46
|
+
};
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const patchPath = (childId: string) =>
|
|
53
|
+
path.join(forksRoot(), `${childId}.patch`);
|
|
54
|
+
|
|
55
|
+
// What a child changed in its own checkout, kept as a patch the parent can
|
|
56
|
+
// apply: its checkout goes with it.
|
|
57
|
+
async function keepPatch(childId: string): Promise<string | null> {
|
|
58
|
+
const wt = worktreeOf(childId);
|
|
59
|
+
if (!fs.existsSync(wt)) return null;
|
|
60
|
+
try {
|
|
61
|
+
await git(wt, ["add", "-A"]);
|
|
62
|
+
const diff = await git(wt, ["diff", "--cached", "HEAD", "--binary"], {
|
|
63
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
64
|
+
});
|
|
65
|
+
if (!diff.trim()) return null;
|
|
66
|
+
const file = patchPath(childId);
|
|
67
|
+
await fs.promises.mkdir(path.dirname(file), { recursive: true });
|
|
68
|
+
await fs.promises.writeFile(file, diff);
|
|
69
|
+
return file;
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface Settlement {
|
|
76
|
+
outcome: ChildOutcome;
|
|
77
|
+
summary: string;
|
|
78
|
+
result?: string | null;
|
|
79
|
+
kind: ResultKind;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Settles, tidies up and starts what was waiting for the room. True when
|
|
83
|
+
// this call was the one that settled it.
|
|
84
|
+
export async function settleAndDeliver(
|
|
85
|
+
childId: string,
|
|
86
|
+
s: Settlement
|
|
87
|
+
): Promise<boolean> {
|
|
88
|
+
const row = getChild(childId);
|
|
89
|
+
if (!row || row.state === "settled") return false;
|
|
90
|
+
let result = s.result ?? null;
|
|
91
|
+
if (row.worktree && s.outcome !== "cancelled") {
|
|
92
|
+
const patch = await keepPatch(childId);
|
|
93
|
+
if (patch)
|
|
94
|
+
result =
|
|
95
|
+
`${result ?? ""}\n\nIts changes are in ${patch} (apply with \`git apply ${patch}\`).`.trim();
|
|
96
|
+
}
|
|
97
|
+
const changed = settleChild(childId, {
|
|
98
|
+
...s,
|
|
99
|
+
result,
|
|
100
|
+
tokens: tokensOf(childId),
|
|
101
|
+
});
|
|
102
|
+
if (!changed) return false;
|
|
103
|
+
// Cancelled by its parent or the user: they know, so nothing is sent.
|
|
104
|
+
if (s.outcome === "cancelled") markDeliveredIds([childId]);
|
|
105
|
+
await removeFork(childId).catch(() => undefined);
|
|
106
|
+
const { admitQueued } = await import("./start");
|
|
107
|
+
await admitQueued(row.parent_session_id).catch(() => undefined);
|
|
108
|
+
const { kickForks } = await import("./tick");
|
|
109
|
+
kickForks();
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// `aos return`. After settling it replaces the result, shown as updated.
|
|
114
|
+
export async function returnChild(
|
|
115
|
+
childId: string,
|
|
116
|
+
text: string,
|
|
117
|
+
failed: boolean
|
|
118
|
+
): Promise<"settled" | "updated"> {
|
|
119
|
+
const row = getChild(childId);
|
|
120
|
+
if (!row) throw new Error("Only a fork child can return a result");
|
|
121
|
+
const body = text.trim();
|
|
122
|
+
if (!body) throw new Error('Give the result: aos return "<result>"');
|
|
123
|
+
const outcome: ChildOutcome = failed ? "failed" : "ok";
|
|
124
|
+
if (
|
|
125
|
+
await settleAndDeliver(childId, {
|
|
126
|
+
outcome,
|
|
127
|
+
summary: summaryOf(body),
|
|
128
|
+
result: body,
|
|
129
|
+
kind: "explicit",
|
|
130
|
+
})
|
|
131
|
+
)
|
|
132
|
+
return "settled";
|
|
133
|
+
updateResult(childId, summaryOf(body), body, outcome);
|
|
134
|
+
return "updated";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function cancelChild(childId: string): Promise<boolean> {
|
|
138
|
+
const row = getChild(childId);
|
|
139
|
+
if (!row || row.state === "settled") return false;
|
|
140
|
+
if (row.state !== "queued") stopChat(childId);
|
|
141
|
+
return settleAndDeliver(childId, {
|
|
142
|
+
outcome: "cancelled",
|
|
143
|
+
summary: "cancelled",
|
|
144
|
+
kind: "implicit",
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function cancelChildren(
|
|
149
|
+
parentId: string,
|
|
150
|
+
ref?: string
|
|
151
|
+
): Promise<ChildRow[]> {
|
|
152
|
+
const targets = ref
|
|
153
|
+
? [findChild(parentId, ref)]
|
|
154
|
+
: childrenOf(parentId).filter((c) => c.state !== "settled");
|
|
155
|
+
const done: ChildRow[] = [];
|
|
156
|
+
for (const c of targets)
|
|
157
|
+
if (await cancelChild(c.child_session_id)) done.push(c);
|
|
158
|
+
return done;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const lastReply = (childId: string): string => {
|
|
162
|
+
const said = lastItems(childId, 40)
|
|
163
|
+
.reverse()
|
|
164
|
+
.find((i) => i.kind === "assistant" && i.text.trim());
|
|
165
|
+
return said?.kind === "assistant" ? said.text.trim() : "";
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// One look at a child that hasn't settled: needing an answer, still working,
|
|
169
|
+
// gone, or idle long enough that its last reply is its result.
|
|
170
|
+
export async function lookAtChild(row: ChildRow, now: number): Promise<void> {
|
|
171
|
+
if (row.state === "queued" || row.state === "settled") return;
|
|
172
|
+
const session = sessionOf(row.child_session_id);
|
|
173
|
+
if (!session || session.archived_at) {
|
|
174
|
+
await settleAndDeliver(row.child_session_id, {
|
|
175
|
+
outcome: "failed",
|
|
176
|
+
summary: "its session was closed before it returned",
|
|
177
|
+
kind: "implicit",
|
|
178
|
+
});
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const { status, activity } = await statusOf(session);
|
|
182
|
+
if (status === "waiting") {
|
|
183
|
+
setState(
|
|
184
|
+
row.child_session_id,
|
|
185
|
+
"needs_input",
|
|
186
|
+
activity ?? "waiting for an answer"
|
|
187
|
+
);
|
|
188
|
+
markIdle(row.child_session_id, null);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (status === "running" || status === "dead") {
|
|
192
|
+
if (row.state !== "running" || row.hold)
|
|
193
|
+
setState(row.child_session_id, "running");
|
|
194
|
+
markIdle(row.child_session_id, null);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (row.state === "starting") return;
|
|
198
|
+
if (row.state !== "running" || row.hold)
|
|
199
|
+
setState(row.child_session_id, "running");
|
|
200
|
+
if (row.idle_since == null) {
|
|
201
|
+
markIdle(row.child_session_id, now);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (now - row.idle_since < FORK_LIMITS.implicitGraceMs) return;
|
|
205
|
+
// It asked its parent something and is waiting for the answer.
|
|
206
|
+
if (openWaits().some((w) => w.waiter_id === row.child_session_id)) return;
|
|
207
|
+
const reply = lastReply(row.child_session_id);
|
|
208
|
+
if (!reply) return;
|
|
209
|
+
await settleAndDeliver(row.child_session_id, {
|
|
210
|
+
outcome: "ok",
|
|
211
|
+
summary: summaryOf(reply),
|
|
212
|
+
result: reply,
|
|
213
|
+
kind: "implicit",
|
|
214
|
+
});
|
|
215
|
+
}
|