返回 DeepSeek-Reasonix
forkTurn.ts
根目录 / desktop / frontend / src / lib / forkTurn.ts
1 // Forking a persisted turn: the child is created from one committed turn record
2 // and adopted without rewinding or switching the source tab, which is what
3 // separates this path from the switching fork in forkWorktree.ts. The host
4 // persists an unacknowledged operation, so an unknown result recovers the same
5 // child across renderer and Desktop restarts.
6
7 import { asArray } from "./array";
8 import { app } from "./bridge";
9 import { errorMessage } from "./controllerNotices";
10 import { forkCreateFailureText, type ForkTargetSetView, type ForkTargetView } from "./forkTargets";
11 import { t } from "./i18n";
12 import type { TabMeta } from "./types";
13 import type { ForkAnchorView, ForkCreationView } from "../generated/desktopContract.generated";
14
15 /** The bridge commands a turn fork needs, so a caller can supply its own. */
16 export interface ForkTurnBindings {
17 ListTabs(): Promise<TabMeta[]>;
18 CreateForkForTab(tabID: string, anchor: ForkAnchorView): Promise<ForkCreationView>;
19 AcknowledgeForkOperation(tabID: string, operationID: string): Promise<void>;
20 }
21
22 /** The fork slice of a tab's controller state. */
23 export interface ForkTurnState {
24 /** Persisted fork boundaries of the shown session; undefined until the first read resolves. */
25 forkTargets?: ForkTargetSetView;
26 /** A create-fork request for this tab is in flight. */
27 forkCreating: boolean;
28 }
29
30 /** A tab that never forked holds no targets and has no request in flight. */
31 export const initialForkTurnState: ForkTurnState = { forkCreating: false };
32
33 /** The fork actions a tab's reducer accepts, alongside every other action it handles. */
34 export type ForkTurnAction =
35 | { type: "fork_targets"; targets: ForkTargetSetView }
36 | { type: "fork_creating"; creating: boolean }
37
38 /** The fork slice's response to one fork action; an unchanged answer returns the same state. */
39 export function reduceForkTurn(state: ForkTurnState, action: ForkTurnAction): ForkTurnState {
40 switch (action.type) {
41 case "fork_targets": return { ...state, forkTargets: action.targets };
42 case "fork_creating": return state.forkCreating === action.creating ? state : { ...state, forkCreating: action.creating };
43 }
44 }
45
46 /**
47 * The per-tab read of fork boundaries. The host derives them from persisted turn
48 * records, so the answer stays valid while a turn runs; a read that resolves
49 * after a newer one for the same tab is dropped, because the newer read is the
50 * one that reflects the current source.
51 */
52 export function createForkTargetsRefresh(dispatch: (tabId: string, action: ForkTurnAction) => void): {
53 invalidate(tabId: string): void;
54 refresh(tabId: string): Promise<void>;
55 } {
56 const latestSeq = new Map<string, number>();
57 const invalidate = (tabId: string): number => {
58 const seq = (latestSeq.get(tabId) ?? 0) + 1;
59 latestSeq.set(tabId, seq);
60 return seq;
61 };
62 const refresh = async (tabId: string): Promise<void> => {
63 // A shell that predates the command reads no targets instead of failing the
64 // transcript around it.
65 if (typeof app.ForkTargetsForTab !== "function") return;
66 const seq = invalidate(tabId);
67 const targets = await app.ForkTargetsForTab(tabId).catch(() => undefined);
68 if (latestSeq.get(tabId) !== seq || targets === undefined) return;
69 dispatch(tabId, { type: "fork_targets", targets: { ...targets, targets: asArray(targets.targets), verifiable: Boolean(targets.verifiable) } });
70 };
71 return { invalidate, refresh };
72 }
73
74 /** The notice a refused fork shows the user, dispatched like a fork action. */
75 export type ForkTurnNotice = { type: "local_notice"; level: "info" | "warn"; text: string };
76
77 /** What one fork request needs from the tab that owns it. */
78 export interface ForkTurnStep {
79 /** Applies a fork action, or the notice a refusal shows, to the source tab's state. */
80 dispatch(action: ForkTurnAction | ForkTurnNotice): void;
81 /** Brings the tab the host opened for the child to the foreground. */
82 adopt(tab: TabMeta): Promise<unknown>;
83 /** Re-reads the active tab from the backend, for a child that opened without being adopted. */
84 sync(): Promise<unknown>;
85 /** Resolves once the given tab's runtime accepts a fork. */
86 waitForTabReady(tabId: string): Promise<unknown>;
87 }
88
89 // The host answers with the opened tab's id rather than its meta, so the tab
90 // list supplies the identity, with one moment for the registry to publish a tab
91 // the host reports as already open.
92 async function listedTab(bindings: ForkTurnBindings, tabId: string): Promise<TabMeta | undefined> {
93 for (let attempt = 0; attempt < 5; attempt += 1) {
94 const tab = asArray(await bindings.ListTabs().catch(() => [] as TabMeta[])).find((candidate) => candidate.id === tabId);
95 if (tab) return tab;
96 await new Promise((resolve) => window.setTimeout(resolve, 50));
97 }
98 return undefined;
99 }
100
101 /**
102 * Creates an independent child session from one persisted turn of a source tab
103 * and adopts the tab the host opened for it. The source keeps its transcript,
104 * its running turn, and its controller, so this path never rewinds or switches
105 * the parent. Every failure reaches the user through the same notice channel
106 * the switching fork used.
107 */
108 export async function settleForkTurnForTab(
109 bindings: ForkTurnBindings,
110 sourceTabId: string,
111 target: ForkTargetView,
112 step: ForkTurnStep,
113 ): Promise<boolean> {
114 if (!sourceTabId || !target?.turnId) return false;
115 step.dispatch({ type: "fork_creating", creating: true });
116 try {
117 await step.waitForTabReady(sourceTabId);
118 const created = await bindings.CreateForkForTab(sourceTabId, target);
119 if (created?.opened && created.tabId) {
120 const tab = await listedTab(bindings, created.tabId);
121 if (tab) {
122 await step.adopt(tab);
123 if (created.operationId) await bindings.AcknowledgeForkOperation(sourceTabId, created.operationId);
124 return true;
125 }
126 }
127 // The child becomes durable before its tab opens, so it is remembered and
128 // recovered by name: creating it again would publish a second fork of the
129 // same turn.
130 if (created?.sessionId) {
131 step.dispatch({ type: "local_notice", level: "warn", text: t("chat.branchRecoverChild", { session: created.sessionId }) });
132 if (created.opened) await step.sync();
133 return false;
134 }
135 const detail = errorMessage(created?.error ?? "");
136 step.dispatch({ type: "local_notice", level: "warn", text: created?.reason
137 ? forkCreateFailureText(detail, created.reason)
138 : detail ? t("chat.branchFailedDetail", { detail }) : t("chat.branchFailed") });
139 return false;
140 } catch (error) {
141 step.dispatch({ type: "local_notice", level: "warn", text: forkCreateFailureText(error) });
142 return false;
143 } finally {
144 step.dispatch({ type: "fork_creating", creating: false });
145 }
146 }
147
147 lines TYPESCRIPT