@saadnvd1/agent-os 0.3.821 → 0.3.825
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 +52 -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/content/whats-new.json +16 -0
- 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/drivers/copilot-ask.ts +38 -5
- 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,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slots for the builds and test runs of fork children. `aos heavy` from a
|
|
3
|
+
* fork child asks for one and waits its turn, so N children never run N
|
|
4
|
+
* suites at once, and none start while the machine's load reads red.
|
|
5
|
+
* First come, first served; a lease the child's CLI stopped renewing (or
|
|
6
|
+
* whose process is gone) is taken back.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { currentLevel, loadEnabled } from "../load/monitor";
|
|
10
|
+
import { bumpHeavy } from "./ledger";
|
|
11
|
+
import { FORK_LIMITS } from "./options";
|
|
12
|
+
|
|
13
|
+
interface Lease {
|
|
14
|
+
sessionId: string;
|
|
15
|
+
pid: number;
|
|
16
|
+
askedAt: number;
|
|
17
|
+
seen: number;
|
|
18
|
+
grantedAt: number | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A holder that hasn't asked again within this long is gone.
|
|
22
|
+
export const LEASE_STALE_MS = 30_000;
|
|
23
|
+
|
|
24
|
+
const g = globalThis as unknown as { __agentosForkLeases?: Lease[] };
|
|
25
|
+
const leases = (g.__agentosForkLeases ??= []);
|
|
26
|
+
|
|
27
|
+
const alive = (pid: number) => {
|
|
28
|
+
try {
|
|
29
|
+
process.kill(pid, 0);
|
|
30
|
+
return true;
|
|
31
|
+
} catch (error) {
|
|
32
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function slotsNow(): number {
|
|
37
|
+
if (!loadEnabled()) return FORK_LIMITS.heavySlotsCalm;
|
|
38
|
+
return currentLevel() === "green" || currentLevel() === null
|
|
39
|
+
? FORK_LIMITS.heavySlotsCalm
|
|
40
|
+
: FORK_LIMITS.heavySlots;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function reap(now: number) {
|
|
44
|
+
for (let i = leases.length - 1; i >= 0; i--) {
|
|
45
|
+
const l = leases[i];
|
|
46
|
+
if (now - l.seen > LEASE_STALE_MS || !alive(l.pid)) leases.splice(i, 1);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function grant(now: number) {
|
|
51
|
+
const held = leases.filter((l) => l.grantedAt !== null).length;
|
|
52
|
+
const room = Math.max(0, slotsNow() - held);
|
|
53
|
+
if (!room) return;
|
|
54
|
+
// No new run while the machine is loaded; what already runs finishes.
|
|
55
|
+
if (loadEnabled() && currentLevel() === "red") return;
|
|
56
|
+
let n = room;
|
|
57
|
+
for (const l of leases) {
|
|
58
|
+
if (n === 0) break;
|
|
59
|
+
if (l.grantedAt === null) {
|
|
60
|
+
l.grantedAt = now;
|
|
61
|
+
n--;
|
|
62
|
+
bumpHeavy(l.sessionId);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface LeaseState {
|
|
68
|
+
granted: boolean;
|
|
69
|
+
// Runs queued before this one, or running in its way.
|
|
70
|
+
ahead: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Asks for a slot, or renews the one held; the same caller asks again until
|
|
74
|
+
// it is granted.
|
|
75
|
+
export function requestLease(
|
|
76
|
+
sessionId: string,
|
|
77
|
+
pid: number,
|
|
78
|
+
now = Date.now()
|
|
79
|
+
): LeaseState {
|
|
80
|
+
reap(now);
|
|
81
|
+
let mine = leases.find((l) => l.sessionId === sessionId && l.pid === pid);
|
|
82
|
+
if (mine) mine.seen = now;
|
|
83
|
+
else
|
|
84
|
+
leases.push(
|
|
85
|
+
(mine = { sessionId, pid, askedAt: now, seen: now, grantedAt: null })
|
|
86
|
+
);
|
|
87
|
+
grant(now);
|
|
88
|
+
if (mine.grantedAt !== null) return { granted: true, ahead: 0 };
|
|
89
|
+
return {
|
|
90
|
+
granted: false,
|
|
91
|
+
ahead: leases.filter(
|
|
92
|
+
(l) => l !== mine && (l.grantedAt !== null || l.askedAt <= mine!.askedAt)
|
|
93
|
+
).length,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function releaseLease(sessionId: string, pid: number): void {
|
|
98
|
+
const i = leases.findIndex((l) => l.sessionId === sessionId && l.pid === pid);
|
|
99
|
+
if (i >= 0) leases.splice(i, 1);
|
|
100
|
+
grant(Date.now());
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const leaseCount = () => leases.length;
|
|
104
|
+
export const resetLeases = () => void leases.splice(0, leases.length);
|
package/lib/fork/held.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { deliveryText } from "./brief";
|
|
2
|
+
import { getGroup, markDeliveredIds, numberOf, undelivered } from "./ledger";
|
|
3
|
+
import type { ChildRow } from "./types";
|
|
4
|
+
|
|
5
|
+
// A delivery as the parent's agent reads it, and as chat shows it.
|
|
6
|
+
export const viewOf = (parentId: string, rows: ChildRow[]) => ({
|
|
7
|
+
text: deliveryText(rows, (c) => numberOf(parentId, c.child_session_id)),
|
|
8
|
+
body: rows
|
|
9
|
+
.map(
|
|
10
|
+
(c) =>
|
|
11
|
+
`Fork ${numberOf(parentId, c.child_session_id)} (${c.label}): ${c.summary ?? c.outcome}`
|
|
12
|
+
)
|
|
13
|
+
.join("\n"),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// Results that are the user's to see first: held for their next message.
|
|
17
|
+
export const wakes = (c: ChildRow) => getGroup(c.group_id)?.delivery !== "next";
|
|
18
|
+
|
|
19
|
+
// The user's own forks report in their next message to the parent: the
|
|
20
|
+
// results come first in it, then what they wrote.
|
|
21
|
+
export function heldForNext(parentId: string): string {
|
|
22
|
+
const rows = undelivered(parentId).filter((c) => !wakes(c));
|
|
23
|
+
if (!rows.length) return "";
|
|
24
|
+
markDeliveredIds(rows.map((c) => c.child_session_id));
|
|
25
|
+
return `${viewOf(parentId, rows).text}\n\n`;
|
|
26
|
+
}
|
|
@@ -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
|
+
}
|