@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
|
@@ -14,7 +14,6 @@ import type { TerminalHandle } from "@/components/Terminal";
|
|
|
14
14
|
import type { Session, Project } from "@/lib/db";
|
|
15
15
|
import { sessionRegistry } from "@/lib/client/session-registry";
|
|
16
16
|
import { cn } from "@/lib/utils";
|
|
17
|
-
import { ConductorPanel } from "@/components/ConductorPanel";
|
|
18
17
|
import { useFileEditor } from "@/hooks/useFileEditor";
|
|
19
18
|
import { MobileTabBar } from "./MobileTabBar";
|
|
20
19
|
import { PairSwitch } from "@/components/Analyst/PairSwitch";
|
|
@@ -208,14 +207,6 @@ export const Pane = memo(function Pane({
|
|
|
208
207
|
// File editor state - lifted here so it persists across view switches
|
|
209
208
|
const fileEditor = useFileEditor();
|
|
210
209
|
|
|
211
|
-
// Check if this session is a conductor (has workers)
|
|
212
|
-
const workerCount = useMemo(() => {
|
|
213
|
-
if (!session) return 0;
|
|
214
|
-
return sessions.filter((s) => s.conductor_session_id === session.id).length;
|
|
215
|
-
}, [session, sessions]);
|
|
216
|
-
|
|
217
|
-
const isConductor = workerCount > 0;
|
|
218
|
-
|
|
219
210
|
// A merged task keeps the log AgentOS wrote of it.
|
|
220
211
|
const merged =
|
|
221
212
|
!!session &&
|
|
@@ -477,10 +468,8 @@ export const Pane = memo(function Pane({
|
|
|
477
468
|
sessions={sessions}
|
|
478
469
|
projects={projects}
|
|
479
470
|
viewMode={viewMode}
|
|
480
|
-
isConductor={isConductor}
|
|
481
471
|
hasLog={hasLog}
|
|
482
472
|
hasReport={hasReport}
|
|
483
|
-
workerCount={workerCount}
|
|
484
473
|
showGit={showGit}
|
|
485
474
|
onMenuClick={onMenuClick}
|
|
486
475
|
onViewModeChange={setViewMode}
|
|
@@ -501,10 +490,8 @@ export const Pane = memo(function Pane({
|
|
|
501
490
|
sessions={sessions}
|
|
502
491
|
viewMode={viewMode}
|
|
503
492
|
isFocused={isFocused}
|
|
504
|
-
isConductor={isConductor}
|
|
505
493
|
hasLog={hasLog}
|
|
506
494
|
hasReport={hasReport}
|
|
507
|
-
workerCount={workerCount}
|
|
508
495
|
canSplit={canSplit}
|
|
509
496
|
canClose={canClose}
|
|
510
497
|
hasAttachedTmux={!!activeTab?.attachedTmux}
|
|
@@ -587,24 +574,6 @@ export const Pane = memo(function Pane({
|
|
|
587
574
|
onOpenInFiles={isLocalSession ? openInFiles : undefined}
|
|
588
575
|
/>
|
|
589
576
|
)}
|
|
590
|
-
|
|
591
|
-
{/* Workers */}
|
|
592
|
-
{viewMode === "workers" && session && (
|
|
593
|
-
<ConductorPanel
|
|
594
|
-
conductorSessionId={session.id}
|
|
595
|
-
onAttachToWorker={(workerId) => {
|
|
596
|
-
setViewMode("terminal");
|
|
597
|
-
const worker = sessions.find((s) => s.id === workerId);
|
|
598
|
-
if (worker && terminalRef) {
|
|
599
|
-
terminalRef.attach({
|
|
600
|
-
sessionName: worker.tmux_name || `claude-${workerId}`,
|
|
601
|
-
hostId: worker.host_id,
|
|
602
|
-
attachOnly: true,
|
|
603
|
-
});
|
|
604
|
-
}
|
|
605
|
-
}}
|
|
606
|
-
/>
|
|
607
|
-
)}
|
|
608
577
|
</div>
|
|
609
578
|
) : (
|
|
610
579
|
<ResizablePanelGroup
|
|
@@ -663,25 +632,6 @@ export const Pane = memo(function Pane({
|
|
|
663
632
|
onOpenInFiles={isLocalSession ? openInFiles : undefined}
|
|
664
633
|
/>
|
|
665
634
|
)}
|
|
666
|
-
|
|
667
|
-
{/* Workers */}
|
|
668
|
-
{viewMode === "workers" && session && (
|
|
669
|
-
<ConductorPanel
|
|
670
|
-
conductorSessionId={session.id}
|
|
671
|
-
onAttachToWorker={(workerId) => {
|
|
672
|
-
setViewMode("terminal");
|
|
673
|
-
const worker = sessions.find((s) => s.id === workerId);
|
|
674
|
-
if (worker && terminalRef) {
|
|
675
|
-
terminalRef.attach({
|
|
676
|
-
sessionName:
|
|
677
|
-
worker.tmux_name || `claude-${workerId}`,
|
|
678
|
-
hostId: worker.host_id,
|
|
679
|
-
attachOnly: true,
|
|
680
|
-
});
|
|
681
|
-
}
|
|
682
|
-
}}
|
|
683
|
-
/>
|
|
684
|
-
)}
|
|
685
635
|
</div>
|
|
686
636
|
</ResizablePanel>
|
|
687
637
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { toast } from "sonner";
|
|
2
2
|
import type { Session } from "@/lib/db";
|
|
3
3
|
import {
|
|
4
|
+
ForkChildrenOpenError,
|
|
4
5
|
useArchiveSession,
|
|
5
6
|
useDoneSession,
|
|
6
7
|
useResurrectSession,
|
|
@@ -57,8 +58,8 @@ export function useDoneAction() {
|
|
|
57
58
|
export function useArchiveAction() {
|
|
58
59
|
const archive = useArchiveSession();
|
|
59
60
|
const unarchive = useUnarchiveSession();
|
|
60
|
-
|
|
61
|
-
archive.mutate(session.id, {
|
|
61
|
+
const run = (session: Session, children?: "stop" | "keep") =>
|
|
62
|
+
archive.mutate(children ? { id: session.id, children } : session.id, {
|
|
62
63
|
onSuccess: () =>
|
|
63
64
|
toast.success(`Archived ${session.name}`, {
|
|
64
65
|
action: {
|
|
@@ -66,8 +67,22 @@ export function useArchiveAction() {
|
|
|
66
67
|
onClick: () => unarchive.mutate(session.id),
|
|
67
68
|
},
|
|
68
69
|
}),
|
|
69
|
-
onError: (e) =>
|
|
70
|
+
onError: (e) =>
|
|
71
|
+
e instanceof ForkChildrenOpenError
|
|
72
|
+
? toast(e.message, {
|
|
73
|
+
duration: 15000,
|
|
74
|
+
action: {
|
|
75
|
+
label: "Stop them",
|
|
76
|
+
onClick: () => run(session, "stop"),
|
|
77
|
+
},
|
|
78
|
+
cancel: {
|
|
79
|
+
label: "Keep them going",
|
|
80
|
+
onClick: () => run(session, "keep"),
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
: toast.error(e.message, { duration: 12000 }),
|
|
70
84
|
});
|
|
85
|
+
return (session: Session) => run(session);
|
|
71
86
|
}
|
|
72
87
|
|
|
73
88
|
// Restore from a session's menu: the same conversation, its worktree back.
|
package/contexts/PaneContext.tsx
CHANGED
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
import { useViewport } from "@/hooks/useViewport";
|
|
30
30
|
|
|
31
31
|
export type ViewMode =
|
|
32
|
-
"terminal" | "files" | "git" | "
|
|
32
|
+
"terminal" | "files" | "git" | "log" | "artifacts" | "report";
|
|
33
33
|
|
|
34
34
|
export interface PaneViewState {
|
|
35
35
|
viewMode: ViewMode;
|
package/data/done/index.ts
CHANGED
|
@@ -73,9 +73,33 @@ export function useDoneSession() {
|
|
|
73
73
|
);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
// Archiving a session whose forks are still running asks what to do with them.
|
|
77
|
+
export class ForkChildrenOpenError extends Error {
|
|
78
|
+
constructor(
|
|
79
|
+
message: string,
|
|
80
|
+
readonly open: number
|
|
81
|
+
) {
|
|
82
|
+
super(message);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
76
86
|
export function useArchiveSession() {
|
|
77
|
-
return useDoneMutation(
|
|
78
|
-
|
|
87
|
+
return useDoneMutation(
|
|
88
|
+
async (input: string | { id: string; children: "stop" | "keep" }) => {
|
|
89
|
+
const { id, children } =
|
|
90
|
+
typeof input === "string" ? { id: input, children: undefined } : input;
|
|
91
|
+
const res = await post(
|
|
92
|
+
`/api/sessions/${id}/archive`,
|
|
93
|
+
children ? { children } : undefined
|
|
94
|
+
);
|
|
95
|
+
const data = await res
|
|
96
|
+
.clone()
|
|
97
|
+
.json()
|
|
98
|
+
.catch(() => null);
|
|
99
|
+
if (res.status === 409 && typeof data?.children === "number")
|
|
100
|
+
throw new ForkChildrenOpenError(data.error, data.children);
|
|
101
|
+
return json(res);
|
|
102
|
+
}
|
|
79
103
|
);
|
|
80
104
|
}
|
|
81
105
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import type { ChildView, GroupView } from "@/lib/fork/types";
|
|
3
|
+
|
|
4
|
+
export type { ChildView, GroupView };
|
|
5
|
+
|
|
6
|
+
export interface ForkChildrenView {
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
// Set when this session is itself a fork child.
|
|
9
|
+
asChild: {
|
|
10
|
+
parentId: string;
|
|
11
|
+
parentName: string;
|
|
12
|
+
n: number;
|
|
13
|
+
label: string;
|
|
14
|
+
state: ChildView["state"];
|
|
15
|
+
worktree: boolean;
|
|
16
|
+
} | null;
|
|
17
|
+
children: ChildView[];
|
|
18
|
+
groups: GroupView[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const forkKeys = {
|
|
22
|
+
all: ["forks"] as const,
|
|
23
|
+
children: (sessionId: string) => ["forks", "children", sessionId] as const,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
async function json<T>(res: Response): Promise<T> {
|
|
27
|
+
const data = await res.json();
|
|
28
|
+
if (!res.ok) throw new Error(data.error || "Request failed");
|
|
29
|
+
return data;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A session's forks. Only asked while the experiment is on; refetched
|
|
33
|
+
// every few seconds while any is still going (results land by themselves).
|
|
34
|
+
export function useForkChildren(sessionId: string, enabled: boolean) {
|
|
35
|
+
return useQuery({
|
|
36
|
+
queryKey: forkKeys.children(sessionId),
|
|
37
|
+
queryFn: async () =>
|
|
38
|
+
json<ForkChildrenView>(
|
|
39
|
+
await fetch(
|
|
40
|
+
`/api/fork/children?session=${encodeURIComponent(sessionId)}`
|
|
41
|
+
)
|
|
42
|
+
),
|
|
43
|
+
enabled,
|
|
44
|
+
staleTime: 2_000,
|
|
45
|
+
refetchInterval: (q) =>
|
|
46
|
+
q.state.data?.children.some((c) => c.state !== "settled") ||
|
|
47
|
+
q.state.data?.asChild
|
|
48
|
+
? 4_000
|
|
49
|
+
: false,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function useStartForks(sessionId: string) {
|
|
54
|
+
const queryClient = useQueryClient();
|
|
55
|
+
return useMutation({
|
|
56
|
+
mutationFn: async (input: { instructions: string[]; worktree: boolean }) =>
|
|
57
|
+
json<{ children: { id: string; n: number; label: string }[] }>(
|
|
58
|
+
await fetch("/api/fork", {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: { "Content-Type": "application/json" },
|
|
61
|
+
body: JSON.stringify({ session: sessionId, ...input }),
|
|
62
|
+
})
|
|
63
|
+
),
|
|
64
|
+
onSuccess: () =>
|
|
65
|
+
queryClient.invalidateQueries({ queryKey: forkKeys.children(sessionId) }),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function useCancelForks(sessionId: string) {
|
|
70
|
+
const queryClient = useQueryClient();
|
|
71
|
+
return useMutation({
|
|
72
|
+
mutationFn: async (input: { ref?: string; all?: boolean }) =>
|
|
73
|
+
json<{ cancelled: string[] }>(
|
|
74
|
+
await fetch("/api/fork/cancel", {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: { "Content-Type": "application/json" },
|
|
77
|
+
body: JSON.stringify({ session: sessionId, ...input }),
|
|
78
|
+
})
|
|
79
|
+
),
|
|
80
|
+
onSuccess: () =>
|
|
81
|
+
queryClient.invalidateQueries({ queryKey: forkKeys.children(sessionId) }),
|
|
82
|
+
});
|
|
83
|
+
}
|
package/lib/chat/commands.ts
CHANGED
|
@@ -83,6 +83,21 @@ export function withFresh(commands: ChatCommand[]): ChatCommand[] {
|
|
|
83
83
|
: [FRESH_COMMAND, ...commands];
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
// AgentOS's own command when forks are on: opens the fork sheet.
|
|
87
|
+
export const FORK_COMMAND: ChatCommand = {
|
|
88
|
+
name: "fork",
|
|
89
|
+
description: "Start background copies of this session",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const isForkCommand = (text: string): boolean =>
|
|
93
|
+
text.trim().toLowerCase() === "/fork";
|
|
94
|
+
|
|
95
|
+
export function withFork(commands: ChatCommand[]): ChatCommand[] {
|
|
96
|
+
return commands.some((c) => c.name === "fork")
|
|
97
|
+
? commands
|
|
98
|
+
: [FORK_COMMAND, ...commands];
|
|
99
|
+
}
|
|
100
|
+
|
|
86
101
|
export interface MenuGroup {
|
|
87
102
|
label: string;
|
|
88
103
|
commands: ChatCommand[];
|
package/lib/chat/runner.ts
CHANGED
|
@@ -87,6 +87,7 @@ import { chatHold, chatRefusal } from "./hold";
|
|
|
87
87
|
import { getHost } from "../hosts";
|
|
88
88
|
import { hostLink } from "../hosts/remote-api";
|
|
89
89
|
import { ownerUi } from "../owner";
|
|
90
|
+
import { heldForNext } from "../fork/held";
|
|
90
91
|
|
|
91
92
|
export {
|
|
92
93
|
refreshCapabilities,
|
|
@@ -432,10 +433,15 @@ export async function sendChat(
|
|
|
432
433
|
id?: string;
|
|
433
434
|
} & SentBy
|
|
434
435
|
): Promise<void> {
|
|
435
|
-
const
|
|
436
|
-
if (!
|
|
436
|
+
const typed = input.text.trim();
|
|
437
|
+
if (!typed && !input.images?.length && !input.files?.length) return;
|
|
437
438
|
if (input.id && taken(sessionId, input.id)) return;
|
|
438
439
|
refuseAway(sessionId);
|
|
440
|
+
// Results of forks the reader started come first in their next message.
|
|
441
|
+
const text =
|
|
442
|
+
!input.from && !input.origin && !input.peer
|
|
443
|
+
? heldForNext(sessionId) + typed
|
|
444
|
+
: typed;
|
|
439
445
|
const id = input.id ?? `user-${Date.now()}-${randomUUID().slice(0, 5)}`;
|
|
440
446
|
// Its worktree is still being set up, its task hasn't launched (held by
|
|
441
447
|
// Pause, say), or it's moving or landing: the message waits its turn.
|
|
@@ -2,6 +2,7 @@ import type { Session } from "../../db";
|
|
|
2
2
|
import type { ChatStartOptions } from "../driver";
|
|
3
3
|
import { refreshSettings } from "../refresh";
|
|
4
4
|
import { sessionProviderEnv } from "../../models/launch";
|
|
5
|
+
import { childExtras } from "../../fork/extras";
|
|
5
6
|
|
|
6
7
|
export type RoleExtras = Pick<
|
|
7
8
|
ChatStartOptions,
|
|
@@ -30,7 +31,12 @@ export async function roleExtras(session: Session): Promise<RoleExtras> {
|
|
|
30
31
|
ownExtras(session),
|
|
31
32
|
sessionProviderEnv(session),
|
|
32
33
|
]);
|
|
33
|
-
|
|
34
|
+
const fork = childExtras(session);
|
|
35
|
+
return {
|
|
36
|
+
...extras,
|
|
37
|
+
...(fork?.toolGuard && { toolGuard: fork.toolGuard }),
|
|
38
|
+
...((env || fork) && { env: { ...env, ...fork?.env } }),
|
|
39
|
+
};
|
|
34
40
|
}
|
|
35
41
|
|
|
36
42
|
async function ownExtras(session: Session): Promise<RoleExtras> {
|
package/lib/chat/worker/host.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import os from "os";
|
|
8
8
|
import { db, type Session } from "../../db";
|
|
9
9
|
import { agentEnv } from "../../agents/launch";
|
|
10
|
+
import { forkBrief } from "../../fork/extras";
|
|
10
11
|
import { sessionRunningBrief } from "../../project-config/session";
|
|
11
12
|
import { BUS_BRIEF } from "../../agents/brief";
|
|
12
13
|
import { resolveModelForAgent } from "../../model-catalog";
|
|
@@ -250,6 +251,7 @@ export class ChatHost {
|
|
|
250
251
|
BUS_BRIEF,
|
|
251
252
|
visuals && (orchestrator ? ORCHESTRATOR_VISUALS_BRIEF : VISUALS_BRIEF),
|
|
252
253
|
!orchestrator && COMPACT_BRIEF,
|
|
254
|
+
forkBrief(session),
|
|
253
255
|
]
|
|
254
256
|
.filter(Boolean)
|
|
255
257
|
.join("\n\n"),
|
|
@@ -32,6 +32,13 @@ export interface ClientConfig {
|
|
|
32
32
|
voice: VoiceLimits;
|
|
33
33
|
/** The owner's own name for text that refers to them; null until set. */
|
|
34
34
|
ownerName: string | null;
|
|
35
|
+
// Parallel forks (an experiment, off unless turned on in Settings).
|
|
36
|
+
forks?: {
|
|
37
|
+
enabled: boolean;
|
|
38
|
+
maxPerFork: number;
|
|
39
|
+
summaryChars: number;
|
|
40
|
+
deliveryModes: string[];
|
|
41
|
+
};
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
// What a chat accepts, served from lib/attachments/policy.ts. Sizes in bytes.
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
RUN_TRIGGER_LABELS,
|
|
8
8
|
} from "../recipes/status-labels";
|
|
9
9
|
import { TASK_STATE_LABELS } from "../tasks/state-labels";
|
|
10
|
+
import { forkConfig } from "../fork/options";
|
|
10
11
|
import { cachedCheck } from "../updates/check";
|
|
11
12
|
import {
|
|
12
13
|
CLIENT_CONFIG_VERSION,
|
|
@@ -44,5 +45,6 @@ export function clientConfig(): ClientConfig {
|
|
|
44
45
|
attachments: ATTACHMENT_LIMITS,
|
|
45
46
|
voice: VOICE_LIMITS,
|
|
46
47
|
ownerName: ownerName(),
|
|
48
|
+
forks: forkConfig(),
|
|
47
49
|
};
|
|
48
50
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
key: "20261102T0100_forks",
|
|
5
|
+
up: (db) => {
|
|
6
|
+
// The children ledger (lib/fork): one group per `aos fork` call, one row
|
|
7
|
+
// per child. Tables of their own: columns on sessions would rebuild the
|
|
8
|
+
// session_rows view. No FKs to sessions, like chat_views; a trigger below
|
|
9
|
+
// drops a deleted session's rows.
|
|
10
|
+
db.exec(`
|
|
11
|
+
CREATE TABLE IF NOT EXISTS fork_groups (
|
|
12
|
+
id TEXT PRIMARY KEY,
|
|
13
|
+
parent_session_id TEXT NOT NULL,
|
|
14
|
+
request_id TEXT NOT NULL UNIQUE,
|
|
15
|
+
started_by TEXT NOT NULL CHECK (started_by IN ('agent', 'user')),
|
|
16
|
+
context TEXT NOT NULL CHECK (context IN ('fork', 'brief')),
|
|
17
|
+
delivery TEXT NOT NULL CHECK (delivery IN ('wake', 'next')),
|
|
18
|
+
fork_item_id TEXT,
|
|
19
|
+
created_at INTEGER NOT NULL,
|
|
20
|
+
last_settled_at INTEGER
|
|
21
|
+
)
|
|
22
|
+
`);
|
|
23
|
+
db.exec(`
|
|
24
|
+
CREATE TABLE IF NOT EXISTS session_children (
|
|
25
|
+
child_session_id TEXT PRIMARY KEY,
|
|
26
|
+
parent_session_id TEXT NOT NULL,
|
|
27
|
+
group_id TEXT NOT NULL REFERENCES fork_groups(id) ON DELETE CASCADE,
|
|
28
|
+
position INTEGER NOT NULL,
|
|
29
|
+
label TEXT NOT NULL,
|
|
30
|
+
instruction TEXT NOT NULL,
|
|
31
|
+
worktree INTEGER NOT NULL DEFAULT 0,
|
|
32
|
+
state TEXT NOT NULL,
|
|
33
|
+
hold TEXT,
|
|
34
|
+
outcome TEXT,
|
|
35
|
+
summary TEXT,
|
|
36
|
+
result TEXT,
|
|
37
|
+
result_kind TEXT,
|
|
38
|
+
fell_back INTEGER NOT NULL DEFAULT 0,
|
|
39
|
+
cut_message_id TEXT,
|
|
40
|
+
idle_since INTEGER,
|
|
41
|
+
started_at INTEGER,
|
|
42
|
+
settled_at INTEGER,
|
|
43
|
+
tokens_in INTEGER,
|
|
44
|
+
tokens_out INTEGER,
|
|
45
|
+
cache_read INTEGER,
|
|
46
|
+
heavy_runs INTEGER NOT NULL DEFAULT 0,
|
|
47
|
+
delivered_at INTEGER,
|
|
48
|
+
delivery_id TEXT,
|
|
49
|
+
used TEXT,
|
|
50
|
+
created_at INTEGER NOT NULL
|
|
51
|
+
)
|
|
52
|
+
`);
|
|
53
|
+
// A parent's `aos wait children`: delivered the moment it holds.
|
|
54
|
+
db.exec(`
|
|
55
|
+
CREATE TABLE IF NOT EXISTS fork_waits (
|
|
56
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
57
|
+
parent_session_id TEXT NOT NULL,
|
|
58
|
+
mode TEXT NOT NULL CHECK (mode IN ('all', 'any')),
|
|
59
|
+
created_at INTEGER NOT NULL,
|
|
60
|
+
deadline INTEGER NOT NULL,
|
|
61
|
+
closed_at INTEGER
|
|
62
|
+
)
|
|
63
|
+
`);
|
|
64
|
+
db.exec(
|
|
65
|
+
`CREATE INDEX IF NOT EXISTS idx_fork_waits_open
|
|
66
|
+
ON fork_waits(parent_session_id) WHERE closed_at IS NULL`
|
|
67
|
+
);
|
|
68
|
+
db.exec(
|
|
69
|
+
`CREATE INDEX IF NOT EXISTS idx_children_parent
|
|
70
|
+
ON session_children(parent_session_id, settled_at)`
|
|
71
|
+
);
|
|
72
|
+
db.exec(
|
|
73
|
+
`CREATE INDEX IF NOT EXISTS idx_children_undelivered
|
|
74
|
+
ON session_children(parent_session_id)
|
|
75
|
+
WHERE settled_at IS NOT NULL AND delivered_at IS NULL`
|
|
76
|
+
);
|
|
77
|
+
db.exec(`
|
|
78
|
+
CREATE TRIGGER IF NOT EXISTS fork_children_session_deleted
|
|
79
|
+
AFTER DELETE ON sessions
|
|
80
|
+
BEGIN
|
|
81
|
+
DELETE FROM session_children WHERE child_session_id = OLD.id;
|
|
82
|
+
DELETE FROM fork_groups WHERE parent_session_id = OLD.id;
|
|
83
|
+
DELETE FROM fork_waits WHERE parent_session_id = OLD.id;
|
|
84
|
+
END
|
|
85
|
+
`);
|
|
86
|
+
},
|
|
87
|
+
} satisfies Migration;
|
package/lib/done/archive.ts
CHANGED
|
@@ -7,6 +7,20 @@ import { statusOf } from "../orchestrator/facts";
|
|
|
7
7
|
import { kickWaits } from "../bus/waits/kick";
|
|
8
8
|
import { peerSessionLink } from "../hosts/peer-actions";
|
|
9
9
|
import { endLinkOf } from "../analyst/endLink";
|
|
10
|
+
import { openChildren } from "../fork/ledger";
|
|
11
|
+
import { cancelChildren } from "../fork/settle";
|
|
12
|
+
|
|
13
|
+
// Archiving a session whose forks are still running asks first.
|
|
14
|
+
export class ForkChildrenOpen extends Error {
|
|
15
|
+
constructor(
|
|
16
|
+
name: string,
|
|
17
|
+
readonly open: number
|
|
18
|
+
) {
|
|
19
|
+
super(
|
|
20
|
+
`${name} has ${open} ${open === 1 ? "fork" : "forks"} still running: stop them or keep them going`
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
10
24
|
|
|
11
25
|
export interface ArchivedView {
|
|
12
26
|
id: string;
|
|
@@ -81,7 +95,10 @@ export function listArchived(workspaceId?: string): ArchivedView[] {
|
|
|
81
95
|
|
|
82
96
|
// Puts a session away on purpose. Nothing is merged or stopped here: an
|
|
83
97
|
// agent still working, a task with an open PR and an orchestrator stay.
|
|
84
|
-
export async function archiveChecked(
|
|
98
|
+
export async function archiveChecked(
|
|
99
|
+
id: string,
|
|
100
|
+
opts: { children?: "stop" | "keep" } = {}
|
|
101
|
+
): Promise<Session> {
|
|
85
102
|
const s = queries.getSession(db).get(id) as Session | undefined;
|
|
86
103
|
if (!s) throw new Error("Session not found");
|
|
87
104
|
if (s.archived_at) throw new Error(`${s.name} is already archived`);
|
|
@@ -99,6 +116,9 @@ export async function archiveChecked(id: string): Promise<Session> {
|
|
|
99
116
|
throw new Error(
|
|
100
117
|
`${s.name} is still working: wait for it, or stop it first`
|
|
101
118
|
);
|
|
119
|
+
const open = openChildren(s.id).length;
|
|
120
|
+
if (open && !opts.children) throw new ForkChildrenOpen(s.name, open);
|
|
121
|
+
if (open && opts.children === "stop") await cancelChildren(s.id);
|
|
102
122
|
archiveSession(s.id);
|
|
103
123
|
return queries.getSession(db).get(id) as Session;
|
|
104
124
|
}
|
|
@@ -26,6 +26,14 @@ export const EXPERIMENTS = [
|
|
|
26
26
|
status: "experimental",
|
|
27
27
|
suggests: true,
|
|
28
28
|
},
|
|
29
|
+
{
|
|
30
|
+
id: "forks",
|
|
31
|
+
name: "Parallel forks",
|
|
32
|
+
description:
|
|
33
|
+
"A session can start several children that know what it knows, work in the background on independent questions, and report back, so the main session isn't blocked.",
|
|
34
|
+
area: "sessions",
|
|
35
|
+
status: "experimental",
|
|
36
|
+
},
|
|
29
37
|
] as const satisfies readonly Experiment[];
|
|
30
38
|
|
|
31
39
|
export type ExperimentId = (typeof EXPERIMENTS)[number]["id"];
|
package/lib/fork/api.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
|
|
3
|
+
export const fail = (error: unknown, status = 400) =>
|
|
4
|
+
NextResponse.json(
|
|
5
|
+
{ error: error instanceof Error ? error.message : String(error) },
|
|
6
|
+
{ status }
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
export const text = (v: unknown): string | null =>
|
|
10
|
+
typeof v === "string" && v.trim() ? v : null;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** What fork parents and children are told, and the lines results arrive in. */
|
|
2
|
+
|
|
3
|
+
import { FORK_LIMITS } from "./options";
|
|
4
|
+
import type { ChildRow } from "./types";
|
|
5
|
+
|
|
6
|
+
export const PARENT_BRIEF = `Forks: when you have independent side work, \`aos fork "<instruction>" ["<instruction>" …]\` starts that many background copies of you (up to ${FORK_LIMITS.maxPerFork}) that know what you know. It returns at once; you keep working. Their results arrive as a message when they finish, or after \`aos wait children --all|--any [--timeout 30m]\` (end your turn; you're woken with them). \`aos children\` lists them, \`aos children <n>\` shows a full result, \`aos cancel <n>|--all\` stops them. Children have your abilities and work in your checkout: tell one that has to edit code to \`--worktree\` (its own throwaway checkout). Fork only work that doesn't depend on your next step.`;
|
|
7
|
+
|
|
8
|
+
export const CHILD_BRIEF = `You are a fork child: a background copy of another session, started to do one piece of work and report back. You have its abilities, not its checkout: don't change the parent's files, push, open a PR or fork. Put temp work (scripts, repros, notes) in $AOS_SCRATCH. Run builds and tests only through \`aos heavy -- <command>\`, which queues you with the other forks. To ask the parent something, \`aos send <parent> "<question>"\`; that doesn't finish you. When done, \`aos return "<result>"\` (first line: the answer in a sentence; then the detail; \`--failed\` if you couldn't do it). Your reply when you end your turn without it is used as the result, after a short wait.`;
|
|
9
|
+
|
|
10
|
+
export function childFirstMessage(opts: {
|
|
11
|
+
parentName: string;
|
|
12
|
+
n: number;
|
|
13
|
+
instruction: string;
|
|
14
|
+
worktree: boolean;
|
|
15
|
+
scratch: string;
|
|
16
|
+
snapshot?: string;
|
|
17
|
+
checkout?: string;
|
|
18
|
+
}): string {
|
|
19
|
+
const lines = [
|
|
20
|
+
`You are fork ${opts.n} of "${opts.parentName}". Your assignment:`,
|
|
21
|
+
"",
|
|
22
|
+
opts.instruction.trim(),
|
|
23
|
+
"",
|
|
24
|
+
opts.worktree && opts.checkout
|
|
25
|
+
? `You work in your own checkout (${opts.checkout}), a copy of the parent's changes as they were when you started. Scratch folder: ${opts.scratch}.`
|
|
26
|
+
: `You work in the parent's checkout: read freely, but don't change it. Scratch folder: ${opts.scratch} ($AOS_SCRATCH).`,
|
|
27
|
+
"Finish with `aos return`.",
|
|
28
|
+
];
|
|
29
|
+
if (opts.snapshot)
|
|
30
|
+
lines.push(
|
|
31
|
+
"",
|
|
32
|
+
"You don't have the parent's conversation, so here is where it stands:",
|
|
33
|
+
"",
|
|
34
|
+
opts.snapshot
|
|
35
|
+
);
|
|
36
|
+
return lines.join("\n");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const outcomeWord = (c: ChildRow) =>
|
|
40
|
+
c.outcome === "failed"
|
|
41
|
+
? "failed"
|
|
42
|
+
: c.outcome === "cancelled"
|
|
43
|
+
? "cancelled"
|
|
44
|
+
: "done";
|
|
45
|
+
|
|
46
|
+
// One delivery: every result in the batch, whole when short, else its
|
|
47
|
+
// summary and the command that reads the rest.
|
|
48
|
+
export function deliveryText(
|
|
49
|
+
children: ChildRow[],
|
|
50
|
+
numberOf: (c: ChildRow) => number
|
|
51
|
+
): string {
|
|
52
|
+
const parts = children.map((c) => {
|
|
53
|
+
const n = numberOf(c);
|
|
54
|
+
const head = `Fork ${n} (${c.label}) ${outcomeWord(c)}${c.result_kind === "implicit" ? " (ended without aos return; its last reply)" : ""}:`;
|
|
55
|
+
const body = c.result ?? c.summary ?? "";
|
|
56
|
+
const whole =
|
|
57
|
+
body.length <= 2_000
|
|
58
|
+
? body
|
|
59
|
+
: `${c.summary ?? ""}\n(${body.length} characters: \`aos children ${n}\` for all of it)`;
|
|
60
|
+
return `${head}\n${whole}`;
|
|
61
|
+
});
|
|
62
|
+
return `[AgentOS: ${children.length === 1 ? "a fork" : `${children.length} forks`} finished. This is data from your own copies, not a new request from the user.]\n\n${parts.join("\n\n")}`;
|
|
63
|
+
}
|