diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx
index 4e3c53268f24..79ea682e6992 100644
--- a/apps/mobile/src/Stack.tsx
+++ b/apps/mobile/src/Stack.tsx
@@ -53,6 +53,7 @@ import { GitCommitSheet } from "./features/threads/git/GitCommitSheet";
import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet";
import { GitOverviewSheet } from "./features/threads/git/GitOverviewSheet";
import { ThreadAgentsSheet } from "./features/threads/ThreadAgentsSheet";
+import { TaskGraphSheet } from "./features/threads/TaskGraphSheet";
import { ThreadQueueSheet } from "./features/threads/ThreadQueueControl";
import { ThreadRouteScreen } from "./features/threads/ThreadRouteScreen";
import { McpAppFullscreenScreen } from "./features/threads/McpAppFullscreenScreen";
@@ -564,6 +565,7 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([
"SettingsSheet",
"ThreadAgents",
"ThreadQueue",
+ "ThreadTaskGraphs",
"ThreadReviewComment",
"ThreadDevicePreview",
"ThreadBrowserPreview",
@@ -818,6 +820,15 @@ const RootStackConfig = createWorkspaceStackNavigator({
sheetGrabberVisible: true,
},
}),
+ ThreadTaskGraphs: createNativeStackScreen({
+ screen: TaskGraphSheet,
+ options: {
+ ...FORM_SHEET_PRESENTATION_OPTIONS,
+ headerShown: false,
+ sheetAllowedDetents: [0.6, 0.95],
+ sheetGrabberVisible: true,
+ },
+ }),
GitOverview: createNativeStackScreen({
screen: GitOverviewSheet,
linking: `${THREAD_LINKING_PREFIX}/git`,
diff --git a/apps/mobile/src/features/threads/TaskGraphSheet.tsx b/apps/mobile/src/features/threads/TaskGraphSheet.tsx
new file mode 100644
index 000000000000..93e6de45462c
--- /dev/null
+++ b/apps/mobile/src/features/threads/TaskGraphSheet.tsx
@@ -0,0 +1,456 @@
+import { useAtomValue } from "@effect/atom-react";
+import {
+ isAtomCommandInterrupted,
+ squashAtomCommandFailure,
+ type AtomCommandResult,
+} from "@t3tools/client-runtime/state/runtime";
+import type {
+ EnvironmentId,
+ ModelSelection,
+ TaskGraph,
+ TaskGraphEdit,
+ TaskGraphNode,
+ ThreadId,
+} from "@t3tools/contracts";
+import { formatModelSlugName } from "@t3tools/shared/model";
+import { taskGraphLayers } from "@t3tools/shared/taskGraph";
+import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
+import * as Haptics from "expo-haptics";
+import { useState } from "react";
+import { Alert, Platform, Pressable, ScrollView, View } from "react-native";
+import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+
+import { AndroidSheetHeader } from "../../components/AndroidScreenHeader";
+import { AppText as Text } from "../../components/AppText";
+import { showConfirmDialog } from "../../components/ConfirmDialogHost";
+import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
+import { useUniwindTheme } from "../../lib/useUniwindTheme";
+import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
+import { useEnvironmentPresentation } from "../../state/presentation";
+import { useEnvironmentQuery } from "../../state/query";
+import { serverEnvironment } from "../../state/server";
+import { useAtomCommand } from "../../state/use-atom-command";
+import { RequestActionButton } from "./RequestActionButton";
+import { SUBAGENT_TONE_TEXT_CLASS, SubagentStatusDot } from "./SubagentStatusDot";
+import {
+ sortTaskGraphs,
+ taskGraphActions,
+ taskGraphNodeActions,
+ taskGraphNodeStatusLabel,
+ taskGraphNodeTone,
+ taskGraphNodeWaitDetail,
+ taskGraphSummaryLabel,
+ taskGraphTone,
+} from "./task-graph-presentation";
+
+const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
+
+type TaskGraphTarget = { readonly environmentId: EnvironmentId; readonly threadId: ThreadId };
+
+const useTaskGraphsQuery = (target: TaskGraphTarget) =>
+ useEnvironmentQuery(
+ serverEnvironment.taskGraphsLive({
+ environmentId: target.environmentId,
+ input: { threadId: target.threadId },
+ }),
+ );
+
+/** The thread's task graphs, live. Null until the first snapshot arrives. */
+export function useThreadTaskGraphs(target: TaskGraphTarget): ReadonlyArray | null {
+ return useTaskGraphsQuery(target).data?.graphs ?? null;
+}
+
+function confirm(input: {
+ readonly title: string;
+ readonly message: string;
+ readonly confirmText: string;
+ readonly onConfirm: () => void;
+}) {
+ if (Platform.OS === "ios") {
+ Alert.alert(input.title, input.message, [
+ { text: "Keep", style: "cancel" },
+ { text: input.confirmText, style: "destructive", onPress: input.onConfirm },
+ ]);
+ return;
+ }
+ showConfirmDialog({ ...input, cancelText: "Keep", destructive: true });
+}
+
+/**
+ * Read-only view of a thread's task graphs, with the actions that move a
+ * graph between states: run a draft, cancel, cancel a branch, retry a node.
+ * Editing the graph itself happens on web and desktop.
+ */
+export function TaskGraphSheet({ route }: StaticScreenProps) {
+ const target = route.params;
+ const navigation = useNavigation();
+ const insets = useSafeAreaInsets();
+ const theme = useUniwindTheme();
+ const query = useTaskGraphsQuery(target);
+ const graphs = query.data === null ? null : sortTaskGraphs(query.data.graphs);
+ const canEdit = useAtomValue(
+ serverEnvironment.editTaskGraph.permissionAtom(target.environmentId),
+ );
+ const canRun = useAtomValue(serverEnvironment.runTaskGraph.permissionAtom(target.environmentId));
+ const canCancel = useAtomValue(
+ serverEnvironment.cancelTaskGraph.permissionAtom(target.environmentId),
+ );
+ const editGraph = useAtomCommand(serverEnvironment.editTaskGraph, {
+ label: "task graph edit",
+ reportFailure: false,
+ });
+ const runGraph = useAtomCommand(serverEnvironment.runTaskGraph, {
+ label: "task graph run",
+ reportFailure: false,
+ });
+ const cancelGraph = useAtomCommand(serverEnvironment.cancelTaskGraph, {
+ label: "task graph cancel",
+ reportFailure: false,
+ });
+ const providers = useAtomValue(serverEnvironment.providersValueAtom(target.environmentId));
+ const environmentLabel =
+ useEnvironmentPresentation(target.environmentId).presentation?.entry.target.label ??
+ "This machine";
+ // Servers without peer support reject this; the graph's own machine still has a name.
+ const peers =
+ useEnvironmentQuery(
+ serverEnvironment.taskGraphPeersLive({ environmentId: target.environmentId, input: {} }),
+ ).data?.peers ?? [];
+ const modelLabel = (selection: ModelSelection) =>
+ providers
+ ?.find((provider) => provider.instanceId === selection.instanceId)
+ ?.models.find((model) => model.slug === selection.model)?.name ??
+ formatModelSlugName(selection.model);
+ /** "Claude Opus 5.5 · on build-box": the node's model and where it ran or is pinned. */
+ const placementLabel = (graph: TaskGraph, node: TaskGraphNode) => {
+ const model = node.modelSelection ?? graph.modelSelection;
+ // A node continuing its dependency's worktree goes where that one ran, known once it starts.
+ const machine =
+ node.assignedEnvironmentId ?? (node.workspace === "dependency" ? null : node.environmentId);
+ const machineLabel =
+ machine === null
+ ? null
+ : machine === target.environmentId
+ ? environmentLabel
+ : (peers.find((peer) => peer.environmentId === machine)?.label ?? "another machine");
+ return [
+ model === null ? null : modelLabel(model),
+ machineLabel === null ? null : `on ${machineLabel}`,
+ ]
+ .filter((part) => part !== null)
+ .join(" · ");
+ };
+ // Keys of actions in flight, so a second tap cannot send the same edit twice.
+ const [pending, setPending] = useState>(() => new Set());
+
+ const perform = async (
+ key: string,
+ failureTitle: string,
+ action: () => Promise>,
+ ) => {
+ setPending((current) => new Set(current).add(key));
+ try {
+ const result = await action();
+ if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
+ Alert.alert(failureTitle, String(squashAtomCommandFailure(result)));
+ }
+ } finally {
+ setPending((current) => {
+ const next = new Set(current);
+ next.delete(key);
+ return next;
+ });
+ }
+ };
+
+ const edit = (graph: TaskGraph, change: TaskGraphEdit & { readonly key: string }) =>
+ perform(
+ `${graph.id}:${change.key}`,
+ change.type === "retry_node" ? "Could not retry task" : "Could not cancel branch",
+ () =>
+ editGraph({
+ environmentId: target.environmentId,
+ input: { graphId: graph.id, edits: [change] },
+ }),
+ );
+
+ const onRun = (graph: TaskGraph) => {
+ void Haptics.selectionAsync();
+ void perform(graph.id, "Could not run graph", () =>
+ runGraph({ environmentId: target.environmentId, input: { graphId: graph.id } }),
+ );
+ };
+
+ const onCancel = (graph: TaskGraph) =>
+ confirm({
+ title: "Cancel task graph?",
+ message: `Every unfinished task in “${graph.title}” stops. Finished tasks and their pull requests stay.`,
+ confirmText: "Cancel graph",
+ onConfirm: () =>
+ void perform(graph.id, "Could not cancel graph", () =>
+ cancelGraph({ environmentId: target.environmentId, input: { graphId: graph.id } }),
+ ),
+ });
+
+ const onCancelBranch = (graph: TaskGraph, node: TaskGraphNode) =>
+ confirm({
+ title: "Cancel this branch?",
+ message: `“${node.title}” and every task that depends on it stop.`,
+ confirmText: "Cancel branch",
+ onConfirm: () => void edit(graph, { type: "cancel_branch", key: node.key }),
+ });
+
+ const onRetry = (graph: TaskGraph, node: TaskGraphNode) => {
+ void Haptics.selectionAsync();
+ void edit(graph, { type: "retry_node", key: node.key });
+ };
+
+ const openNodeThread = (node: TaskGraphNode) => {
+ if (node.threadId === null) return;
+ void Haptics.selectionAsync();
+ // Replace rather than push, like the agents sheet: the node thread belongs
+ // in the workspace stack, not on top of a dismissed sheet.
+ navigation.dispatch(
+ StackActions.replace("Thread", {
+ environmentId: node.assignedEnvironmentId ?? target.environmentId,
+ threadId: node.threadId,
+ }),
+ );
+ };
+
+ const openPullRequest = async (url: string) => {
+ if (!(await tryOpenExternalUrl(url, "pull-request"))) {
+ Alert.alert("Unable to open PR", "The pull request could not be opened.");
+ }
+ };
+
+ const content = (
+
+ {query.error !== null && graphs === null ? (
+ {query.error}
+ ) : graphs === null ? (
+ Loading task graphs…
+ ) : graphs.length === 0 ? (
+
+ No task graphs on this thread.
+
+ ) : (
+ graphs.map((graph) => {
+ const actions = taskGraphActions(graph);
+ const busy = pending.has(graph.id);
+ return (
+
+
+
+
+
+ {graph.title}
+
+
+
+ {taskGraphSummaryLabel(graph)} · from {graph.baseRef}
+
+
+ {(actions.run && canRun) || (actions.cancel && canCancel) ? (
+
+ {actions.run && canRun ? (
+ onRun(graph)} />
+ ) : null}
+ {actions.cancel && canCancel ? (
+ onCancel(graph)}
+ />
+ ) : null}
+
+ ) : null}
+ {taskGraphLayers(graph.nodes).map((layer, index) => (
+
+
+ Step {index + 1}
+
+ {layer.map((node) => {
+ const nodeActions = taskGraphNodeActions(graph, node);
+ return (
+ openNodeThread(node)}
+ onOpenPullRequest={(url) => void openPullRequest(url)}
+ onCancelBranch={() => onCancelBranch(graph, node)}
+ onRetry={() => onRetry(graph, node)}
+ />
+ );
+ })}
+
+ ))}
+
+ );
+ })
+ )}
+
+ );
+
+ if (Platform.OS === "ios") {
+ // A plain formSheet screen never renders a stack header, so it comes from
+ // a nested native stack inside the sheet (same shape as the agents sheet).
+ return (
+
+
+
+ {content}
+
+
+
+
+ );
+ }
+
+ return (
+
+ navigation.goBack()} />
+ {content}
+
+ );
+}
+
+function NodeRow(props: {
+ readonly node: TaskGraphNode;
+ /** Model and machine, empty when neither is known. */
+ readonly placement: string;
+ readonly busy: boolean;
+ readonly canCancelBranch: boolean;
+ readonly canRetry: boolean;
+ readonly onOpen: () => void;
+ readonly onOpenPullRequest: (url: string) => void;
+ readonly onCancelBranch: () => void;
+ readonly onRetry: () => void;
+}) {
+ const { node } = props;
+ const tone = taskGraphNodeTone(node.status);
+ const pullRequestUrl = node.pullRequestResult?.url ?? null;
+ const pullRequestError =
+ node.pullRequestResult?.status === "failed" ? node.pullRequestResult.error : null;
+ const detail = node.status === "succeeded" ? pullRequestError : (node.error ?? pullRequestError);
+ // Read once: "today" versus "tomorrow" only needs to be right when the sheet opens.
+ const [now] = useState(Date.now);
+ const wait = taskGraphNodeWaitDetail(node, now);
+ const hasActions = pullRequestUrl !== null || props.canCancelBranch || props.canRetry;
+
+ const summary = (
+
+
+
+
+ {node.title}
+
+
+ {taskGraphNodeStatusLabel(node.status)}
+
+
+ {wait !== null ? (
+
+ {wait}
+
+ ) : null}
+ {props.placement !== "" ? (
+
+ {props.placement}
+
+ ) : null}
+ {node.branch !== null ? (
+
+ {node.branch}
+
+ ) : null}
+ {detail ? (
+
+ {detail}
+
+ ) : null}
+
+ );
+
+ return (
+
+ {node.threadId === null ? (
+ {summary}
+ ) : (
+
+ {summary}
+
+ )}
+ {hasActions ? (
+
+ {pullRequestUrl !== null ? (
+ props.onOpenPullRequest(pullRequestUrl)} />
+ ) : null}
+ {props.canRetry ? (
+
+ ) : null}
+ {props.canCancelBranch ? (
+
+ ) : null}
+
+ ) : null}
+
+ );
+}
+
+function NodeAction(props: {
+ readonly label: string;
+ readonly disabled?: boolean;
+ readonly onPress: () => void;
+}) {
+ return (
+
+ {props.label}
+
+ );
+}
diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
index e4f489115f3a..84053d13dbc8 100644
--- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
@@ -144,6 +144,8 @@ import {
import { ComposerPopoverHost } from "./ComposerPopoverHost";
import { ThreadFeed, type ThreadFeedHistoryControls } from "./ThreadFeed";
import { useThreadTurnSubagents } from "./ThreadAgentsSheet";
+import { useThreadTaskGraphs } from "./TaskGraphSheet";
+import { resolveTaskGraphSegment } from "./task-graph-presentation";
import { ComposerQueuedEditBanner } from "./ComposerQueuedEdit";
import { useThreadQueuedCount } from "./ThreadQueueControl";
import type { ThreadContentPresentation } from "./threadContentPresentation";
@@ -420,6 +422,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
threadId: props.selectedThread.id,
});
const agentsSegment = resolveSubagentPillSegment(turnSubagents);
+ const taskGraphSegment = resolveTaskGraphSegment(
+ useThreadTaskGraphs({ environmentId: props.environmentId, threadId: props.selectedThread.id }),
+ );
const composerEditorRef = useRef(null);
// A provider-native subagent shows status instead of a composer.
const isProviderSubagent = isProviderNativeSubagentThread(props.selectedThread.source);
@@ -568,6 +573,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
showWorkingControl ||
queuedCount > 0 ||
agentsSegment !== null ||
+ taskGraphSegment !== null ||
devicePreviews.length > 0 ||
browserTabs.tabs.length > 0 ||
props.connectionStateLabel !== "connected" ||
@@ -1266,6 +1272,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
threadId: props.selectedThread.id,
});
}}
+ taskGraph={taskGraphSegment}
+ onOpenTaskGraph={() => {
+ Keyboard.dismiss();
+ navigation.navigate("ThreadTaskGraphs", {
+ environmentId: props.environmentId,
+ threadId: props.selectedThread.id,
+ });
+ }}
queuedCount={queuedCount}
onOpenQueue={() => {
Keyboard.dismiss();
diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx
index 8672e3834521..9218f4fb3e84 100644
--- a/apps/mobile/src/features/threads/floating-working-control.tsx
+++ b/apps/mobile/src/features/threads/floating-working-control.tsx
@@ -29,6 +29,8 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
import { BrowserPreviewButton } from "../browser/browser-preview-button";
import { DevicePreviewButton } from "../devices/device-preview-button";
import type { FloatingWorkingStatus } from "./floating-working-status";
+import { SubagentStatusDot } from "./SubagentStatusDot";
+import type { TaskGraphSegment } from "./task-graph-presentation";
import { ShimmeringWorkContent } from "./thread-work-log";
import { useVisibleSecondClock } from "./use-visible-second-clock";
@@ -75,6 +77,8 @@ export function FloatingWorkingControl(props: {
readonly onScrollToEnd: () => void;
readonly agents: SubagentPillSegment | null;
readonly onOpenAgents: () => void;
+ readonly taskGraph: TaskGraphSegment | null;
+ readonly onOpenTaskGraph: () => void;
readonly queuedCount: number;
readonly onOpenQueue: () => void;
/** Extra distance to rise above the anchor, e.g. an overlay card's coverage. */
@@ -84,11 +88,14 @@ export function FloatingWorkingControl(props: {
const [overlayWidth, setOverlayWidth] = useState(windowWidth);
const [queueWidth, setQueueWidth] = useState(0);
const [agentsWidth, setAgentsWidth] = useState(0);
+ const [taskGraphWidth, setTaskGraphWidth] = useState(0);
const hasQueue = props.queuedCount > 0;
const hasPreview = props.devicePreview !== null || props.browserPreview !== null;
const [previewWidth, setPreviewWidth] = useState(0);
const agents = props.agents;
const hasAgents = agents !== null;
+ const taskGraph = props.taskGraph;
+ const hasTaskGraph = taskGraph !== null;
// Segments keep their measured width; only the status label absorbs the
// remainder, so a long "Working 12m 04s" truncates before a count does.
const labelWidth = Math.max(
@@ -98,6 +105,7 @@ export function FloatingWorkingControl(props: {
32 -
(hasQueue ? queueWidth : 0) -
(hasAgents ? agentsWidth : 0) -
+ (hasTaskGraph ? taskGraphWidth : 0) -
(hasPreview ? previewWidth : 0),
);
const separationProgress = useSharedValue(props.showScrollToEnd ? 1 : 0);
@@ -135,7 +143,7 @@ export function FloatingWorkingControl(props: {
// Forget the width while no label is shown so the next one appears at its
// own size instead of animating from the previous label's.
const hasStatus = props.status !== null;
- const hasCapsule = hasStatus || hasQueue || hasAgents || hasPreview;
+ const hasCapsule = hasStatus || hasQueue || hasAgents || hasTaskGraph || hasPreview;
useEffect(() => {
if (!hasStatus) {
measuredWidthRef.current = null;
@@ -159,12 +167,14 @@ export function FloatingWorkingControl(props: {
const previewCompact =
hasStatus ||
hasAgents ||
+ hasTaskGraph ||
hasQueue ||
(props.devicePreview !== null && props.browserPreview !== null);
- // The queue, agents, questions, and reconnect labels have separate tap targets.
+ // The queue, agents, task graph, questions, and reconnect labels have separate tap targets.
const statusInteractive =
props.status?.kind === "connection" || props.status?.kind === "child-input";
- const capsuleInteractive = statusInteractive || hasQueue || hasAgents || hasPreview;
+ const capsuleInteractive =
+ statusInteractive || hasQueue || hasAgents || hasTaskGraph || hasPreview;
// The host stays centered on the capsule, but its measurement constraint
// comes from the overlay, independent of the capsule's current width.
const statusContent =
@@ -225,6 +235,26 @@ export function FloatingWorkingControl(props: {
) : null}
+ {taskGraph !== null ? (
+ setTaskGraphWidth(event.nativeEvent.layout.width)}
+ // Capped like the queue so agents, graph and queue still fit on a narrow screen.
+ style={{ maxWidth: Math.min(overlayWidth, windowWidth) * 0.4 }}
+ className="h-11 flex-row items-center gap-1.5 px-3 active:opacity-70"
+ >
+ {hasStatus || hasPreview || hasAgents ? (
+
+ ) : null}
+
+
+ {taskGraph.label}
+
+
+ ) : null}
{hasQueue ? (
- {hasStatus || hasPreview || hasAgents ? (
+ {hasStatus || hasPreview || hasAgents || hasTaskGraph ? (
) : null}
diff --git a/apps/mobile/src/features/threads/task-graph-presentation.test.ts b/apps/mobile/src/features/threads/task-graph-presentation.test.ts
new file mode 100644
index 000000000000..2eeaa646b964
--- /dev/null
+++ b/apps/mobile/src/features/threads/task-graph-presentation.test.ts
@@ -0,0 +1,182 @@
+import type { TaskGraph, TaskGraphNode } from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ resolveTaskGraphSegment,
+ sortTaskGraphs,
+ taskGraphActions,
+ taskGraphNodeActions,
+ taskGraphNodeWaitDetail,
+ taskGraphSummaryLabel,
+} from "./task-graph-presentation";
+
+const node = (key: string, status: TaskGraphNode["status"], dependsOn: string[] = []) =>
+ ({ key, status, dependsOn, title: key }) as unknown as TaskGraphNode;
+
+const graph = (
+ status: TaskGraph["status"],
+ nodes: TaskGraphNode[],
+ overrides: Partial = {},
+) =>
+ ({
+ id: `graph-${status}`,
+ title: "Ship auth",
+ status,
+ nodes,
+ updatedAt: "2026-10-01T00:00:00.000Z",
+ ...overrides,
+ }) as unknown as TaskGraph;
+
+describe("taskGraphSummaryLabel", () => {
+ it("counts finished tasks while running", () => {
+ expect(
+ taskGraphSummaryLabel(
+ graph("running", [node("a", "succeeded"), node("b", "running"), node("c", "pending")]),
+ ),
+ ).toBe("Running · 1 of 3 done");
+ });
+
+ it("counts waiting tasks while running", () => {
+ expect(
+ taskGraphSummaryLabel(graph("running", [node("a", "succeeded"), node("b", "waiting")])),
+ ).toBe("Running · 1 of 2 done · 1 waiting");
+ });
+
+ it("names failures on a failed graph", () => {
+ expect(
+ taskGraphSummaryLabel(graph("failed", [node("a", "failed"), node("b", "skipped", ["a"])])),
+ ).toBe("Failed · 1 failed");
+ });
+
+ it("counts tasks on a draft", () => {
+ expect(taskGraphSummaryLabel(graph("draft", [node("a", "pending")]))).toBe("Draft · 1 task");
+ });
+});
+
+describe("resolveTaskGraphSegment", () => {
+ it("is absent without graphs", () => {
+ expect(resolveTaskGraphSegment(null)).toBeNull();
+ expect(resolveTaskGraphSegment([])).toBeNull();
+ });
+
+ it("prefers an open graph over a newer finished one", () => {
+ const segment = resolveTaskGraphSegment([
+ graph("succeeded", [node("a", "succeeded")], { updatedAt: "2026-10-09T00:00:00.000Z" }),
+ graph("running", [node("a", "succeeded"), node("b", "running")]),
+ ]);
+ expect(segment).toMatchObject({ label: "Graph 1/2", tone: "working" });
+ });
+
+ it("stays reachable once every graph has finished", () => {
+ expect(resolveTaskGraphSegment([graph("failed", [node("a", "failed")])])).toMatchObject({
+ label: "Graph failed",
+ tone: "failed",
+ });
+ });
+
+ it("counts several open graphs", () => {
+ expect(
+ resolveTaskGraphSegment([
+ graph("draft", [node("a", "pending")]),
+ graph("running", [node("a", "running")]),
+ ])?.label,
+ ).toBe("2 graphs");
+ });
+});
+
+describe("sortTaskGraphs", () => {
+ it("puts running before draft before finished, newest first within each", () => {
+ const sorted = sortTaskGraphs([
+ graph("succeeded", [], { id: "old" as never, updatedAt: "2026-10-01T00:00:00.000Z" }),
+ graph("draft", [], { id: "draft" as never }),
+ graph("failed", [], { id: "new" as never, updatedAt: "2026-10-05T00:00:00.000Z" }),
+ graph("running", [], { id: "running" as never }),
+ ]);
+ expect(sorted.map((entry) => entry.id)).toEqual(["running", "draft", "new", "old"]);
+ });
+});
+
+describe("taskGraphActions", () => {
+ it("runs only drafts and cancels only open graphs", () => {
+ expect(taskGraphActions({ status: "draft" })).toEqual({ run: true, cancel: true });
+ expect(taskGraphActions({ status: "running" })).toEqual({ run: false, cancel: true });
+ expect(taskGraphActions({ status: "failed" })).toEqual({ run: false, cancel: false });
+ });
+});
+
+describe("taskGraphNodeActions", () => {
+ it("cancels the branch of unfinished nodes in an open graph", () => {
+ const nodes = [node("a", "running"), node("b", "pending", ["a"])];
+ expect(taskGraphNodeActions(graph("running", nodes), nodes[0]!)).toEqual({
+ cancelBranch: true,
+ retry: false,
+ });
+ expect(taskGraphNodeActions(graph("running", nodes), nodes[1]!).cancelBranch).toBe(true);
+ });
+
+ it("cancels the branch of a waiting node, started or not", () => {
+ const nodes = [node("a", "waiting")];
+ expect(taskGraphNodeActions(graph("running", nodes), nodes[0]!)).toEqual({
+ cancelBranch: true,
+ retry: false,
+ });
+ });
+
+ it("retries failed nodes, including in a finished graph", () => {
+ const nodes = [node("a", "failed")];
+ expect(taskGraphNodeActions(graph("failed", nodes), nodes[0]!)).toEqual({
+ cancelBranch: false,
+ retry: true,
+ });
+ });
+
+ it("offers no retry for a skipped node whose dependency still blocks it", () => {
+ const nodes = [node("a", "failed"), node("b", "skipped", ["a"])];
+ expect(taskGraphNodeActions(graph("failed", nodes), nodes[1]!).retry).toBe(false);
+ });
+
+ it("retries a skipped node once its dependency has succeeded", () => {
+ const nodes = [node("a", "succeeded"), node("b", "skipped", ["a"])];
+ expect(taskGraphNodeActions(graph("failed", nodes), nodes[1]!).retry).toBe(true);
+ });
+
+ it("takes no edits on a cancelled graph", () => {
+ const nodes = [node("a", "cancelled")];
+ expect(taskGraphNodeActions(graph("cancelled", nodes), nodes[0]!)).toEqual({
+ cancelBranch: false,
+ retry: false,
+ });
+ });
+});
+
+describe("taskGraphNodeWaitDetail", () => {
+ const now = new Date(2026, 9, 10, 9, 0).getTime();
+ const waitUntil = new Date(2026, 9, 10, 15, 40).toISOString();
+ const time = new Date(waitUntil).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
+ const waiting = (overrides: Partial) =>
+ ({ status: "waiting", waitUntil, threadId: null, ...overrides }) as TaskGraphNode;
+
+ it("says when a scheduled node starts", () => {
+ expect(taskGraphNodeWaitDetail(waiting({ waitReason: "scheduled" }), now)).toBe(
+ `Starts ${time}`,
+ );
+ });
+
+ it("says whether a limited node starts or continues its thread after the reset", () => {
+ expect(taskGraphNodeWaitDetail(waiting({ waitReason: "usage_limit" }), now)).toBe(
+ `Usage limit reached — starts after the reset, ${time}`,
+ );
+ expect(
+ taskGraphNodeWaitDetail(
+ waiting({ waitReason: "usage_limit", threadId: "thread-a" as TaskGraphNode["threadId"] }),
+ now,
+ ),
+ ).toBe(`Usage limit reached — continues on its thread after the reset, ${time}`);
+ });
+
+ it("names the day once the wait ends after today", () => {
+ const tomorrow = waiting({ waitReason: "scheduled" });
+ expect(taskGraphNodeWaitDetail(tomorrow, now - 86_400_000)).toBe(`Starts tomorrow at ${time}`);
+ expect(taskGraphNodeWaitDetail({ ...tomorrow, status: "pending" }, now)).toBeNull();
+ });
+});
diff --git a/apps/mobile/src/features/threads/task-graph-presentation.ts b/apps/mobile/src/features/threads/task-graph-presentation.ts
new file mode 100644
index 000000000000..27af27d669f1
--- /dev/null
+++ b/apps/mobile/src/features/threads/task-graph-presentation.ts
@@ -0,0 +1,204 @@
+import type {
+ TaskGraph,
+ TaskGraphNode,
+ TaskGraphNodeStatus,
+ TaskGraphStatus,
+} from "@t3tools/contracts";
+import { isActiveTaskGraphNodeStatus } from "@t3tools/shared/taskGraph";
+
+import type { SubagentRowTone } from "./threadAgentsPresentation";
+
+const BLOCKING_STATUSES: ReadonlySet = new Set([
+ "failed",
+ "cancelled",
+ "skipped",
+]);
+
+export const isOpenTaskGraph = (graph: Pick): boolean =>
+ graph.status === "draft" || graph.status === "running";
+
+export function taskGraphNodeTone(status: TaskGraphNodeStatus): SubagentRowTone {
+ if (isActiveTaskGraphNodeStatus(status)) return "working";
+ if (status === "succeeded") return "completed";
+ if (status === "failed") return "failed";
+ return "stopped";
+}
+
+export function taskGraphTone(status: TaskGraphStatus): SubagentRowTone {
+ if (status === "running") return "working";
+ if (status === "succeeded") return "completed";
+ if (status === "failed") return "failed";
+ return "stopped";
+}
+
+export function taskGraphNodeStatusLabel(status: TaskGraphNodeStatus): string {
+ switch (status) {
+ case "pending":
+ return "Pending";
+ case "waiting":
+ return "Waiting";
+ case "running":
+ return "Running";
+ case "delivering":
+ return "Opening PR";
+ case "succeeded":
+ return "Done";
+ case "failed":
+ return "Failed";
+ case "cancelled":
+ return "Cancelled";
+ case "skipped":
+ return "Skipped";
+ }
+}
+
+const localDayStart = (date: Date) =>
+ new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
+
+/** An upcoming instant: "3:40 PM" today, "tomorrow at 3:40 PM", else "Oct 13, 3:40 PM". */
+function formatTaskGraphWaitTime(iso: string, now: number): string {
+ const at = new Date(iso);
+ if (!Number.isFinite(at.getTime())) return "";
+ const time = at.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
+ // Round so DST-shifted 23/25 hour days still count as whole days.
+ const days = Math.round((localDayStart(at) - localDayStart(new Date(now))) / 86_400_000);
+ if (days <= 0) return time;
+ if (days === 1) return `tomorrow at ${time}`;
+ const date = at.toLocaleDateString([], {
+ month: "short",
+ day: "numeric",
+ ...(at.getFullYear() !== new Date(now).getFullYear() ? { year: "numeric" as const } : {}),
+ });
+ return `${date}, ${time}`;
+}
+
+/** Why a waiting node waits and what happens next, as on web. Null when it is not waiting. */
+export function taskGraphNodeWaitDetail(
+ node: Pick,
+ now: number,
+): string | null {
+ if (node.status !== "waiting") return null;
+ const at = node.waitUntil === null ? null : formatTaskGraphWaitTime(node.waitUntil, now);
+ if (node.waitReason === "usage_limit") {
+ const next =
+ node.threadId === null ? "starts after the reset" : "continues on its thread after the reset";
+ return `Usage limit reached — ${next}${at === null ? "" : `, ${at}`}`;
+ }
+ return at === null ? "Waiting to start" : `Starts ${at}`;
+}
+
+export function taskGraphStatusLabel(status: TaskGraphStatus): string {
+ switch (status) {
+ case "draft":
+ return "Draft";
+ case "running":
+ return "Running";
+ case "succeeded":
+ return "Done";
+ case "failed":
+ return "Failed";
+ case "cancelled":
+ return "Cancelled";
+ }
+}
+
+/** One-line status for a graph, such as "Running · 2 of 5 done". */
+export function taskGraphSummaryLabel(graph: Pick): string {
+ const total = graph.nodes.length;
+ const tasks = `${total} ${total === 1 ? "task" : "tasks"}`;
+ if (graph.status === "draft" || graph.status === "succeeded") {
+ return `${taskGraphStatusLabel(graph.status)} · ${tasks}`;
+ }
+ if (graph.status === "running") {
+ const done = graph.nodes.filter((node) => node.status === "succeeded").length;
+ const waiting = graph.nodes.filter((node) => node.status === "waiting").length;
+ return `Running · ${done} of ${total} done${waiting > 0 ? ` · ${waiting} waiting` : ""}`;
+ }
+ const failed = graph.nodes.filter((node) => node.status === "failed").length;
+ return failed > 0
+ ? `${taskGraphStatusLabel(graph.status)} · ${failed} failed`
+ : `${taskGraphStatusLabel(graph.status)} · ${tasks}`;
+}
+
+/** Open graphs first (running before draft), then the most recently changed. */
+export function sortTaskGraphs(graphs: ReadonlyArray): ReadonlyArray {
+ const rank = (graph: TaskGraph) =>
+ graph.status === "running" ? 0 : graph.status === "draft" ? 1 : 2;
+ return [...graphs].sort(
+ (left, right) => rank(left) - rank(right) || right.updatedAt.localeCompare(left.updatedAt),
+ );
+}
+
+export interface TaskGraphSegment {
+ readonly label: string;
+ readonly accessibilityLabel: string;
+ readonly tone: SubagentRowTone;
+}
+
+/**
+ * The thread's task graph entry point. Finished graphs keep it visible so a
+ * failed node can still be retried and its pull requests reached.
+ */
+export function resolveTaskGraphSegment(
+ graphs: ReadonlyArray | null,
+): TaskGraphSegment | null {
+ if (graphs === null || graphs.length === 0) return null;
+ const open = graphs.filter(isOpenTaskGraph);
+ if (open.length > 1) {
+ return {
+ label: `${open.length} graphs`,
+ accessibilityLabel: `${open.length} open task graphs`,
+ tone: open.some((graph) => graph.status === "running") ? "working" : "stopped",
+ };
+ }
+ const graph = sortTaskGraphs(graphs)[0]!;
+ const label =
+ graph.status === "running"
+ ? `Graph ${graph.nodes.filter((node) => node.status === "succeeded").length}/${graph.nodes.length}`
+ : `Graph ${taskGraphStatusLabel(graph.status).toLowerCase()}`;
+ return {
+ label,
+ accessibilityLabel: `Task graph ${graph.title}, ${taskGraphSummaryLabel(graph)}`,
+ tone: taskGraphTone(graph.status),
+ };
+}
+
+export interface TaskGraphActions {
+ readonly run: boolean;
+ readonly cancel: boolean;
+}
+
+export const taskGraphActions = (graph: Pick): TaskGraphActions => ({
+ run: graph.status === "draft",
+ cancel: isOpenTaskGraph(graph),
+});
+
+export interface TaskGraphNodeActions {
+ readonly cancelBranch: boolean;
+ readonly retry: boolean;
+}
+
+/**
+ * What a node offers. A cancelled graph takes no edits. Retrying a skipped
+ * node whose dependency still has not succeeded would only skip it again, so
+ * that retry belongs on the dependency instead.
+ */
+export function taskGraphNodeActions(
+ graph: Pick,
+ node: Pick,
+): TaskGraphNodeActions {
+ if (graph.status === "cancelled") return { cancelBranch: false, retry: false };
+ const cancelBranch =
+ isOpenTaskGraph(graph) &&
+ (node.status === "pending" ||
+ node.status === "waiting" ||
+ isActiveTaskGraphNodeStatus(node.status));
+ if (node.status === "failed" || node.status === "cancelled") return { cancelBranch, retry: true };
+ if (node.status !== "skipped") return { cancelBranch, retry: false };
+ const statusByKey = new Map(graph.nodes.map((other) => [other.key, other.status]));
+ const blocked = node.dependsOn.some((key) => {
+ const status = statusByKey.get(key);
+ return status !== undefined && BLOCKING_STATUSES.has(status);
+ });
+ return { cancelBranch, retry: !blocked };
+}
diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts
index 5b8d483dc102..9d7cb6d32e12 100644
--- a/apps/server/src/auth/RpcAuthorization.ts
+++ b/apps/server/src/auth/RpcAuthorization.ts
@@ -13,6 +13,7 @@ import {
AuthSettingsWriteScope,
AuthProvidersManageScope,
AuthEnvironmentMaintainScope,
+ TASK_GRAPH_PEER_SCOPES,
AuthFilesystemReadScope,
AuthFilesystemWriteScope,
AuthDiagnosticsReadScope,
@@ -115,6 +116,8 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.serverGetBackgroundPolicy]: AuthOrchestrationReadScope,
[WS_METHODS.scheduledTasksList]: AuthOrchestrationReadScope,
[WS_METHODS.scheduledTasksSubscribe]: AuthOrchestrationReadScope,
+ [WS_METHODS.taskGraphsSubscribe]: AuthOrchestrationReadScope,
+ [WS_METHODS.taskGraphPeersSubscribe]: AuthOrchestrationReadScope,
[WS_METHODS.secretsAnswerRequest]: AuthOrchestrationOperateScope,
// Delivery logs hold request bodies, so they need the same scope as the URL.
[WS_METHODS.scheduledTasksListWebhookDeliveries]: AuthOrchestrationOperateScope,
@@ -266,6 +269,10 @@ const requiredScopesForRpcCall = (
: AuthOrchestrationReadScope,
];
}
+ if (method === WS_METHODS.taskGraphPeersIssueGrant) {
+ // A grant carries these scopes, so never hand out more than the caller holds itself.
+ return [AuthEnvironmentMaintainScope, ...TASK_GRAPH_PEER_SCOPES];
+ }
if (method === WS_METHODS.serverUpdateSettings) return requiredScopesForSettingsUpdate(payload);
if (method === WS_METHODS.projectsMutate) {
return requiredScopesForProjectMutation(decodeProjectMutation(payload));
diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts
index 6079ee714f69..bc5a3f868808 100644
--- a/apps/server/src/git/GitManager.ts
+++ b/apps/server/src/git/GitManager.ts
@@ -2216,6 +2216,7 @@ export const make = Effect.gen(function* () {
cwd: string,
fallbackBranch: string | null,
emit: GitActionProgressEmitter,
+ baseBranchOverride?: string,
) {
const provider = yield* sourceControlProvider(cwd);
const terms = getChangeRequestTerminologyForKind(provider.kind);
@@ -2253,7 +2254,9 @@ export const make = Effect.gen(function* () {
};
}
- const baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef, headContext);
+ const baseBranch =
+ baseBranchOverride ??
+ (yield* resolveBaseBranch(cwd, branch, details.upstreamRef, headContext));
yield* emit({
kind: "phase_started",
phase: "pr",
@@ -3010,7 +3013,13 @@ export const make = Effect.gen(function* () {
.pipe(
Effect.tap(() => Ref.set(currentPhase, Option.some("pr"))),
Effect.flatMap(() =>
- runPrStep(textGenerationSettings, input.cwd, currentBranch, progress.emit),
+ runPrStep(
+ textGenerationSettings,
+ input.cwd,
+ currentBranch,
+ progress.emit,
+ input.baseBranch,
+ ),
),
)
: { status: "skipped_not_requested" as const };
diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts
index 0e2b52696712..26d7181d5d6a 100644
--- a/apps/server/src/mcp/McpHttpServer.ts
+++ b/apps/server/src/mcp/McpHttpServer.ts
@@ -47,6 +47,8 @@ import {
} from "./toolkits/preview/tools.ts";
import * as WorktreeHandlers from "./toolkits/worktree/handlers.ts";
import { WorktreeToolkit } from "./toolkits/worktree/tools.ts";
+import * as TaskGraphHandlers from "./toolkits/taskGraph/handlers.ts";
+import { TaskGraphToolkit } from "./toolkits/taskGraph/tools.ts";
import * as WorktreeMcpService from "./WorktreeMcpService.ts";
import * as PullRequestsHandlers from "./toolkits/pullRequests/handlers.ts";
import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts";
@@ -816,6 +818,8 @@ export const layerOrchestratorToolkit = toolkitRegistration(
export const layerThreadToolkit = toolkitRegistration(ThreadToolkit, ThreadHandlers.layer);
+export const layerTaskGraphToolkit = toolkitRegistration(TaskGraphToolkit, TaskGraphHandlers.layer);
+
const layerWorktreeToolkitRegistration = toolkitRegistration(
WorktreeToolkit,
WorktreeHandlers.layer,
@@ -870,6 +874,7 @@ export const layer = Layer.mergeAll(
layerPreviewToolkit,
layerOrchestratorToolkit,
layerThreadToolkit,
+ layerTaskGraphToolkit,
layerAttachmentToolkit,
layerProjectRegistration,
layerEnvironmentToolkit,
diff --git a/apps/server/src/mcp/toolkits/taskGraph/handlers.ts b/apps/server/src/mcp/toolkits/taskGraph/handlers.ts
new file mode 100644
index 000000000000..c3ed8ebca62c
--- /dev/null
+++ b/apps/server/src/mcp/toolkits/taskGraph/handlers.ts
@@ -0,0 +1,103 @@
+import { OrchestratorMcpFailure, type TaskGraphId, type ThreadId } from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+
+import * as McpInvocationContext from "../../McpInvocationContext.ts";
+import * as McpToolAccess from "../../McpToolAccess.ts";
+import * as ServerEnvironment from "../../../environment/ServerEnvironment.ts";
+import * as TaskGraphPeers from "../../../taskGraph/TaskGraphPeers.ts";
+import * as TaskGraphService from "../../../taskGraph/TaskGraphService.ts";
+import { TaskGraphToolkit } from "./tools.ts";
+
+const failure = (code: "invalid_request" | "orchestration_error", message: string) =>
+ new OrchestratorMcpFailure({ code, message });
+
+/** The calling thread; the access gate already refused callers without one. */
+const callerThread = Effect.gen(function* () {
+ const scope = yield* McpInvocationContext.McpInvocationContext;
+ if (scope.thread === undefined) {
+ return yield* failure(
+ "invalid_request",
+ "Task graphs need an agent running inside a T3 thread.",
+ );
+ }
+ return scope.thread.threadId;
+});
+
+const orchestrationError = (error: { readonly message: string }) =>
+ failure("orchestration_error", error.message);
+
+/** A graph the calling thread created; agents only steer their own graphs. */
+const ownGraph = (graphId: TaskGraphId, threadId: ThreadId) =>
+ Effect.gen(function* () {
+ const service = yield* TaskGraphService.TaskGraphService;
+ const graph = yield* service.get(graphId).pipe(Effect.mapError(orchestrationError));
+ if (graph.threadId !== threadId) {
+ return yield* failure("invalid_request", "That task graph belongs to another thread.");
+ }
+ return service;
+ });
+
+const handlers = {
+ task_graph_create: McpToolAccess.actsAsCaller((input) =>
+ Effect.gen(function* () {
+ const threadId = yield* callerThread;
+ const service = yield* TaskGraphService.TaskGraphService;
+ const graph = yield* service
+ .create({ ...input, threadId })
+ .pipe(Effect.mapError(orchestrationError));
+ return { graph };
+ }),
+ ),
+ task_graph_list: McpToolAccess.readsAsCaller(() =>
+ Effect.gen(function* () {
+ const threadId = yield* callerThread;
+ const service = yield* TaskGraphService.TaskGraphService;
+ const graphs = yield* service
+ .listForThread(threadId)
+ .pipe(Effect.mapError(orchestrationError));
+ const local = yield* (yield* ServerEnvironment.ServerEnvironment).getDescriptor;
+ const { peers } = yield* (yield* TaskGraphPeers.TaskGraphPeers).list.pipe(
+ Effect.mapError(orchestrationError),
+ );
+ return {
+ graphs,
+ machines: [
+ {
+ environmentId: local.environmentId,
+ label: local.label,
+ thisMachine: true,
+ connected: true,
+ },
+ ...peers.map((peer) => ({
+ environmentId: peer.environmentId,
+ label: peer.label,
+ thisMachine: false,
+ connected: peer.status === "connected",
+ })),
+ ],
+ };
+ }),
+ ),
+ task_graph_edit: McpToolAccess.actsAsCaller(({ graphId, edits }) =>
+ Effect.gen(function* () {
+ const service = yield* ownGraph(graphId, yield* callerThread);
+ return {
+ graph: yield* service.edit(graphId, edits).pipe(Effect.mapError(orchestrationError)),
+ };
+ }),
+ ),
+ task_graph_run: McpToolAccess.actsAsCaller(({ graphId }) =>
+ Effect.gen(function* () {
+ const service = yield* ownGraph(graphId, yield* callerThread);
+ return { graph: yield* service.run(graphId).pipe(Effect.mapError(orchestrationError)) };
+ }),
+ ),
+ task_graph_cancel: McpToolAccess.actsAsCaller(({ graphId }) =>
+ Effect.gen(function* () {
+ const service = yield* ownGraph(graphId, yield* callerThread);
+ return { graph: yield* service.cancel(graphId).pipe(Effect.mapError(orchestrationError)) };
+ }),
+ ),
+} satisfies McpToolAccess.Handlers;
+
+export const layer = McpToolAccess.toLayer(TaskGraphToolkit, handlers);
diff --git a/apps/server/src/mcp/toolkits/taskGraph/tools.ts b/apps/server/src/mcp/toolkits/taskGraph/tools.ts
new file mode 100644
index 000000000000..cbc8c0ec3dc4
--- /dev/null
+++ b/apps/server/src/mcp/toolkits/taskGraph/tools.ts
@@ -0,0 +1,130 @@
+import {
+ EnvironmentId,
+ MAX_TASK_GRAPH_NODES,
+ OrchestratorMcpFailure,
+ TaskGraph,
+ TaskGraphEdit,
+ TaskGraphId,
+ TaskGraphNodeInput,
+ TrimmedNonEmptyString,
+} from "@t3tools/contracts";
+import { Tool, Toolkit } from "effect/ai";
+import * as Schema from "effect/Schema";
+
+import * as McpInvocationContext from "../../McpInvocationContext.ts";
+import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
+import * as ServerEnvironment from "../../../environment/ServerEnvironment.ts";
+import * as TaskGraphPeers from "../../../taskGraph/TaskGraphPeers.ts";
+import * as TaskGraphService from "../../../taskGraph/TaskGraphService.ts";
+
+const dependencies = [
+ McpInvocationContext.McpInvocationContext,
+ ThreadManagementService.ThreadManagementService,
+ TaskGraphService.TaskGraphService,
+ TaskGraphPeers.TaskGraphPeers,
+ ServerEnvironment.ServerEnvironment,
+];
+
+const GraphResult = Schema.Struct({ graph: TaskGraph });
+const GraphTarget = Schema.Struct({ graphId: TaskGraphId });
+
+export const TaskGraphCreateTool = Tool.make("task_graph_create", {
+ description:
+ "Needs an agent running inside a T3 thread. Plan work as a graph of agent tasks and run it. Each node runs as its own T3 thread, in parallel where dependencies allow, and receives every dependency's final summary. Write each prompt so an agent with no access to this conversation can do it. " +
+ "WORKSPACES: by default a node gets its own git worktree, branching from its first dependency's branch (or baseRef, default this thread's branch). workspace 'dependency' continues in the first dependency's worktree and branch instead, so several nodes build one branch in turn. workspace 'root' works in the project folder with no branch, commit or PR; use it for read-only work such as reviews and audits. " +
+ "PULL REQUESTS: a succeeding node's work is committed. Nodes nothing depends on also push and open a PR; set pullRequest per node to change that. A PR targets the nearest earlier node (following first dependencies) that has its own PR, else the graph base, so PRs only on branch ends give one PR each against the base, and PRs on inner nodes too give a stack. " +
+ "STACKED PRS: when the user asks for stacked PRs, do not do the work yourself one layer after another. Make each layer a node (or a short 'dependency' chain) with pullRequest true, each depending on the layer below, and put independent work for a layer in its own subtree that merges back before the next layer. One graph then produces the whole stack. " +
+ "MERGING: a node with several dependencies starts from the first one's branch and merges the others, resolving conflicts. Only add such a combining node when the user asks for the pieces to be reconciled or they cannot work apart; otherwise leave the branches independent, each ending in its own PR. " +
+ "MACHINES: set environmentId on a node to pin it to a machine listed by task_graph_list (only when the user asks); otherwise omit it and nodes are balanced across machines by load. A 'dependency' node always runs where its dependency ran. Set modelSelection on a node only when the user asks for a different model. " +
+ "TIMING: set startAt on a node to hold it until a time the user asks for, such as overnight. A node never starts into a used-up usage limit: it waits for the reset. A node whose run is stopped by a usage limit waits too, and continues on its own thread at the reset, so do not retry or replace it. " +
+ "RUNNING: if the user asked to see, review or edit the plan first, pass run=false so it waits as a draft they can edit in the chat; if they asked to just do it, pass run=true; otherwise omit run to follow their setting. Change a graph later with task_graph_edit.",
+ parameters: Schema.Struct({
+ title: TrimmedNonEmptyString.check(Schema.isMaxLength(120)),
+ nodes: Schema.Array(TaskGraphNodeInput).check(
+ Schema.isMinLength(1),
+ Schema.isMaxLength(MAX_TASK_GRAPH_NODES),
+ ),
+ baseRef: Schema.optional(TrimmedNonEmptyString),
+ run: Schema.optional(Schema.Boolean),
+ }),
+ success: GraphResult,
+ failure: OrchestratorMcpFailure,
+ failureMode: "return",
+ dependencies,
+})
+ .annotate(Tool.Title, "Create a task graph")
+ // Running nodes can commit, push and open pull requests.
+ .annotate(Tool.Destructive, true)
+ .annotate(Tool.OpenWorld, true);
+
+const TaskGraphListTool = Tool.make("task_graph_list", {
+ description:
+ "Needs an agent running inside a T3 thread. List the task graphs this thread created, with every node's status, thread, machine, branch, summary and pull request, and the machines nodes can run on (pass one's environmentId to pin a node there).",
+ success: Schema.Struct({
+ graphs: Schema.Array(TaskGraph),
+ machines: Schema.Array(
+ Schema.Struct({
+ environmentId: EnvironmentId,
+ label: Schema.String,
+ thisMachine: Schema.Boolean,
+ connected: Schema.Boolean,
+ }),
+ ),
+ }),
+ failure: OrchestratorMcpFailure,
+ failureMode: "return",
+ dependencies,
+})
+ .annotate(Tool.Title, "List task graphs")
+ .annotate(Tool.Readonly, true)
+ .annotate(Tool.Destructive, false)
+ .annotate(Tool.Idempotent, true);
+
+const TaskGraphEditTool = Tool.make("task_graph_edit", {
+ description:
+ "Change a task graph from this thread, running or not. Edits apply in order and all or nothing. add_node adds work (it starts as soon as its dependencies succeeded); update_node and remove_node change nodes that have not started; cancel_branch cuts a node and everything below it, stopping any that are running; retry_node reruns a node that failed or was cancelled, along with what was skipped below it.",
+ parameters: Schema.Struct({
+ graphId: TaskGraphId,
+ edits: Schema.Array(TaskGraphEdit).check(Schema.isMinLength(1)),
+ }),
+ success: GraphResult,
+ failure: OrchestratorMcpFailure,
+ failureMode: "return",
+ dependencies,
+})
+ .annotate(Tool.Title, "Edit a task graph")
+ .annotate(Tool.Destructive, true);
+
+const TaskGraphRunTool = Tool.make("task_graph_run", {
+ description:
+ "Start a draft task graph from this thread. Only call this when the user asks to run it.",
+ parameters: GraphTarget,
+ success: GraphResult,
+ failure: OrchestratorMcpFailure,
+ failureMode: "return",
+ dependencies,
+})
+ .annotate(Tool.Title, "Run a task graph")
+ // Running nodes can commit, push and open pull requests.
+ .annotate(Tool.Destructive, true)
+ .annotate(Tool.OpenWorld, true);
+
+const TaskGraphCancelTool = Tool.make("task_graph_cancel", {
+ description:
+ "Cancel a whole task graph from this thread, stopping every running node. Finished nodes and their pull requests stay.",
+ parameters: GraphTarget,
+ success: GraphResult,
+ failure: OrchestratorMcpFailure,
+ failureMode: "return",
+ dependencies,
+})
+ .annotate(Tool.Title, "Cancel a task graph")
+ .annotate(Tool.Destructive, true);
+
+export const TaskGraphToolkit = Toolkit.make(
+ TaskGraphCreateTool,
+ TaskGraphListTool,
+ TaskGraphEditTool,
+ TaskGraphRunTool,
+ TaskGraphCancelTool,
+);
diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts
index c0006f7fe171..3f1ad32e7b4e 100644
--- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts
+++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts
@@ -1,4 +1,6 @@
import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts";
+import * as TaskGraphPeers from "../../../taskGraph/TaskGraphPeers.ts";
+import * as TaskGraphService from "../../../taskGraph/TaskGraphService.ts";
import * as ProjectionStore from "../../../orchestration-v2/ProjectionStore.ts";
import * as DeviceService from "../../../device/DeviceService.ts";
import * as ServerConfig from "../../../config.ts";
@@ -37,6 +39,8 @@ import * as PreviewBrowser from "../../../preview/PreviewBrowser.ts";
const layerStubServices = Layer.mergeAll(
Layer.mock(Orchestrator.OrchestratorV2)({}),
+ Layer.mock(TaskGraphService.TaskGraphService)({}),
+ Layer.mock(TaskGraphPeers.TaskGraphPeers)({}),
Layer.mock(ProjectionStore.ProjectionStoreV2)({}),
Layer.mock(DeviceService.DeviceService)({}),
Layer.mock(ThreadManagementService.ThreadManagementService)({}),
diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts
index 167ce8d11fff..206dee76fbd2 100644
--- a/apps/server/src/observability/RpcInstrumentation.ts
+++ b/apps/server/src/observability/RpcInstrumentation.ts
@@ -84,6 +84,16 @@ const RPC_AGGREGATES = {
[WS_METHODS.serverReportClientActivity]: "server",
[WS_METHODS.serverReportHostPowerState]: "server",
[WS_METHODS.serverGetBackgroundPolicy]: "server",
+ [WS_METHODS.taskGraphsSubscribe]: "taskGraphs",
+ [WS_METHODS.taskGraphsCreate]: "taskGraphs",
+ [WS_METHODS.taskGraphsEdit]: "taskGraphs",
+ [WS_METHODS.taskGraphsRun]: "taskGraphs",
+ [WS_METHODS.taskGraphsCancel]: "taskGraphs",
+ [WS_METHODS.taskGraphPeersSubscribe]: "taskGraphs",
+ [WS_METHODS.taskGraphPeersAdd]: "taskGraphs",
+ [WS_METHODS.taskGraphPeersRemove]: "taskGraphs",
+ [WS_METHODS.taskGraphPeersSetWeight]: "taskGraphs",
+ [WS_METHODS.taskGraphPeersIssueGrant]: "taskGraphs",
[WS_METHODS.scheduledTasksList]: "scheduledTasks",
[WS_METHODS.scheduledTasksSubscribe]: "scheduledTasks",
[WS_METHODS.scheduledTasksUpsert]: "scheduledTasks",
diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts
index 623b1e084118..32b133c187c6 100644
--- a/apps/server/src/orchestration-v2/runtimeLayer.ts
+++ b/apps/server/src/orchestration-v2/runtimeLayer.ts
@@ -50,6 +50,8 @@ import * as ThreadLifecycleService from "./ThreadLifecycleService.ts";
import * as ThreadForkService from "./ThreadForkService.ts";
import * as TurnItemPositionStore from "./TurnItemPositionStore.ts";
import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
+import * as TaskGraphPeers from "../taskGraph/TaskGraphPeers.ts";
+import * as TaskGraphService from "../taskGraph/TaskGraphService.ts";
import * as SecretRequests from "../secrets/SecretRequests.ts";
/** The shared application event log and its command receipts. */
@@ -281,6 +283,18 @@ const layerScheduledTaskProvided = ScheduledTaskService.layer.pipe(
),
),
);
+const layerTaskGraphPeersProvided = TaskGraphPeers.layer.pipe(Layer.provide(layerProjectService));
+const layerTaskGraphProvided = TaskGraphService.layer.pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ layerTaskGraphPeersProvided,
+ layerThreadLaunchProvided,
+ layerThreadManagementProvided,
+ layerOrchestratorProvided,
+ layerProjectService,
+ ),
+ ),
+);
const layerProviderContinuationWorkerProvided = ProviderContinuationService.layer.pipe(
Layer.provide(
Layer.mergeAll(
@@ -354,6 +368,8 @@ export const layerProduction = Layer.mergeAll(
layerThreadLaunchProvided,
layerThreadLifecycleProvided,
layerScheduledTaskProvided,
+ layerTaskGraphProvided,
+ layerTaskGraphPeersProvided,
layerSecretRequestsProvided,
UsageLimitRecoveryWorker.layer.pipe(
Layer.provide(Layer.mergeAll(ProjectionStore.layer, layerThreadManagementProvided)),
diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts
index 7990c53ac454..aba6b06205ac 100644
--- a/apps/server/src/persistence/Migrations.ts
+++ b/apps/server/src/persistence/Migrations.ts
@@ -75,6 +75,7 @@ import Migration0058 from "./Migrations/058_WebhookRelayDeliveries.ts";
import Migration0059 from "./Migrations/059_McpAppModelContext.ts";
import Migration0060 from "./Migrations/060_ThreadSnapshotWindowIndexes.ts";
import Migration0061 from "./Migrations/061_PullRequestReviewGuides.ts";
+import Migration0062 from "./Migrations/062_TaskGraphs.ts";
/**
* Migration loader with all migrations defined inline.
@@ -150,6 +151,7 @@ export const migrationEntries = [
[59, "McpAppModelContext", Migration0059],
[60, "ThreadSnapshotWindowIndexes", Migration0060],
[61, "PullRequestReviewGuides", Migration0061],
+ [62, "TaskGraphs", Migration0062],
] as const;
export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
diff --git a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts
index 7d5c845cbf22..537908dfeb55 100644
--- a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts
+++ b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts
@@ -13,7 +13,7 @@ layer("055_OrchestrationV2", (it) => {
Effect.sync(() => {
assert.deepStrictEqual(
migrationEntries.map(([id]) => id),
- Array.from({ length: 61 }, (_, index) => index + 1),
+ Array.from({ length: 62 }, (_, index) => index + 1),
);
}),
);
@@ -33,6 +33,7 @@ layer("055_OrchestrationV2", (it) => {
[59, "McpAppModelContext"],
[60, "ThreadSnapshotWindowIndexes"],
[61, "PullRequestReviewGuides"],
+ [62, "TaskGraphs"],
]);
assert.deepStrictEqual(yield* runMigrations(), []);
@@ -60,6 +61,7 @@ layer("055_OrchestrationV2", (it) => {
{ migration_id: 59, name: "McpAppModelContext" },
{ migration_id: 60, name: "ThreadSnapshotWindowIndexes" },
{ migration_id: 61, name: "PullRequestReviewGuides" },
+ { migration_id: 62, name: "TaskGraphs" },
]);
const tables = yield* sql<{ readonly name: string }>`
diff --git a/apps/server/src/persistence/Migrations/062_TaskGraphs.ts b/apps/server/src/persistence/Migrations/062_TaskGraphs.ts
new file mode 100644
index 000000000000..ca07eef0126c
--- /dev/null
+++ b/apps/server/src/persistence/Migrations/062_TaskGraphs.ts
@@ -0,0 +1,41 @@
+import * as Effect from "effect/Effect";
+import * as SqlClient from "effect/sql/SqlClient";
+
+export default Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+
+ // A graph is written whole on every change; its nodes live in graph_json.
+ // Graphs are small (at most 48 nodes) and always read as a unit.
+ yield* sql`
+ CREATE TABLE IF NOT EXISTS task_graphs (
+ graph_id TEXT PRIMARY KEY,
+ project_id TEXT NOT NULL,
+ thread_id TEXT NOT NULL,
+ status TEXT NOT NULL,
+ graph_json TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ )
+ `;
+ yield* sql`
+ CREATE INDEX IF NOT EXISTS idx_task_graphs_thread
+ ON task_graphs(thread_id, created_at)
+ `;
+ yield* sql`
+ CREATE INDEX IF NOT EXISTS idx_task_graphs_running
+ ON task_graphs(status)
+ WHERE status = 'running'
+ `;
+
+ // Peer machines this server may place nodes on. The session token for each
+ // lives in the secret store, never in this table.
+ yield* sql`
+ CREATE TABLE IF NOT EXISTS task_graph_peers (
+ environment_id TEXT PRIMARY KEY,
+ label TEXT NOT NULL,
+ http_base_url TEXT NOT NULL,
+ weight INTEGER NOT NULL,
+ added_at TEXT NOT NULL
+ )
+ `;
+});
diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts
index 5881fdfda408..71c77fbc3dab 100644
--- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts
+++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts
@@ -42,6 +42,7 @@ describe("V2 preview upgrade", () => {
[59, "McpAppModelContext"],
[60, "ThreadSnapshotWindowIndexes"],
[61, "PullRequestReviewGuides"],
+ [62, "TaskGraphs"],
]);
assert.deepStrictEqual(yield* runMigrations(), []);
assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports);
@@ -126,6 +127,7 @@ describe("V2 preview upgrade", () => {
[59, "McpAppModelContext"],
[60, "ThreadSnapshotWindowIndexes"],
[61, "PullRequestReviewGuides"],
+ [62, "TaskGraphs"],
]);
}).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
);
diff --git a/apps/server/src/taskGraph/TaskGraphPeers.ts b/apps/server/src/taskGraph/TaskGraphPeers.ts
new file mode 100644
index 000000000000..e61fa61f660b
--- /dev/null
+++ b/apps/server/src/taskGraph/TaskGraphPeers.ts
@@ -0,0 +1,829 @@
+import * as NodeSocket from "@effect/platform-node/NodeSocket";
+import {
+ CommandId,
+ EnvironmentId,
+ ORCHESTRATION_V2_WS_METHODS,
+ repositoryGroupingKeyOf,
+ TaskGraphError,
+ WS_METHODS,
+ WsRpcGroup,
+ type HostResourcesSnapshot,
+ type ModelSelection,
+ type OrchestrationProjectShell,
+ type OrchestrationV2ShellStreamItem,
+ type OrchestrationV2ThreadLaunchInput,
+ type OrchestrationV2ThreadShell,
+ type ProjectId,
+ type RuntimeMode,
+ type TaskGraph,
+ type TaskGraphNode,
+ type TaskGraphPeer,
+ type TaskGraphPeerGrant,
+ type TaskGraphPeerListResult,
+ TASK_GRAPH_PEER_SCOPES,
+ type ThreadId,
+} from "@t3tools/contracts";
+import { resolveRemotePairingTarget } from "@t3tools/shared/remote";
+import * as Cause from "effect/Cause";
+import * as Clock from "effect/Clock";
+import * as Context from "effect/Context";
+import * as DateTime from "effect/DateTime";
+import * as Duration from "effect/Duration";
+import * as Effect from "effect/Effect";
+import * as FiberMap from "effect/FiberMap";
+import * as FetchHttpClient from "effect/http/FetchHttpClient";
+import * as HttpClient from "effect/http/HttpClient";
+import * as HttpClientRequest from "effect/http/HttpClientRequest";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as PubSub from "effect/PubSub";
+import * as Queue from "effect/Queue";
+import * as Schedule from "effect/Schedule";
+import * as Schema from "effect/Schema";
+import * as Stream from "effect/Stream";
+import * as SqlClient from "effect/sql/SqlClient";
+import * as RpcClient from "effect/rpc/RpcClient";
+import * as RpcSerialization from "effect/rpc/RpcSerialization";
+import * as Socket from "effect/socket/Socket";
+
+import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
+import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
+import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
+import * as ProjectService from "../project/ProjectService.ts";
+import type { NodeOutcome } from "./TaskGraphService.ts";
+
+/**
+ * How a finished peer node's work leaves the peer: pushed so other machines can
+ * build on it, with a PR against `baseBranch` when the node opens one, or not
+ * at all for work in the project folder.
+ */
+export interface PeerDelivery {
+ readonly action: "commit_push" | "commit_push_pr" | "none";
+ readonly baseBranch: string | null;
+}
+
+export interface PeerNodeStart {
+ readonly environmentId: EnvironmentId;
+ readonly graph: TaskGraph;
+ readonly node: TaskGraphNode;
+ readonly threadId: ThreadId;
+ readonly workspaceStrategy: OrchestrationV2ThreadLaunchInput["workspaceStrategy"];
+ readonly prompt: string;
+ readonly modelSelection: ModelSelection;
+ readonly runtimeMode: RuntimeMode;
+ readonly delivery: PeerDelivery;
+}
+
+export interface PeerCandidate {
+ readonly environmentId: EnvironmentId;
+ readonly resources: HostResourcesSnapshot | null;
+ readonly receivedAt: number;
+ /** Zero never takes balanced work, but a node pinned there still runs there. */
+ readonly weight: number;
+ /** Nodes this machine runs there now. */
+ readonly running: number;
+}
+
+/**
+ * Other machines this environment may run task graph nodes on. The graph
+ * service asks for placement candidates, hands over nodes placed on a peer,
+ * and hears back through `completions` when one ends.
+ */
+export class TaskGraphPeers extends Context.Service<
+ TaskGraphPeers,
+ {
+ /** Whether any peer is paired; branches are then pushed so other machines can reach them. */
+ readonly hasPeers: Effect.Effect;
+ /** Connected peers hosting the same repository, with fresh load, under `maxNodesPerPeer`. */
+ /** Connected peers with the project open, whatever their load; the caller applies limits. */
+ readonly candidates: (input: {
+ readonly projectId: ProjectId;
+ }) => Effect.Effect>;
+ readonly startNode: (input: PeerNodeStart) => Effect.Effect;
+ /** Watches a node started before a restart. */
+ readonly track: (input: {
+ readonly environmentId: EnvironmentId;
+ readonly threadId: ThreadId;
+ readonly delivery: PeerDelivery;
+ }) => Effect.Effect;
+ readonly interruptNode: (input: {
+ readonly environmentId: EnvironmentId;
+ readonly threadId: ThreadId;
+ }) => Effect.Effect;
+ readonly completions: Stream.Stream<{
+ readonly threadId: ThreadId;
+ readonly outcome: NodeOutcome;
+ }>;
+ readonly list: Effect.Effect;
+ readonly subscribe: Stream.Stream;
+ readonly add: (input: {
+ readonly pairingUrl: string;
+ readonly label?: string | undefined;
+ }) => Effect.Effect;
+ readonly remove: (
+ environmentId: EnvironmentId,
+ ) => Effect.Effect;
+ /** A grant another machine redeems to run nodes here; see `TaskGraphPeerGrant`. */
+ readonly issueGrant: (label: string) => Effect.Effect;
+ readonly setWeight: (
+ environmentId: EnvironmentId,
+ weight: number,
+ ) => Effect.Effect;
+ }
+>()("t3/taskGraph/TaskGraphPeers") {}
+
+/** No peers: every node runs on this machine. Used by tests. */
+export const layerNone = Layer.succeed(
+ TaskGraphPeers,
+ TaskGraphPeers.of({
+ hasPeers: Effect.succeed(false),
+ candidates: () => Effect.succeed([]),
+ startNode: () => Effect.fail(new TaskGraphError({ message: "No peer machines are paired." })),
+ track: () => Effect.void,
+ interruptNode: () => Effect.void,
+ completions: Stream.never,
+ list: Effect.succeed({ peers: [] }),
+ subscribe: Stream.make({ peers: [] }),
+ add: () => Effect.fail(new TaskGraphError({ message: "Peers are unavailable." })),
+ remove: () => Effect.succeed({ peers: [] }),
+ issueGrant: () => Effect.fail(new TaskGraphError({ message: "Peers are unavailable." })),
+ setWeight: () => Effect.succeed({ peers: [] }),
+ }),
+);
+
+const DEFAULT_PEER_WEIGHT = 50;
+const tokenSecretName = (environmentId: string) => `task-graph-peer-token:${environmentId}`;
+
+/** What delivering a peer node's branch produced: its PR, or why only the PR step failed. */
+interface PeerDelivered {
+ readonly url: string | null;
+ readonly prError: string | undefined;
+}
+
+const peerError = (message: string, cause?: unknown) =>
+ new TaskGraphError({ message, ...(cause === undefined ? {} : { cause }) });
+
+const TERMINAL_THREAD_STATUSES = new Set([
+ "completed",
+ "failed",
+ "cancelled",
+ "interrupted",
+ "rolled_back",
+]);
+
+/** The outcome a finished peer thread implies, before delivery; null while it still runs. */
+function peerThreadOutcome(
+ thread: Pick,
+): "succeeded" | "failed" | "cancelled" | null {
+ if (thread.activeRunId !== null || !TERMINAL_THREAD_STATUSES.has(thread.status)) return null;
+ if (thread.status === "completed") return "succeeded";
+ return thread.status === "cancelled" || thread.status === "interrupted" ? "cancelled" : "failed";
+}
+
+const EnvironmentDescriptorJson = Schema.Struct({
+ environmentId: EnvironmentId,
+ label: Schema.String,
+});
+const TokenResponse = Schema.Struct({
+ access_token: Schema.String,
+ token_type: Schema.String,
+});
+const decodeDescriptor = Schema.decodeUnknownEffect(EnvironmentDescriptorJson);
+const decodeToken = Schema.decodeUnknownEffect(TokenResponse);
+
+const toWsUrl = (httpBaseUrl: string) => {
+ const url = new URL("/ws", httpBaseUrl);
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
+ url.searchParams.set("orchestrationProtocol", "2");
+ return url.toString();
+};
+
+const protocolLayer = (httpBaseUrl: string, token: string) =>
+ RpcClient.layerProtocolSocket().pipe(
+ Layer.provide(
+ Socket.layerWebSocket(toWsUrl(httpBaseUrl)).pipe(
+ Layer.provide(
+ Layer.succeed(
+ Socket.WebSocketConstructor,
+ (url, protocols) =>
+ new NodeSocket.NodeWS.WebSocket(url, protocols as string | string[] | undefined, {
+ headers: { authorization: `Bearer ${token}` },
+ }) as unknown as globalThis.WebSocket,
+ ),
+ ),
+ ),
+ ),
+ Layer.provide(RpcSerialization.layerJson),
+ );
+
+const makeClient = RpcClient.make(WsRpcGroup);
+type PeerClient = Effect.Success;
+
+interface PeerRow {
+ readonly environment_id: string;
+ readonly label: string;
+ readonly http_base_url: string;
+ readonly weight: number;
+ readonly added_at: string;
+}
+
+interface PeerLive {
+ status: TaskGraphPeer["status"];
+ error: string | null;
+ client: PeerClient | null;
+ readonly projects: Map;
+ readonly threads: Map;
+}
+
+interface Tracked {
+ readonly environmentId: EnvironmentId;
+ readonly delivery: PeerDelivery;
+ delivering: boolean;
+ /** True until the peer accepts the launch; until then a snapshot may not list the thread yet. */
+ launching: boolean;
+}
+
+const make = Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+ const secrets = yield* ServerSecretStore.ServerSecretStore;
+ const environment = yield* ServerEnvironment.ServerEnvironment;
+ const projects = yield* ProjectService.ProjectService;
+ const auth = yield* EnvironmentAuth.EnvironmentAuth;
+ const localEnvironmentId = yield* environment.getEnvironmentId;
+ const http = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);
+
+ /** Reads a JSON body from a peer during pairing; any failure means the link is unusable. */
+ const fetchJson = (request: HttpClientRequest.HttpClientRequest) =>
+ http.execute(request).pipe(
+ Effect.flatMap((response) => response.json),
+ Effect.timeout("10 seconds"),
+ Effect.mapError((cause) =>
+ peerError(`Could not reach ${new URL(request.url).origin}.`, cause),
+ ),
+ );
+
+ const live = new Map();
+ const tracked = new Map();
+ const connections = yield* FiberMap.make();
+ const changes = yield* PubSub.sliding(1);
+ const completions = yield* Queue.unbounded<{ threadId: ThreadId; outcome: NodeOutcome }>();
+ const notify = PubSub.publish(changes, undefined).pipe(Effect.asVoid);
+
+ const readRows = sql`
+ SELECT environment_id, label, http_base_url, weight, added_at
+ FROM task_graph_peers ORDER BY added_at
+ `.pipe(Effect.mapError((cause) => peerError("Could not read peer machines.", cause)));
+
+ const list = readRows.pipe(
+ Effect.map((rows) => ({
+ peers: rows.map((row): TaskGraphPeer => {
+ const state = live.get(row.environment_id);
+ return {
+ environmentId: EnvironmentId.make(row.environment_id),
+ label: row.label,
+ httpBaseUrl: row.http_base_url,
+ weight: row.weight,
+ status: state?.status ?? "connecting",
+ error: state?.error ?? null,
+ addedAt: row.added_at,
+ };
+ }),
+ })),
+ );
+
+ const setStatus = (
+ environmentId: string,
+ status: TaskGraphPeer["status"],
+ error: string | null,
+ ) =>
+ Effect.suspend(() => {
+ const state = live.get(environmentId);
+ if (state === undefined || (state.status === status && state.error === error))
+ return Effect.void;
+ state.status = status;
+ state.error = error;
+ return notify;
+ });
+
+ // --- Remote node lifecycle ----------------------------------------------------
+
+ /** Commits and pushes a finished peer thread's branch there, then reports the node. */
+ const finishTracked = (environmentId: string, thread: OrchestrationV2ThreadShell) =>
+ Effect.gen(function* () {
+ const entry = tracked.get(thread.id);
+ const state = live.get(environmentId);
+ const outcome = peerThreadOutcome(thread);
+ if (entry === undefined || entry.delivering || outcome === null || state?.client == null)
+ return;
+ entry.delivering = true;
+ if (outcome !== "succeeded") {
+ tracked.delete(thread.id);
+ yield* Queue.offer(completions, {
+ threadId: thread.id,
+ outcome: { type: outcome, error: `The node's run on ${environmentId} ${thread.status}.` },
+ });
+ return;
+ }
+ const summary =
+ thread.latestVisibleMessage?.role === "assistant" ? thread.latestVisibleMessage.text : null;
+ const delivered =
+ thread.worktreePath === null || entry.delivery.action === "none"
+ ? Option.none()
+ : Option.some(
+ yield* state.client[WS_METHODS.gitRunStackedAction]({
+ actionId: `task-graph:${thread.id}`,
+ cwd: thread.worktreePath,
+ action: entry.delivery.action,
+ ...(entry.delivery.baseBranch === null
+ ? {}
+ : { baseBranch: entry.delivery.baseBranch }),
+ threadId: thread.id,
+ projectId: thread.projectId,
+ }).pipe(
+ Stream.runCollect,
+ Effect.flatMap((events): Effect.Effect => {
+ for (const event of events) {
+ // As on this machine, a failed PR step alone leaves the node succeeded:
+ // its work is already committed and pushed for dependents.
+ if (
+ event.kind === "action_failed" &&
+ event.phase === "pr" &&
+ entry.delivery.action === "commit_push_pr"
+ ) {
+ return Effect.succeed({ url: null, prError: event.message });
+ }
+ if (event.kind === "action_failed")
+ return Effect.fail(peerError(event.message));
+ if (event.kind === "action_finished") {
+ return Effect.succeed({
+ url: event.result.pr.url ?? null,
+ prError: undefined,
+ });
+ }
+ }
+ return Effect.fail(peerError("The peer did not report how delivery went."));
+ }),
+ ),
+ );
+ tracked.delete(thread.id);
+ // Settle the finished thread there, as a local node's is. Servers without
+ // settlement refuse the command; the node still counts as succeeded.
+ yield* state.client[ORCHESTRATION_V2_WS_METHODS.dispatchCommand]({
+ type: "thread.settle",
+ commandId: CommandId.make(`task-graph-settle:${thread.id}`),
+ threadId: thread.id,
+ }).pipe(
+ Effect.catchCause((cause) =>
+ Effect.logWarning("Could not settle a peer task graph node's thread", {
+ environmentId,
+ threadId: thread.id,
+ cause: Cause.pretty(cause),
+ }),
+ ),
+ );
+ yield* Queue.offer(completions, {
+ threadId: thread.id,
+ outcome: {
+ type: "succeeded",
+ summary,
+ branch: thread.branch,
+ worktreePath: thread.worktreePath,
+ ...(entry.delivery.action === "commit_push_pr" && Option.isSome(delivered)
+ ? delivered.value.prError === undefined
+ ? { pullRequestUrl: delivered.value.url }
+ : { pullRequestError: delivered.value.prError }
+ : {}),
+ },
+ });
+ }).pipe(
+ Effect.catchCause((cause) => {
+ tracked.delete(thread.id);
+ return Queue.offer(completions, {
+ threadId: thread.id,
+ outcome: {
+ type: "failed",
+ error: `Could not push the node's branch from the peer: ${Cause.pretty(cause)}`,
+ },
+ }).pipe(Effect.asVoid);
+ }),
+ );
+
+ const handleShellItem = (
+ environmentId: string,
+ state: PeerLive,
+ item: OrchestrationV2ShellStreamItem,
+ ) =>
+ Effect.gen(function* () {
+ const changed: OrchestrationV2ThreadShell[] = [];
+ switch (item.kind) {
+ case "snapshot": {
+ // An enrichment refresh only patches repository identity; it never replaces state.
+ if (item.resolvedRepositoryIdentityRoots !== undefined) {
+ for (const project of item.snapshot.projects) {
+ const known = state.projects.get(project.id);
+ if (known !== undefined && known.workspaceRoot === project.workspaceRoot) {
+ state.projects.set(project.id, {
+ ...known,
+ repositoryIdentity: project.repositoryIdentity,
+ });
+ }
+ }
+ break;
+ }
+ state.projects.clear();
+ state.threads.clear();
+ for (const project of item.snapshot.projects) state.projects.set(project.id, project);
+ for (const thread of [...item.snapshot.threads, ...item.snapshot.archivedThreads]) {
+ state.threads.set(thread.id, thread);
+ changed.push(thread);
+ }
+ // A tracked thread the full snapshot lacks was deleted, or never created before a
+ // restart; no removal event will come, so fail its node rather than wait forever.
+ for (const [threadId, entry] of tracked) {
+ if (
+ entry.environmentId !== environmentId ||
+ entry.launching ||
+ state.threads.has(threadId)
+ ) {
+ continue;
+ }
+ tracked.delete(threadId);
+ yield* Queue.offer(completions, {
+ threadId,
+ outcome: { type: "failed", error: "The node's thread is missing on the peer." },
+ });
+ }
+ break;
+ }
+ case "project.updated":
+ state.projects.set(item.project.id, item.project);
+ break;
+ case "project.removed":
+ state.projects.delete(item.projectId);
+ break;
+ case "thread.updated":
+ state.threads.set(item.thread.id, item.thread);
+ changed.push(item.thread);
+ break;
+ case "thread.removed": {
+ state.threads.delete(item.threadId);
+ const entry = tracked.get(item.threadId);
+ if (entry !== undefined && entry.environmentId === environmentId) {
+ tracked.delete(item.threadId);
+ yield* Queue.offer(completions, {
+ threadId: item.threadId,
+ outcome: { type: "failed", error: "The node's thread was deleted on the peer." },
+ });
+ }
+ break;
+ }
+ default:
+ break;
+ }
+ for (const thread of changed) {
+ if (tracked.get(thread.id)?.environmentId === environmentId) {
+ // Delivery waits on the peer's git work; do not hold up the shell stream.
+ yield* finishTracked(environmentId, thread).pipe(Effect.forkDetach);
+ }
+ }
+ });
+
+ /** One connection to a peer, until it drops. */
+ const connectOnce = (row: PeerRow, token: string, state: PeerLive) =>
+ Effect.gen(function* () {
+ const client = yield* makeClient;
+ const config = yield* client[WS_METHODS.serverGetConfig]({});
+ if (config.environment.environmentId !== row.environment_id) {
+ return yield* peerError(
+ `The machine at ${row.http_base_url} is now a different environment. Pair it again.`,
+ );
+ }
+ state.client = client;
+ yield* setStatus(row.environment_id, "connected", null);
+ yield* client[ORCHESTRATION_V2_WS_METHODS.subscribeShell]({}).pipe(
+ Stream.runForEach((item) => handleShellItem(row.environment_id, state, item)),
+ );
+ }).pipe(
+ Effect.scoped,
+ Effect.provide(protocolLayer(row.http_base_url, token)),
+ Effect.ensuring(Effect.sync(() => (state.client = null))),
+ );
+
+ const connect = (row: PeerRow) =>
+ Effect.gen(function* () {
+ const token = yield* secrets.get(tokenSecretName(row.environment_id));
+ if (Option.isNone(token)) {
+ return yield* setStatus(
+ row.environment_id,
+ "unauthorized",
+ "The peer's credential is missing. Pair it again.",
+ );
+ }
+ const state = live.get(row.environment_id)!;
+ const tokenText = new TextDecoder().decode(token.value);
+ yield* connectOnce(row, tokenText, state).pipe(
+ Effect.catchCause((cause) => {
+ const message = Cause.pretty(cause);
+ return setStatus(
+ row.environment_id,
+ /\b401\b|unauthori[sz]ed/i.test(message) ? "unauthorized" : "unreachable",
+ message.split("\n")[0] ?? message,
+ );
+ }),
+ Effect.repeat(Schedule.spaced("15 seconds")),
+ );
+ }).pipe(Effect.ignoreCause);
+
+ const startConnection = (row: PeerRow) =>
+ Effect.gen(function* () {
+ live.set(row.environment_id, {
+ status: "connecting",
+ error: null,
+ client: null,
+ projects: new Map(),
+ threads: new Map(),
+ });
+ yield* FiberMap.run(connections, row.environment_id, connect(row));
+ });
+
+ yield* readRows.pipe(
+ Effect.flatMap((rows) => Effect.forEach(rows, startConnection, { discard: true })),
+ Effect.catchCause((cause) =>
+ Effect.logWarning("Could not start task graph peer connections", {
+ cause: Cause.pretty(cause),
+ }),
+ ),
+ );
+
+ // --- Service ------------------------------------------------------------------
+
+ const repositoryKeyOf = (projectId: ProjectId) =>
+ projects.getShell(projectId).pipe(
+ Effect.map((shell) =>
+ Option.flatMap(shell, (project) =>
+ Option.fromNullishOr(project.repositoryIdentity).pipe(
+ Option.map(repositoryGroupingKeyOf),
+ ),
+ ),
+ ),
+ Effect.orElseSucceed(() => Option.none()),
+ );
+
+ const peerProjectFor = (state: PeerLive, key: string) =>
+ [...state.projects.values()].find(
+ (project) =>
+ project.repositoryIdentity != null &&
+ repositoryGroupingKeyOf(project.repositoryIdentity) === key,
+ );
+
+ const candidates: TaskGraphPeers["Service"]["candidates"] = ({ projectId }) =>
+ Effect.gen(function* () {
+ const key = yield* repositoryKeyOf(projectId);
+ if (Option.isNone(key)) return [];
+ const rows = yield* readRows.pipe(Effect.orElseSucceed(() => []));
+ const usable = rows.flatMap((row) => {
+ const state = live.get(row.environment_id);
+ const running = [...tracked.values()].filter(
+ (entry) => entry.environmentId === row.environment_id,
+ ).length;
+ return state?.client != null && peerProjectFor(state, key.value) !== undefined
+ ? [{ row, client: state.client, running }]
+ : [];
+ });
+ return yield* Effect.forEach(
+ usable,
+ ({ row, client, running }) =>
+ client[WS_METHODS.serverGetHostResources]({}).pipe(
+ Effect.timeout("5 seconds"),
+ Effect.orElseSucceed(() => null),
+ Effect.flatMap((resources) =>
+ Clock.currentTimeMillis.pipe(
+ Effect.map((receivedAt) => ({
+ environmentId: EnvironmentId.make(row.environment_id),
+ resources,
+ receivedAt,
+ weight: row.weight,
+ running,
+ })),
+ ),
+ ),
+ ),
+ { concurrency: "unbounded" },
+ );
+ });
+
+ const startNode: TaskGraphPeers["Service"]["startNode"] = (input) =>
+ Effect.gen(function* () {
+ const state = live.get(input.environmentId);
+ const client = state?.client;
+ if (state === undefined || client == null) {
+ return yield* peerError(`The peer ${input.environmentId} is not connected.`);
+ }
+ const key = yield* repositoryKeyOf(input.graph.projectId);
+ const project = Option.isNone(key) ? undefined : peerProjectFor(state, key.value);
+ if (project === undefined) {
+ return yield* peerError("The peer has no project for this repository.");
+ }
+ const entry: Tracked = {
+ environmentId: input.environmentId,
+ delivery: input.delivery,
+ delivering: false,
+ launching: true,
+ };
+ tracked.set(input.threadId, entry);
+ yield* client[ORCHESTRATION_V2_WS_METHODS.launchThread]({
+ commandId: CommandId.make(`task-graph-launch:${input.threadId}`),
+ creationSource: "server",
+ threadId: input.threadId,
+ projectId: project.id,
+ title: input.node.title,
+ modelSelection: input.modelSelection,
+ runtimeMode: input.runtimeMode,
+ interactionMode: "default",
+ workspaceStrategy: input.workspaceStrategy,
+ initialMessage: { text: input.prompt, attachments: [] },
+ }).pipe(
+ Effect.tapError(() => Effect.sync(() => tracked.delete(input.threadId))),
+ Effect.mapError((cause) => peerError("The peer could not start the node.", cause)),
+ );
+ entry.launching = false;
+ });
+
+ const track: TaskGraphPeers["Service"]["track"] = ({ environmentId, threadId, delivery }) =>
+ Effect.gen(function* () {
+ tracked.set(threadId, { environmentId, delivery, delivering: false, launching: false });
+ const thread = live.get(environmentId)?.threads.get(threadId);
+ if (thread !== undefined) yield* finishTracked(environmentId, thread).pipe(Effect.forkDetach);
+ });
+
+ const interruptNode: TaskGraphPeers["Service"]["interruptNode"] = ({ environmentId, threadId }) =>
+ Effect.gen(function* () {
+ tracked.delete(threadId);
+ const state = live.get(environmentId);
+ const runId = state?.threads.get(threadId)?.activeRunId;
+ if (state?.client == null || runId == null) return;
+ yield* state.client[ORCHESTRATION_V2_WS_METHODS.dispatchCommand]({
+ type: "run.interrupt",
+ commandId: CommandId.make(`task-graph-stop:${threadId}`),
+ threadId,
+ runId,
+ reason: "The task graph branch was cancelled.",
+ });
+ }).pipe(
+ Effect.catchCause((cause) =>
+ Effect.logWarning("Could not stop a peer task graph node", {
+ environmentId,
+ threadId,
+ cause: Cause.pretty(cause),
+ }),
+ ),
+ );
+
+ const add: TaskGraphPeers["Service"]["add"] = (input) =>
+ Effect.gen(function* () {
+ const target = yield* Effect.try({
+ try: () => resolveRemotePairingTarget({ pairingUrl: input.pairingUrl }),
+ catch: (cause) => peerError("That is not a pairing link.", cause),
+ });
+ const descriptor = yield* fetchJson(
+ HttpClientRequest.get(
+ new URL("/.well-known/t3/environment", target.httpBaseUrl).toString(),
+ ),
+ ).pipe(
+ Effect.flatMap((body) =>
+ decodeDescriptor(body).pipe(
+ Effect.mapError((cause) =>
+ peerError("The link does not point at a T3 Code server.", cause),
+ ),
+ ),
+ ),
+ );
+ if (descriptor.environmentId === localEnvironmentId) {
+ return yield* peerError("That link points at this machine.");
+ }
+ const label = input.label ?? (descriptor.label.trim() || target.httpBaseUrl);
+ const token = yield* fetchJson(
+ HttpClientRequest.post(new URL("/oauth/token", target.httpBaseUrl).toString()).pipe(
+ HttpClientRequest.bodyUrlParams({
+ grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
+ subject_token: target.credential,
+ subject_token_type: "urn:t3:params:oauth:token-type:environment-bootstrap",
+ requested_token_type: "urn:ietf:params:oauth:token-type:access_token",
+ scope: TASK_GRAPH_PEER_SCOPES.join(" "),
+ client_label: "T3 Code task graphs",
+ client_device_type: "bot",
+ }),
+ ),
+ ).pipe(
+ Effect.flatMap((body) =>
+ decodeToken(body).pipe(
+ Effect.mapError((cause) => peerError("The peer refused the link.", cause)),
+ ),
+ ),
+ );
+ if (token.token_type.toLowerCase() !== "bearer") {
+ return yield* peerError("The peer issued a credential this server cannot use.");
+ }
+ yield* secrets
+ .set(
+ tokenSecretName(descriptor.environmentId),
+ new TextEncoder().encode(token.access_token),
+ )
+ .pipe(
+ Effect.mapError((cause) => peerError("Could not store the peer's credential.", cause)),
+ );
+ const addedAt = DateTime.formatIso(yield* DateTime.now);
+ const row: PeerRow = {
+ environment_id: descriptor.environmentId,
+ label,
+ http_base_url: target.httpBaseUrl,
+ weight: DEFAULT_PEER_WEIGHT,
+ added_at: addedAt,
+ };
+ yield* sql`
+ INSERT INTO task_graph_peers (environment_id, label, http_base_url, weight, added_at)
+ VALUES (${row.environment_id}, ${row.label}, ${row.http_base_url}, ${row.weight}, ${row.added_at})
+ ON CONFLICT (environment_id) DO UPDATE SET
+ label = excluded.label,
+ http_base_url = excluded.http_base_url
+ `.pipe(Effect.mapError((cause) => peerError("Could not save the peer.", cause)));
+ yield* startConnection(row);
+ yield* notify;
+ return yield* list;
+ });
+
+ const remove: TaskGraphPeers["Service"]["remove"] = (environmentId) =>
+ Effect.gen(function* () {
+ yield* FiberMap.remove(connections, environmentId);
+ live.delete(environmentId);
+ yield* sql`DELETE FROM task_graph_peers WHERE environment_id = ${environmentId}`.pipe(
+ Effect.mapError((cause) => peerError("Could not remove the peer.", cause)),
+ );
+ // Forgetting the token is what unpairs on this side; the peer lists the
+ // session under its connected clients until someone revokes it there.
+ yield* secrets.remove(tokenSecretName(environmentId)).pipe(Effect.ignore);
+ for (const [threadId, entry] of tracked) {
+ if (entry.environmentId !== environmentId) continue;
+ tracked.delete(threadId);
+ yield* Queue.offer(completions, {
+ threadId,
+ outcome: { type: "failed", error: "The peer running this node was removed." },
+ });
+ }
+ yield* notify;
+ return yield* list;
+ });
+
+ const setWeight: TaskGraphPeers["Service"]["setWeight"] = (environmentId, weight) =>
+ sql`UPDATE task_graph_peers SET weight = ${weight} WHERE environment_id = ${environmentId}`.pipe(
+ Effect.mapError((cause) => peerError("Could not update the peer.", cause)),
+ Effect.andThen(notify),
+ Effect.andThen(list),
+ );
+
+ const issueGrant: TaskGraphPeers["Service"]["issueGrant"] = (label) =>
+ auth
+ .createPairingLink({
+ scopes: TASK_GRAPH_PEER_SCOPES,
+ subject: "task-graph-peer",
+ ttl: Duration.minutes(5),
+ label: `Task graphs: ${label}`,
+ })
+ .pipe(
+ Effect.map((link) => ({
+ credential: link.credential,
+ expiresAt: DateTime.formatIso(link.expiresAt),
+ })),
+ Effect.mapError((cause) => peerError("Could not issue a pairing grant.", cause)),
+ );
+
+ const subscribe = Stream.unwrap(
+ Effect.gen(function* () {
+ const subscription = yield* PubSub.subscribe(changes);
+ return Stream.concat(
+ Stream.fromEffect(list),
+ Stream.fromSubscription(subscription).pipe(Stream.mapEffect(() => list)),
+ );
+ }),
+ );
+
+ return TaskGraphPeers.of({
+ hasPeers: readRows.pipe(
+ Effect.map((rows) => rows.length > 0),
+ Effect.orElseSucceed(() => false),
+ ),
+ candidates,
+ startNode,
+ track,
+ interruptNode,
+ completions: Stream.fromQueue(completions),
+ list,
+ subscribe,
+ add,
+ remove,
+ issueGrant,
+ setWeight,
+ });
+});
+
+export const layer = Layer.effect(TaskGraphPeers, make).pipe(Layer.provide(FetchHttpClient.layer));
diff --git a/apps/server/src/taskGraph/TaskGraphService.test.ts b/apps/server/src/taskGraph/TaskGraphService.test.ts
new file mode 100644
index 000000000000..6bb273817d56
--- /dev/null
+++ b/apps/server/src/taskGraph/TaskGraphService.test.ts
@@ -0,0 +1,639 @@
+import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
+import { assert, it } from "@effect/vitest";
+import {
+ EnvironmentId,
+ type GitRunStackedActionInput,
+ type OrchestrationV2DomainEvent,
+ type OrchestrationV2ServerCommand,
+ type OrchestrationV2ThreadProjection,
+ ThreadId,
+} from "@t3tools/contracts";
+import * as Deferred from "effect/Deferred";
+import * as DateTime from "effect/DateTime";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Queue from "effect/Queue";
+import * as Stream from "effect/Stream";
+import * as TestClock from "effect/testing/TestClock";
+
+import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
+import * as GitWorkflow from "../git/GitWorkflowService.ts";
+import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
+import * as ThreadLaunch from "../orchestration-v2/ThreadLaunchService.ts";
+import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts";
+import * as SqlitePersistence from "../persistence/Sqlite.ts";
+import * as ProjectService from "../project/ProjectService.ts";
+import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
+import * as HostResources from "../resourceTelemetry/HostResources.ts";
+import * as Scheduler from "../scheduling/Scheduler.ts";
+import * as ServerSettings from "../serverSettings.ts";
+import * as TaskGraphPeers from "./TaskGraphPeers.ts";
+import * as TaskGraphService from "./TaskGraphService.ts";
+
+const PARENT = ThreadId.make("thread-parent");
+
+interface Harness {
+ readonly launches: Queue.Queue;
+ readonly gitActions: Array;
+ readonly interrupted: Array;
+ /** Node threads the service settled, in order. */
+ readonly settled: Queue.Queue;
+ /** Commands the service dispatched to threads, such as arming usage limit recovery. */
+ readonly dispatched: Array;
+ /** Replaces a node thread's projection, then emits a run event with `status`. */
+ readonly setThread: (
+ threadId: ThreadId,
+ projection: OrchestrationV2ThreadProjection,
+ status: string,
+ ) => Effect.Effect;
+ readonly report: Deferred.Deferred;
+ /**
+ * Sets a node thread's latest run, completed by default, with a reply on a
+ * branch, then emits the run event.
+ */
+ readonly finish: (
+ launch: ThreadLaunch.ThreadLaunchInput,
+ result: { readonly reply: string; readonly branch: string; readonly status?: string },
+ ) => Effect.Effect;
+ /** Makes the next git deliveries fail in `phase`, or succeed again with null. */
+ readonly failGitIn: (phase: "commit" | "push" | "pr" | null) => void;
+}
+
+const projection = (input: {
+ readonly id: ThreadId;
+ readonly branch: string | null;
+ readonly worktreePath: string | null;
+ readonly runStatus?: string;
+ readonly reply?: string;
+}) =>
+ ({
+ thread: {
+ id: input.id,
+ projectId: "project-1",
+ modelSelection: { instanceId: "codex", model: "gpt-5" },
+ runtimeMode: "full-access",
+ branch: input.branch,
+ worktreePath: input.worktreePath,
+ },
+ runs: input.runStatus === undefined ? [] : [{ status: input.runStatus, ordinal: 1 }],
+ messages: input.reply === undefined ? [] : [{ role: "assistant", text: input.reply }],
+ turnItems: [],
+ }) as unknown as OrchestrationV2ThreadProjection;
+
+/** Builds the service against in-memory SQLite and recording fakes for everything it drives. */
+const withService = (
+ body: (
+ service: TaskGraphService.TaskGraphService["Service"],
+ harness: Harness,
+ ) => Effect.Effect ,
+ peers: Layer.Layer = TaskGraphPeers.layerNone,
+) =>
+ Effect.gen(function* () {
+ const launches = yield* Queue.unbounded();
+ const events = yield* Queue.unbounded();
+ const report = yield* Deferred.make();
+ const projections = new Map([
+ [PARENT, projection({ id: PARENT, branch: "main", worktreePath: null })],
+ ]);
+ const gitActions: Array = [];
+ const interrupted: Array = [];
+ const settled = yield* Queue.unbounded();
+ const dispatched: Array = [];
+ let gitFailurePhase: "commit" | "push" | "pr" | null = null;
+
+ const harness: Harness = {
+ failGitIn: (phase) => {
+ gitFailurePhase = phase;
+ },
+ launches,
+ gitActions,
+ interrupted,
+ settled,
+ dispatched,
+ setThread: (threadId, next, status) =>
+ Effect.gen(function* () {
+ projections.set(threadId, next);
+ yield* Queue.offer(events, {
+ type: "run.updated",
+ threadId,
+ payload: { status },
+ } as unknown as OrchestrationV2DomainEvent);
+ }),
+ report,
+ finish: (launch, result) =>
+ Effect.gen(function* () {
+ const threadId = launch.threadId!;
+ projections.set(
+ threadId,
+ projection({
+ id: threadId,
+ branch: result.branch,
+ worktreePath: `/worktrees/${result.branch}`,
+ runStatus: result.status ?? "completed",
+ reply: result.reply,
+ }),
+ );
+ yield* Queue.offer(events, {
+ type: "run.updated",
+ threadId,
+ payload: { status: result.status ?? "completed" },
+ } as unknown as OrchestrationV2DomainEvent);
+ }),
+ };
+
+ const layer = TaskGraphService.layer.pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ Layer.mock(ThreadLaunch.ThreadLaunchService)({
+ launch: (input) =>
+ Queue.offer(launches, input).pipe(
+ Effect.as({
+ threadId: input.threadId!,
+ projection: projection({ id: input.threadId!, branch: null, worktreePath: null }),
+ resumed: false,
+ }),
+ ),
+ }),
+ Layer.mock(ThreadManagement.ThreadManagementService)({
+ getThreadProjection: (threadId) =>
+ Effect.suspend(() => {
+ const found = projections.get(threadId);
+ return found === undefined
+ ? Effect.die(`no thread ${threadId}`)
+ : Effect.succeed(found);
+ }),
+ streamDomainEvents: Stream.fromQueue(events),
+ sendToThread: (input) =>
+ Deferred.succeed(report, input.text).pipe(Effect.as({} as never)),
+ dispatch: (command) =>
+ Effect.sync(() => {
+ dispatched.push(command);
+ return {} as never;
+ }),
+ settleThread: (input) =>
+ Queue.offer(settled, input.threadId).pipe(Effect.as({ sequence: 0 })),
+ interruptThread: (input) =>
+ Effect.sync(() => {
+ interrupted.push(input.threadId);
+ return { type: "no_active_run" as const };
+ }),
+ }),
+ Layer.mock(GitWorkflow.GitWorkflowService)({
+ runStackedAction: (input, options) =>
+ Effect.suspend(() => {
+ gitActions.push(input);
+ const phase = gitFailurePhase;
+ if (phase !== null) {
+ return (
+ options?.progressReporter?.publish({
+ kind: "action_failed",
+ actionId: input.actionId,
+ cwd: input.cwd,
+ action: input.action,
+ phase,
+ message: `${phase} failed`,
+ } as never) ?? Effect.void
+ ).pipe(Effect.andThen(Effect.fail(new Error(`${phase} failed`) as never)));
+ }
+ return Effect.succeed({
+ pr:
+ input.action === "commit_push_pr"
+ ? { status: "created", url: `https://pr/${input.cwd}` }
+ : {},
+ } as never);
+ }),
+ }),
+ Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
+ Layer.mock(HostResources.HostResources)({
+ read: Effect.succeed({
+ sampledAt: 0,
+ cpuUtilization: 0.1,
+ cpuCount: 8,
+ availableMemoryBytes: 8e9,
+ totalMemoryBytes: 16e9,
+ }),
+ }),
+ Layer.mock(ServerEnvironment.ServerEnvironment)({
+ getEnvironmentId: Effect.succeed(EnvironmentId.make("env-local")),
+ }),
+ // Linking the created PR is best effort; these fakes make it log and move on.
+ Layer.mock(Orchestrator.OrchestratorV2)({}),
+ Layer.mock(ProjectService.ProjectService)({}),
+ ServerSettings.layerTest({}),
+ peers,
+ Scheduler.layer,
+ NodeCrypto.layer,
+ ),
+ ),
+ );
+ return yield* TaskGraphService.TaskGraphService.pipe(
+ Effect.flatMap((service) => body(service, harness)),
+ Effect.provide(layer),
+ );
+ }).pipe(Effect.provide(SqlitePersistence.layerMemory));
+
+const node = (key: string, dependsOn: ReadonlyArray = []) => ({
+ key,
+ title: key,
+ prompt: `Do ${key}.`,
+ dependsOn,
+});
+
+it.effect("fans out, merges, opens a PR at the end, and reports to the proposing thread", () =>
+ withService((service, harness) =>
+ Effect.gen(function* () {
+ yield* service.create({
+ threadId: PARENT,
+ title: "Security audit",
+ nodes: [node("a"), node("b", ["a"]), node("c", ["a"]), node("d", ["b", "c"])],
+ run: true,
+ });
+
+ const a = yield* Queue.take(harness.launches);
+ assert.equal(a.title, "a");
+ // The node's thread does not exist yet; reuseExistingThread would make the launcher
+ // look for it and fail every launch.
+ assert.isUndefined(a.reuseExistingThread);
+ assert.isDefined(a.threadId);
+ assert.deepEqual(a.workspaceStrategy, { type: "worktree", baseRef: "main" });
+ yield* harness.finish(a, { reply: "a found two issues", branch: "t3/a" });
+
+ // Both branches of the fan-out start from a's branch once a succeeds.
+ const fanOut = [yield* Queue.take(harness.launches), yield* Queue.take(harness.launches)];
+ assert.deepEqual(fanOut.map((launch) => launch.title).toSorted(), ["b", "c"]);
+ for (const launch of fanOut) {
+ assert.deepEqual(launch.workspaceStrategy, { type: "worktree", baseRef: "t3/a" });
+ assert.include(launch.initialMessage!.text, "a found two issues");
+ yield* harness.finish(launch, {
+ reply: `${launch.title} fixed`,
+ branch: `t3/${launch.title}`,
+ });
+ }
+
+ const d = yield* Queue.take(harness.launches);
+ assert.equal(d.title, "d");
+ assert.deepEqual(d.workspaceStrategy, { type: "worktree", baseRef: "t3/b" });
+ assert.include(d.initialMessage!.text, "- t3/c (from 'c')");
+ assert.include(d.initialMessage!.text, "b fixed");
+ assert.include(d.initialMessage!.text, "c fixed");
+ yield* harness.finish(d, { reply: "merged", branch: "t3/d" });
+
+ const report = yield* Deferred.await(harness.report);
+ assert.include(report, 'Task graph "Security audit" finished: succeeded.');
+ // Each succeeded node's thread is settled, so finished work leaves the active list.
+ const settled = yield* Effect.forEach([a, ...fanOut, d], () => Queue.take(harness.settled));
+ assert.deepEqual(
+ settled.toSorted(),
+ [a, ...fanOut, d].map((launch) => launch.threadId!).toSorted(),
+ );
+ assert.include(report, "PR: https://pr//worktrees/t3/d");
+ // Only the branch end opens a PR, so it targets the graph base and carries all the work.
+ assert.equal(
+ harness.gitActions.find((action) => action.action === "commit_push_pr")?.baseBranch,
+ "main",
+ );
+ // Inner nodes commit so dependents can branch from them; only the end opens a PR.
+ assert.deepEqual(
+ harness.gitActions.map((action) => [action.cwd, action.action]),
+ [
+ ["/worktrees/t3/a", "commit"],
+ ...fanOut
+ .map((launch) => [`/worktrees/t3/${launch.title}`, "commit"])
+ .toSorted(([left], [right]) => left!.localeCompare(right!)),
+ ["/worktrees/t3/d", "commit_push_pr"],
+ ].toSorted(([left], [right]) => left!.localeCompare(right!)),
+ );
+ }),
+ ),
+);
+
+it.effect("cutting off a running branch stops its thread and skips what depends on it", () =>
+ withService((service, harness) =>
+ Effect.gen(function* () {
+ const graph = yield* service.create({
+ threadId: PARENT,
+ title: "Features",
+ nodes: [node("a"), node("b", ["a"])],
+ run: true,
+ });
+ const a = yield* Queue.take(harness.launches);
+
+ const edited = yield* service.edit(graph.id, [{ type: "cancel_branch", key: "a" }]);
+
+ assert.deepEqual(harness.interrupted, [a.threadId]);
+ assert.deepEqual(
+ edited.nodes.map((graphNode) => graphNode.status),
+ ["cancelled", "cancelled"],
+ );
+ assert.equal(edited.status, "cancelled");
+ assert.include(yield* Deferred.await(harness.report), "finished: cancelled");
+ }),
+ ),
+);
+
+it.effect("a draft waits for run, and edits before it starts change what launches", () =>
+ withService((service, harness) =>
+ Effect.gen(function* () {
+ const draft = yield* service.create({
+ threadId: PARENT,
+ title: "Plan",
+ nodes: [node("a")],
+ run: false,
+ });
+ assert.equal(draft.status, "draft");
+ assert.equal(yield* Queue.size(harness.launches), 0);
+
+ yield* service.edit(draft.id, [
+ { type: "update_node", key: "a", prompt: "Do a differently." },
+ { type: "add_node", node: node("b") },
+ ]);
+ yield* service.run(draft.id);
+
+ const launched = [yield* Queue.take(harness.launches), yield* Queue.take(harness.launches)];
+ assert.deepEqual(launched.map((launch) => launch.title).toSorted(), ["a", "b"]);
+ assert.include(
+ launched.find((launch) => launch.title === "a")!.initialMessage!.text,
+ "Do a differently.",
+ );
+ }),
+ ),
+);
+
+it.effect("places a node on a peer with more free capacity and pushes local branches for it", () =>
+ Effect.gen(function* () {
+ const started = yield* Queue.unbounded();
+ const peers = Layer.succeed(
+ TaskGraphPeers.TaskGraphPeers,
+ TaskGraphPeers.TaskGraphPeers.of({
+ hasPeers: Effect.succeed(true),
+ // A much bigger idle machine: it wins the load-balancing score.
+ candidates: () =>
+ Effect.succeed([
+ {
+ environmentId: EnvironmentId.make("env-peer"),
+ resources: {
+ sampledAt: 0,
+ cpuUtilization: 0,
+ cpuCount: 64,
+ availableMemoryBytes: 100e9,
+ totalMemoryBytes: 128e9,
+ },
+ // it.effect runs on a test clock that starts at 0.
+ receivedAt: 0,
+ weight: 50,
+ running: 0,
+ },
+ ]),
+ startNode: (input) => Queue.offer(started, input).pipe(Effect.asVoid),
+ track: () => Effect.void,
+ interruptNode: () => Effect.void,
+ completions: Stream.never,
+ list: Effect.succeed({ peers: [] }),
+ subscribe: Stream.never,
+ add: () => Effect.die("unused"),
+ remove: () => Effect.die("unused"),
+ issueGrant: () => Effect.die("unused"),
+ setWeight: () => Effect.die("unused"),
+ }),
+ );
+ yield* withService(
+ (service) =>
+ Effect.gen(function* () {
+ yield* service.create({
+ threadId: PARENT,
+ title: "Spread",
+ nodes: [node("a")],
+ run: true,
+ });
+ const start = yield* Queue.take(started);
+ assert.equal(start.environmentId, "env-peer");
+ assert.deepEqual(start.workspaceStrategy, {
+ type: "worktree",
+ baseRef: "main",
+ startFromOrigin: true,
+ });
+ // A branch end opens its PR from the peer.
+ assert.deepEqual(start.delivery, { action: "commit_push_pr", baseBranch: "main" });
+ }),
+ peers,
+ );
+ }),
+);
+
+it.effect("follows a failed node that someone continues in its own thread", () =>
+ withService((service, harness) =>
+ Effect.gen(function* () {
+ const graph = yield* service.create({
+ threadId: PARENT,
+ title: "Roadmap",
+ nodes: [node("a"), node("b", ["a"])],
+ run: true,
+ });
+ const a = yield* Queue.take(harness.launches);
+ yield* harness.finish(a, { reply: "crashed", branch: "t3/a", status: "failed" });
+ assert.include(yield* Deferred.await(harness.report), "finished: failed");
+ // A failed node's thread stays active so someone can pick it up.
+ assert.equal(yield* Queue.size(harness.settled), 0);
+
+ // The user sends a follow-up in a's thread and that run succeeds.
+ yield* harness.finish(a, { reply: "", branch: "t3/a", status: "running" });
+ yield* harness.finish(a, { reply: "fixed it", branch: "t3/a" });
+
+ const b = yield* Queue.take(harness.launches);
+ assert.equal(b.title, "b");
+ assert.include(b.initialMessage!.text, "fixed it");
+ const resumed = yield* service.get(graph.id);
+ assert.equal(resumed.status, "running");
+ assert.deepEqual(
+ resumed.nodes.map((graphNode) => graphNode.status),
+ ["succeeded", "running"],
+ );
+ }),
+ ),
+);
+
+it.effect("stacks PRs, continues a worktree in place, and never commits project-folder work", () =>
+ withService((service, harness) =>
+ Effect.gen(function* () {
+ yield* service.create({
+ threadId: PARENT,
+ title: "Stack",
+ nodes: [
+ { ...node("review"), workspace: "root" },
+ { ...node("a", ["review"]), pullRequest: true },
+ { ...node("a-tests", ["a"]), workspace: "dependency" },
+ { ...node("b", ["a-tests"]), pullRequest: true },
+ ],
+ run: true,
+ });
+
+ const review = yield* Queue.take(harness.launches);
+ assert.deepEqual(review.workspaceStrategy, { type: "root" });
+ yield* harness.finish(review, { reply: "read it all", branch: "main" });
+
+ const a = yield* Queue.take(harness.launches);
+ assert.deepEqual(a.workspaceStrategy, { type: "worktree", baseRef: "main" });
+ yield* harness.finish(a, { reply: "layer one", branch: "t3/a" });
+
+ const aTests = yield* Queue.take(harness.launches);
+ assert.deepEqual(aTests.workspaceStrategy, {
+ type: "existing_worktree",
+ worktreePath: "/worktrees/t3/a",
+ branch: "t3/a",
+ });
+ yield* harness.finish(aTests, { reply: "tests added", branch: "t3/a" });
+
+ const b = yield* Queue.take(harness.launches);
+ yield* harness.finish(b, { reply: "layer two", branch: "t3/b" });
+ yield* Deferred.await(harness.report);
+
+ // The review committed nothing; each layer's PR targets the layer below it.
+ assert.deepEqual(
+ harness.gitActions.map((action) => [action.cwd, action.action, action.baseBranch]),
+ [
+ ["/worktrees/t3/a", "commit_push_pr", "main"],
+ // a-tests continues a's branch, which has a PR, so it pushes for that PR to get it.
+ ["/worktrees/t3/a", "commit_push", undefined],
+ ["/worktrees/t3/b", "commit_push_pr", "t3/a"],
+ ],
+ );
+ }),
+ ),
+);
+
+/** A thread whose only run stopped on a usage limit that resets at `resetAt`. */
+const limitedThread = (threadId: ThreadId, resetAt: string) =>
+ ({
+ ...projection({ id: threadId, branch: "t3/a", worktreePath: "/worktrees/t3/a" }),
+ runs: [
+ {
+ id: "run-1",
+ status: "failed",
+ ordinal: 1,
+ rootNodeId: "node-1",
+ startedAt: DateTime.makeUnsafe(0),
+ completedAt: DateTime.makeUnsafe(1_000),
+ },
+ ],
+ turnItems: [
+ {
+ id: "item-1",
+ type: "error",
+ status: "failed",
+ runId: "run-1",
+ nodeId: "node-1",
+ ordinal: 1,
+ updatedAt: DateTime.makeUnsafe(1_000),
+ failure: { class: "usage_limit", message: "Limit reached", resetAt },
+ },
+ ],
+ }) as unknown as OrchestrationV2ThreadProjection;
+
+it.effect("holds a node stopped by a usage limit and continues it on its thread at the reset", () =>
+ withService((service, harness) =>
+ Effect.gen(function* () {
+ const graph = yield* service.create({
+ threadId: PARENT,
+ title: "Limits",
+ nodes: [node("a"), node("b", ["a"])],
+ run: true,
+ });
+ const a = yield* Queue.take(harness.launches);
+ const resetAt = "2026-10-10T05:00:00.000Z";
+ yield* harness.setThread(a.threadId!, limitedThread(a.threadId!, resetAt), "failed");
+
+ // The arm command is the last thing the service does for the limited run.
+ while (harness.dispatched.length === 0) yield* Effect.yieldNow;
+ assert.deepEqual(harness.dispatched[0], {
+ type: "thread.metadata.update",
+ commandId: `task-graph-limit-arm:${a.threadId}:run-1`,
+ threadId: a.threadId,
+ limitRecovery: { runId: "run-1", resetAt, autoResume: true },
+ } as never);
+ const waiting = (yield* service.get(graph.id)).nodes[0]!;
+ assert.deepEqual(
+ [waiting.status, waiting.waitUntil, waiting.waitReason],
+ ["waiting", resetAt, "usage_limit"],
+ );
+
+ // At the reset, recovery starts a new run on the same thread, which then succeeds.
+ yield* harness.setThread(a.threadId!, limitedThread(a.threadId!, resetAt), "running");
+ yield* harness.finish(a, { reply: "done after reset", branch: "t3/a" });
+ const b = yield* Queue.take(harness.launches);
+ assert.include(b.initialMessage!.text, "done after reset");
+ }),
+ ),
+);
+
+it.effect("waits for a node's start time", () =>
+ withService((service, harness) =>
+ Effect.gen(function* () {
+ const graph = yield* service.create({
+ threadId: PARENT,
+ title: "Overnight",
+ nodes: [{ ...node("a"), startAt: "1970-01-01T00:01:00.000Z" }],
+ run: true,
+ });
+ assert.deepEqual(
+ [(yield* service.get(graph.id)).nodes[0]!.status, yield* Queue.size(harness.launches)],
+ ["waiting", 0],
+ );
+ yield* TestClock.adjust("2 minutes");
+ const a = yield* Queue.take(harness.launches);
+ assert.equal(a.title, "a");
+ }),
+ ),
+);
+
+it.effect("only a failed PR step leaves a node succeeded; a failed commit fails it", () =>
+ withService((service, harness) =>
+ Effect.gen(function* () {
+ const graph = yield* service.create({
+ threadId: PARENT,
+ title: "Delivery",
+ nodes: [node("a"), node("b")],
+ run: true,
+ });
+ const first = yield* Queue.take(harness.launches);
+ const second = yield* Queue.take(harness.launches);
+ const [a, b] = first.title === "a" ? [first, second] : [second, first];
+
+ harness.failGitIn("pr");
+ yield* harness.finish(a, { reply: "a done", branch: "t3/a" });
+ yield* Queue.take(harness.settled);
+ harness.failGitIn("commit");
+ yield* harness.finish(b, { reply: "b done", branch: "t3/b" });
+ assert.include(yield* Deferred.await(harness.report), "finished: failed");
+
+ const ended = yield* service.get(graph.id);
+ const byKey = new Map(ended.nodes.map((entry) => [entry.key, entry]));
+ assert.equal(byKey.get("a")?.status, "succeeded");
+ assert.deepEqual(byKey.get("a")?.pullRequestResult, {
+ status: "failed",
+ url: null,
+ error: "pr failed",
+ });
+ assert.equal(byKey.get("b")?.status, "failed");
+ assert.include(byKey.get("b")?.error ?? "", "commit failed");
+ assert.isNotNull(byKey.get("b")?.completedAt);
+ }),
+ ),
+);
+
+it.effect("delivers a node once when its completion arrives twice", () =>
+ withService((service, harness) =>
+ Effect.gen(function* () {
+ yield* service.create({ threadId: PARENT, title: "Once", nodes: [node("a")], run: true });
+ const a = yield* Queue.take(harness.launches);
+ // Two terminal events for the same run, handled concurrently.
+ yield* harness.finish(a, { reply: "a done", branch: "t3/a" });
+ yield* harness.finish(a, { reply: "a done", branch: "t3/a" });
+ assert.include(yield* Deferred.await(harness.report), "finished: succeeded");
+ assert.equal(yield* Queue.take(harness.settled), a.threadId);
+ assert.deepEqual(
+ harness.gitActions.map((action) => action.cwd),
+ ["/worktrees/t3/a"],
+ );
+ }),
+ ),
+);
diff --git a/apps/server/src/taskGraph/TaskGraphService.ts b/apps/server/src/taskGraph/TaskGraphService.ts
new file mode 100644
index 000000000000..1f59a94df183
--- /dev/null
+++ b/apps/server/src/taskGraph/TaskGraphService.ts
@@ -0,0 +1,1219 @@
+import {
+ CommandId,
+ EnvironmentId,
+ MessageId,
+ TaskGraph,
+ TaskGraphError,
+ TaskGraphId,
+ ThreadId,
+ type HostResourcesSnapshot,
+ type ServerProvider,
+ type TaskGraphCreateInput,
+ type TaskGraphEdit,
+ type TaskGraphListResult,
+ type TaskGraphNode,
+} from "@t3tools/contracts";
+import {
+ applyTaskGraphEdits,
+ buildTaskGraphNodePrompt,
+ deriveTaskGraphStatus,
+ isActiveTaskGraphNodeStatus,
+ isTerminalTaskGraphNodeStatus,
+ newTaskGraphNode,
+ readyTaskGraphNodes,
+ resumeTaskGraphNode,
+ skipUnreachableTaskGraphNodes,
+ taskGraphNodeOpensPullRequest,
+ taskGraphNodeSharesPullRequestBranch,
+ taskGraphNodeSummary,
+ taskGraphPullRequestBase,
+ validateTaskGraphNodes,
+} from "@t3tools/shared/taskGraph";
+import { chooseLoadBalancedEnvironment } from "@t3tools/shared/loadBalancing";
+import {
+ latestRootProviderFailure,
+ usageLimitBlockedRun,
+} from "@t3tools/shared/orchestrationV2ThreadError";
+import * as Cause from "effect/Cause";
+import * as Clock from "effect/Clock";
+import * as Context from "effect/Context";
+import * as Crypto from "effect/Crypto";
+import * as DateTime from "effect/DateTime";
+import * as Effect from "effect/Effect";
+import * as Exit from "effect/Exit";
+import * as Layer from "effect/Layer";
+import * as PubSub from "effect/PubSub";
+import * as Schema from "effect/Schema";
+import * as Semaphore from "effect/Semaphore";
+import * as Stream from "effect/Stream";
+import * as SqlClient from "effect/sql/SqlClient";
+
+import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
+import * as GitWorkflow from "../git/GitWorkflowService.ts";
+import { linkCreatedPullRequest } from "../git/linkCreatedPullRequest.ts";
+import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
+import * as ThreadLaunch from "../orchestration-v2/ThreadLaunchService.ts";
+import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts";
+import * as ProjectService from "../project/ProjectService.ts";
+import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
+import * as HostResources from "../resourceTelemetry/HostResources.ts";
+import * as Scheduler from "../scheduling/Scheduler.ts";
+import { forkParked } from "../serverActivation.ts";
+import * as ServerSettings from "../serverSettings.ts";
+import * as TaskGraphPeers from "./TaskGraphPeers.ts";
+
+/**
+ * Runs task graphs: plans of agent tasks with dependencies, where each node is
+ * its own thread in its own worktree. The orchestrator stays unaware of graphs;
+ * this service launches node threads through `ThreadLaunchService`, reads their
+ * outcome from the domain event stream, and opens a pull request per branch end.
+ *
+ * Every graph change goes through one lock and is written whole, so a crash
+ * leaves either the old graph or the new one. Thread launches and git work run
+ * outside the lock against state already recorded as `running`/`delivering`,
+ * and are idempotent by command id so recovery can repeat them.
+ */
+export class TaskGraphService extends Context.Service<
+ TaskGraphService,
+ {
+ readonly create: (input: TaskGraphCreateInput) => Effect.Effect;
+ readonly get: (graphId: TaskGraphId) => Effect.Effect;
+ readonly listForThread: (
+ threadId: ThreadId,
+ ) => Effect.Effect, TaskGraphError>;
+ /** Emits the thread's graphs on subscribe and again after each change to one of them. */
+ readonly subscribeThread: (
+ threadId: ThreadId,
+ ) => Stream.Stream;
+ readonly edit: (
+ graphId: TaskGraphId,
+ edits: ReadonlyArray,
+ ) => Effect.Effect;
+ readonly run: (graphId: TaskGraphId) => Effect.Effect;
+ readonly cancel: (graphId: TaskGraphId) => Effect.Effect;
+ /** A node that ran on a peer finished there; recorded like a local node's end. */
+ readonly completeRemoteNode: (input: {
+ readonly threadId: ThreadId;
+ readonly outcome: NodeOutcome;
+ }) => Effect.Effect;
+ }
+>()("t3/taskGraph/TaskGraphService") {}
+
+export type NodeOutcome =
+ | {
+ readonly type: "succeeded";
+ readonly summary: string | null;
+ readonly branch: string | null;
+ readonly worktreePath: string | null;
+ readonly pullRequestUrl?: string | null;
+ /** The node's PR step failed after its work was committed and pushed. */
+ readonly pullRequestError?: string;
+ }
+ | { readonly type: "failed" | "cancelled"; readonly error: string };
+
+/** Above this the host is too busy to take another node; matches client load balancing. */
+const MAX_CPU_UTILIZATION = 0.95;
+const MIN_AVAILABLE_MEMORY_FRACTION = 0.05;
+
+function hostHasCapacity(snapshot: HostResourcesSnapshot): boolean {
+ if (snapshot.cpuUtilization !== null && snapshot.cpuUtilization >= MAX_CPU_UTILIZATION) {
+ return false;
+ }
+ return (
+ snapshot.totalMemoryBytes <= 0 ||
+ snapshot.availableMemoryBytes / snapshot.totalMemoryBytes > MIN_AVAILABLE_MEMORY_FRACTION
+ );
+}
+
+/**
+ * When a provider instance's usage limit resets, if one of its windows is used
+ * up right now; null when it has room or reports no limits.
+ */
+function usageLimitResetFor(
+ provider: Pick | undefined,
+ nowMs: number,
+): string | null {
+ let latest: string | null = null;
+ for (const window of provider?.usageLimits?.windows ?? []) {
+ if (window.usedPercent < 100 || window.resetsAt === undefined) continue;
+ if (Date.parse(window.resetsAt) <= nowMs) continue;
+ if (latest === null || Date.parse(window.resetsAt) > Date.parse(latest))
+ latest = window.resetsAt;
+ }
+ return latest;
+}
+
+/** This machine's weight against its peers' (peers default to the same). */
+const LOCAL_WEIGHT = 50;
+
+const peerDelivery = (graph: TaskGraph, node: TaskGraphNode): TaskGraphPeers.PeerDelivery =>
+ node.workspace === "root"
+ ? { action: "none", baseBranch: null }
+ : taskGraphNodeOpensPullRequest(graph.nodes, node)
+ ? { action: "commit_push_pr", baseBranch: taskGraphPullRequestBase(graph, node) }
+ : { action: "commit_push", baseBranch: null };
+
+/**
+ * How a node's thread gets its workspace: a new worktree from its first
+ * dependency's branch (or the graph base), that dependency's worktree itself,
+ * or the project folder. Null when a continued worktree was never recorded.
+ */
+const nodeWorkspaceStrategy = (
+ graph: TaskGraph,
+ node: TaskGraphNode,
+ options: { readonly baseFromOrigin: boolean },
+): ThreadLaunch.ThreadLaunchWorkspaceStrategy | null => {
+ const first = graph.nodes.find((candidate) => candidate.key === node.dependsOn[0]);
+ switch (node.workspace) {
+ case "root":
+ return { type: "root" };
+ case "dependency":
+ return first?.worktreePath == null
+ ? null
+ : {
+ type: "existing_worktree",
+ worktreePath: first.worktreePath,
+ ...(first.branch === null ? {} : { branch: first.branch }),
+ };
+ case "worktree":
+ return {
+ type: "worktree",
+ baseRef: first?.branch ?? graph.baseRef,
+ ...(options.baseFromOrigin ? { startFromOrigin: true } : {}),
+ };
+ }
+};
+
+const decodeGraphJson = Schema.decodeUnknownEffect(Schema.fromJsonString(TaskGraph));
+const encodeGraphJson = Schema.encodeSync(Schema.fromJsonString(TaskGraph));
+
+interface TaskGraphRow {
+ readonly graph_json: string;
+}
+
+const graphError = (message: string, graphId?: TaskGraphId, cause?: unknown) =>
+ new TaskGraphError({
+ message,
+ ...(graphId === undefined ? {} : { graphId }),
+ ...(cause === undefined ? {} : { cause }),
+ });
+
+function errorMessage(error: unknown): string {
+ if (Cause.isCause(error)) return Cause.pretty(error);
+ if (error instanceof Error) return error.message;
+ return String(error);
+}
+
+const nowIso = DateTime.now.pipe(Effect.map((now) => DateTime.formatIso(now)));
+
+/** The message the proposing thread receives when its graph ends. */
+function taskGraphReport(graph: TaskGraph): string {
+ const lines = graph.nodes.map((node) => {
+ const pr = node.pullRequestResult;
+ const prText =
+ pr === null
+ ? ""
+ : pr.status === "opened"
+ ? ` — PR: ${pr.url ?? "opened"}`
+ : ` — PR failed: ${pr.error ?? "unknown error"}`;
+ const detail = node.status === "succeeded" ? "" : node.error === null ? "" : ` (${node.error})`;
+ return `- ${node.key} "${node.title}": ${node.status}${detail}${prText}`;
+ });
+ return [`Task graph "${graph.title}" finished: ${graph.status}.`, ...lines].join("\n");
+}
+
+const make = Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+ const crypto = yield* Crypto.Crypto;
+ const launcher = yield* ThreadLaunch.ThreadLaunchService;
+ const threads = yield* ThreadManagement.ThreadManagementService;
+ const git = yield* GitWorkflow.GitWorkflowService;
+ const hostResources = yield* HostResources.HostResources;
+ const providers = yield* ProviderRegistry.ProviderRegistry;
+ const settings = yield* ServerSettings.ServerSettingsService;
+ const scheduler = yield* Scheduler.Scheduler;
+ const environment = yield* ServerEnvironment.ServerEnvironment;
+ const peers = yield* TaskGraphPeers.TaskGraphPeers;
+ const orchestrator = yield* Orchestrator.OrchestratorV2;
+ const projects = yield* ProjectService.ProjectService;
+ const localEnvironmentId = yield* environment.getEnvironmentId;
+
+ const lock = yield* Semaphore.make(1);
+ const locked = lock.withPermits(1);
+ const changes = yield* PubSub.unbounded();
+ /** Node threads still running locally, so run events map back to their node. */
+ const nodeByThread = new Map();
+ /** Threads of nodes stopped by a usage limit, which continue there at the reset. */
+ const waitingThreads = new Set();
+ /** Threads of local nodes that failed or were cancelled, in case someone continues them. */
+ const endedByThread = new Map();
+
+ const readGraph = (graphId: TaskGraphId) =>
+ sql`SELECT graph_json FROM task_graphs WHERE graph_id = ${graphId}`.pipe(
+ Effect.flatMap((rows) =>
+ rows[0] === undefined ? Effect.succeed(null) : decodeGraphJson(rows[0].graph_json),
+ ),
+ Effect.mapError((cause) => graphError("Could not read task graph.", graphId, cause)),
+ );
+
+ const requireGraph = (graphId: TaskGraphId) =>
+ readGraph(graphId).pipe(
+ Effect.flatMap((graph) =>
+ graph === null
+ ? Effect.fail(graphError("Task graph not found.", graphId))
+ : Effect.succeed(graph),
+ ),
+ );
+
+ const readGraphs = (rows: ReadonlyArray) =>
+ Effect.forEach(rows, (row) => decodeGraphJson(row.graph_json)).pipe(
+ Effect.mapError((cause) => graphError("Could not read task graphs.", undefined, cause)),
+ );
+
+ const runningGraphs = sql`
+ SELECT graph_json FROM task_graphs WHERE status = 'running' ORDER BY created_at
+ `.pipe(
+ Effect.mapError((cause) => graphError("Could not list task graphs.", undefined, cause)),
+ Effect.flatMap(readGraphs),
+ );
+
+ /** Graphs a node may still come back to life in: anything not cancelled as a whole. */
+ const resumableGraphs = sql`
+ SELECT graph_json FROM task_graphs WHERE status IN ('running', 'failed') ORDER BY created_at
+ `.pipe(
+ Effect.mapError((cause) => graphError("Could not list task graphs.", undefined, cause)),
+ Effect.flatMap(readGraphs),
+ );
+
+ const indexGraph = (graph: TaskGraph) => {
+ for (const node of graph.nodes) {
+ if (node.threadId === null) continue;
+ const target = { graphId: graph.id, key: node.key };
+ if (isActiveTaskGraphNodeStatus(node.status) || node.status === "waiting") {
+ nodeByThread.set(node.threadId, target);
+ } else {
+ nodeByThread.delete(node.threadId);
+ }
+ if (node.status === "waiting") waitingThreads.add(node.threadId);
+ else waitingThreads.delete(node.threadId);
+ if (
+ (node.status === "failed" || node.status === "cancelled") &&
+ graph.status !== "cancelled" &&
+ node.assignedEnvironmentId === localEnvironmentId
+ ) {
+ endedByThread.set(node.threadId, target);
+ } else {
+ endedByThread.delete(node.threadId);
+ }
+ }
+ };
+
+ /**
+ * Someone continued a failed or cancelled node in its own thread. Follow
+ * that run instead of leaving the node, and everything after it, stuck.
+ */
+ const resumeNode = (graphId: TaskGraphId, key: string) =>
+ locked(
+ Effect.gen(function* () {
+ const graph = yield* requireGraph(graphId);
+ if (graph.status === "cancelled") return;
+ const nodes = resumeTaskGraphNode(graph.nodes, key);
+ if (nodes === null) return;
+ yield* writeGraph({ ...graph, status: "running", nodes });
+ }),
+ ).pipe(
+ Effect.catchCause((cause) =>
+ Effect.logWarning("Could not resume task graph node", {
+ graphId,
+ key,
+ cause: Cause.pretty(cause),
+ }),
+ ),
+ );
+
+ /** Writes a graph after settling skips and status. Returns the stored graph. */
+ const writeGraph = (graph: TaskGraph) =>
+ Effect.gen(function* () {
+ const now = yield* nowIso;
+ const nodes = skipUnreachableTaskGraphNodes(graph.nodes, now);
+ const stored: TaskGraph = {
+ ...graph,
+ nodes,
+ status: deriveTaskGraphStatus(graph.status, nodes),
+ updatedAt: now,
+ };
+ yield* sql`
+ INSERT INTO task_graphs (graph_id, project_id, thread_id, status, graph_json, created_at, updated_at)
+ VALUES (${stored.id}, ${stored.projectId}, ${stored.threadId}, ${stored.status},
+ ${encodeGraphJson(stored)}, ${stored.createdAt}, ${stored.updatedAt})
+ ON CONFLICT (graph_id) DO UPDATE SET
+ status = excluded.status,
+ graph_json = excluded.graph_json,
+ updated_at = excluded.updated_at
+ `.pipe(Effect.mapError((cause) => graphError("Could not save task graph.", graph.id, cause)));
+ indexGraph(stored);
+ yield* PubSub.publish(changes, stored.threadId);
+ return stored;
+ });
+
+ const updateNode = (
+ graphId: TaskGraphId,
+ key: string,
+ update: (node: TaskGraphNode, graph: TaskGraph) => TaskGraphNode,
+ ) =>
+ locked(
+ Effect.gen(function* () {
+ const graph = yield* requireGraph(graphId);
+ const before = graph.status;
+ const stored = yield* writeGraph({
+ ...graph,
+ nodes: graph.nodes.map((node) => (node.key === key ? update(node, graph) : node)),
+ });
+ return { stored, finished: before === "running" && stored.status !== "running" };
+ }),
+ ).pipe(Effect.tap(({ stored, finished }) => (finished ? reportFinished(stored) : Effect.void)));
+
+ /** Tells the proposing thread how its graph ended, queued behind any turn in progress. */
+ const reportFinished = (graph: TaskGraph) =>
+ threads
+ .sendToThread({
+ projectId: graph.projectId,
+ commandId: CommandId.make(`task-graph-report:${graph.id}:${graph.updatedAt}`),
+ threadId: graph.threadId,
+ messageId: MessageId.make(`task-graph-report:${graph.id}:${graph.updatedAt}`),
+ text: taskGraphReport(graph),
+ attachments: [],
+ mode: "queue",
+ createdBy: "system",
+ creationSource: "server",
+ })
+ .pipe(
+ Effect.asVoid,
+ Effect.catchCause((cause) =>
+ Effect.logWarning("Could not report finished task graph", {
+ graphId: graph.id,
+ cause: Cause.pretty(cause),
+ }),
+ ),
+ );
+
+ /** A node held by a usage limit is running again on its thread. */
+ const wakeNode = (graphId: TaskGraphId, key: string) =>
+ updateNode(graphId, key, (node) =>
+ node.status === "waiting" && node.threadId !== null
+ ? { ...node, status: "running", waitUntil: null, waitReason: null }
+ : node,
+ ).pipe(
+ Effect.asVoid,
+ Effect.catchCause((cause) =>
+ Effect.logWarning("Could not wake task graph node", {
+ graphId,
+ key,
+ cause: Cause.pretty(cause),
+ }),
+ ),
+ );
+
+ // --- Finishing nodes -------------------------------------------------------
+
+ /** Commits the node's work so dependents can branch from it, and opens its PR. */
+ const deliver = (graph: TaskGraph, node: TaskGraphNode, worktreePath: string) =>
+ Effect.gen(function* () {
+ const opensPullRequest = taskGraphNodeOpensPullRequest(graph.nodes, node);
+ // With peers paired, a dependent may run elsewhere and needs the branch on origin.
+ const push =
+ (yield* peers.hasPeers) || taskGraphNodeSharesPullRequestBranch(graph.nodes, node);
+ const baseBranch = opensPullRequest ? taskGraphPullRequestBase(graph, node) : null;
+ let failedPhase: string | null = null;
+ const delivered = yield* git
+ .runStackedAction(
+ {
+ actionId: `task-graph:${graph.id}:${node.key}:${node.threadId}`,
+ cwd: worktreePath,
+ action: opensPullRequest ? "commit_push_pr" : push ? "commit_push" : "commit",
+ ...(baseBranch === null ? {} : { baseBranch }),
+ ...(node.threadId === null ? {} : { threadId: node.threadId }),
+ projectId: graph.projectId,
+ },
+ {
+ progressReporter: {
+ publish: (event) =>
+ Effect.sync(() => {
+ if (event.kind === "action_failed") failedPhase = event.phase;
+ }),
+ },
+ },
+ )
+ .pipe(
+ // Only a failed PR step leaves the work committed and pushed for dependents;
+ // a commit or push failure fails the node.
+ Effect.map((result) => ({ result, prError: null })),
+ Effect.catch((error) =>
+ opensPullRequest && failedPhase === "pr"
+ ? Effect.succeed({ result: null, prError: error.message })
+ : Effect.fail(error),
+ ),
+ );
+ if (delivered.result === null) {
+ return { status: "failed", url: null, error: delivered.prError } as const;
+ }
+ const result = delivered.result;
+ if (opensPullRequest && node.threadId !== null) {
+ yield* linkCreatedPullRequest({
+ threadId: node.threadId,
+ result,
+ commandId: Effect.succeed(CommandId.make(`task-graph-pr-link:${node.threadId}`)),
+ }).pipe(
+ Effect.provideService(Orchestrator.OrchestratorV2, orchestrator),
+ Effect.provideService(ProjectService.ProjectService, projects),
+ );
+ }
+ return opensPullRequest
+ ? ({ status: "opened", url: result.pr.url ?? null, error: null } as const)
+ : null;
+ });
+
+ /** A succeeded node's thread has nothing left to do; settle it so it leaves the active list. */
+ const settleNodeThread = (threadId: ThreadId) =>
+ threads
+ .settleThread({
+ threadId,
+ commandId: CommandId.make(`task-graph-settle:${threadId}`),
+ byOwnAgent: false,
+ })
+ .pipe(
+ Effect.asVoid,
+ Effect.catchCause((cause) =>
+ Effect.logWarning("Could not settle a succeeded task graph node's thread", {
+ threadId,
+ cause: Cause.pretty(cause),
+ }),
+ ),
+ );
+
+ const finishLocalNode = (graphId: TaskGraphId, key: string, threadId: ThreadId) =>
+ Effect.gen(function* () {
+ const projection = yield* threads.getThreadProjection(threadId);
+ // A follow-up the user sent to the node thread keeps the node running.
+ if (ThreadManagement.latestActiveRun(projection) !== undefined) return;
+ const run = ThreadManagement.latestRun(projection);
+ nodeByThread.delete(threadId);
+ const limited = usageLimitBlockedRun(projection.runs, projection.turnItems, null);
+ const resetAt =
+ limited === null
+ ? null
+ : (latestRootProviderFailure(limited, projection.turnItems)?.resetAt ?? null);
+ if (limited !== null && resetAt !== null && limited.id === run?.id) {
+ // Hold the node and let usage-limit recovery continue its thread at the reset.
+ yield* updateNode(graphId, key, (node) =>
+ node.status === "running"
+ ? { ...node, status: "waiting", waitUntil: resetAt, waitReason: "usage_limit" }
+ : node,
+ );
+ yield* threads
+ .dispatch({
+ type: "thread.metadata.update",
+ commandId: CommandId.make(`task-graph-limit-arm:${threadId}:${limited.id}`),
+ threadId,
+ limitRecovery: { runId: limited.id, resetAt, autoResume: true },
+ })
+ .pipe(
+ Effect.catchCause((cause) =>
+ Effect.logWarning("Could not arm usage limit recovery for a task graph node", {
+ threadId,
+ cause: Cause.pretty(cause),
+ }),
+ ),
+ );
+ return;
+ }
+ if (run === undefined || run.status !== "completed") {
+ const completedAt = yield* nowIso;
+ const status =
+ run?.status === "cancelled" || run?.status === "interrupted" ? "cancelled" : "failed";
+ yield* updateNode(graphId, key, (node, _graph) =>
+ node.status === "running"
+ ? {
+ ...node,
+ status,
+ error:
+ run === undefined
+ ? "The node's thread has no run."
+ : `The node's run ${run.status}.`,
+ completedAt,
+ }
+ : node,
+ ).pipe(Effect.tap(() => advance));
+ return;
+ }
+ const lastReply = projection.messages.findLast(
+ (message) => message.role === "assistant" && message.text.trim().length > 0,
+ );
+ // Completions run concurrently, so only the call that moves the node out of
+ // `running` under the lock delivers it; any overlapping call stops here.
+ let claimed = false;
+ const { stored } = yield* updateNode(graphId, key, (node) => {
+ if (node.status !== "running") return node;
+ claimed = true;
+ return {
+ ...node,
+ status: "delivering",
+ branch: projection.thread.branch,
+ worktreePath: projection.thread.worktreePath,
+ summary: lastReply === undefined ? null : taskGraphNodeSummary(lastReply.text),
+ };
+ });
+ const node = stored.nodes.find((candidate) => candidate.key === key);
+ if (!claimed || node === undefined || node.status !== "delivering") return;
+ const worktreePath = projection.thread.worktreePath;
+ // Work in the project folder is never committed.
+ const delivered =
+ worktreePath === null || node.workspace === "root"
+ ? Exit.succeed(null)
+ : yield* Effect.exit(deliver(stored, node, worktreePath));
+ const completedAt = yield* nowIso;
+ const finished = yield* updateNode(graphId, key, (current) =>
+ current.status !== "delivering"
+ ? current
+ : Exit.isSuccess(delivered)
+ ? // A failed PR alone still succeeds: the work is committed and pushed for dependents.
+ { ...current, status: "succeeded", pullRequestResult: delivered.value, completedAt }
+ : {
+ ...current,
+ status: "failed",
+ error: `Could not commit the node's work: ${errorMessage(delivered.cause)}`,
+ completedAt,
+ },
+ );
+ if (
+ finished.stored.nodes.find((candidate) => candidate.key === key)?.status === "succeeded"
+ ) {
+ yield* settleNodeThread(threadId);
+ }
+ yield* advance;
+ }).pipe(
+ Effect.catchCause((cause) =>
+ Effect.logWarning("Could not finish task graph node", {
+ graphId,
+ key,
+ cause: Cause.pretty(cause),
+ }),
+ ),
+ );
+
+ const completeRemoteNode: TaskGraphService["Service"]["completeRemoteNode"] = ({
+ threadId,
+ outcome,
+ }) =>
+ Effect.gen(function* () {
+ const target = nodeByThread.get(threadId);
+ if (target === undefined) return;
+ nodeByThread.delete(threadId);
+ const completedAt = yield* nowIso;
+ yield* updateNode(target.graphId, target.key, (node) => {
+ if (!isActiveTaskGraphNodeStatus(node.status)) return node;
+ if (outcome.type !== "succeeded") {
+ return { ...node, status: outcome.type, error: outcome.error, completedAt };
+ }
+ return {
+ ...node,
+ status: "succeeded",
+ summary: outcome.summary === null ? null : taskGraphNodeSummary(outcome.summary),
+ branch: outcome.branch,
+ worktreePath: outcome.worktreePath,
+ pullRequestResult:
+ outcome.pullRequestError !== undefined
+ ? { status: "failed", url: null, error: outcome.pullRequestError }
+ : outcome.pullRequestUrl === undefined
+ ? null
+ : { status: "opened", url: outcome.pullRequestUrl, error: null },
+ completedAt,
+ };
+ });
+ yield* advance;
+ }).pipe(
+ Effect.catchCause((cause) =>
+ Effect.logWarning("Could not record remote task graph node", {
+ threadId,
+ cause: Cause.pretty(cause),
+ }),
+ ),
+ );
+
+ // --- Starting nodes --------------------------------------------------------
+
+ /**
+ * Where a ready node runs: its pinned machine, or whichever of this machine
+ * and its peers has the most free capacity, scored like client load balancing.
+ * Null when nowhere has room; the node waits for the next pass.
+ */
+ const placeNode = (
+ graph: TaskGraph,
+ node: TaskGraphNode,
+ local: { readonly resources: HostResourcesSnapshot; readonly hasRoom: boolean },
+ maxNodes: number,
+ peerCandidates: ReadonlyArray,
+ pending: ReadonlyMap,
+ ): Effect.Effect =>
+ Effect.gen(function* () {
+ // Continuing a worktree means running on the machine that has it.
+ const continued =
+ node.workspace === "dependency"
+ ? (graph.nodes.find((candidate) => candidate.key === node.dependsOn[0])
+ ?.assignedEnvironmentId ?? null)
+ : null;
+ const pinned = continued ?? node.environmentId;
+ if (pinned === localEnvironmentId) {
+ return local.hasRoom ? localEnvironmentId : null;
+ }
+ // Peers count a node once it starts there, after this pass; this pass's placements count too.
+ const remote = peerCandidates.filter(
+ (candidate) => candidate.running + (pending.get(candidate.environmentId) ?? 0) < maxNodes,
+ );
+ if (pinned !== null) {
+ return remote.some((candidate) => candidate.environmentId === pinned) ? pinned : null;
+ }
+ if (remote.length === 0) return local.hasRoom ? localEnvironmentId : null;
+ const now = yield* Clock.currentTimeMillis;
+ const chosen = chooseLoadBalancedEnvironment(
+ [
+ ...(local.hasRoom
+ ? [
+ {
+ environmentId: localEnvironmentId,
+ resources: local.resources,
+ receivedAt: now,
+ weight: LOCAL_WEIGHT,
+ },
+ ]
+ : []),
+ ...remote,
+ ],
+ now,
+ );
+ // The scorer skips a machine without a CPU sample yet; this one can still take work.
+ if (chosen === null) return local.hasRoom ? localEnvironmentId : null;
+ return EnvironmentId.make(chosen);
+ });
+
+ const launchNode = (graph: TaskGraph, node: TaskGraphNode) =>
+ Effect.gen(function* () {
+ const threadId = node.threadId!;
+ const parent = yield* threads.getThreadProjection(graph.threadId);
+ const firstDependency = graph.nodes.find((candidate) => candidate.key === node.dependsOn[0]);
+ const prompt = buildTaskGraphNodePrompt(graph, node);
+ const modelSelection =
+ node.modelSelection ?? graph.modelSelection ?? parent.thread.modelSelection;
+ const remote = node.assignedEnvironmentId !== localEnvironmentId;
+ // A dependency that ran on another machine pushed its branch; it is only on origin here.
+ const workspaceStrategy = nodeWorkspaceStrategy(graph, node, {
+ baseFromOrigin:
+ remote ||
+ (firstDependency !== undefined &&
+ firstDependency.assignedEnvironmentId !== localEnvironmentId),
+ });
+ if (workspaceStrategy === null) {
+ return yield* graphError(
+ `'${node.dependsOn[0]}' left no worktree to continue in.`,
+ graph.id,
+ );
+ }
+ if (remote) {
+ yield* peers.startNode({
+ environmentId: node.assignedEnvironmentId!,
+ graph,
+ node,
+ threadId,
+ workspaceStrategy,
+ prompt,
+ modelSelection,
+ runtimeMode: parent.thread.runtimeMode,
+ delivery: peerDelivery(graph, node),
+ });
+ return;
+ }
+ yield* launcher.launch({
+ commandId: CommandId.make(`task-graph-launch:${threadId}`),
+ threadId,
+ projectId: graph.projectId,
+ title: node.title,
+ modelSelection,
+ runtimeMode: parent.thread.runtimeMode,
+ interactionMode: "default",
+ workspaceStrategy,
+ initialMessage: {
+ messageId: MessageId.make(`task-graph-message:${threadId}`),
+ senderThreadId: graph.threadId,
+ text: prompt,
+ attachments: [],
+ },
+ createdBy: "agent",
+ creationSource: "server",
+ });
+ }).pipe(
+ Effect.catchCause((cause) =>
+ Effect.gen(function* () {
+ const completedAt = yield* nowIso;
+ nodeByThread.delete(node.threadId!);
+ yield* updateNode(graph.id, node.key, (current) =>
+ current.status === "running"
+ ? {
+ ...current,
+ status: "failed",
+ error: `Could not start the node: ${errorMessage(cause)}`,
+ completedAt,
+ }
+ : current,
+ );
+ }),
+ ),
+ );
+
+ /**
+ * Starts every node that is ready and fits: under the environment's
+ * concurrency limit, and on a machine that is not overloaded. Nodes that do
+ * not fit wait for the next scheduler tick or the next node to finish.
+ */
+ const advance: Effect.Effect = Effect.gen(function* () {
+ const { taskGraphMaxConcurrentNodes } = yield* settings.getSettings;
+ // Asking peers for their load is a network call per peer, so it happens once per pass and
+ // outside the lock that edits, cancels and completions wait on.
+ const projectIds = [...new Set((yield* runningGraphs).map((graph) => graph.projectId))];
+ const peerCandidates = new Map(
+ yield* Effect.forEach(
+ projectIds,
+ (projectId) =>
+ peers.candidates({ projectId }).pipe(Effect.map((found) => [projectId, found] as const)),
+ { concurrency: "unbounded" },
+ ),
+ );
+ const toLaunch = yield* locked(
+ Effect.gen(function* () {
+ const graphs = yield* runningGraphs;
+ let active = graphs
+ .flatMap((graph) => graph.nodes)
+ .filter(
+ (node) =>
+ isActiveTaskGraphNodeStatus(node.status) &&
+ node.assignedEnvironmentId === localEnvironmentId,
+ ).length;
+ const host = yield* hostResources.read;
+ const hostFree = hostHasCapacity(host);
+ const started: Array<{ graph: TaskGraph; node: TaskGraphNode }> = [];
+ const now = yield* nowIso;
+ const nowMs = Date.parse(now);
+ const localProviders = yield* providers.getProviders;
+ const placedOnPeers = new Map();
+ for (const graph of graphs) {
+ const settled = skipUnreachableTaskGraphNodes(graph.nodes, now);
+ const ready = readyTaskGraphNodes(settled, now);
+ const assigned = new Map();
+ for (const node of ready) {
+ if (node.startAt !== null && Date.parse(node.startAt) > nowMs) {
+ if (node.status !== "waiting") {
+ assigned.set(node.key, {
+ ...node,
+ status: "waiting",
+ waitUntil: node.startAt,
+ waitReason: "scheduled",
+ });
+ }
+ continue;
+ }
+ const environmentId = yield* placeNode(
+ graph,
+ node,
+ { resources: host, hasRoom: hostFree && active < taskGraphMaxConcurrentNodes },
+ taskGraphMaxConcurrentNodes,
+ peerCandidates.get(graph.projectId) ?? [],
+ placedOnPeers,
+ );
+ if (environmentId === null) continue;
+ if (environmentId !== localEnvironmentId) {
+ placedOnPeers.set(environmentId, (placedOnPeers.get(environmentId) ?? 0) + 1);
+ }
+ if (environmentId === localEnvironmentId) {
+ // Starting into a used-up limit would only fail; wait for the reset instead.
+ const instanceId = (node.modelSelection ?? graph.modelSelection)?.instanceId;
+ const resetAt = usageLimitResetFor(
+ localProviders.find((provider) => provider.instanceId === instanceId),
+ nowMs,
+ );
+ if (resetAt !== null) {
+ if (node.waitUntil !== resetAt) {
+ assigned.set(node.key, {
+ ...node,
+ status: "waiting",
+ waitUntil: resetAt,
+ waitReason: "usage_limit",
+ });
+ }
+ continue;
+ }
+ active += 1;
+ }
+ assigned.set(node.key, {
+ ...node,
+ status: "running",
+ waitUntil: null,
+ waitReason: null,
+ assignedEnvironmentId: environmentId,
+ threadId: ThreadId.make(`task-graph-node:${yield* crypto.randomUUIDv4}`),
+ startedAt: yield* nowIso,
+ });
+ }
+ // Skips can settle a graph with nothing to start, but an unchanged graph
+ // is not rewritten: every write is pushed to each client watching it.
+ const nodes = settled.map((node) => assigned.get(node.key) ?? node);
+ if (nodes.every((node, index) => node === graph.nodes[index])) continue;
+ const stored = yield* writeGraph({ ...graph, nodes });
+ if (stored.status !== "running") yield* reportFinished(stored).pipe(Effect.forkDetach);
+ // Nodes set waiting above have no thread yet; they launch on a later pass.
+ for (const node of assigned.values()) {
+ if (node.status === "running") started.push({ graph: stored, node });
+ }
+ }
+ return started;
+ }),
+ );
+ yield* Effect.forEach(toLaunch, ({ graph, node }) => launchNode(graph, node), {
+ concurrency: "unbounded",
+ discard: true,
+ });
+ }).pipe(
+ Effect.catchCause((cause) =>
+ Effect.logWarning("Could not advance task graphs", { cause: Cause.pretty(cause) }),
+ ),
+ );
+
+ const stopNodes = (graph: TaskGraph, nodes: ReadonlyArray) =>
+ Effect.forEach(
+ nodes,
+ (node) => {
+ if (node.threadId === null) return Effect.void;
+ nodeByThread.delete(node.threadId);
+ if (
+ node.assignedEnvironmentId !== localEnvironmentId &&
+ node.assignedEnvironmentId !== null
+ ) {
+ return peers.interruptNode({
+ environmentId: node.assignedEnvironmentId,
+ threadId: node.threadId,
+ });
+ }
+ const disarm =
+ node.status === "waiting"
+ ? threads.dispatch({
+ type: "thread.metadata.update",
+ commandId: CommandId.make(`task-graph-limit-disarm:${node.threadId}`),
+ threadId: node.threadId,
+ limitRecovery: null,
+ })
+ : Effect.void;
+ return Effect.andThen(
+ disarm,
+ threads
+ .interruptThread({
+ projectId: graph.projectId,
+ commandId: CommandId.make(`task-graph-stop:${node.threadId}`),
+ threadId: node.threadId,
+ reason: "The task graph branch was cancelled.",
+ })
+ .pipe(Effect.asVoid),
+ );
+ },
+ { discard: true },
+ ).pipe(
+ Effect.catchCause((cause) =>
+ Effect.logWarning("Could not stop cancelled task graph nodes", {
+ graphId: graph.id,
+ cause: Cause.pretty(cause),
+ }),
+ ),
+ );
+
+ // --- Public API -------------------------------------------------------------
+
+ const create: TaskGraphService["Service"]["create"] = (input) =>
+ Effect.gen(function* () {
+ const parent = yield* threads
+ .getThreadProjection(input.threadId)
+ .pipe(
+ Effect.mapError((cause) => graphError("Could not read the thread.", undefined, cause)),
+ );
+ const nodes = input.nodes.map(newTaskGraphNode);
+ const invalid = validateTaskGraphNodes(nodes);
+ if (invalid !== null) return yield* graphError(invalid);
+ const { taskGraphAutoRun } = yield* settings.getSettings.pipe(
+ Effect.mapError((cause) => graphError("Could not read settings.", undefined, cause)),
+ );
+ const now = yield* nowIso;
+ const uuid = yield* crypto.randomUUIDv4.pipe(
+ Effect.mapError((cause) => graphError("Could not create task graph id.", undefined, cause)),
+ );
+ const graph = yield* locked(
+ writeGraph({
+ id: TaskGraphId.make(`task-graph:${uuid}`),
+ projectId: parent.thread.projectId,
+ threadId: parent.thread.id,
+ title: input.title,
+ baseRef: input.baseRef ?? parent.thread.branch ?? "HEAD",
+ modelSelection: parent.thread.modelSelection,
+ status: (input.run ?? taskGraphAutoRun) ? "running" : "draft",
+ nodes,
+ createdAt: now,
+ updatedAt: now,
+ }),
+ );
+ if (graph.status === "running") yield* advance;
+ return yield* requireGraph(graph.id);
+ });
+
+ const edit: TaskGraphService["Service"]["edit"] = (graphId, edits) =>
+ Effect.gen(function* () {
+ const { graph, stopped, finished } = yield* locked(
+ Effect.gen(function* () {
+ const current = yield* requireGraph(graphId);
+ if (current.status === "cancelled") {
+ return yield* graphError("This task graph was cancelled.", graphId);
+ }
+ const result = applyTaskGraphEdits(current.nodes, edits, yield* nowIso);
+ if (!result.ok) return yield* graphError(result.error, graphId);
+ const stopped = current.nodes.filter((node) => {
+ const next = result.nodes.find((candidate) => candidate.key === node.key);
+ return (
+ (isActiveTaskGraphNodeStatus(node.status) ||
+ (node.status === "waiting" && node.threadId !== null)) &&
+ next?.status === "cancelled"
+ );
+ });
+ // Retrying a node in a finished graph puts the graph back to work.
+ const reopened = result.nodes.some((node) => node.status === "pending");
+ const status =
+ current.status === "draft" ? "draft" : reopened ? "running" : current.status;
+ const graph = yield* writeGraph({ ...current, status, nodes: result.nodes });
+ return {
+ graph,
+ stopped,
+ finished: current.status === "running" && graph.status !== "running",
+ };
+ }),
+ );
+ yield* stopNodes(graph, stopped);
+ if (finished) yield* reportFinished(graph);
+ if (graph.status === "running") yield* advance;
+ return yield* requireGraph(graphId);
+ });
+
+ const run: TaskGraphService["Service"]["run"] = (graphId) =>
+ Effect.gen(function* () {
+ yield* locked(
+ Effect.gen(function* () {
+ const graph = yield* requireGraph(graphId);
+ if (graph.status !== "draft") {
+ return yield* graphError("Only a draft task graph can be run.", graphId);
+ }
+ yield* writeGraph({ ...graph, status: "running" });
+ }),
+ );
+ yield* advance;
+ return yield* requireGraph(graphId);
+ });
+
+ const cancel: TaskGraphService["Service"]["cancel"] = (graphId) =>
+ Effect.gen(function* () {
+ const { graph, stopped } = yield* locked(
+ Effect.gen(function* () {
+ const current = yield* requireGraph(graphId);
+ const completedAt = yield* nowIso;
+ const nodes = current.nodes.map((node) =>
+ isTerminalTaskGraphNodeStatus(node.status)
+ ? node
+ : { ...node, status: "cancelled" as const, error: "Cancelled.", completedAt },
+ );
+ const graph = yield* writeGraph({ ...current, status: "cancelled", nodes });
+ return {
+ graph,
+ stopped: current.nodes.filter(
+ (node) =>
+ isActiveTaskGraphNodeStatus(node.status) ||
+ (node.status === "waiting" && node.threadId !== null),
+ ),
+ };
+ }),
+ );
+ yield* stopNodes(graph, stopped);
+ return graph;
+ });
+
+ const listForThread: TaskGraphService["Service"]["listForThread"] = (threadId) =>
+ sql`
+ SELECT graph_json FROM task_graphs WHERE thread_id = ${threadId} ORDER BY created_at
+ `.pipe(
+ Effect.mapError((cause) => graphError("Could not list task graphs.", undefined, cause)),
+ Effect.flatMap(readGraphs),
+ );
+
+ const subscribeThread: TaskGraphService["Service"]["subscribeThread"] = (threadId) =>
+ Stream.unwrap(
+ Effect.gen(function* () {
+ // Subscribe before the snapshot so a change between the two is not lost.
+ const subscription = yield* PubSub.subscribe(changes);
+ return Stream.concat(
+ Stream.fromEffect(listForThread(threadId)),
+ Stream.fromSubscription(subscription).pipe(
+ Stream.filter((changed) => changed === threadId),
+ Stream.mapEffect(() => listForThread(threadId)),
+ ),
+ ).pipe(Stream.map((graphs) => ({ graphs })));
+ }),
+ );
+
+ // --- Startup ----------------------------------------------------------------
+
+ // Rebuild the thread index, then catch up on node runs that ended while the
+ // server was down. Launches are idempotent by command id, so a node that was
+ // marked running but never launched is launched again.
+ yield* Effect.gen(function* () {
+ for (const graph of yield* resumableGraphs) indexGraph(graph);
+ // A node continued in its thread while the server was down: follow it, and
+ // record it if that run already finished after the node had failed.
+ yield* Effect.forEach(
+ [...endedByThread],
+ ([threadId, { graphId, key }]) =>
+ Effect.gen(function* () {
+ const projection = yield* threads.getThreadProjection(threadId);
+ const graph = yield* requireGraph(graphId);
+ const endedAt = graph.nodes.find((node) => node.key === key)?.completedAt ?? null;
+ const run = ThreadManagement.latestRun(projection);
+ const ranAgain =
+ ThreadManagement.latestActiveRun(projection) !== undefined ||
+ (run?.completedAt != null &&
+ endedAt !== null &&
+ DateTime.toEpochMillis(run.completedAt) >
+ DateTime.toEpochMillis(DateTime.makeUnsafe(endedAt)));
+ if (!ranAgain) return;
+ yield* resumeNode(graphId, key);
+ yield* finishLocalNode(graphId, key, threadId);
+ }).pipe(Effect.ignoreCause),
+ { discard: true },
+ );
+ const graphs = yield* runningGraphs;
+ // A node held by a usage limit whose thread was continued while the server was down.
+ yield* Effect.forEach(
+ graphs.flatMap((graph) =>
+ graph.nodes
+ .filter((node) => node.status === "waiting" && node.threadId !== null)
+ .map((node) => ({ graph, node })),
+ ),
+ ({ graph, node }) =>
+ Effect.gen(function* () {
+ const projection = yield* threads.getThreadProjection(node.threadId!);
+ if (ThreadManagement.latestRun(projection)?.status === "failed") return;
+ yield* wakeNode(graph.id, node.key);
+ yield* finishLocalNode(graph.id, node.key, node.threadId!);
+ }).pipe(Effect.ignoreCause),
+ { discard: true },
+ );
+ yield* Effect.forEach(
+ graphs.flatMap((graph) =>
+ graph.nodes
+ .filter(
+ (node) =>
+ node.status === "running" && node.assignedEnvironmentId === localEnvironmentId,
+ )
+ .map((node) => ({ graph, node })),
+ ),
+ ({ graph, node }) =>
+ threads.getThreadProjection(node.threadId!).pipe(
+ Effect.matchEffect({
+ onFailure: () => launchNode(graph, node),
+ onSuccess: () => finishLocalNode(graph.id, node.key, node.threadId!),
+ }),
+ ),
+ { discard: true },
+ );
+ yield* Effect.forEach(
+ graphs.flatMap((graph) =>
+ graph.nodes
+ .filter(
+ (node) =>
+ isActiveTaskGraphNodeStatus(node.status) &&
+ node.assignedEnvironmentId !== null &&
+ node.assignedEnvironmentId !== localEnvironmentId,
+ )
+ .map((node) => ({ graph, node })),
+ ),
+ ({ graph, node }) =>
+ peers.track({
+ environmentId: node.assignedEnvironmentId!,
+ threadId: node.threadId!,
+ delivery: peerDelivery(graph, node),
+ }),
+ { discard: true },
+ );
+ // A node mid-delivery when the server stopped redelivers; the git action is idempotent.
+ yield* Effect.forEach(
+ graphs.flatMap((graph) =>
+ graph.nodes
+ .filter(
+ (node) =>
+ node.status === "delivering" && node.assignedEnvironmentId === localEnvironmentId,
+ )
+ .map((node) => ({ graph, node })),
+ ),
+ ({ graph, node }) =>
+ updateNode(graph.id, node.key, (current) => ({ ...current, status: "running" })).pipe(
+ Effect.andThen(finishLocalNode(graph.id, node.key, node.threadId!)),
+ ),
+ { discard: true },
+ );
+ }).pipe(
+ Effect.catchCause((cause) =>
+ Effect.logWarning("Could not recover task graphs", { cause: Cause.pretty(cause) }),
+ ),
+ );
+
+ yield* forkParked(
+ Stream.runForEach(threads.streamDomainEvents, (event) => {
+ if (event.type !== "run.updated") return Effect.void;
+ if (!ThreadManagement.isTerminalRunStatus(event.payload.status)) {
+ const waiting = waitingThreads.has(event.threadId)
+ ? nodeByThread.get(event.threadId)
+ : undefined;
+ if (waiting !== undefined) return wakeNode(waiting.graphId, waiting.key);
+ // Only a new run passes through an active status, so a repeated update of
+ // the run that failed never resumes the node.
+ const ended = endedByThread.get(event.threadId);
+ return ended === undefined ? Effect.void : resumeNode(ended.graphId, ended.key);
+ }
+ const target = nodeByThread.get(event.threadId);
+ // Delivery commits, pushes and writes a PR, so finish each node in its own fiber
+ // rather than hold up every other graph's events. Status guards make a repeat a no-op.
+ return target === undefined
+ ? Effect.void
+ : finishLocalNode(target.graphId, target.key, event.threadId).pipe(Effect.forkDetach);
+ }).pipe(
+ Effect.catchCause((cause) =>
+ Effect.logWarning("Task graph event stream failed", { cause: Cause.pretty(cause) }),
+ ),
+ ),
+ );
+ yield* forkParked(Stream.runForEach(peers.completions, completeRemoteNode));
+
+ // Retries nodes held back by load or the concurrency limit, and picks up peer capacity.
+ yield* scheduler.register("task-graphs", advance);
+
+ return TaskGraphService.of({
+ create,
+ get: requireGraph,
+ listForThread,
+ subscribeThread,
+ edit,
+ run,
+ cancel,
+ completeRemoteNode,
+ });
+});
+
+export const layer = Layer.effect(TaskGraphService, make);
diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
index 80921ec4b5a2..cff52cd272a3 100644
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -123,6 +123,8 @@ import * as ThreadLaunchService from "./orchestration-v2/ThreadLaunchService.ts"
import * as ThreadMessageIntake from "./orchestration-v2/ThreadMessageIntake.ts";
import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts";
+import * as TaskGraphPeers from "./taskGraph/TaskGraphPeers.ts";
+import * as TaskGraphs from "./taskGraph/TaskGraphService.ts";
import * as SecretRequests from "./secrets/SecretRequests.ts";
import {
archivedShellStreamItemFromThreadShell,
@@ -1224,6 +1226,8 @@ const layerWsRpc = (
const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService;
const providerSessionManager = yield* ProviderSessionManager.ProviderSessionManagerV2;
const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService;
+ const taskGraphs = yield* TaskGraphs.TaskGraphService;
+ const taskGraphPeers = yield* TaskGraphPeers.TaskGraphPeers;
const secretRequests = yield* SecretRequests.SecretRequests;
const pullRequests = yield* PullRequestService.PullRequestService;
const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService;
@@ -2048,6 +2052,21 @@ const layerWsRpc = (
[WS_METHODS.scheduledTasksSubscribe]: (_input) =>
scheduledTasks.subscribeList().pipe(Stream.map(withVisibleWebhookUrls)),
[WS_METHODS.scheduledTasksUpsert]: (input) => scheduledTasks.upsert(input),
+ [WS_METHODS.taskGraphsSubscribe]: (input) => taskGraphs.subscribeThread(input.threadId),
+ [WS_METHODS.taskGraphsCreate]: (input) =>
+ taskGraphs.create(input).pipe(Effect.map((graph) => ({ graph }))),
+ [WS_METHODS.taskGraphsEdit]: (input) =>
+ taskGraphs.edit(input.graphId, input.edits).pipe(Effect.map((graph) => ({ graph }))),
+ [WS_METHODS.taskGraphsRun]: (input) =>
+ taskGraphs.run(input.graphId).pipe(Effect.map((graph) => ({ graph }))),
+ [WS_METHODS.taskGraphsCancel]: (input) =>
+ taskGraphs.cancel(input.graphId).pipe(Effect.map((graph) => ({ graph }))),
+ [WS_METHODS.taskGraphPeersSubscribe]: (_input) => taskGraphPeers.subscribe,
+ [WS_METHODS.taskGraphPeersAdd]: (input) => taskGraphPeers.add(input),
+ [WS_METHODS.taskGraphPeersRemove]: (input) => taskGraphPeers.remove(input.environmentId),
+ [WS_METHODS.taskGraphPeersIssueGrant]: (input) => taskGraphPeers.issueGrant(input.label),
+ [WS_METHODS.taskGraphPeersSetWeight]: (input) =>
+ taskGraphPeers.setWeight(input.environmentId, input.weight),
[WS_METHODS.scheduledTasksSetEnabled]: (input) =>
Effect.annotateCurrentSpan({ "scheduled_task.id": input.id }).pipe(
Effect.andThen(scheduledTasks.setEnabled(input)),
diff --git a/apps/web/package.json b/apps/web/package.json
index 4d10f89743b6..5b8168776074 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -15,6 +15,7 @@
"@base-ui/react": "^1.4.1",
"@clerk/electron": "catalog:",
"@clerk/react": "catalog:",
+ "@dagrejs/dagre": "^3.1.1",
"@daypicker/react": "^10.0.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
@@ -50,6 +51,7 @@
"@tiptap/pm": "^3.31.3",
"@tiptap/react": "^3.31.3",
"@tiptap/starter-kit": "^3.31.3",
+ "@xyflow/react": "^12.12.0",
"class-variance-authority": "^0.7.1",
"culori": "^4.0.2",
"dompurify": "^3.4.16",
diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts
index 1e79964caa0a..5ba4dda5eb44 100644
--- a/apps/web/src/components/ChatView.logic.ts
+++ b/apps/web/src/components/ChatView.logic.ts
@@ -1104,6 +1104,19 @@ export async function waitForStartedServerThread(
});
}
+/** Resolves true once a thread created by this client shows up in its shell list. */
+export async function waitForServerThreadShell(
+ threadRef: ScopedThreadRef,
+ timeoutMs = 5_000,
+): Promise {
+ return waitForAtomValue({
+ registry: appAtomRegistry,
+ atom: environmentThreadShells.threadShellAtom(threadRef),
+ predicate: (thread) => thread != null,
+ timeoutMs,
+ });
+}
+
/**
* Runs `revert` (the rollback command `requestId`) and resolves once the
* message's run is rolled back. Rejects with the server's reason as soon as
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 84f5b476a133..6e878c711c6c 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -83,6 +83,7 @@ import {
resolveEnvironmentMachineKind,
RuntimeMode,
TerminalOpenInput,
+ type TaskGraphNode,
type WorktreeSetupSnapshot,
} from "@t3tools/contracts";
import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection";
@@ -487,6 +488,8 @@ import {
type QueuedRunsControlHandle,
type EditQueuedRunRequest,
} from "./chat/QueuedRunsControl";
+import { DraftTaskGraphDialog, TaskGraphCards } from "./chat/TaskGraphCard";
+import { useNewTaskGraph } from "./chat/useTaskGraphCommands";
import { useLinkedThreadPullRequest } from "./ThreadStatusIndicators";
import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack";
import { ThreadStatusLine } from "./chat/ThreadStatusLine";
@@ -556,6 +559,7 @@ import {
startNewThreadForProject,
codexArtifactTemplatePromptToAppend,
waitForStartedServerThread,
+ waitForServerThreadShell,
shouldRefocusComposerOnWindowFocus,
} from "./ChatView.logic";
import { useLocalStorage } from "~/hooks/useLocalStorage";
@@ -10735,6 +10739,91 @@ export default function ChatView(props: ChatViewProps) {
composerRef,
]);
+ const newTaskGraph = useNewTaskGraph(environmentId);
+ // On a new chat the graph is drafted in the client; its thread and the
+ // graph are only created when the user runs it.
+ const [draftTaskGraphOpen, setDraftTaskGraphOpen] = useState(false);
+ // The composer's model when the draft opened: shown as the graph default and used on Run.
+ const [draftTaskGraphModel, setDraftTaskGraphModel] = useState(null);
+ const onNewTaskGraph = useCallback(async () => {
+ if (!activeThread) return;
+ if (!isServerThread) {
+ setDraftTaskGraphModel(composerRef.current?.getSendContext()?.selectedModelSelection ?? null);
+ setDraftTaskGraphOpen(true);
+ return;
+ }
+ const error = await newTaskGraph.start(activeThread.id);
+ if (error !== null) {
+ toastManager.add({
+ type: "error",
+ title: "Could not start a task graph",
+ description: error,
+ });
+ }
+ }, [activeThread, composerRef, isServerThread, newTaskGraph]);
+ const onRunDraftTaskGraph = useCallback(
+ async (nodes: ReadonlyArray): Promise => {
+ const modelSelection = draftTaskGraphModel;
+ if (!activeThread || !activeProject || !modelSelection) {
+ return "Pick a project and model before running the graph.";
+ }
+ const nextThreadId = newThreadId();
+ const createResult = await createThread({
+ environmentId,
+ input: {
+ threadId: nextThreadId,
+ projectId: activeProject.id,
+ title: "New task graph",
+ modelSelection,
+ runtimeMode,
+ interactionMode: "default",
+ branch: activeThreadBranch,
+ worktreePath: activeThread.worktreePath,
+ createdAt: new Date().toISOString(),
+ },
+ });
+ if (createResult._tag === "Failure") {
+ const failure = squashAtomCommandFailure(createResult);
+ return failure instanceof Error ? failure.message : "Could not create the thread.";
+ }
+ const created = await newTaskGraph.createAndRun(nextThreadId, nodes);
+ if (created.outcome === "failed") {
+ await deleteThread({ environmentId, input: { threadId: nextThreadId } });
+ return created.error;
+ }
+ // An interrupted request may have started the graph: keep its thread and open it
+ // rather than risk running the same tasks twice from the draft.
+ if (created.outcome === "uncertain") {
+ toastManager.add({
+ type: "warning",
+ title: "The task graph request was interrupted",
+ description: "Check the new chat to see whether the graph started.",
+ });
+ }
+ // The graph is running now, so its thread stays even if the shell is slow to arrive;
+ // waiting only keeps the route from opening before the thread is known.
+ await waitForServerThreadShell(scopeThreadRef(environmentId, nextThreadId));
+ setDraftTaskGraphOpen(false);
+ await navigate({
+ to: "/$environmentId/$threadId",
+ params: { environmentId, threadId: nextThreadId },
+ });
+ return null;
+ },
+ [
+ activeProject,
+ activeThread,
+ activeThreadBranch,
+ createThread,
+ deleteThread,
+ draftTaskGraphModel,
+ environmentId,
+ navigate,
+ newTaskGraph,
+ runtimeMode,
+ ],
+ );
+
const getModelDisabledReason = useCallback(
(instanceId: ProviderInstanceId, model: string): string | null => {
if (!activeThread) {
@@ -11665,6 +11754,11 @@ export default function ChatView(props: ChatViewProps) {
promptHistoryMessages={timelineMessages}
isServerThread={isServerThread}
isLocalDraftThread={isLocalDraftThread}
+ onNewTaskGraph={
+ newTaskGraph.canCreate && canOperateThread
+ ? () => void onNewTaskGraph()
+ : null
+ }
forceExpandedOnMobile={
forceExpandedMobileComposer && isDraftHeroState
}
@@ -11701,25 +11795,31 @@ export default function ChatView(props: ChatViewProps) {
isPreparingWorktree={isPreparingWorktree && !sendQueuesBehindSetup}
queuedRunsControl={
isServerThread && activeThread ? (
-
+ <>
+
+
+ >
) : null
}
bannerItems={composerBannerItems}
@@ -12137,6 +12237,18 @@ export default function ChatView(props: ChatViewProps) {
+ {!isServerThread && activeThread && activeProject ? (
+
+ ) : null}
{expandedImage && (
;
isServerThread: boolean;
isLocalDraftThread: boolean;
+ /** Starts a task graph on this thread, creating the thread first on a new chat. */
+ onNewTaskGraph: (() => void) | null;
forceExpandedOnMobile: boolean;
projectSelectionRequired: boolean;
@@ -1646,7 +1649,7 @@ export interface ChatComposerProps {
onPageScrollKeyUp: (key: string) => void;
onPageScrollRelease: () => void;
- // Queued runs strip rendered above the composer (v2 queue/steer).
+ // Attachments rendered above the composer: task graphs and the queued runs strip.
queuedRunsControl?: ReactNode;
// Queued-message edit mode: attachments already stored on the message being
// edited. Rendered in the attachment strip with a remove control; removal is
@@ -1726,6 +1729,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
promptHistoryMessages,
isServerThread: _isServerThread,
isLocalDraftThread: _isLocalDraftThread,
+ onNewTaskGraph,
forceExpandedOnMobile,
projectSelectionRequired,
phase,
@@ -7662,6 +7666,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
>
) : null}
+ {onNewTaskGraph ? (
+
+ event.preventDefault()}
+ onClick={onNewTaskGraph}
+ aria-label="New task graph"
+ />
+ }
+ >
+
+
+ New task graph
+
+ ) : null}
import("./TaskGraphEditor");
+const TaskGraphEditor = lazy(loadTaskGraphEditor);
+
+const EMPTY_GRAPHS: ReadonlyArray = [];
+
+/** Finished graphs hidden from the chat for the rest of this session. */
+const useDismissedTaskGraphs = create<{
+ readonly ids: ReadonlySet;
+ readonly dismiss: (id: string) => void;
+}>((set) => ({
+ ids: new Set(),
+ dismiss: (id) => set((state) => ({ ids: new Set(state.ids).add(id) })),
+}));
+
+const openExternal = (url: string) => void readLocalApi()?.shell.openExternal(url);
+
+/**
+ * The task graphs a thread proposed, docked above its composer, each drawn as
+ * a left-to-right diagram. Any graph collapses to a summary line, and finished
+ * ones can be dismissed. Fed by the live subscription, so status needs no refresh.
+ */
+export function TaskGraphCards(props: {
+ readonly environmentId: EnvironmentId;
+ readonly threadId: ThreadId;
+}) {
+ const graphsQuery = useEnvironmentQuery(
+ serverEnvironment.taskGraphsLive({
+ environmentId: props.environmentId,
+ input: { threadId: props.threadId },
+ }),
+ );
+ const dismissed = useDismissedTaskGraphs((state) => state.ids);
+ const dismiss = useDismissedTaskGraphs((state) => state.dismiss);
+ // A load error renders nothing: servers from before task graphs reject the
+ // subscription, and every thread on them would otherwise carry an error row.
+ const graphs = (graphsQuery.data?.graphs ?? EMPTY_GRAPHS).filter(
+ (graph) => !(dismissed.has(graph.id) && isTaskGraphFinished(graph.status)),
+ );
+ return graphs.map((graph) => (
+ dismiss(graph.id)}
+ />
+ ));
+}
+
+function TaskGraphCard(props: {
+ readonly environmentId: EnvironmentId;
+ readonly graph: TaskGraph;
+ readonly onDismiss: () => void;
+}) {
+ const { graph } = props;
+ const finished = isTaskGraphFinished(graph.status);
+ // Expanded by default, finished or not: a graph that ran on its own should still show its shape.
+ const [expanded, setExpanded] = useState(true);
+ const [editorOpenLocally, setEditorOpen] = useState(false);
+ // A graph just started from the composer opens straight into its editor.
+ const requestedNodeKey = useTaskGraphEditorRequest((state) =>
+ state.graphId === graph.id ? state.nodeKey : null,
+ );
+ const clearEditorRequest = useTaskGraphEditorRequest((state) => state.clear);
+ const editorOpen = editorOpenLocally || requestedNodeKey !== null;
+ const [editorNodeKey, setEditorNodeKey] = useState(null);
+ const openEditor = (nodeKey: string | null) => {
+ setEditorNodeKey(nodeKey);
+ setEditorOpen(true);
+ };
+ const commands = useTaskGraphCommands(props.environmentId, graph);
+ const labels = useTaskGraphLabels(props.environmentId, graph);
+ const listId = useId();
+ const pullRequests = taskGraphPullRequestLinks(graph.nodes);
+
+ return (
+
+
+
+
+
+
+
+ {graph.title}
+
+
+ {TASK_GRAPH_STATUS_LABEL[graph.status]} · {taskGraphProgressLabel(graph)}
+
+
+
+ {taskGraphResourcesLabel(graph, labels.modelLabel)}
+
+ {!expanded
+ ? pullRequests.map((pullRequest) => (
+ openExternal(pullRequest.url)}
+ >
+
+ {pullRequest.label}
+
+ ))
+ : null}
+
+
+ {graph.status === "draft" ? (
+ void commands.run()}
+ >
+
+ Run
+
+ ) : null}
+
+ void loadTaskGraphEditor()}
+ onFocus={() => void loadTaskGraphEditor()}
+ onClick={() => openEditor(null)}
+ />
+ }
+ >
+
+
+ {graph.status === "draft" ? "Edit graph" : "Open graph"}
+
+ {!finished ? (
+
+ void commands.cancel()}
+ />
+ }
+ >
+
+
+
+ {graph.status === "draft" ? "Discard draft" : "Cancel graph"}
+
+
+ ) : null}
+ setExpanded(!expanded)}
+ >
+
+
+ {finished ? (
+
+ ) : null}
+
+
+ {commands.error !== null ? (
+
+
+ {commands.error}
+
+
+ ) : null}
+
+ {expanded ? (
+ {
+ void loadTaskGraphEditor();
+ openEditor(key);
+ }}
+ />
+ ) : null}
+
+
+ {
+ setEditorOpen(open);
+ if (!open && requestedNodeKey !== null) clearEditorRequest();
+ }}
+ >
+
+ {editorOpen ? (
+
+
+
+ }
+ >
+
+
+ ) : null}
+
+
+
+ );
+}
+
+/**
+ * The editor for a graph drafted on a new chat. Nothing reaches the server
+ * until Run, which creates the chat's thread and the graph together; closing
+ * keeps the draft for as long as the new chat stays open.
+ */
+export function DraftTaskGraphDialog(props: {
+ readonly environmentId: EnvironmentId;
+ readonly projectId: ProjectId;
+ readonly threadId: ThreadId;
+ readonly baseRef: string | null;
+ readonly modelSelection: ModelSelection | null;
+ readonly open: boolean;
+ readonly onOpenChange: (open: boolean) => void;
+ readonly onRun: (nodes: ReadonlyArray) => Promise;
+}) {
+ const draft = useDraftTaskGraph({
+ canRun: true,
+ onRun: props.onRun,
+ onDiscard: () => props.onOpenChange(false),
+ });
+ const [createdAt] = useState(() => new Date().toISOString());
+ const graph: TaskGraph = {
+ id: TaskGraphId.make("draft"),
+ projectId: props.projectId,
+ threadId: props.threadId,
+ title: "New task graph",
+ baseRef: props.baseRef ?? "HEAD",
+ modelSelection: props.modelSelection,
+ status: "draft",
+ nodes: draft.nodes,
+ createdAt,
+ updatedAt: createdAt,
+ };
+ const labels = useTaskGraphLabels(props.environmentId, graph);
+ return (
+
+
+ {props.open ? (
+
+
+
+ }
+ >
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/web/src/components/chat/TaskGraphDiagram.tsx b/apps/web/src/components/chat/TaskGraphDiagram.tsx
new file mode 100644
index 000000000000..794f42db1d46
--- /dev/null
+++ b/apps/web/src/components/chat/TaskGraphDiagram.tsx
@@ -0,0 +1,287 @@
+import type { EnvironmentId, TaskGraphNode } from "@t3tools/contracts";
+import { taskGraphLayers } from "@t3tools/shared/taskGraph";
+import { Link } from "@tanstack/react-router";
+import { CornerDownRightIcon, FolderIcon, GitBranchIcon } from "lucide-react";
+import { type ReactNode, useEffect, useRef } from "react";
+
+import { cn } from "~/lib/utils";
+import { usePrimarySettings } from "../../hooks/useSettings";
+import { formatUpcomingTimestamp } from "../../timestampFormat";
+import { PreviewCard, PreviewCardPopup, PreviewCardTrigger } from "../ui/preview-card";
+import {
+ pullRequestLabel,
+ TASK_GRAPH_DIAGRAM,
+ TASK_GRAPH_NODE_STATUS_DOT_CLASS,
+ TASK_GRAPH_NODE_STATUS_LABEL,
+ taskGraphDiagramLayout,
+ taskGraphDiagramScrollLeft,
+ taskGraphNodeStatusText,
+ taskGraphNodeWaitDetail,
+ taskGraphWorkspaceLabel,
+} from "./taskGraphView";
+import type { TaskGraphLabels } from "./useTaskGraphLabels";
+
+/**
+ * The graph as boxes and curves, left to right, for the card above the
+ * composer. Static: status changes recolour dots, nothing animates. Once the
+ * first columns are done, the card scrolls to where work is left.
+ */
+export function TaskGraphDiagram(props: {
+ readonly environmentId: EnvironmentId;
+ readonly nodes: ReadonlyArray;
+ readonly labels: TaskGraphLabels;
+ readonly onOpenPullRequest: (url: string) => void;
+ /** Opens the editor on a task; bound to double-click and Enter on its box. */
+ readonly onEditNode: (key: string) => void;
+}) {
+ const layers = taskGraphLayers(props.nodes);
+ const layout = taskGraphDiagramLayout(layers);
+ const scrollLeft = taskGraphDiagramScrollLeft(layers);
+ const rootRef = useRef(null);
+ // Runs only when the leading column changes, so status updates never undo a manual scroll.
+ useEffect(() => {
+ rootRef.current?.closest('[data-slot="scroll-area-viewport"]')?.scrollTo({ left: scrollLeft });
+ }, [scrollLeft, rootRef]);
+ const titles = new Map(props.nodes.map((node) => [node.key, node.title]));
+ const titleOf = (key: string) => titles.get(key) ?? key;
+ const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat);
+ const formatTime = (iso: string) => formatUpcomingTimestamp(iso, timestampFormat);
+
+ return (
+
+
+ {layout.edges.map((edge) => (
+
+ ))}
+
+ {layout.nodes.map(({ node, x, y }) => (
+
props.onEditNode(node.key)}
+ />
+ ))}
+
+ );
+}
+
+function TaskGraphDiagramNode(props: {
+ readonly environmentId: EnvironmentId;
+ readonly node: TaskGraphNode;
+ readonly labels: TaskGraphLabels;
+ readonly x: number;
+ readonly y: number;
+ readonly onOpenPullRequest: (url: string) => void;
+ readonly titleOf: (key: string) => string;
+ /** Renders an upcoming instant, such as when a wait ends. */
+ readonly formatTime: (iso: string) => string;
+ readonly onEdit: () => void;
+}) {
+ const { node } = props;
+ const pullRequestUrl = node.pullRequestResult?.url ?? null;
+ const machine = props.labels.nodeMachineLabel(node);
+ const status = taskGraphNodeStatusText(node, props.formatTime);
+ const title = {node.title} ;
+ const box = (
+ {
+ if (event.key === "Enter" && event.target === event.currentTarget) props.onEdit();
+ }}
+ className={cn(
+ "absolute flex cursor-default select-none flex-col justify-center gap-0.5 rounded-md border bg-background px-2 text-xs outline-none hover:border-ring/60 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring",
+ node.status === "failed" ? "border-destructive/50" : "border-border",
+ (node.status === "skipped" || node.status === "cancelled") && "opacity-60",
+ )}
+ style={{
+ left: props.x,
+ top: props.y,
+ width: TASK_GRAPH_DIAGRAM.nodeWidth,
+ height: TASK_GRAPH_DIAGRAM.nodeHeight,
+ }}
+ >
+
+
+ {title}
+
+
+
+ {status} · {machine === "Auto" ? machine : `on ${machine}`}
+
+ {pullRequestUrl !== null ? (
+ props.onOpenPullRequest(pullRequestUrl)}
+ >
+ {pullRequestLabel(pullRequestUrl)}
+
+ ) : null}
+
+
+ {props.labels.nodeModelLabel(node)}
+
+
+
+ );
+ return (
+
+
+
+
+
+
+ );
+}
+
+/** What a hovered box expands to: the task itself, what it waits on, and how it went. */
+function TaskGraphNodeDetails(props: {
+ readonly environmentId: EnvironmentId;
+ readonly node: TaskGraphNode;
+ readonly labels: TaskGraphLabels;
+ readonly titleOf: (key: string) => string;
+ readonly formatTime: (iso: string) => string;
+}) {
+ const { node } = props;
+ const pullRequestError =
+ node.pullRequestResult?.status === "failed" ? node.pullRequestResult.error : null;
+ const wait = taskGraphNodeWaitDetail(node, props.formatTime);
+ return (
+
+
+
+ {node.title}
+
+ {TASK_GRAPH_NODE_STATUS_LABEL[node.status]}
+
+
+
+ {node.prompt}
+
+ {wait !== null ?
{wait} : null}
+ {node.dependsOn.length > 0 ? (
+
+ {node.dependsOn.map(props.titleOf).join(", ")}
+
+ ) : null}
+
{props.labels.nodeModelLabel(node)}
+
+ {props.labels.nodeMachineLabel(node)}
+
+
+ {taskGraphWorkspaceLabel(node, props.titleOf)}
+
+ {node.branch !== null ? (
+
+ {node.branch}
+
+ ) : null}
+ {node.summary !== null ? (
+
+ {node.summary}
+
+ ) : null}
+ {node.error !== null ? (
+
{node.error}
+ ) : null}
+ {pullRequestError !== null ? (
+
+ Pull request failed: {pullRequestError}
+
+ ) : null}
+
+ Double-click to edit
+ {node.threadId !== null ? (
+
+ Open thread
+
+ ) : null}
+
+
+ );
+}
+
+/** The box's workspace hint: an icon, plus the dependency's name when it continues one. */
+function TaskGraphWorkspaceBadge(props: {
+ readonly node: TaskGraphNode;
+ readonly titleOf: (key: string) => string;
+}) {
+ const { node } = props;
+ const dependency = node.dependsOn[0];
+ if (node.workspace === "dependency" && dependency !== undefined) {
+ return (
+
+
+ Continues
+ {props.titleOf(dependency)}
+
+ );
+ }
+ const Icon = node.workspace === "root" ? FolderIcon : GitBranchIcon;
+ return (
+
+ );
+}
+
+function TaskGraphNodeDetail(props: { readonly label: string; readonly children: ReactNode }) {
+ return (
+
+ {props.label}
+ {props.children}
+
+ );
+}
diff --git a/apps/web/src/components/chat/TaskGraphEditor.css b/apps/web/src/components/chat/TaskGraphEditor.css
new file mode 100644
index 000000000000..fa980dbcb4b7
--- /dev/null
+++ b/apps/web/src/components/chat/TaskGraphEditor.css
@@ -0,0 +1,13 @@
+/*
+ * xyflow draws edges, handles, the connection line and the dot grid itself,
+ * so its theme variables map to ours here instead of in classes on elements
+ * we render. Loaded with the editor chunk only.
+ */
+.react-flow[data-task-graph-canvas] {
+ --xy-edge-stroke: var(--muted-foreground);
+ --xy-edge-stroke-selected: var(--ring);
+ --xy-edge-stroke-width: 1.5;
+ --xy-connectionline-stroke: var(--ring);
+ --xy-handle-background-color: var(--muted-foreground);
+ --xy-background-pattern-dots-color: var(--border);
+}
diff --git a/apps/web/src/components/chat/TaskGraphEditor.tsx b/apps/web/src/components/chat/TaskGraphEditor.tsx
new file mode 100644
index 000000000000..13b1fb37e756
--- /dev/null
+++ b/apps/web/src/components/chat/TaskGraphEditor.tsx
@@ -0,0 +1,1174 @@
+import "@xyflow/react/dist/base.css";
+import "./TaskGraphEditor.css";
+
+import type {
+ EnvironmentId,
+ ModelSelection,
+ TaskGraph,
+ TaskGraphNode,
+ TaskGraphNodeWorkspace,
+} from "@t3tools/contracts";
+import {
+ isActiveTaskGraphNodeStatus,
+ isUnstartedTaskGraphNode,
+ isTerminalTaskGraphNodeStatus,
+ taskGraphNodeOpensPullRequest,
+} from "@t3tools/shared/taskGraph";
+import {
+ localSnoozeDate,
+ localSnoozeTime,
+ resolveCustomSnooze,
+} from "@t3tools/client-runtime/state/thread-settled";
+import { Link } from "@tanstack/react-router";
+import {
+ Background,
+ Handle,
+ Position,
+ ReactFlow,
+ useReactFlow,
+ type Connection,
+ type NodeProps,
+} from "@xyflow/react";
+import {
+ CalendarIcon,
+ MessageSquareTextIcon,
+ PlayIcon,
+ PlusIcon,
+ RotateCcwIcon,
+ ScissorsIcon,
+ SquareIcon,
+ Trash2Icon,
+ UnlinkIcon,
+} from "lucide-react";
+import { useEffect, useId, useMemo, useState } from "react";
+
+import { cn } from "~/lib/utils";
+import { readLocalApi } from "~/localApi";
+import { usePrimarySettings } from "../../hooks/useSettings";
+import { getCustomModelOptionsByInstance } from "../../modelSelection";
+import { NO_PROVIDER_MODEL_SELECTION } from "../../providerInstances";
+import { formatUpcomingTimestamp, weekStartsOn } from "../../timestampFormat";
+import { Button } from "../ui/button";
+import { Calendar } from "../ui/calendar";
+import { DialogDescription, DialogHeader, DialogTitle } from "../ui/dialog";
+import { Input } from "../ui/input";
+import { Label } from "../ui/label";
+import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
+import { ScrollArea } from "../ui/scroll-area";
+import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
+import { Textarea } from "../ui/textarea";
+import { Toggle, ToggleGroup } from "../ui/toggle-group";
+import { PullRequestGlyph } from "../pullRequest/pullRequestIcons";
+import { ProviderModelPicker } from "./ProviderModelPicker";
+import {
+ layoutTaskGraph,
+ taskGraphCanvasEdgeId,
+ taskGraphCanvasElements,
+ taskGraphLayoutSignature,
+ type TaskGraphCanvasNode,
+ type TaskGraphCanvasPoint,
+} from "./taskGraphCanvasLayout";
+import {
+ canContinueTaskGraphWorktree,
+ isTaskGraphFinished,
+ pullRequestLabel,
+ TASK_GRAPH_NODE_STATUS_DOT_CLASS,
+ TASK_GRAPH_NODE_STATUS_LABEL,
+ TASK_GRAPH_STATUS_LABEL,
+ type TaskGraphJoinMachine,
+ taskGraphJoinMachines,
+ taskGraphNodeKeyFromTitle,
+ taskGraphNodeWaitDetail,
+ taskGraphProgressLabel,
+} from "./taskGraphView";
+import type { TaskGraphCommands } from "./useTaskGraphCommands";
+import { TASK_GRAPH_PEER_STATUS_HINT, type TaskGraphLabels } from "./useTaskGraphLabels";
+
+type Selection =
+ | { readonly kind: "none" }
+ | { readonly kind: "node"; readonly key: string }
+ | { readonly kind: "edge"; readonly dependency: string; readonly dependent: string };
+
+const NO_SELECTION: Selection = { kind: "none" };
+
+// One editor is open at a time; keeping its last layout lets status updates
+// from a running graph skip dagre until nodes or dependencies change.
+let cachedLayout: {
+ readonly signature: string;
+ readonly positions: ReadonlyMap;
+} | null = null;
+
+function graphLayout(nodes: ReadonlyArray) {
+ const signature = taskGraphLayoutSignature(nodes);
+ if (cachedLayout?.signature !== signature) {
+ cachedLayout = { signature, positions: layoutTaskGraph(nodes) };
+ }
+ return cachedLayout;
+}
+
+const openExternal = (url: string) => void readLocalApi()?.shell.openExternal(url);
+
+function TaskCanvasNode({ data, selected }: NodeProps) {
+ const { node, opensPullRequest } = data;
+ return (
+
+
+
+
+ {node.title}
+
+
+ {node.key}
+ {TASK_GRAPH_NODE_STATUS_LABEL[node.status]}
+ {opensPullRequest ? (
+
+ ) : null}
+
+
+
+ );
+}
+
+const NODE_TYPES = { task: TaskCanvasNode };
+
+/** Fits the view on mount; keyed by layout so it refits on structure changes, not status updates. */
+function FitViewOnMount() {
+ const { fitView } = useReactFlow();
+ useEffect(() => {
+ void fitView({ maxZoom: 1 });
+ }, [fitView]);
+ return null;
+}
+
+/**
+ * Canvas editor for one task graph, loaded only when the card opens it. The
+ * subscription's graph is the only copy: every change is sent as an edit and
+ * the canvas redraws from what the server reports back.
+ */
+export default function TaskGraphEditor(props: {
+ readonly environmentId: EnvironmentId;
+ readonly graph: TaskGraph;
+ readonly commands: TaskGraphCommands;
+ readonly labels: TaskGraphLabels;
+ /** Task to select on open, such as the box double-clicked in the chat diagram. */
+ readonly initialNodeKey?: string | null;
+}) {
+ const { graph, commands } = props;
+ const [selectionState, setSelection] = useState(() =>
+ props.initialNodeKey == null ? NO_SELECTION : { kind: "node", key: props.initialNodeKey },
+ );
+ const layout = graphLayout(graph.nodes);
+ const selection = resolveSelection(selectionState, graph);
+ const { nodes, edges } = taskGraphCanvasElements(graph.nodes, layout.positions, {
+ nodeKey: selection.kind === "node" ? selection.key : null,
+ edgeId:
+ selection.kind === "edge"
+ ? taskGraphCanvasEdgeId(selection.dependency, selection.dependent)
+ : null,
+ });
+ const canEdit = commands.canEdit && !commands.busy;
+ const finished = isTaskGraphFinished(graph.status);
+
+ const unstartedNode = (key: string) =>
+ graph.nodes.find((node) => node.key === key && isUnstartedTaskGraphNode(node));
+
+ const connect = (connection: Connection) => {
+ const dependent = unstartedNode(connection.target);
+ if (dependent === undefined || dependent.dependsOn.includes(connection.source)) return;
+ void commands.edit([
+ {
+ type: "update_node",
+ key: dependent.key,
+ dependsOn: [...dependent.dependsOn, connection.source],
+ },
+ ]);
+ };
+
+ // Adds a placeholder task to the graph and selects it for editing: after the
+ // selected task, continuing its worktree where it can, or with nothing
+ // selected, as the parent of the unstarted tasks at the start (a started
+ // task can no longer be made to wait).
+ const addTask = async () => {
+ const after = selection.kind === "node" ? selection.key : null;
+ const parent = graph.nodes.find((node) => node.key === after);
+ const key = taskGraphNodeKeyFromTitle(
+ "New task",
+ graph.nodes.map((node) => node.key),
+ );
+ const children =
+ after === null
+ ? graph.nodes.filter(
+ (node) => node.dependsOn.length === 0 && isUnstartedTaskGraphNode(node),
+ )
+ : [];
+ const accepted = await commands.edit([
+ {
+ type: "add_node",
+ node: {
+ key,
+ title: "New task",
+ prompt: "Describe what this task should do.",
+ dependsOn: after === null ? [] : [after],
+ ...(parent !== undefined && canContinueTaskGraphWorktree(graph.nodes, parent)
+ ? { workspace: "dependency" as const }
+ : {}),
+ },
+ },
+ ...children.map((child) => ({
+ type: "update_node" as const,
+ key: child.key,
+ dependsOn: [key],
+ })),
+ ]);
+ if (accepted) setSelection({ kind: "node", key });
+ };
+
+ return (
+ <>
+
+
+
+ {graph.title}
+
+ {TASK_GRAPH_STATUS_LABEL[graph.status]} · {taskGraphProgressLabel(graph)} · from{" "}
+ {graph.baseRef}
+
+
+
+
void addTask()}>
+
+ Add task
+
+ {!finished ? (
+
void commands.cancel()}
+ >
+
+ {graph.status === "draft" ? "Discard draft" : "Cancel graph"}
+
+ ) : null}
+ {graph.status === "draft" ? (
+
void commands.run()}
+ >
+
+ Run
+
+ ) : null}
+
+
+ {commands.error !== null ? (
+
+ {commands.error}
+
+ ) : null}
+
+
+
+
+ connection.source !== connection.target &&
+ unstartedNode(connection.target) !== undefined
+ }
+ onConnect={connect}
+ onNodeClick={(_event, node) => setSelection({ kind: "node", key: node.id })}
+ onEdgeClick={(_event, edge) =>
+ setSelection({ kind: "edge", dependency: edge.source, dependent: edge.target })
+ }
+ onPaneClick={() => setSelection(NO_SELECTION)}
+ data-task-graph-canvas
+ >
+
+
+
+
+
+
+ >
+ );
+}
+
+/** Drops a selection the server has since removed, such as a deleted node. */
+function resolveSelection(selection: Selection, graph: TaskGraph): Selection {
+ if (selection.kind === "node") {
+ return graph.nodes.some((node) => node.key === selection.key) ? selection : NO_SELECTION;
+ }
+ if (selection.kind === "edge") {
+ const dependent = graph.nodes.find((node) => node.key === selection.dependent);
+ return dependent?.dependsOn.includes(selection.dependency) ? selection : NO_SELECTION;
+ }
+ return selection;
+}
+
+function SelectionPanel(props: {
+ readonly environmentId: EnvironmentId;
+ readonly graph: TaskGraph;
+ readonly commands: TaskGraphCommands;
+ readonly labels: TaskGraphLabels;
+ readonly selection: Selection;
+ readonly onSelect: (selection: Selection) => void;
+}) {
+ const { graph, commands, selection } = props;
+ const canEdit = commands.canEdit && !commands.busy;
+
+ if (selection.kind === "edge") {
+ const dependent = graph.nodes.find((node) => node.key === selection.dependent);
+ const dependency = graph.nodes.find((node) => node.key === selection.dependency);
+ if (dependent === undefined || dependency === undefined) return null;
+ return (
+
+
Dependency
+
+ {dependent.title} starts after{" "}
+ {dependency.title} succeeds.
+
+ {isUnstartedTaskGraphNode(dependent) ? (
+
+ void commands.edit([
+ {
+ type: "update_node",
+ key: dependent.key,
+ dependsOn: dependent.dependsOn.filter((key) => key !== dependency.key),
+ },
+ ])
+ }
+ >
+
+ Remove dependency
+
+ ) : (
+
+ {dependent.title} has started, so its dependencies are fixed.
+
+ )}
+
+ );
+ }
+
+ if (selection.kind === "node") {
+ const node = graph.nodes.find((candidate) => candidate.key === selection.key);
+ if (node === undefined) return null;
+ return (
+
+ );
+ }
+
+ return (
+
+
Graph
+
+ Each task runs as its own thread, in its own worktree unless set otherwise. Drag from the
+ right edge of a task to the left edge of another to make the second wait for the first.
+
+
Tasks that nothing depends on open a pull request unless you turn it off.
+
Select a task or dependency to change it.
+
+ Add task puts the new task after the selected one, continuing its worktree. With nothing
+ selected, it becomes the parent of the tasks at the start.
+
+
+ );
+}
+
+// Dependency choices carry a prefix, so no task key (keys are slugs) can equal the "any machine" value.
+const JOIN_AUTO = "any";
+const joinValue = (dependencyKey: string) => `dependency:${dependencyKey}`;
+
+/**
+ * Where a task that merges branches from several machines brings them
+ * together: on one dependency's machine, continuing its worktree and pulling
+ * the other branches in through origin, or in a new worktree wherever load
+ * balancing puts it.
+ */
+function TaskJoinMachineField(props: {
+ readonly node: TaskGraphNode;
+ readonly options: ReadonlyArray;
+ readonly labels: TaskGraphLabels;
+ readonly disabled: boolean;
+ readonly titleOf: (key: string) => string;
+ readonly onChoose: (dependencyKey: string | null) => void;
+}) {
+ const id = useId();
+ const continued =
+ props.node.workspace === "dependency" ? (props.node.dependsOn[0] ?? null) : null;
+ const current = continued === null ? JOIN_AUTO : joinValue(continued);
+ const optionLabel = (option: TaskGraphJoinMachine) =>
+ `${props.labels.machineLabel(option.machine)}, continuing ${option.dependency.title}`;
+ const chosen = props.options.find((option) => option.dependency.key === continued);
+ // The continued dependency is left out of the options while its machine is unknown,
+ // such as when it is balanced automatically and has not started yet.
+ const pendingLabel =
+ continued !== null && chosen === undefined
+ ? `Machine not known yet, continuing ${props.titleOf(continued)}`
+ : null;
+ return (
+
+
Bring branches together on
+
{
+ if (value === current) return;
+ if (value === JOIN_AUTO) {
+ props.onChoose(null);
+ return;
+ }
+ const option = props.options.find((entry) => joinValue(entry.dependency.key) === value);
+ if (option !== undefined) props.onChoose(option.dependency.key);
+ }}
+ >
+
+
+ {chosen !== undefined
+ ? optionLabel(chosen)
+ : (pendingLabel ?? "Any machine (new worktree)")}
+
+
+
+ Any machine (new worktree)
+ {pendingLabel !== null && continued !== null ? (
+ {pendingLabel}
+ ) : null}
+ {props.options.map((option) => (
+
+ {optionLabel(option)}
+
+ ))}
+
+
+
+ The branches this task merges are on different machines. The other branches are pushed and
+ pulled in through origin.
+
+
+ );
+}
+
+function NodePanel(props: {
+ readonly environmentId: EnvironmentId;
+ readonly graph: TaskGraph;
+ readonly node: TaskGraphNode;
+ readonly commands: TaskGraphCommands;
+ readonly labels: TaskGraphLabels;
+}) {
+ const { graph, node, commands } = props;
+ const placement: TaskNodePlacementContext = {
+ graph,
+ labels: props.labels,
+ dependency: graph.nodes.find((candidate) => candidate.key === node.dependsOn[0]) ?? null,
+ };
+ const canEdit = commands.canEdit && !commands.busy;
+ const opensByDefault = taskGraphNodeOpensPullRequest(graph.nodes, {
+ ...node,
+ pullRequest: null,
+ });
+ const canCancelBranch =
+ isActiveTaskGraphNodeStatus(node.status) ||
+ node.status === "waiting" ||
+ (node.status === "pending" && graph.status !== "draft");
+ const canRetry = isTerminalTaskGraphNodeStatus(node.status) && node.status !== "succeeded";
+ const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat);
+ const wait = taskGraphNodeWaitDetail(node, (iso) =>
+ formatUpcomingTimestamp(iso, timestampFormat),
+ );
+
+ const actions = (
+
+ {canCancelBranch ? (
+ void commands.edit([{ type: "cancel_branch", key: node.key }])}
+ >
+
+ Cancel branch
+
+ ) : null}
+ {canRetry ? (
+ void commands.edit([{ type: "retry_node", key: node.key }])}
+ >
+
+ Retry
+
+ ) : null}
+
+ );
+
+ const joinMachines = taskGraphJoinMachines(graph.nodes, node);
+
+ if (isUnstartedTaskGraphNode(node)) {
+ return (
+
+ {wait !== null ?
{wait}
: null}
+ {joinMachines !== null ? (
+
graph.nodes.find((entry) => entry.key === key)?.title ?? key}
+ onChoose={(dependencyKey) =>
+ void commands.edit([
+ {
+ type: "update_node",
+ key: node.key,
+ ...(dependencyKey === null
+ ? { workspace: "worktree" }
+ : {
+ dependsOn: [
+ dependencyKey,
+ ...node.dependsOn.filter((key) => key !== dependencyKey),
+ ],
+ workspace: "dependency",
+ // Continuing a worktree runs where it is, so a pin no longer applies.
+ environmentId: null,
+ }),
+ },
+ ])
+ }
+ />
+ ) : null}
+ {
+ await commands.edit([
+ {
+ type: "update_node",
+ key: node.key,
+ ...(values.title === node.title ? {} : { title: values.title }),
+ ...(values.prompt === node.prompt ? {} : { prompt: values.prompt }),
+ ...(values.pullRequest === node.pullRequest
+ ? {}
+ : { pullRequest: values.pullRequest }),
+ ...(sameModel(values.modelSelection, node.modelSelection)
+ ? {}
+ : { modelSelection: values.modelSelection }),
+ ...(values.environmentId === node.environmentId
+ ? {}
+ : { environmentId: values.environmentId }),
+ ...(values.workspace === node.workspace ? {} : { workspace: values.workspace }),
+ ...(values.startAt === node.startAt ? {} : { startAt: values.startAt }),
+ },
+ ]);
+ }}
+ />
+
+ void commands.edit([{ type: "remove_node", key: node.key }])}
+ >
+
+ Delete task
+
+ {actions}
+
+
+ );
+ }
+
+ const pullRequest = node.pullRequestResult;
+ const pullRequestUrl = pullRequest?.url ?? null;
+ return (
+
+
+
{node.title}
+
+
+ {TASK_GRAPH_NODE_STATUS_LABEL[node.status]}
+ {node.key}
+
+
+ {wait !== null ?
{wait}
: null}
+ {node.error !== null ?
{node.error}
: null}
+ {node.branch !== null ? (
+
{node.branch}
+ ) : null}
+
+ {node.threadId !== null ? (
+
+ }
+ >
+
+ Open thread
+
+ ) : null}
+ {pullRequestUrl !== null ? (
+
openExternal(pullRequestUrl)}>
+
+ {pullRequestLabel(pullRequestUrl)}
+
+ ) : null}
+
+ {pullRequest?.status === "failed" && pullRequest.error !== null ? (
+
Pull request failed: {pullRequest.error}
+ ) : null}
+ {actions}
+
+
+
Prompt
+
{node.prompt}
+
+ {node.summary !== null ? (
+
+
Result
+
{node.summary}
+
+ ) : null}
+
+ );
+}
+
+interface TaskNodeValues {
+ readonly title: string;
+ readonly prompt: string;
+ /** Null follows the default: a pull request only when nothing depends on the task. */
+ readonly pullRequest: boolean | null;
+ /** Null runs with the graph's model. */
+ readonly modelSelection: ModelSelection | null;
+ /** Null balances across machines when the task starts. */
+ readonly environmentId: EnvironmentId | null;
+ readonly workspace: TaskGraphNodeWorkspace;
+ /** Earliest start as an ISO time; null starts as soon as its dependencies succeed. */
+ readonly startAt: string | null;
+}
+
+type TaskNodePlacement = Pick;
+
+/** What the placement fields resolve names against; `dependency` is the task's first dependency. */
+interface TaskNodePlacementContext {
+ readonly graph: TaskGraph;
+ readonly labels: TaskGraphLabels;
+ readonly dependency: TaskGraphNode | null;
+}
+
+const sameModel = (left: ModelSelection | null, right: ModelSelection | null) =>
+ left === right ||
+ (left !== null &&
+ right !== null &&
+ left.instanceId === right.instanceId &&
+ left.model === right.model);
+
+const PULL_REQUEST_CHOICES = ["auto", "on", "off"] as const;
+type PullRequestChoice = (typeof PULL_REQUEST_CHOICES)[number];
+
+const toChoice = (value: boolean | null): PullRequestChoice =>
+ value === null ? "auto" : value ? "on" : "off";
+const fromChoice = (choice: PullRequestChoice): boolean | null =>
+ choice === "auto" ? null : choice === "on";
+
+/** Form text stays local until submitted; everything else comes from the server. */
+function TaskNodeForm(props: {
+ readonly heading: string;
+ readonly initial: TaskNodeValues;
+ readonly placement: TaskNodePlacementContext;
+ readonly opensByDefault: boolean;
+ readonly disabled: boolean;
+ readonly submitLabel: string;
+ readonly onSubmit: (values: TaskNodeValues) => Promise;
+}) {
+ const id = useId();
+ const [title, setTitle] = useState(props.initial.title);
+ const [prompt, setPrompt] = useState(props.initial.prompt);
+ const [pullRequest, setPullRequest] = useState(props.initial.pullRequest);
+ const [placement, setPlacement] = useState(props.initial);
+ const [start, setStart] = useState(() => initialStartChoice(props.initial.startAt));
+ const startAt = resolveStartAt(start, props.initial.startAt);
+ const values: TaskNodeValues = {
+ title: title.trim(),
+ prompt: prompt.trim(),
+ pullRequest,
+ modelSelection: placement.modelSelection,
+ environmentId: placement.environmentId,
+ workspace: placement.workspace,
+ startAt,
+ };
+ const dirty =
+ values.title !== props.initial.title ||
+ values.prompt !== props.initial.prompt ||
+ values.pullRequest !== props.initial.pullRequest ||
+ !sameModel(values.modelSelection, props.initial.modelSelection) ||
+ values.environmentId !== props.initial.environmentId ||
+ values.workspace !== props.initial.workspace ||
+ values.startAt !== props.initial.startAt;
+ const startAtInvalid = start.scheduled && startAt === null;
+ const valid =
+ values.title.length > 0 &&
+ values.title.length <= 120 &&
+ values.prompt.length > 0 &&
+ !startAtInvalid;
+
+ return (
+
+ );
+}
+
+/** The start-at field's local date and time; kept while unscheduled so toggling back restores it. */
+interface StartChoice {
+ readonly scheduled: boolean;
+ readonly date: Date;
+ /** Local "HH:mm". */
+ readonly time: string;
+}
+
+function initialStartChoice(startAt: string | null): StartChoice {
+ const at = startAt === null ? new Date(Date.now() + 3_600_000) : new Date(startAt);
+ return { scheduled: startAt !== null, date: at, time: localSnoozeTime(at) };
+}
+
+/**
+ * The chosen start as an ISO time, or null when it starts once ready or the
+ * choice is not a future time. An untouched existing start keeps its exact
+ * value, even once it has passed, so saving other fields does not reject it.
+ */
+function resolveStartAt(choice: StartChoice, initial: string | null): string | null {
+ if (!choice.scheduled) return null;
+ const date = localSnoozeDate(choice.date);
+ if (initial !== null) {
+ const was = new Date(initial);
+ if (date === localSnoozeDate(was) && choice.time === localSnoozeTime(was)) return initial;
+ }
+ return resolveCustomSnooze({ mode: "date", date, time: choice.time }, new Date());
+}
+
+/** When a task may start: as soon as its dependencies succeed, or no earlier than a local time. */
+function TaskNodeStartAtField(props: {
+ readonly value: StartChoice;
+ readonly invalid: boolean;
+ readonly disabled: boolean;
+ readonly onChange: (next: StartChoice) => void;
+}) {
+ const id = useId();
+ const [calendarOpen, setCalendarOpen] = useState(false);
+ const { value } = props;
+ return (
+
+
Start at
+
{
+ if (next[0] === "ready" || next[0] === "at") {
+ props.onChange({ ...value, scheduled: next[0] === "at" });
+ }
+ }}
+ >
+ As soon as ready
+ At a time
+
+ {value.scheduled ? (
+
+
+
+ }
+ >
+ {value.date.toLocaleDateString(undefined, {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ })}
+
+
+
+ {
+ props.onChange({ ...value, date: selected });
+ setCalendarOpen(false);
+ }}
+ />
+
+
+
props.onChange({ ...value, time: event.target.value })}
+ />
+
+ ) : null}
+ {props.invalid ? (
+
Choose a date and time in the future.
+ ) : null}
+
+ );
+}
+
+const AUTO_MACHINE = "auto";
+
+const WORKSPACE_CHOICES: ReadonlyArray = ["worktree", "dependency", "root"];
+
+/**
+ * Model, machine and workspace for one task. Editable while the task is
+ * pending; afterwards the same fields show, disabled, what it was set to.
+ */
+function TaskNodePlacementFields(props: {
+ readonly context: TaskNodePlacementContext;
+ readonly values: TaskNodePlacement;
+ readonly disabled: boolean;
+ readonly onChange?: (next: Partial) => void;
+}) {
+ const id = useId();
+ const { graph, labels, dependency } = props.context;
+ const { modelSelection, environmentId, workspace } = props.values;
+ const change = (next: Partial) => {
+ if (!props.disabled) props.onChange?.(next);
+ };
+
+ // Null on old graphs, made before graphs recorded their thread's model.
+ const graphModel = graph.modelSelection;
+ const defaultLabel =
+ graphModel === null ? "Thread default" : `Graph default (${labels.modelLabel(graphModel)})`;
+ const shownModel = modelSelection ?? graphModel;
+ const activeInstanceId =
+ shownModel?.instanceId ??
+ labels.instanceEntries[0]?.instanceId ??
+ NO_PROVIDER_MODEL_SELECTION.instanceId;
+ const activeModel = shownModel?.model ?? "";
+ const modelOptionsByInstance = useMemo(
+ () =>
+ getCustomModelOptionsByInstance(
+ labels.settings,
+ labels.providers,
+ activeInstanceId,
+ activeModel,
+ ),
+ [labels.settings, labels.providers, activeInstanceId, activeModel],
+ );
+
+ const follows = workspace === "dependency" && dependency !== null;
+ const machineLabel = follows
+ ? `Follows ${dependency.title} (${labels.nodeMachineLabel(dependency)})`
+ : environmentId === null
+ ? "Auto (balance load)"
+ : environmentId === labels.environmentId
+ ? `${labels.environmentLabel} (this machine)`
+ : labels.machineLabel(environmentId);
+ // A pin to a machine that is no longer paired still shows, so it can be cleared.
+ const stalePin =
+ environmentId !== null &&
+ environmentId !== labels.environmentId &&
+ !labels.peers.some((peer) => peer.environmentId === environmentId)
+ ? environmentId
+ : null;
+ const continueLabel =
+ dependency === null
+ ? "Continue a dependency's worktree"
+ : `Continue ${dependency.title}'s worktree`;
+
+ return (
+ <>
+
+
Model
+
+ change({
+ // Picking the same model again keeps its stored provider options.
+ modelSelection:
+ modelSelection !== null &&
+ modelSelection.instanceId === instanceId &&
+ modelSelection.model === model
+ ? modelSelection
+ : { instanceId, model },
+ })
+ }
+ />
+ {modelSelection !== null && !props.disabled ? (
+ change({ modelSelection: null })}
+ >
+ Use {defaultLabel.charAt(0).toLowerCase() + defaultLabel.slice(1)}
+
+ ) : null}
+
+
+ Machine
+ {
+ if (value === AUTO_MACHINE) change({ environmentId: null });
+ else if (value === labels.environmentId)
+ change({ environmentId: labels.environmentId });
+ else {
+ const peer = labels.peers.find((candidate) => candidate.environmentId === value);
+ if (peer !== undefined) change({ environmentId: peer.environmentId });
+ }
+ }}
+ >
+
+ {machineLabel}
+
+
+ Auto (balance load)
+
+ {labels.environmentLabel} (this machine)
+
+ {labels.peers.map((peer) => {
+ const hint = TASK_GRAPH_PEER_STATUS_HINT[peer.status];
+ return (
+
+ {hint === null ? peer.label : `${peer.label} (${hint})`}
+
+ );
+ })}
+ {stalePin !== null ? (
+ {labels.machineLabel(stalePin)}
+ ) : null}
+
+
+
+
+
Workspace
+
{
+ const next = WORKSPACE_CHOICES.find((choice) => choice === value);
+ if (next === undefined) return;
+ // A continuing task runs where its dependency's worktree is, so a pin no longer applies.
+ change(
+ next === "dependency"
+ ? { workspace: next, environmentId: null }
+ : { workspace: next },
+ );
+ }}
+ >
+
+
+ {workspace === "worktree"
+ ? "New worktree"
+ : workspace === "root"
+ ? "Project folder (no worktree)"
+ : continueLabel}
+
+
+
+ New worktree
+
+ {continueLabel}
+
+ Project folder (no worktree)
+
+
+
+ {workspace === "root"
+ ? "Read-only work: no branch, commit or pull request."
+ : workspace === "worktree"
+ ? "Its own new worktree and branch."
+ : dependency === null
+ ? "Needs a dependency whose worktree it continues."
+ : `Builds on ${dependency.title}'s branch, on the machine it ran on.`}
+
+
+ >
+ );
+}
diff --git a/apps/web/src/components/chat/taskGraphCanvasLayout.test.ts b/apps/web/src/components/chat/taskGraphCanvasLayout.test.ts
new file mode 100644
index 000000000000..440fa919a4df
--- /dev/null
+++ b/apps/web/src/components/chat/taskGraphCanvasLayout.test.ts
@@ -0,0 +1,110 @@
+import type { TaskGraphNode } from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ layoutTaskGraph,
+ TASK_GRAPH_CANVAS_NODE_HEIGHT,
+ TASK_GRAPH_CANVAS_NODE_WIDTH,
+ taskGraphCanvasElements,
+ taskGraphLayoutSignature,
+} from "./taskGraphCanvasLayout";
+
+const node = (key: string, dependsOn: string[] = [], overrides: Partial = {}) =>
+ ({
+ key,
+ title: key,
+ prompt: "Do it",
+ dependsOn,
+ pullRequest: null,
+ modelSelection: null,
+ environmentId: null,
+ workspace: "worktree",
+ startAt: null,
+ status: "pending",
+ waitUntil: null,
+ waitReason: null,
+ assignedEnvironmentId: null,
+ threadId: null,
+ branch: null,
+ worktreePath: null,
+ summary: null,
+ error: null,
+ pullRequestResult: null,
+ startedAt: null,
+ completedAt: null,
+ ...overrides,
+ }) satisfies TaskGraphNode;
+
+// a -> b, a -> c, (b, c) -> d
+const diamond = [node("a"), node("b", ["a"]), node("c", ["a"]), node("d", ["b", "c"])];
+
+const overlaps = (
+ first: { readonly x: number; readonly y: number },
+ second: { readonly x: number; readonly y: number },
+) =>
+ Math.abs(first.x - second.x) < TASK_GRAPH_CANVAS_NODE_WIDTH &&
+ Math.abs(first.y - second.y) < TASK_GRAPH_CANVAS_NODE_HEIGHT;
+
+describe("layoutTaskGraph", () => {
+ it("places every dependency to the left of the nodes that wait on it", () => {
+ const positions = layoutTaskGraph(diamond);
+ for (const current of diamond) {
+ for (const dependency of current.dependsOn) {
+ expect(positions.get(dependency)!.x).toBeLessThan(positions.get(current.key)!.x);
+ }
+ }
+ });
+
+ it("does not overlap nodes", () => {
+ const positions = [...layoutTaskGraph(diamond).values()];
+ for (const [index, position] of positions.entries()) {
+ for (const other of positions.slice(index + 1)) {
+ expect(overlaps(position, other)).toBe(false);
+ }
+ }
+ });
+
+ it("ignores dependencies on nodes that are not in the graph", () => {
+ const positions = layoutTaskGraph([node("a", ["gone"])]);
+ expect([...positions.keys()]).toEqual(["a"]);
+ });
+});
+
+describe("taskGraphLayoutSignature", () => {
+ it("changes with structure but not with status", () => {
+ const running = diamond.map((current) => ({ ...current, status: "running" as const }));
+ expect(taskGraphLayoutSignature(running)).toBe(taskGraphLayoutSignature(diamond));
+ expect(taskGraphLayoutSignature([...diamond, node("e", ["d"])])).not.toBe(
+ taskGraphLayoutSignature(diamond),
+ );
+ });
+});
+
+describe("taskGraphCanvasElements", () => {
+ it("draws one edge per dependency from the dependency to the dependent", () => {
+ const { nodes, edges } = taskGraphCanvasElements(diamond, layoutTaskGraph(diamond), {
+ nodeKey: "b",
+ edgeId: "c->d",
+ });
+ expect(edges.map((edge) => [edge.source, edge.target, edge.selected])).toEqual([
+ ["a", "b", false],
+ ["a", "c", false],
+ ["b", "d", false],
+ ["c", "d", true],
+ ]);
+ expect(nodes.filter((current) => current.selected).map((current) => current.id)).toEqual(["b"]);
+ });
+
+ it("marks which nodes open a pull request", () => {
+ const { nodes } = taskGraphCanvasElements(diamond, layoutTaskGraph(diamond), {
+ nodeKey: null,
+ edgeId: null,
+ });
+ expect(nodes.map((current) => [current.id, current.data.opensPullRequest])).toEqual([
+ ["a", false],
+ ["b", false],
+ ["c", false],
+ ["d", true],
+ ]);
+ });
+});
diff --git a/apps/web/src/components/chat/taskGraphCanvasLayout.ts b/apps/web/src/components/chat/taskGraphCanvasLayout.ts
new file mode 100644
index 000000000000..8da06dff74c1
--- /dev/null
+++ b/apps/web/src/components/chat/taskGraphCanvasLayout.ts
@@ -0,0 +1,115 @@
+import dagre from "@dagrejs/dagre";
+import type { TaskGraphNode } from "@t3tools/contracts";
+import { taskGraphNodeOpensPullRequest } from "@t3tools/shared/taskGraph";
+import type { Edge, Node } from "@xyflow/react";
+
+/**
+ * Turns a task graph into canvas nodes and edges. Only the lazily loaded
+ * editor imports this, so dagre and xyflow stay out of the main bundle.
+ */
+
+export const TASK_GRAPH_CANVAS_NODE_WIDTH = 224;
+export const TASK_GRAPH_CANVAS_NODE_HEIGHT = 64;
+
+export type TaskGraphCanvasNodeData = {
+ readonly node: TaskGraphNode;
+ readonly opensPullRequest: boolean;
+};
+export type TaskGraphCanvasNode = Node;
+
+export type TaskGraphCanvasEdgeData = {
+ readonly dependency: string;
+ readonly dependent: string;
+};
+export type TaskGraphCanvasEdge = Edge;
+
+export interface TaskGraphCanvasPoint {
+ readonly x: number;
+ readonly y: number;
+}
+
+export const taskGraphCanvasEdgeId = (dependency: string, dependent: string): string =>
+ `${dependency}->${dependent}`;
+
+/**
+ * Changes only when nodes or dependencies change, so status updates from a
+ * running graph reuse the previous layout instead of running dagre again.
+ */
+export function taskGraphLayoutSignature(nodes: ReadonlyArray): string {
+ return nodes.map((node) => `${node.key}:${node.dependsOn.join(",")}`).join("|");
+}
+
+/** Top-left canvas positions by node key, flowing left to right from dependencies. */
+export function layoutTaskGraph(
+ nodes: ReadonlyArray,
+): ReadonlyMap {
+ const graph = new dagre.graphlib.Graph();
+ graph.setGraph({ rankdir: "LR", nodesep: 24, ranksep: 64, marginx: 16, marginy: 16 });
+ graph.setDefaultEdgeLabel(() => ({}));
+ const keys = new Set(nodes.map((node) => node.key));
+ for (const node of nodes) {
+ graph.setNode(node.key, {
+ width: TASK_GRAPH_CANVAS_NODE_WIDTH,
+ height: TASK_GRAPH_CANVAS_NODE_HEIGHT,
+ });
+ }
+ for (const node of nodes) {
+ for (const dependency of node.dependsOn) {
+ if (keys.has(dependency)) graph.setEdge(dependency, node.key);
+ }
+ }
+ dagre.layout(graph);
+ const positions = new Map();
+ for (const node of nodes) {
+ const { x = 0, y = 0 } = graph.node(node.key) ?? {};
+ // Dagre reports centers; the canvas positions nodes by their top-left corner.
+ positions.set(node.key, {
+ x: x - TASK_GRAPH_CANVAS_NODE_WIDTH / 2,
+ y: y - TASK_GRAPH_CANVAS_NODE_HEIGHT / 2,
+ });
+ }
+ return positions;
+}
+
+export interface TaskGraphCanvasSelection {
+ readonly nodeKey: string | null;
+ readonly edgeId: string | null;
+}
+
+export function taskGraphCanvasElements(
+ nodes: ReadonlyArray,
+ positions: ReadonlyMap,
+ selection: TaskGraphCanvasSelection,
+): { readonly nodes: TaskGraphCanvasNode[]; readonly edges: TaskGraphCanvasEdge[] } {
+ const keys = new Set(nodes.map((node) => node.key));
+ return {
+ nodes: nodes.map((node) => ({
+ id: node.key,
+ type: "task",
+ position: positions.get(node.key) ?? { x: 0, y: 0 },
+ width: TASK_GRAPH_CANVAS_NODE_WIDTH,
+ height: TASK_GRAPH_CANVAS_NODE_HEIGHT,
+ // Nodes have a fixed size. Declaring it lets xyflow keep handle positions
+ // when a status update rebuilds these objects, instead of re-measuring.
+ measured: { width: TASK_GRAPH_CANVAS_NODE_WIDTH, height: TASK_GRAPH_CANVAS_NODE_HEIGHT },
+ selected: selection.nodeKey === node.key,
+ draggable: false,
+ data: { node, opensPullRequest: taskGraphNodeOpensPullRequest(nodes, node) },
+ })),
+ edges: nodes.flatMap((node) =>
+ node.dependsOn.flatMap((dependency) => {
+ if (!keys.has(dependency)) return [];
+ const id = taskGraphCanvasEdgeId(dependency, node.key);
+ return [
+ {
+ id,
+ source: dependency,
+ target: node.key,
+ selected: selection.edgeId === id,
+ data: { dependency, dependent: node.key },
+ },
+ ];
+ }),
+ ),
+ };
+}
diff --git a/apps/web/src/components/chat/taskGraphView.test.ts b/apps/web/src/components/chat/taskGraphView.test.ts
new file mode 100644
index 000000000000..ccc66b702ba1
--- /dev/null
+++ b/apps/web/src/components/chat/taskGraphView.test.ts
@@ -0,0 +1,319 @@
+import type { TaskGraphNode } from "@t3tools/contracts";
+import { EnvironmentId, ProviderInstanceId, TaskGraphNodeKey, ThreadId } from "@t3tools/contracts";
+import * as Schema from "effect/Schema";
+import { taskGraphLayers } from "@t3tools/shared/taskGraph";
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ canContinueTaskGraphWorktree,
+ pullRequestLabel,
+ TASK_GRAPH_DIAGRAM,
+ taskGraphDiagramLayout,
+ taskGraphDiagramScrollLeft,
+ taskGraphJoinMachines,
+ taskGraphNodeKeyFromTitle,
+ taskGraphNodeMachine,
+ taskGraphNodeStatusText,
+ taskGraphNodeWaitDetail,
+ taskGraphProgressLabel,
+ taskGraphPullRequestLinks,
+ taskGraphResourcesLabel,
+} from "./taskGraphView";
+
+const isNodeKey = Schema.is(TaskGraphNodeKey);
+
+const node = (key: string, overrides: Partial = {}): TaskGraphNode => ({
+ key,
+ title: key,
+ prompt: "Do it",
+ dependsOn: [],
+ pullRequest: null,
+ modelSelection: null,
+ environmentId: null,
+ workspace: "worktree",
+ startAt: null,
+ status: "pending",
+ waitUntil: null,
+ waitReason: null,
+ assignedEnvironmentId: null,
+ threadId: null,
+ branch: null,
+ worktreePath: null,
+ summary: null,
+ error: null,
+ pullRequestResult: null,
+ startedAt: null,
+ completedAt: null,
+ ...overrides,
+});
+
+describe("taskGraphNodeKeyFromTitle", () => {
+ it("slugs a title into a valid key", () => {
+ expect(taskGraphNodeKeyFromTitle("Audit the Auth flow!", [])).toBe("audit-the-auth-flow");
+ expect(taskGraphNodeKeyFromTitle(" Ünïcode — café ", [])).toBe("unicode-cafe");
+ });
+
+ it("falls back when the title has no usable characters", () => {
+ expect(taskGraphNodeKeyFromTitle("!!!", [])).toBe("task");
+ expect(taskGraphNodeKeyFromTitle("日本語", ["task"])).toBe("task-2");
+ });
+
+ it("adds a numeric suffix until the key is free", () => {
+ expect(taskGraphNodeKeyFromTitle("Write tests", ["write-tests", "write-tests-2"])).toBe(
+ "write-tests-3",
+ );
+ });
+
+ it("keeps long keys within the length limit, suffix included", () => {
+ const title = "a".repeat(30) + " " + "b".repeat(30);
+ const first = taskGraphNodeKeyFromTitle(title, []);
+ const second = taskGraphNodeKeyFromTitle(title, [first]);
+ expect(first).toHaveLength(48);
+ expect(second).toHaveLength(48);
+ expect(second.endsWith("-2")).toBe(true);
+ expect([first, second].every(isNodeKey)).toBe(true);
+ });
+
+ it("never ends the base in a dash after truncating", () => {
+ const title = "a".repeat(46) + " bc";
+ const key = taskGraphNodeKeyFromTitle(title, ["a".repeat(46) + "-b"]);
+ expect(key).toBe("a".repeat(46) + "-2");
+ expect(isNodeKey(key)).toBe(true);
+ });
+});
+
+describe("pullRequestLabel", () => {
+ it("reads the number from common hosts", () => {
+ expect(pullRequestLabel("https://github.com/o/r/pull/42")).toBe("#42");
+ expect(pullRequestLabel("https://gitlab.com/o/r/-/merge_requests/7")).toBe("#7");
+ expect(pullRequestLabel("https://bitbucket.org/o/r/pull-requests/3/")).toBe("#3");
+ expect(pullRequestLabel("https://example.com/review")).toBe("PR");
+ });
+});
+
+describe("task graph summaries", () => {
+ it("counts progress for a running graph", () => {
+ const nodes = [
+ node("a", { status: "succeeded" }),
+ node("b", { status: "running" }),
+ node("c", { status: "failed" }),
+ node("d"),
+ node("e", { status: "waiting", waitUntil: "2026-10-10T15:40:00.000Z" }),
+ ];
+ expect(taskGraphProgressLabel({ status: "running", nodes })).toBe(
+ "1 of 5 done · 1 running · 1 waiting · 1 failed",
+ );
+ expect(taskGraphProgressLabel({ status: "draft", nodes: [node("a")] })).toBe("1 task");
+ });
+
+ it("names when a waiting node's wait ends and why", () => {
+ const formatTime = (iso: string) => `<${iso.slice(11, 16)}>`;
+ const waitUntil = "2026-10-10T15:40:00.000Z";
+ const scheduled = node("a", { status: "waiting", waitReason: "scheduled", waitUntil });
+ const limited = node("b", { status: "waiting", waitReason: "usage_limit", waitUntil });
+ const stopped = { ...limited, threadId: ThreadId.make("thread-b") };
+
+ expect(taskGraphNodeStatusText(scheduled, formatTime)).toBe("Waiting · until <15:40>");
+ expect(taskGraphNodeStatusText(limited, formatTime)).toBe("Usage limit · resets <15:40>");
+ expect(taskGraphNodeStatusText(node("c"), formatTime)).toBe("Pending");
+
+ expect(taskGraphNodeWaitDetail(scheduled, formatTime)).toBe("Starts <15:40>");
+ expect(taskGraphNodeWaitDetail(limited, formatTime)).toBe(
+ "Usage limit reached — starts after the reset, <15:40>",
+ );
+ expect(taskGraphNodeWaitDetail(stopped, formatTime)).toBe(
+ "Usage limit reached — continues on its thread after the reset, <15:40>",
+ );
+ expect(taskGraphNodeWaitDetail(node("c"), formatTime)).toBeNull();
+ });
+
+ it("lists only nodes with an opened pull request URL", () => {
+ const links = taskGraphPullRequestLinks([
+ node("a", {
+ pullRequestResult: { status: "opened", url: "https://github.com/o/r/pull/9", error: null },
+ }),
+ node("b", { pullRequestResult: { status: "failed", url: null, error: "push rejected" } }),
+ node("c"),
+ ]);
+ expect(links).toEqual([
+ { key: "a", title: "a", url: "https://github.com/o/r/pull/9", label: "#9" },
+ ]);
+ });
+});
+
+describe("task graph models and machines", () => {
+ const local = EnvironmentId.make("local");
+ const buildBox = EnvironmentId.make("build-box");
+ const opus = { instanceId: ProviderInstanceId.make("claude"), model: "opus" };
+ const gpt = { instanceId: ProviderInstanceId.make("codex"), model: "gpt" };
+ const mini = { instanceId: ProviderInstanceId.make("codex"), model: "mini" };
+ const label = (selection: { readonly model: string }) => selection.model;
+
+ it("places a node where it started, else after the worktree it continues, else where pinned", () => {
+ const nodes = [
+ node("a", { assignedEnvironmentId: buildBox, environmentId: local }),
+ node("b", { workspace: "dependency", dependsOn: ["a"], environmentId: local }),
+ node("c", { workspace: "dependency", dependsOn: ["b"] }),
+ node("d", { environmentId: local }),
+ node("e"),
+ ];
+ const machineOf = (key: string) =>
+ taskGraphNodeMachine(
+ nodes,
+ nodes.find((entry) => entry.key === key)!,
+ );
+ expect(["a", "b", "c", "d", "e"].map(machineOf)).toEqual([
+ buildBox,
+ buildBox,
+ buildBox,
+ local,
+ null,
+ ]);
+ });
+
+ it("names distinct models, the graph's default included, and counts machines", () => {
+ const graph = {
+ modelSelection: opus,
+ nodes: [
+ node("a", { environmentId: local }),
+ node("b", { modelSelection: gpt, assignedEnvironmentId: buildBox }),
+ node("c", { modelSelection: opus }),
+ ],
+ };
+ expect(taskGraphResourcesLabel(graph, label)).toBe("opus · gpt · 2 machines");
+ expect(
+ taskGraphResourcesLabel(
+ { ...graph, nodes: [...graph.nodes, node("d", { modelSelection: mini })] },
+ label,
+ ),
+ ).toBe("opus · gpt +1 · 2 machines");
+ });
+
+ it("calls a graph with nothing pinned or started auto-balanced", () => {
+ expect(taskGraphResourcesLabel({ modelSelection: null, nodes: [node("a")] }, label)).toBe(
+ "auto-balanced",
+ );
+ expect(
+ taskGraphResourcesLabel(
+ { modelSelection: gpt, nodes: [node("a", { environmentId: local })] },
+ label,
+ ),
+ ).toBe("gpt · 1 machine");
+ });
+});
+
+describe("taskGraphDiagramLayout", () => {
+ const { nodeWidth, nodeHeight, columnGap, rowGap } = TASK_GRAPH_DIAGRAM;
+
+ it("lays a fan-out and merge left to right with one edge per dependency", () => {
+ // audit -> (fix-a, fix-b) -> review, the shape of the security audit example.
+ const layout = taskGraphDiagramLayout(
+ taskGraphLayers([
+ node("audit"),
+ node("fix-a", { dependsOn: ["audit"] }),
+ node("fix-b", { dependsOn: ["audit"] }),
+ node("review", { dependsOn: ["fix-a", "fix-b"] }),
+ ]),
+ );
+ const at = (key: string) => layout.nodes.find((entry) => entry.node.key === key)!;
+
+ expect(layout.width).toBe(3 * nodeWidth + 2 * columnGap);
+ expect(layout.height).toBe(2 * nodeHeight + rowGap);
+ expect([at("audit").x, at("fix-a").x, at("review").x]).toEqual([
+ 0,
+ nodeWidth + columnGap,
+ 2 * (nodeWidth + columnGap),
+ ]);
+ // Single-node columns sit centred against the two-node column.
+ expect(at("audit").y).toBe((nodeHeight + rowGap) / 2);
+ expect(at("review").y).toBe(at("audit").y);
+ expect(layout.edges.map((edge) => edge.key)).toEqual([
+ "audit->fix-a",
+ "audit->fix-b",
+ "fix-a->review",
+ "fix-b->review",
+ ]);
+ expect(layout.edges[0]!.path.startsWith(`M ${nodeWidth} `)).toBe(true);
+ });
+
+ it("is empty for an empty graph", () => {
+ expect(taskGraphDiagramLayout([])).toEqual({ width: 0, height: 0, nodes: [], edges: [] });
+ });
+});
+
+describe("task graph joins across machines", () => {
+ const local = EnvironmentId.make("local");
+ const buildBox = EnvironmentId.make("build-box");
+
+ it("offers each dependency's machine when the joined branches are on different machines", () => {
+ const nodes = [
+ node("a", { environmentId: local }),
+ node("b", { assignedEnvironmentId: buildBox }),
+ node("c", { workspace: "root", environmentId: buildBox }),
+ node("join", { dependsOn: ["a", "b", "c"] }),
+ ];
+ const options = taskGraphJoinMachines(nodes, nodes[3]!);
+ expect(
+ options?.map((option) => [option.dependency.key, option.machine, option.available]),
+ ).toEqual([
+ ["a", local, true],
+ ["b", buildBox, true],
+ ["c", buildBox, false],
+ ]);
+ });
+
+ it("has nothing to reconcile on one machine, unplaced, or with a single dependency", () => {
+ const nodes = [
+ node("a", { environmentId: local }),
+ node("b", { environmentId: local }),
+ node("c"),
+ node("same", { dependsOn: ["a", "b"] }),
+ node("unplaced", { dependsOn: ["a", "c"] }),
+ node("single", { dependsOn: ["a"] }),
+ ];
+ expect(nodes.slice(3).map((entry) => taskGraphJoinMachines(nodes, entry))).toEqual([
+ null,
+ null,
+ null,
+ ]);
+ });
+
+ it("lets only one task continue a worktree", () => {
+ const nodes = [
+ node("a"),
+ node("b", { workspace: "dependency", dependsOn: ["a"] }),
+ node("c", { workspace: "root" }),
+ ];
+ expect(canContinueTaskGraphWorktree(nodes, nodes[0]!)).toBe(false);
+ expect(canContinueTaskGraphWorktree(nodes, nodes[0]!, "b")).toBe(true);
+ expect(canContinueTaskGraphWorktree(nodes, nodes[2]!)).toBe(false);
+ });
+});
+
+describe("taskGraphDiagramScrollLeft", () => {
+ const column = TASK_GRAPH_DIAGRAM.nodeWidth + TASK_GRAPH_DIAGRAM.columnGap;
+ const at = (statuses: ReadonlyArray>) =>
+ taskGraphDiagramScrollLeft(
+ statuses.map((layer, depth) =>
+ layer.map((status, row) => node(`n${depth}-${row}`, { status })),
+ ),
+ );
+
+ it("leads with the first column that has work left", () => {
+ expect(at([["pending"], ["pending"]])).toBe(0);
+ expect(at([["succeeded"], ["succeeded", "running"], ["pending"]])).toBe(
+ column - TASK_GRAPH_DIAGRAM.columnGap,
+ );
+ expect(at([["succeeded"], ["skipped", "cancelled"], ["waiting"]])).toBe(
+ 2 * column - TASK_GRAPH_DIAGRAM.columnGap,
+ );
+ });
+
+ it("keeps a failed task in view and shows the end once everything is done", () => {
+ expect(at([["failed"], ["skipped"]])).toBe(0);
+ expect(at([["succeeded"], ["succeeded"], ["succeeded"]])).toBe(
+ 2 * column - TASK_GRAPH_DIAGRAM.columnGap,
+ );
+ expect(at([])).toBe(0);
+ });
+});
diff --git a/apps/web/src/components/chat/taskGraphView.ts b/apps/web/src/components/chat/taskGraphView.ts
new file mode 100644
index 000000000000..fd02ca6c777d
--- /dev/null
+++ b/apps/web/src/components/chat/taskGraphView.ts
@@ -0,0 +1,358 @@
+import type {
+ EnvironmentId,
+ ModelSelection,
+ TaskGraph,
+ TaskGraphNode,
+ TaskGraphNodeStatus,
+ TaskGraphStatus,
+} from "@t3tools/contracts";
+
+/**
+ * Presentation helpers shared by the task graph card and its canvas editor.
+ * Kept free of canvas imports so the card does not pull the editor's bundle.
+ */
+
+export const TASK_GRAPH_STATUS_LABEL: Record = {
+ draft: "Draft",
+ running: "Running",
+ succeeded: "Succeeded",
+ failed: "Failed",
+ cancelled: "Cancelled",
+};
+
+export const TASK_GRAPH_NODE_STATUS_LABEL: Record = {
+ pending: "Pending",
+ waiting: "Waiting",
+ running: "Running",
+ delivering: "Opening PR",
+ succeeded: "Succeeded",
+ failed: "Failed",
+ cancelled: "Cancelled",
+ skipped: "Skipped",
+};
+
+/** Static status dots: a running node is a solid color, never an animation. */
+export const TASK_GRAPH_NODE_STATUS_DOT_CLASS: Record = {
+ pending: "bg-muted-foreground/40",
+ waiting: "bg-warning",
+ running: "bg-sky-500",
+ delivering: "bg-sky-500",
+ succeeded: "bg-emerald-500",
+ failed: "bg-destructive",
+ cancelled: "bg-muted-foreground/70",
+ skipped: "bg-muted-foreground/70",
+};
+
+export const isTaskGraphFinished = (status: TaskGraphStatus): boolean =>
+ status === "succeeded" || status === "failed" || status === "cancelled";
+
+export interface TaskGraphProgress {
+ readonly total: number;
+ readonly succeeded: number;
+ readonly failed: number;
+ readonly active: number;
+ readonly waiting: number;
+}
+
+export function taskGraphProgress(nodes: ReadonlyArray): TaskGraphProgress {
+ let succeeded = 0;
+ let failed = 0;
+ let active = 0;
+ let waiting = 0;
+ for (const node of nodes) {
+ if (node.status === "succeeded") succeeded += 1;
+ else if (node.status === "failed") failed += 1;
+ else if (node.status === "running" || node.status === "delivering") active += 1;
+ else if (node.status === "waiting") waiting += 1;
+ }
+ return { total: nodes.length, succeeded, failed, active, waiting };
+}
+
+/** One line for the card header, such as "2 of 5 done · 1 waiting · 1 failed". */
+export function taskGraphProgressLabel(graph: Pick): string {
+ const { total, succeeded, failed, active, waiting } = taskGraphProgress(graph.nodes);
+ if (graph.status === "draft") return `${total} task${total === 1 ? "" : "s"}`;
+ const parts = [`${succeeded} of ${total} done`];
+ if (active > 0) parts.push(`${active} running`);
+ if (waiting > 0) parts.push(`${waiting} waiting`);
+ if (failed > 0) parts.push(`${failed} failed`);
+ return parts.join(" · ");
+}
+
+/**
+ * A node's status in a few words, naming when a wait ends, such as
+ * "Usage limit · resets 3:40 PM". `formatTime` renders an upcoming instant.
+ */
+export function taskGraphNodeStatusText(
+ node: Pick,
+ formatTime: (iso: string) => string,
+): string {
+ if (node.status !== "waiting" || node.waitUntil === null) {
+ return TASK_GRAPH_NODE_STATUS_LABEL[node.status];
+ }
+ const at = formatTime(node.waitUntil);
+ return node.waitReason === "usage_limit" ? `Usage limit · resets ${at}` : `Waiting · until ${at}`;
+}
+
+/** Why a waiting node waits and what happens next, for its details. Null when it is not waiting. */
+export function taskGraphNodeWaitDetail(
+ node: Pick,
+ formatTime: (iso: string) => string,
+): string | null {
+ if (node.status !== "waiting") return null;
+ const at = node.waitUntil === null ? null : formatTime(node.waitUntil);
+ if (node.waitReason === "usage_limit") {
+ const next =
+ node.threadId === null ? "starts after the reset" : "continues on its thread after the reset";
+ return `Usage limit reached — ${next}${at === null ? "" : `, ${at}`}`;
+ }
+ return at === null ? "Waiting to start" : `Starts ${at}`;
+}
+
+/** The model a node runs with: its own, else the graph's. Null when neither is known. */
+export const taskGraphNodeModel = (
+ graph: Pick,
+ node: Pick,
+): ModelSelection | null => node.modelSelection ?? graph.modelSelection;
+
+/**
+ * The machine a node runs or will run on, the way the server places it: where
+ * it started, else its first dependency's machine when it continues that
+ * worktree, else where it is pinned. Null means it will be balanced automatically.
+ */
+export function taskGraphNodeMachine(
+ nodes: ReadonlyArray,
+ node: TaskGraphNode,
+): EnvironmentId | null {
+ let current = node;
+ // Bounded by the node count, so a malformed cycle cannot loop forever.
+ for (let hops = 0; hops <= nodes.length; hops += 1) {
+ if (current.assignedEnvironmentId !== null) return current.assignedEnvironmentId;
+ if (current.workspace !== "dependency") return current.environmentId;
+ const dependency = nodes.find((candidate) => candidate.key === current.dependsOn[0]);
+ if (dependency === undefined) return current.environmentId;
+ current = dependency;
+ }
+ return null;
+}
+
+/**
+ * Whether a new or edited task may continue `dependency`'s worktree: the
+ * dependency has one, and no other task already continues it.
+ */
+export function canContinueTaskGraphWorktree(
+ nodes: ReadonlyArray,
+ dependency: TaskGraphNode,
+ exceptKey: string | null = null,
+): boolean {
+ return (
+ dependency.workspace !== "root" &&
+ !nodes.some(
+ (other) =>
+ other.key !== exceptKey &&
+ other.workspace === "dependency" &&
+ other.dependsOn[0] === dependency.key,
+ )
+ );
+}
+
+export interface TaskGraphJoinMachine {
+ readonly dependency: TaskGraphNode;
+ readonly machine: EnvironmentId;
+ /** False when that dependency's worktree cannot be continued. */
+ readonly available: boolean;
+}
+
+/**
+ * For a task that joins branches known to be on different machines, the
+ * machines it could bring them together on, each by continuing that
+ * dependency's worktree there. Null when there is nothing to reconcile: fewer
+ * than two dependencies, or no two on known, different machines.
+ */
+export function taskGraphJoinMachines(
+ nodes: ReadonlyArray,
+ node: TaskGraphNode,
+): ReadonlyArray | null {
+ const placed = node.dependsOn.flatMap((key) => {
+ const dependency = nodes.find((candidate) => candidate.key === key);
+ const machine = dependency === undefined ? null : taskGraphNodeMachine(nodes, dependency);
+ return dependency === undefined || machine === null ? [] : [{ dependency, machine }];
+ });
+ if (new Set(placed.map((entry) => entry.machine)).size < 2) return null;
+ return placed.map((entry) => ({
+ ...entry,
+ available: canContinueTaskGraphWorktree(nodes, entry.dependency, node.key),
+ }));
+}
+
+/**
+ * The models and machines a graph spreads over, for the card header, such as
+ * "Claude Opus 5.5 · GPT-5 +1 · 2 machines". Machines count only where nodes
+ * started or are pinned; with none of either the graph is "auto-balanced".
+ */
+export function taskGraphResourcesLabel(
+ graph: Pick,
+ modelLabel: (selection: ModelSelection) => string,
+): string {
+ const models = new Set();
+ const machines = new Set();
+ for (const node of graph.nodes) {
+ const model = taskGraphNodeModel(graph, node);
+ if (model !== null) models.add(modelLabel(model));
+ const machine = taskGraphNodeMachine(graph.nodes, node);
+ if (machine !== null) machines.add(machine);
+ }
+ const labels = [...models];
+ const parts = labels.slice(0, 2);
+ if (labels.length > 2) parts[1] = `${parts[1]} +${labels.length - 2}`;
+ parts.push(
+ machines.size === 0
+ ? "auto-balanced"
+ : `${machines.size} machine${machines.size === 1 ? "" : "s"}`,
+ );
+ return parts.join(" · ");
+}
+
+/** Where a node works, in words, such as "Continues Audit auth's worktree". */
+export function taskGraphWorkspaceLabel(
+ node: TaskGraphNode,
+ titleOf: (key: string) => string,
+): string {
+ if (node.workspace === "root") return "Project folder, no branch";
+ const dependency = node.dependsOn[0];
+ if (node.workspace === "dependency" && dependency !== undefined) {
+ return `Continues ${titleOf(dependency)}'s worktree`;
+ }
+ return "New worktree";
+}
+
+/** "#123" for a GitHub, GitLab or Bitbucket pull request URL, otherwise "PR". */
+export function pullRequestLabel(url: string): string {
+ const number = /\/(?:pull|merge_requests|pull-requests)\/(\d+)/.exec(url)?.[1];
+ return number === undefined ? "PR" : `#${number}`;
+}
+
+export interface TaskGraphPullRequestLink {
+ readonly key: string;
+ readonly title: string;
+ readonly url: string;
+ readonly label: string;
+}
+
+export function taskGraphPullRequestLinks(
+ nodes: ReadonlyArray,
+): ReadonlyArray {
+ return nodes.flatMap((node) => {
+ const url = node.pullRequestResult?.url;
+ return url ? [{ key: node.key, title: node.title, url, label: pullRequestLabel(url) }] : [];
+ });
+}
+
+const KEY_MAX_LENGTH = 48;
+
+/**
+ * A node key derived from a title that matches `TaskGraphNodeKey` and is not
+ * already taken, such as "Audit auth" -> "audit-auth" or "audit-auth-2".
+ */
+export function taskGraphNodeKeyFromTitle(title: string, taken: Iterable): string {
+ const used = new Set(taken);
+ const base =
+ title
+ .normalize("NFKD")
+ .replace(/\p{Mn}/gu, "")
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, KEY_MAX_LENGTH)
+ .replace(/-+$/, "") || "task";
+ if (!used.has(base)) return base;
+ for (let suffix = 2; ; suffix += 1) {
+ const tail = `-${suffix}`;
+ const candidate = `${base.slice(0, KEY_MAX_LENGTH - tail.length).replace(/-+$/, "")}${tail}`;
+ if (!used.has(candidate)) return candidate;
+ }
+}
+
+/** Box size and spacing of the inline diagram, in CSS pixels. */
+export const TASK_GRAPH_DIAGRAM = {
+ nodeWidth: 188,
+ nodeHeight: 58,
+ columnGap: 40,
+ rowGap: 10,
+} as const;
+
+export interface TaskGraphDiagramLayout {
+ readonly width: number;
+ readonly height: number;
+ readonly nodes: ReadonlyArray<{
+ readonly node: TaskGraphNode;
+ readonly x: number;
+ readonly y: number;
+ }>;
+ readonly edges: ReadonlyArray<{ readonly key: string; readonly path: string }>;
+}
+
+/**
+ * How far right the card scrolls so the first column with work left leads,
+ * with a gap's width of the column before it still in view. Zero while the
+ * first column has work left; the last column once everything is done. Failed
+ * tasks count as work left, so they stay in view.
+ */
+export function taskGraphDiagramScrollLeft(
+ layers: ReadonlyArray>,
+): number {
+ const open = layers.findIndex((layer) =>
+ layer.some(
+ (node) =>
+ node.status !== "succeeded" && node.status !== "skipped" && node.status !== "cancelled",
+ ),
+ );
+ const column = open === -1 ? layers.length - 1 : open;
+ if (column <= 0) return 0;
+ const { nodeWidth, columnGap } = TASK_GRAPH_DIAGRAM;
+ return column * (nodeWidth + columnGap) - columnGap;
+}
+
+/**
+ * Lays a graph out left to right for the inline card: one column per
+ * dependency depth, each column centred on the tallest, and a curve from the
+ * right edge of every dependency to the left edge of the node that waits on it.
+ * Plain arithmetic, so the card never loads the editor's layout library.
+ */
+export function taskGraphDiagramLayout(
+ layers: ReadonlyArray>,
+): TaskGraphDiagramLayout {
+ const { nodeWidth, nodeHeight, columnGap, rowGap } = TASK_GRAPH_DIAGRAM;
+ const columnHeight = (count: number) => count * nodeHeight + Math.max(0, count - 1) * rowGap;
+ const height = Math.max(0, ...layers.map((layer) => columnHeight(layer.length)));
+ const width = Math.max(0, layers.length * nodeWidth + (layers.length - 1) * columnGap);
+ const positions = new Map();
+ const nodes = layers.flatMap((layer, column) => {
+ const top = (height - columnHeight(layer.length)) / 2;
+ return layer.map((node, row) => {
+ const position = {
+ x: column * (nodeWidth + columnGap),
+ y: top + row * (nodeHeight + rowGap),
+ };
+ positions.set(node.key, position);
+ return { node, ...position };
+ });
+ });
+ const edges = nodes.flatMap(({ node, x, y }) =>
+ node.dependsOn.flatMap((dependencyKey) => {
+ const from = positions.get(dependencyKey);
+ if (from === undefined) return [];
+ const startX = from.x + nodeWidth;
+ const startY = from.y + nodeHeight / 2;
+ const endY = y + nodeHeight / 2;
+ const middleX = (startX + x) / 2;
+ return [
+ {
+ key: `${dependencyKey}->${node.key}`,
+ path: `M ${startX} ${startY} C ${middleX} ${startY}, ${middleX} ${endY}, ${x} ${endY}`,
+ },
+ ];
+ }),
+ );
+ return { width, height, nodes, edges };
+}
diff --git a/apps/web/src/components/chat/useTaskGraphCommands.ts b/apps/web/src/components/chat/useTaskGraphCommands.ts
new file mode 100644
index 000000000000..21cb4bb4504f
--- /dev/null
+++ b/apps/web/src/components/chat/useTaskGraphCommands.ts
@@ -0,0 +1,221 @@
+import { useAtomValue } from "@effect/atom-react";
+import {
+ isAtomCommandInterrupted,
+ squashAtomCommandFailure,
+ type AtomCommandResult,
+} from "@t3tools/client-runtime/state/runtime";
+import {
+ AuthOrchestrationOperateScope,
+ type EnvironmentId,
+ type TaskGraph,
+ type TaskGraphEdit,
+ type TaskGraphNode,
+ type TaskGraphNodeInput,
+ type ThreadId,
+} from "@t3tools/contracts";
+import { applyTaskGraphEdits, newTaskGraphNode } from "@t3tools/shared/taskGraph";
+import { useState } from "react";
+import { create } from "zustand";
+
+import { serverEnvironment } from "../../state/server";
+import { readEnvironmentScope } from "../../state/session";
+import { useAtomCommand } from "../../state/use-atom-command";
+
+function failureMessage(result: AtomCommandResult): string | null {
+ if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return null;
+ const failure = squashAtomCommandFailure(result);
+ return failure instanceof Error ? failure.message : String(failure);
+}
+
+/**
+ * Graph mutations for the card and the editor. Edits are checked against the
+ * graph's current server state before they are sent, so a rule violation shows
+ * inline without a round trip; the server re-checks and stays authoritative.
+ */
+export function useTaskGraphCommands(environmentId: EnvironmentId, graph: TaskGraph) {
+ const canEdit = useAtomValue(serverEnvironment.editTaskGraph.permissionAtom(environmentId));
+ const canRun = useAtomValue(serverEnvironment.runTaskGraph.permissionAtom(environmentId));
+ const canCancel = useAtomValue(serverEnvironment.cancelTaskGraph.permissionAtom(environmentId));
+ const editGraph = useAtomCommand(serverEnvironment.editTaskGraph, "task graph edit");
+ const runGraph = useAtomCommand(serverEnvironment.runTaskGraph, "task graph run");
+ const cancelGraph = useAtomCommand(serverEnvironment.cancelTaskGraph, "task graph cancel");
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+
+ const settle = async (send: () => Promise>) => {
+ if (busy || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return false;
+ setBusy(true);
+ setError(null);
+ const result = await send();
+ setBusy(false);
+ // An interrupted command shows no message but still did not happen.
+ setError(failureMessage(result));
+ return result._tag === "Success";
+ };
+
+ /** Resolves true once the server accepted the edits. */
+ const edit = (edits: ReadonlyArray) => {
+ const checked = applyTaskGraphEdits(graph.nodes, edits, new Date().toISOString());
+ if (!checked.ok) {
+ setError(checked.error);
+ return Promise.resolve(false);
+ }
+ return settle(() => editGraph({ environmentId, input: { graphId: graph.id, edits } }));
+ };
+
+ return {
+ canEdit,
+ canRun,
+ canCancel,
+ busy,
+ error,
+ clearError: () => setError(null),
+ edit,
+ run: () => settle(() => runGraph({ environmentId, input: { graphId: graph.id } })),
+ cancel: () => settle(() => cancelGraph({ environmentId, input: { graphId: graph.id } })),
+ };
+}
+
+export type TaskGraphCommands = ReturnType;
+
+/** The task a graph started from the composer begins with; the editor opens on it. */
+const FIRST_TASK: TaskGraphNodeInput = {
+ key: "task-1",
+ title: "New task",
+ prompt: "Describe what this task should do.",
+ dependsOn: [],
+};
+
+/**
+ * A graph whose editor should open as soon as its card mounts. Set when the
+ * user starts a graph from the composer, cleared when that editor closes.
+ */
+export const useTaskGraphEditorRequest = create<{
+ readonly graphId: string | null;
+ readonly nodeKey: string | null;
+ readonly request: (graphId: string, nodeKey: string) => void;
+ readonly clear: () => void;
+}>((set) => ({
+ graphId: null,
+ nodeKey: null,
+ request: (graphId, nodeKey) => set({ graphId, nodeKey }),
+ clear: () => set({ graphId: null, nodeKey: null }),
+}));
+
+/**
+ * Creates graphs from the composer. `start` puts an empty draft on an
+ * existing thread and opens its editor; `create` sends a finished graph.
+ * `start` resolves to an error message or null.
+ */
+export function useNewTaskGraph(environmentId: EnvironmentId) {
+ const canCreate = useAtomValue(serverEnvironment.createTaskGraph.permissionAtom(environmentId));
+ const createGraph = useAtomCommand(serverEnvironment.createTaskGraph, "task graph create");
+ const request = useTaskGraphEditorRequest((state) => state.request);
+ const send = async (
+ threadId: ThreadId,
+ nodes: ReadonlyArray,
+ run: boolean,
+ ): Promise<
+ { readonly graphId: string } | { readonly error: string; readonly interrupted: boolean }
+ > => {
+ if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) {
+ return { error: "You don't have permission to start task graphs here.", interrupted: false };
+ }
+ const result = await createGraph({
+ environmentId,
+ input: { threadId, title: "New task graph", nodes, run },
+ });
+ if (result._tag === "Success") return { graphId: result.value.graph.id };
+ // An interrupted request may still have reached the server and created the graph.
+ const interrupted = isAtomCommandInterrupted(result);
+ return {
+ error: failureMessage(result) ?? "The request was interrupted. Try again.",
+ interrupted,
+ };
+ };
+ const start = async (threadId: ThreadId): Promise => {
+ const result = await send(threadId, [FIRST_TASK], false);
+ if ("error" in result) return result.error;
+ request(result.graphId, FIRST_TASK.key);
+ return null;
+ };
+ /**
+ * Creates and starts a finished graph. "uncertain" means the request was
+ * interrupted, so the graph may exist; the caller must not discard its thread.
+ */
+ const createAndRun = async (
+ threadId: ThreadId,
+ nodes: ReadonlyArray,
+ ): Promise<
+ | { readonly outcome: "created" | "uncertain" }
+ | { readonly outcome: "failed"; readonly error: string }
+ > => {
+ const result = await send(threadId, nodes.map(taskGraphNodeInput), true);
+ if (!("error" in result)) return { outcome: "created" };
+ return result.interrupted
+ ? { outcome: "uncertain" }
+ : { outcome: "failed", error: result.error };
+ };
+ return { canCreate, start, createAndRun };
+}
+
+function taskGraphNodeInput(node: TaskGraphNode): TaskGraphNodeInput {
+ return {
+ key: node.key,
+ title: node.title,
+ prompt: node.prompt,
+ dependsOn: node.dependsOn,
+ ...(node.pullRequest === null ? {} : { pullRequest: node.pullRequest }),
+ ...(node.modelSelection === null ? {} : { modelSelection: node.modelSelection }),
+ environmentId: node.environmentId,
+ workspace: node.workspace,
+ startAt: node.startAt,
+ };
+}
+
+/**
+ * Commands for a graph drafted on a new chat, before its thread exists. Edits
+ * stay in the client, checked by the same rules the server applies; `run`
+ * hands the nodes to `onRun`, which creates the thread and the graph together.
+ */
+export function useDraftTaskGraph(input: {
+ readonly canRun: boolean;
+ readonly onRun: (nodes: ReadonlyArray) => Promise;
+ readonly onDiscard: () => void;
+}) {
+ const [nodes, setNodes] = useState>(() => [
+ newTaskGraphNode(FIRST_TASK),
+ ]);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const commands: TaskGraphCommands = {
+ canEdit: true,
+ canRun: input.canRun,
+ canCancel: true,
+ busy,
+ error,
+ clearError: () => setError(null),
+ edit: (edits) => {
+ const checked = applyTaskGraphEdits(nodes, edits, new Date().toISOString());
+ setError(checked.ok ? null : checked.error);
+ if (checked.ok) setNodes(checked.nodes);
+ return Promise.resolve(checked.ok);
+ },
+ run: async () => {
+ if (busy) return false;
+ setBusy(true);
+ setError(null);
+ const message = await input.onRun(nodes);
+ setBusy(false);
+ setError(message);
+ return message === null;
+ },
+ cancel: () => {
+ setNodes([newTaskGraphNode(FIRST_TASK)]);
+ setError(null);
+ input.onDiscard();
+ return Promise.resolve(true);
+ },
+ };
+ return { nodes, commands, firstNodeKey: FIRST_TASK.key };
+}
diff --git a/apps/web/src/components/chat/useTaskGraphLabels.ts b/apps/web/src/components/chat/useTaskGraphLabels.ts
new file mode 100644
index 000000000000..c0fc5c7b762f
--- /dev/null
+++ b/apps/web/src/components/chat/useTaskGraphLabels.ts
@@ -0,0 +1,90 @@
+import { useAtomValue } from "@effect/atom-react";
+import type {
+ EnvironmentId,
+ ModelSelection,
+ TaskGraph,
+ TaskGraphNode,
+ TaskGraphPeer,
+} from "@t3tools/contracts";
+import { useMemo } from "react";
+
+import { useEnvironmentSettings } from "../../hooks/useSettings";
+import {
+ applyProviderInstanceSettings,
+ deriveProviderInstanceEntries,
+ sortProviderInstanceEntries,
+} from "../../providerInstances";
+import { useEnvironment } from "../../state/environments";
+import { useEnvironmentQuery } from "../../state/query";
+import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server";
+import { getTriggerDisplayModelLabel } from "./providerIconUtils";
+import { taskGraphNodeMachine, taskGraphNodeModel } from "./taskGraphView";
+
+const EMPTY_PEERS: ReadonlyArray = [];
+
+export const TASK_GRAPH_PEER_STATUS_HINT: Record = {
+ connected: null,
+ connecting: "connecting",
+ unreachable: "unreachable",
+ unauthorized: "unauthorized",
+};
+
+/**
+ * Model and machine names for a graph's nodes, resolved against the graph's
+ * environment: its provider catalogue for model labels, and its paired peers
+ * for machine labels. The card, diagram and editor share one instance.
+ */
+export function useTaskGraphLabels(environmentId: EnvironmentId, graph: TaskGraph) {
+ const settings = useEnvironmentSettings(environmentId);
+ const providers =
+ useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS;
+ const instanceEntries = useMemo(
+ () =>
+ sortProviderInstanceEntries(
+ applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings),
+ ),
+ [providers, settings],
+ );
+ const environmentLabel = useEnvironment(environmentId)?.label ?? "This machine";
+ // Servers without peer support reject the subscription; the graph's own machine still shows.
+ const peersQuery = useEnvironmentQuery(
+ serverEnvironment.taskGraphPeersLive({ environmentId, input: {} }),
+ );
+ const peers = peersQuery.data?.peers ?? EMPTY_PEERS;
+
+ const modelLabel = (selection: ModelSelection): string => {
+ const entry = instanceEntries.find(
+ (candidate) => candidate.instanceId === selection.instanceId,
+ );
+ const model = entry?.models.find((candidate) => candidate.slug === selection.model);
+ return model ? getTriggerDisplayModelLabel(model) : selection.model;
+ };
+ const machineLabel = (id: EnvironmentId): string =>
+ id === environmentId
+ ? environmentLabel
+ : (peers.find((peer) => peer.environmentId === id)?.label ?? "Another machine");
+ const nodeMachine = (node: TaskGraphNode) => taskGraphNodeMachine(graph.nodes, node);
+
+ return {
+ environmentId,
+ environmentLabel,
+ peers,
+ instanceEntries,
+ settings,
+ providers,
+ modelLabel,
+ machineLabel,
+ /** The node's model, or "Thread default" when neither it nor the graph names one. */
+ nodeModelLabel: (node: TaskGraphNode): string => {
+ const selection = taskGraphNodeModel(graph, node);
+ return selection === null ? "Thread default" : modelLabel(selection);
+ },
+ /** Where the node runs, or "Auto" while it will be balanced on start. */
+ nodeMachineLabel: (node: TaskGraphNode): string => {
+ const machine = nodeMachine(node);
+ return machine === null ? "Auto" : machineLabel(machine);
+ },
+ };
+}
+
+export type TaskGraphLabels = ReturnType;
diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx
index 443ac6e0288a..91b7a545e617 100644
--- a/apps/web/src/components/settings/ConnectionsSettings.tsx
+++ b/apps/web/src/components/settings/ConnectionsSettings.tsx
@@ -91,6 +91,7 @@ import {
} from "./EnvironmentRow";
import { FoldedSettingsSection } from "./FoldedSettingsSection";
import { LoadBalancingSettings } from "./LoadBalancingSettings";
+import { TaskGraphMachinesSettings } from "./TaskGraphMachinesSettings";
import { GitHubRoutingSettings } from "./GitHubRoutingSettings";
import { Input } from "../ui/input";
import { CommandShortcut } from "../ui/command";
@@ -4169,6 +4170,10 @@ export function ConnectionsSettings() {
/>
) : null}
+
);
diff --git a/apps/web/src/components/settings/LoadBalancingSettings.tsx b/apps/web/src/components/settings/LoadBalancingSettings.tsx
index 1784e3732105..89f95f7cafd9 100644
--- a/apps/web/src/components/settings/LoadBalancingSettings.tsx
+++ b/apps/web/src/components/settings/LoadBalancingSettings.tsx
@@ -12,14 +12,14 @@ import { EnvironmentRow, environmentTransportLabel } from "./EnvironmentRow";
import { FoldedSettingsSection } from "./FoldedSettingsSection";
import { searchableSetting } from "./settingsSearch";
-const preferences = [
+export const loadPreferences = [
{ value: 100, label: "Prefer" },
{ value: 50, label: "Normal" },
{ value: 25, label: "Less often" },
{ value: 0, label: "Manual only" },
] as const;
-type LoadPreference = (typeof preferences)[number]["value"];
+type LoadPreference = (typeof loadPreferences)[number]["value"];
/** Snaps a saved weight (older builds stored a slider value) onto the four preferences. */
export function loadPreferenceForWeight(weight: number | undefined): LoadPreference {
@@ -29,7 +29,7 @@ export function loadPreferenceForWeight(weight: number | undefined): LoadPrefere
}
function preferenceLabel(preference: LoadPreference): string {
- return preferences.find((entry) => entry.value === preference)!.label;
+ return loadPreferences.find((entry) => entry.value === preference)!.label;
}
/**
@@ -97,7 +97,7 @@ export function LoadBalancingSettings({
subtitle={environmentTransportLabel(environment)}
>
- {preferences.map(({ value, label }) => (
+ {loadPreferences.map(({ value, label }) => (
{label}
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index db1c7749ee7b..e58cf0a10d8b 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -634,6 +634,13 @@ export function useSettingsRestore(onRestored?: () => void) {
DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate
? ["Continue threads after restarts"]
: []),
+ ...(settings.taskGraphAutoRun !== DEFAULT_UNIFIED_SETTINGS.taskGraphAutoRun
+ ? ["Run task graphs automatically"]
+ : []),
+ ...(settings.taskGraphMaxConcurrentNodes !==
+ DEFAULT_UNIFIED_SETTINGS.taskGraphMaxConcurrentNodes
+ ? ["Task graph nodes at once"]
+ : []),
...(isBackgroundActivityDirty ? ["Background activity"] : []),
...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode
? ["New thread mode"]
@@ -707,6 +714,8 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.persistComposerContextStrip,
settings.enableProviderUpdateChecks,
settings.continueThreadsAfterServerUpdate,
+ settings.taskGraphAutoRun,
+ settings.taskGraphMaxConcurrentNodes,
settings.sidebarAutoSettleAfterDays,
settings.sidebarAutoSettleOnMerge,
settings.autoResumeLimitedThreads,
@@ -821,6 +830,8 @@ export function useSettingsRestore(onRestored?: () => void) {
responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode,
enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks,
continueThreadsAfterServerUpdate: DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate,
+ taskGraphAutoRun: DEFAULT_UNIFIED_SETTINGS.taskGraphAutoRun,
+ taskGraphMaxConcurrentNodes: DEFAULT_UNIFIED_SETTINGS.taskGraphMaxConcurrentNodes,
backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity,
backgroundActivityProfile: DEFAULT_UNIFIED_SETTINGS.backgroundActivityProfile,
automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval,
@@ -2248,6 +2259,7 @@ export function GeneralSettingsPanel() {
const backgroundActivityProfileOption = resolveBackgroundActivityProfileOption(settings);
const mixedBackgroundActivity = useScopedSettingsMixed(["backgroundActivity"]);
const mixedAddProjectBaseDirectory = useScopedSettingsMixed(["addProjectBaseDirectory"]);
+ const mixedTaskGraphMaxConcurrentNodes = useScopedSettingsMixed(["taskGraphMaxConcurrentNodes"]);
const mixedTextGenerationModel = useScopedSettingsMixed(["textGenerationModelSelection"]);
const backgroundActivityDescription =
backgroundActivityProfileOption === "advanced"
@@ -3104,6 +3116,79 @@ export function GeneralSettingsPanel() {
/>
}
/>
+
+ updateSettings({ taskGraphAutoRun: DEFAULT_UNIFIED_SETTINGS.taskGraphAutoRun })
+ }
+ />
+ ) : null
+ }
+ control={
+ updateSettings({ taskGraphAutoRun: Boolean(checked) })}
+ aria-label="Run task graphs automatically"
+ />
+ }
+ />
+
+ updateSettings({
+ taskGraphMaxConcurrentNodes:
+ DEFAULT_UNIFIED_SETTINGS.taskGraphMaxConcurrentNodes,
+ })
+ }
+ />
+ ) : null
+ }
+ control={
+ {
+ if (value === null) return;
+ updateSettings({
+ taskGraphMaxConcurrentNodes: Math.min(16, Math.max(1, Math.round(value))),
+ });
+ }}
+ >
+
+
+
+
+
+
+ }
+ />
diff --git a/apps/web/src/components/settings/TaskGraphMachinesSettings.tsx b/apps/web/src/components/settings/TaskGraphMachinesSettings.tsx
new file mode 100644
index 000000000000..ee69615d9972
--- /dev/null
+++ b/apps/web/src/components/settings/TaskGraphMachinesSettings.tsx
@@ -0,0 +1,407 @@
+import { useAtomValue } from "@effect/atom-react";
+import type { EnvironmentId, TaskGraphPeer } from "@t3tools/contracts";
+import {
+ type AtomCommandResult,
+ isAtomCommandInterrupted,
+ squashAtomCommandFailure,
+} from "@t3tools/client-runtime/state/runtime";
+import { type FormEvent, useState } from "react";
+
+import { requestConfirmDialog } from "~/confirmDialog";
+import { isLoopbackHost } from "@t3tools/shared/preview";
+import * as Option from "effect/Option";
+import { environmentSession, readPreparedConnection } from "~/state/session";
+import type { EnvironmentPresentation } from "~/state/environments";
+import { useEnvironmentQuery } from "~/state/query";
+import { serverEnvironment } from "~/state/server";
+import { useAtomCommand } from "~/state/use-atom-command";
+import { Badge } from "../ui/badge";
+import { Button } from "../ui/button";
+import { Input } from "../ui/input";
+import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
+import { Spinner } from "../ui/spinner";
+import { stackedThreadToast, toastManager } from "../ui/toast";
+import { EnvironmentRow } from "./EnvironmentRow";
+import { FoldedSettingsSection } from "./FoldedSettingsSection";
+import { loadPreferenceForWeight, loadPreferences } from "./LoadBalancingSettings";
+import { searchableSetting } from "./settingsSearch";
+
+const statusBadgeVariant = {
+ connected: "success",
+ connecting: "info",
+ unreachable: "warning",
+ unauthorized: "error",
+} as const satisfies Record;
+
+function failureMessage(error: unknown, fallback: string): string {
+ return error instanceof Error && error.message.length > 0 ? error.message : fallback;
+}
+
+/**
+ * Closed-header summary: every peer by name, with its status when it is not
+ * connected, so a broken machine shows without opening the section.
+ */
+function summarizeTaskGraphPeers(peers: ReadonlyArray): string {
+ if (peers.length === 0) return "None";
+ return peers
+ .map((peer) => (peer.status === "connected" ? peer.label : `${peer.label} ${peer.status}`))
+ .join(" · ");
+}
+
+/**
+ * Folded section listing the machines the given environment may run task graph
+ * nodes on. The list lives on that server, so it is shared by every client and
+ * editing it needs the environment's maintain permission; without it the list
+ * is read-only.
+ */
+export function TaskGraphMachinesSettings({
+ environment,
+ environments,
+}: {
+ readonly environment: EnvironmentPresentation | null;
+ /** This client's machines; connected ones can be added in one step. */
+ readonly environments: ReadonlyArray;
+}) {
+ const environmentId = environment?.environmentId ?? null;
+ const connected =
+ environment?.connection.phase === "connected" && environment.serverConfig !== null;
+ const peersQuery = useEnvironmentQuery(
+ connected && environmentId !== null
+ ? serverEnvironment.taskGraphPeersLive({ environmentId, input: {} })
+ : null,
+ );
+ const canAdd = useAtomValue(serverEnvironment.addTaskGraphPeer.permissionAtom(environmentId));
+
+ if (environmentId === null || !connected) return null;
+
+ const peers = peersQuery.data?.peers ?? [];
+ const { id, title } = searchableSetting("task-graph-machines");
+ return (
+
+
+ Let agents' task graphs run nodes on your other machines. Add one this app is connected to,
+ including over T3 Connect, or paste a pairing link from it; this machine gets access to
+ start, watch and stop threads and push branches there, nothing else.
+
+ {peersQuery.error && !peersQuery.data ? (
+ {peersQuery.error}
+ ) : null}
+ {peers.map((peer) => (
+
+ ))}
+ {peers.length > 0 ? (
+
+ This machine's session also shows in each machine's connected clients, where it can be
+ revoked.
+
+ ) : null}
+ {canAdd
+ ? environments
+ .filter(
+ (candidate) =>
+ candidate.environmentId !== environmentId &&
+ candidate.connection.phase === "connected" &&
+ !peers.some((peer) => peer.environmentId === candidate.environmentId),
+ )
+ .map((candidate) => (
+
+ ))
+ : null}
+ {canAdd ? : null}
+
+ );
+}
+
+function TaskGraphPeerRow({
+ environmentId,
+ peer,
+}: {
+ readonly environmentId: EnvironmentId;
+ readonly peer: TaskGraphPeer;
+}) {
+ const canSetWeight = useAtomValue(
+ serverEnvironment.setTaskGraphPeerWeight.permissionAtom(environmentId),
+ );
+ const canRemove = useAtomValue(
+ serverEnvironment.removeTaskGraphPeer.permissionAtom(environmentId),
+ );
+ const setWeight = useAtomCommand(serverEnvironment.setTaskGraphPeerWeight, {
+ reportFailure: false,
+ });
+ const remove = useAtomCommand(serverEnvironment.removeTaskGraphPeer, { reportFailure: false });
+ const [busy, setBusy] = useState(false);
+
+ const run = async (
+ action: () => Promise>,
+ failureTitle: string,
+ ) => {
+ setBusy(true);
+ let result: AtomCommandResult;
+ try {
+ result = await action();
+ } finally {
+ setBusy(false);
+ }
+ if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title: failureTitle,
+ description: failureMessage(squashAtomCommandFailure(result), "Try again."),
+ }),
+ );
+ }
+ };
+
+ const confirmRemove = async () => {
+ // No mounted confirm host means no removal.
+ const confirmed = await requestConfirmDialog(
+ `Remove ${peer.label}?\nTask graphs stop running nodes there.`,
+ { variant: "destructive" },
+ );
+ if (confirmed !== true) return;
+ await run(
+ () => remove({ environmentId, input: { environmentId: peer.environmentId } }),
+ `Could not remove ${peer.label}`,
+ );
+ };
+
+ return (
+ {peer.error}
: null}
+ >
+ {peer.status}
+ {
+ if (weight === null) return;
+ void run(
+ () =>
+ setWeight({ environmentId, input: { environmentId: peer.environmentId, weight } }),
+ `Could not update ${peer.label}`,
+ );
+ }}
+ >
+
+
+
+
+ {loadPreferences.map(({ value, label }) => (
+
+ {label}
+
+ ))}
+
+
+ {canRemove ? (
+ void confirmRemove()}
+ >
+ Remove
+
+ ) : null}
+
+ );
+}
+
+/**
+ * A machine this client is connected to but the environment has not paired.
+ * Adding asks that machine for a short-lived grant and hands it, with the
+ * address this client reaches it at, to the environment. That address is the
+ * T3 Connect hostname for a relay connection, so relay-only machines work too.
+ */
+const hostnameOf = (url: string | undefined): string | null => {
+ if (url === undefined) return null;
+ try {
+ return new URL(url).hostname;
+ } catch {
+ return null;
+ }
+};
+
+function ConnectedMachineRow({
+ environmentId,
+ environmentLabel,
+ candidate,
+}: {
+ readonly environmentId: EnvironmentId;
+ readonly environmentLabel: string;
+ readonly candidate: EnvironmentPresentation;
+}) {
+ const canGrant = useAtomValue(
+ serverEnvironment.issueTaskGraphPeerGrant.permissionAtom(candidate.environmentId),
+ );
+ const issueGrant = useAtomCommand(serverEnvironment.issueTaskGraphPeerGrant, {
+ reportFailure: false,
+ });
+ const add = useAtomCommand(serverEnvironment.addTaskGraphPeer, { reportFailure: false });
+ const candidateUrl = Option.getOrNull(
+ useAtomValue(environmentSession.preparedConnectionValueAtom(candidate.environmentId)),
+ )?.httpBaseUrl;
+ const targetUrl = Option.getOrNull(
+ useAtomValue(environmentSession.preparedConnectionValueAtom(environmentId)),
+ )?.httpBaseUrl;
+ // The environment redeems the pairing at the address this app uses, so a loopback address
+ // usually fails from another computer. Both can still share a computer this app reaches over
+ // the network, so this only warns; the pairing attempt reports whether it worked.
+ const candidateHost = hostnameOf(candidateUrl);
+ const targetHost = hostnameOf(targetUrl);
+ const likelyUnreachable =
+ candidateHost !== null &&
+ targetHost !== null &&
+ isLoopbackHost(candidateHost) &&
+ !isLoopbackHost(targetHost);
+ const [adding, setAdding] = useState(false);
+ const [error, setError] = useState(null);
+
+ const commandError = (result: AtomCommandResult, fallback: string) =>
+ result._tag === "Failure" && !isAtomCommandInterrupted(result)
+ ? failureMessage(squashAtomCommandFailure(result), fallback)
+ : null;
+
+ const addMachine = async () => {
+ const connection = readPreparedConnection(candidate.environmentId);
+ if (adding || connection === null) return;
+ setAdding(true);
+ setError(null);
+ try {
+ const grant = await issueGrant({
+ environmentId: candidate.environmentId,
+ input: { label: environmentLabel },
+ });
+ if (grant._tag !== "Success") {
+ setError(commandError(grant, `${candidate.label} would not issue a pairing grant.`));
+ return;
+ }
+ const pairingUrl = new URL("/pair", connection.httpBaseUrl);
+ pairingUrl.hash = `token=${grant.value.credential}`;
+ const result = await add({
+ environmentId,
+ input: { pairingUrl: pairingUrl.toString(), label: candidate.label },
+ });
+ setError(commandError(result, `Could not add ${candidate.label}.`));
+ } catch {
+ setError(`Could not add ${candidate.label}.`);
+ } finally {
+ setAdding(false);
+ }
+ };
+
+ return (
+
+
+ {candidate.label}
+ void addMachine()}
+ >
+ {adding ? : null}
+ Add
+
+
+ {!canGrant ? (
+
+ Your session on {candidate.label} cannot pair other machines.
+
+ ) : likelyUnreachable ? (
+
+ This app reaches {candidate.label} at a local address, which {environmentLabel} can only
+ use if both run on the same computer. If adding fails, paste a pairing link from{" "}
+ {candidate.label} with an address {environmentLabel} can reach.
+
+ ) : null}
+ {error ?
{error}
: null}
+
+ );
+}
+
+function AddTaskGraphPeerForm({ environmentId }: { readonly environmentId: EnvironmentId }) {
+ const add = useAtomCommand(serverEnvironment.addTaskGraphPeer, { reportFailure: false });
+ const [pairingUrl, setPairingUrl] = useState("");
+ const [label, setLabel] = useState("");
+ const [adding, setAdding] = useState(false);
+ const [error, setError] = useState(null);
+
+ const submit = async (event: FormEvent) => {
+ event.preventDefault();
+ const trimmedUrl = pairingUrl.trim();
+ if (adding || trimmedUrl.length === 0) return;
+ const trimmedLabel = label.trim();
+ setAdding(true);
+ setError(null);
+ let result: AtomCommandResult;
+ try {
+ result = await add({
+ environmentId,
+ input: {
+ pairingUrl: trimmedUrl,
+ ...(trimmedLabel.length > 0 ? { label: trimmedLabel } : {}),
+ },
+ });
+ } finally {
+ setAdding(false);
+ }
+ if (result._tag === "Failure") {
+ if (!isAtomCommandInterrupted(result)) {
+ setError(failureMessage(squashAtomCommandFailure(result), "Could not add the machine."));
+ }
+ return;
+ }
+ setPairingUrl("");
+ setLabel("");
+ };
+
+ return (
+ void submit(event)}>
+
+ setPairingUrl(event.target.value)}
+ placeholder="Pairing link"
+ aria-label="Pairing link from the other machine"
+ disabled={adding}
+ />
+ setLabel(event.target.value)}
+ placeholder="Label (optional)"
+ aria-label="Machine label"
+ maxLength={80}
+ disabled={adding}
+ />
+
+ {adding ? : null}
+ Add machine
+
+
+ {error ? {error}
: null}
+
+ );
+}
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index 0ee55ef3785b..85bb142ac078 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -518,6 +518,20 @@ export const SETTINGS_SEARCH_ITEMS = [
scope: "environment-defaults",
searchTerms: ["base directory folder browser path home"],
},
+ {
+ id: "task-graph-auto-run",
+ title: "Run task graphs automatically",
+ to: "/settings/general",
+ scope: "environment-defaults",
+ searchTerms: ["agent plan parallel tasks worktrees draft start immediately wait edit run"],
+ },
+ {
+ id: "task-graph-concurrency",
+ title: "Task graph nodes at once",
+ to: "/settings/general",
+ scope: "environment-defaults",
+ searchTerms: ["parallel concurrent limit maximum agents running worktrees"],
+ },
{
id: "unpin-confirmation",
title: "Unpin confirmation",
@@ -996,6 +1010,14 @@ export const SETTINGS_SEARCH_ITEMS = [
"automatic machine environment resources cpu memory capacity preference weight shared projects",
],
},
+ {
+ id: "task-graph-machines",
+ title: "Task graph machines",
+ to: "/settings/connections",
+ searchTerms: [
+ "task graphs peers remote machines nodes pairing link distribute agents worktrees preference weight",
+ ],
+ },
{
id: "github-routing",
title: "GitHub sharing",
diff --git a/docs/README.md b/docs/README.md
index d7628960cb8b..d3ecc89e3bf5 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -5,6 +5,7 @@
- [Install T3 Code](./user/install.md)
- [Messages and context](./user/composer.md)
- [Working with threads](./user/thread-sidebar.md)
+- [Task graphs](./user/task-graphs.md)
- [Permission modes](./user/permission-modes.md)
- [Terminal history](./user/terminal.md)
- [Source control](./user/source-control.md)
diff --git a/docs/internals/task-graphs.md b/docs/internals/task-graphs.md
new file mode 100644
index 000000000000..b318b9289b42
--- /dev/null
+++ b/docs/internals/task-graphs.md
@@ -0,0 +1,78 @@
+# Task graphs
+
+A task graph is a plan of agent tasks with dependencies, owned by the thread
+that proposed it. User docs: [Task graphs](../user/task-graphs.md).
+
+## Outside the orchestrator, on purpose
+
+`TaskGraphService` (`apps/server/src/taskGraph/`) is a client of orchestration,
+not part of it. It launches node threads through `ThreadLaunchService`, learns
+that a node ended from `run.updated` in the domain event stream, and reports
+back through `sendToThread`. The orchestrator never sees a graph.
+
+Two reasons. Starting a node is multi-step I/O: worktree provisioning, setup
+scripts and branch naming already live in `ThreadLaunchService`, and delivery
+is `GitWorkflowService.runStackedAction`. Neither belongs in the pure decider.
+And a node on a peer machine is a thread on a different server, which the local
+event log cannot hold anyway.
+
+The cost is that graph state is not event-sourced. A graph is one JSON row
+written whole under a single lock. Node launches are keyed by a command ID
+derived from the node's pre-allocated thread ID, so recovery after a crash can
+repeat a launch safely. A node recorded `running` before its launch committed
+is launched again on startup.
+
+## Edits are one code path
+
+Agents (MCP `task_graph_*` tools), the web editor and mobile all send the same
+`TaskGraphEdit` operations, checked by `applyTaskGraphEdits` in
+`@t3tools/shared/taskGraph`. Clients run it first for instant errors; the
+server's run is the one that counts.
+
+## Pull request bases
+
+Worktree creation records each new branch's `gh-merge-base` as the branch it
+came from, which would point a branch-end PR at an inner node's branch that has
+no PR of its own. Delivery passes `baseBranch` to `runStackedAction` instead,
+from `taskGraphPullRequestBase`: the nearest ancestor along first dependencies
+that opens its own PR, skipping nodes that share the branch, else the graph
+base. The same value goes to peers, so stacks work across machines.
+
+## Usage limits
+
+A run that hits a usage limit ends `failed`, with the reset on its failure turn
+item. For a node, `finishLocalNode` reads that, moves the node to `waiting`, and
+arms the thread's `limitRecovery` with `autoResume`, so the existing
+`UsageLimitRecoveryWorker` continues the thread at the reset regardless of the
+user's global auto-resume setting. The node wakes on that new run's first
+active `run.updated`. Cancelling a waiting node clears the recovery, or the
+worker would resume it and the node would come back. Before a launch, a local
+node whose provider instance reports a used-up window waits for `resetsAt`.
+Nodes on peers do neither yet: a limit there fails the node.
+
+## Peers
+
+A peer is another environment this server holds a session on.
+
+- **Adding through a client:** a client connected to both machines asks the
+ peer for a grant (`taskGraphPeers.issueGrant`), a five-minute pairing
+ credential limited to `TASK_GRAPH_PEER_SCOPES` that the caller must already
+ hold. It hands that to this server with the address it reaches the peer at.
+ For a T3 Connect connection that is the tunnel hostname, which forwards plain
+ HTTP and WebSocket to the peer, so the server never needs T3 Connect
+ credentials of its own; the peer's own auth accepts the bearer session.
+- **Pairing:** pairing exchanges a normal pairing link at the peer's
+ `/oauth/token`, narrowed to `orchestration:read`, `orchestration:operate`
+ and `source-control:write`, as a `bot` client. The token lives in the secret
+ store, never in the `task_graph_peers` table. Removing a peer forgets the
+ token; revoking the session is the peer owner's job.
+- **Limits:** bearer sessions carry no runtime-mode ceiling. A node on a peer
+ runs in the proposing thread's mode, so pairing a peer means trusting this
+ machine's agents with that mode there.
+- **Branches:** they move through the shared remote. While any peer is
+ paired, every node pushes its branch, because placement happens after a
+ dependency finishes and the dependent may land anywhere. A node whose first
+ dependency ran elsewhere starts from origin.
+- **Placement:** uses `chooseLoadBalancedEnvironment` from
+ `@t3tools/shared/loadBalancing`, the same scorer client load balancing uses.
+ This machine counts as one candidate, gated by `taskGraphMaxConcurrentNodes`.
diff --git a/docs/user/task-graphs.md b/docs/user/task-graphs.md
new file mode 100644
index 000000000000..b1ede34180a5
--- /dev/null
+++ b/docs/user/task-graphs.md
@@ -0,0 +1,118 @@
+# Task graphs
+
+A task graph splits one request into agent tasks that run in parallel. Ask an
+agent something like "audit auth, billing and uploads for security issues and
+fix what you find", and it can plan a graph: one node per area, then a node
+that combines their work. Each node runs as its own thread in its own git
+worktree, and starts as soon as the nodes it depends on have succeeded.
+
+You can also start one yourself: choose the task graph button next to the
+attach button in the composer. It opens the editor on a draft, where you add
+tasks and choose **Run**. On a new chat, nothing is created until you choose
+**Run**, which creates the chat and the graph together.
+
+The graph appears above the composer of the thread that planned it. Open a
+node to watch its thread, or open the editor to change the plan.
+
+## Running or reviewing first
+
+By default a graph starts as soon as the agent proposes it. To review plans
+first, turn off **Settings → General → Run task graphs automatically**; graphs
+then wait as drafts until you choose **Run**. Your wording wins over the
+setting in either direction: "show me the plan first" gets a draft, and "just
+do it" runs straight away.
+
+**Task graph nodes at once** in the same section limits how many nodes run on
+this machine at the same time. Nodes also wait while the machine is nearly out
+of CPU or memory.
+
+## Waiting for a time or a usage reset
+
+Give a task a **Start at** time in the editor, or ask the agent to start it
+later, and it waits until then.
+
+Tasks never start into a used-up usage limit: a task whose model is out of
+usage on this machine waits for the reset instead. If a task's run stops
+because it hits a usage limit, the task waits rather than failing, and
+continues on its own thread when the limit resets. The graph shows when each
+waiting task will go again. Cancel the branch to stop waiting.
+
+## Changing a graph
+
+Hover a task to see its prompt, model, machine and workspace. Double-click it
+to open the editor with that task selected.
+
+In the editor, **Add task** adds a task after the selected one, continuing its
+worktree. With nothing selected, the new task goes first and the tasks at the
+start wait for it. You can edit or delete tasks that have not started,
+and drag between tasks to add a dependency. For each task that has not
+started you can also choose its model, the machine it runs on, and its
+workspace. While the graph runs:
+
+- **Cancel branch** stops a task and everything that depends on it.
+- **Retry** reruns a task that failed or was cancelled, along with the tasks
+ that were skipped because of it.
+- **Cancel graph** stops everything that is still running.
+
+You can also ask the agent to change the graph in chat, such as "drop the
+billing branch" or "add a docs task after the merge". Mobile shows the graph
+and offers Run, Cancel, Cancel branch and Retry.
+
+If you continue a failed task in its own thread, the graph follows that run
+and picks up again from there when it finishes.
+
+## Branches and pull requests
+
+Each task has a workspace:
+
+- **New worktree**, the default. A task with no dependencies branches from
+ the thread's branch; otherwise it branches from its first dependency's
+ branch.
+- **Continue a dependency's worktree**. The task works on in its first
+ dependency's worktree and branch, on the same machine, so several tasks can
+ build one branch in turn.
+- **Project folder**. No worktree, branch, commit or pull request. Use it for
+ read-only work such as reviews.
+
+A task with several dependencies starts from the first one's branch and merges
+the others; its agent resolves any merge conflicts. When those branches are on
+different machines, the editor offers **Bring branches together on**: pick a
+dependency's machine to continue its worktree there, and the other branches
+come in through your remote. Agents only add such a
+combining task when you ask for the work to be reconciled or the pieces cannot
+work apart. Otherwise each branch stays separate and ends in its own pull
+request.
+
+When a task succeeds, its work is committed. Tasks that nothing depends on
+also push and open a pull request against the graph's base branch. Ask for
+stacked pull requests, or turn on pull requests for tasks in the middle of the
+graph, and each pull request targets the nearest earlier task with its own
+pull request instead, so the graph produces a stack. When the whole graph
+finishes, the thread that planned it gets a summary with each task's result
+and pull request.
+
+## Running tasks on other machines
+
+A graph can spread its tasks across your other T3 Code machines. Open
+**Settings → Connections → Task graph machines** on the machine that runs the
+graph. Machines this app is already connected to, over T3 Connect or directly,
+are listed there: choose **Add**. The other machine needs this version of T3
+Code for that. You can also paste a pairing link from the other machine (see
+[Remote access](./remote-access.md)). Both machines need the project open from
+the same repository.
+
+A machine added over T3 Connect is reached at its T3 Connect address. If that
+address changes, for example after you unlink and relink the machine, it shows
+as unreachable; remove it and add it again.
+
+This machine only gets permission to start, watch and stop threads and push
+branches there. It cannot read files, open terminals or change settings. To
+unpair, remove the machine here, then revoke the session from the other
+machine's connected clients.
+
+Tasks go to the machine you pick for them in the editor. Tasks left on
+**Auto** go to whichever machine has the most free CPU and memory, adjusted by
+each machine's preference (**Prefer**, **Normal**, **Less often** or **Manual
+only**). With a machine paired, task branches are pushed to your remote so the
+other machines can build on them. Tasks on another machine run in the same
+permission mode as the thread that planned the graph.
diff --git a/packages/client-runtime/src/load-balancing.ts b/packages/client-runtime/src/load-balancing.ts
index 0b938c6c090e..a322f7490dac 100644
--- a/packages/client-runtime/src/load-balancing.ts
+++ b/packages/client-runtime/src/load-balancing.ts
@@ -1,40 +1,2 @@
-import type { HostResourcesSnapshot } from "@t3tools/contracts";
-
-/** Callers supply only connected machines hosting the project and selected provider. */
-export function chooseLoadBalancedEnvironment(
- candidates: ReadonlyArray<{
- environmentId: string;
- resources: HostResourcesSnapshot | null;
- /** Client receipt time avoids comparing clocks on different machines. */
- receivedAt?: number;
- weight: number;
- }>,
- now: number,
-): string | null {
- let selected: string | null = null;
- let bestScore = 0;
- for (const { environmentId, resources, receivedAt, weight } of candidates) {
- const sampledAt = receivedAt ?? resources?.sampledAt ?? 0;
- if (
- !resources ||
- !Number.isFinite(weight) ||
- weight <= 0 ||
- now - sampledAt > 15_000 ||
- sampledAt > now + 5_000 ||
- resources.cpuUtilization === null ||
- resources.cpuUtilization >= 0.95 ||
- resources.totalMemoryBytes <= 0 ||
- resources.cpuCount <= 0
- ) {
- continue;
- }
- const memoryAvailable = resources.availableMemoryBytes / resources.totalMemoryBytes;
- if (memoryAvailable <= 0.05) continue;
- const score = weight * resources.cpuCount * (1 - resources.cpuUtilization) * memoryAvailable;
- if (score > bestScore) {
- selected = environmentId;
- bestScore = score;
- }
- }
- return selected;
-}
+// The scorer is shared with the server, which places task graph nodes the same way.
+export { chooseLoadBalancedEnvironment } from "@t3tools/shared/loadBalancing";
diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts
index c11a49897b88..dfe186acd560 100644
--- a/packages/client-runtime/src/rpc/client.ts
+++ b/packages/client-runtime/src/rpc/client.ts
@@ -58,6 +58,8 @@ export type EnvironmentSubscriptionRpcTag =
| typeof WS_METHODS.subscribeServerConfig
| typeof WS_METHODS.subscribeServerLifecycle
| typeof WS_METHODS.scheduledTasksSubscribe
+ | typeof WS_METHODS.taskGraphsSubscribe
+ | typeof WS_METHODS.taskGraphPeersSubscribe
| typeof WS_METHODS.serverGetStorageCleanupReport
| typeof WS_METHODS.subscribeTerminalEvents
| typeof WS_METHODS.subscribeTerminalMetadata
diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts
index 31aadd0025bb..63b2a0f7aa1f 100644
--- a/packages/client-runtime/src/state/server.ts
+++ b/packages/client-runtime/src/state/server.ts
@@ -1099,6 +1099,16 @@ export function createServerEnvironmentAtoms(
label: "environment-data:server:scheduled-tasks:live",
tag: WS_METHODS.scheduledTasksSubscribe,
}),
+ /** Machines this environment may run task graph nodes on, with connection status. */
+ taskGraphPeersLive: createEnvironmentRpcSubscriptionAtomFamily(runtime, {
+ label: "environment-data:server:task-graph-peers:live",
+ tag: WS_METHODS.taskGraphPeersSubscribe,
+ }),
+ /** A thread's task graphs: snapshot on subscribe, fresh list after every change. */
+ taskGraphsLive: createEnvironmentRpcSubscriptionAtomFamily(runtime, {
+ label: "environment-data:server:task-graphs:live",
+ tag: WS_METHODS.taskGraphsSubscribe,
+ }),
// A cold transcript scan is measured in seconds, so keep the result around
// long enough that switching windows or re-rendering does not rescan.
// Slow sources answer from cache first: that summary stays on screen, with
@@ -1331,6 +1341,48 @@ export function createServerEnvironmentAtoms(
scheduler: configScheduler,
concurrency: configConcurrency,
}),
+ addTaskGraphPeer: createEnvironmentRpcCommand(runtime, {
+ label: "environment-data:server:task-graph-peer:add",
+ tag: WS_METHODS.taskGraphPeersAdd,
+ scheduler: configScheduler,
+ concurrency: configConcurrency,
+ }),
+ removeTaskGraphPeer: createEnvironmentRpcCommand(runtime, {
+ label: "environment-data:server:task-graph-peer:remove",
+ tag: WS_METHODS.taskGraphPeersRemove,
+ scheduler: configScheduler,
+ concurrency: configConcurrency,
+ }),
+ issueTaskGraphPeerGrant: createEnvironmentRpcCommand(runtime, {
+ label: "environment-data:server:task-graph-peer:issue-grant",
+ tag: WS_METHODS.taskGraphPeersIssueGrant,
+ }),
+ setTaskGraphPeerWeight: createEnvironmentRpcCommand(runtime, {
+ label: "environment-data:server:task-graph-peer:set-weight",
+ tag: WS_METHODS.taskGraphPeersSetWeight,
+ scheduler: configScheduler,
+ concurrency: configConcurrency,
+ }),
+ createTaskGraph: createEnvironmentRpcCommand(runtime, {
+ label: "environment-data:server:task-graph:create",
+ tag: WS_METHODS.taskGraphsCreate,
+ }),
+ // One lane per graph so edits from the editor apply in the order they were made.
+ editTaskGraph: createEnvironmentRpcCommand(runtime, {
+ label: "environment-data:server:task-graph:edit",
+ tag: WS_METHODS.taskGraphsEdit,
+ concurrency: { mode: "serial", key: ({ input }) => input.graphId },
+ }),
+ runTaskGraph: createEnvironmentRpcCommand(runtime, {
+ label: "environment-data:server:task-graph:run",
+ tag: WS_METHODS.taskGraphsRun,
+ concurrency: { mode: "singleFlight", key: ({ input }) => input.graphId },
+ }),
+ cancelTaskGraph: createEnvironmentRpcCommand(runtime, {
+ label: "environment-data:server:task-graph:cancel",
+ tag: WS_METHODS.taskGraphsCancel,
+ concurrency: { mode: "singleFlight", key: ({ input }) => input.graphId },
+ }),
// Off the config lane: answering a card must not queue behind settings
// edits. One answer per card at a time.
answerSecretRequest: createEnvironmentRpcCommand(runtime, {
diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts
index 3ca88df66b19..0f9fc458861f 100644
--- a/packages/contracts/src/baseSchemas.ts
+++ b/packages/contracts/src/baseSchemas.ts
@@ -384,6 +384,8 @@ export const RuntimeRequestId = makeEntityId("RuntimeRequestId");
export type RuntimeRequestId = typeof RuntimeRequestId.Type;
export const RuntimeTaskId = makeEntityId("RuntimeTaskId");
export type RuntimeTaskId = typeof RuntimeTaskId.Type;
+export const TaskGraphId = makeEntityId("TaskGraphId");
+export type TaskGraphId = typeof TaskGraphId.Type;
export const ScheduledTaskId = makeEntityId("ScheduledTaskId");
/** A one-use handle to a secret the user entered for an agent; the agent never sees the value. */
export const SecretRef = makeEntityId("SecretRef");
diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts
index e85e5df7769c..b621fbe55212 100644
--- a/packages/contracts/src/clientRpcPermissions.ts
+++ b/packages/contracts/src/clientRpcPermissions.ts
@@ -1,6 +1,7 @@
import * as Schema from "effect/Schema";
import { GitPreparePullRequestThreadInput } from "./git.ts";
import {
+ AuthEnvironmentMaintainScope,
AuthOrchestrationOperateScope,
AuthSettingsWriteScope,
AuthSourceControlWriteScope,
@@ -47,6 +48,16 @@ export const CLIENT_GUARDED_RPC_SCOPES = {
[WS_METHODS.scheduledTasksRunNow]: AuthOrchestrationOperateScope,
[WS_METHODS.scheduledTasksRotateWebhookToken]: AuthOrchestrationOperateScope,
[ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: AuthOrchestrationOperateScope,
+ [WS_METHODS.taskGraphsCreate]: AuthOrchestrationOperateScope,
+ [WS_METHODS.taskGraphsEdit]: AuthOrchestrationOperateScope,
+ [WS_METHODS.taskGraphsRun]: AuthOrchestrationOperateScope,
+ [WS_METHODS.taskGraphsCancel]: AuthOrchestrationOperateScope,
+ // Pairing a peer stores a credential and lets this machine start work there.
+ [WS_METHODS.taskGraphPeersAdd]: AuthEnvironmentMaintainScope,
+ [WS_METHODS.taskGraphPeersRemove]: AuthEnvironmentMaintainScope,
+ [WS_METHODS.taskGraphPeersSetWeight]: AuthEnvironmentMaintainScope,
+ // The grant also needs every scope it hands out; the server checks that.
+ [WS_METHODS.taskGraphPeersIssueGrant]: AuthEnvironmentMaintainScope,
} as const;
export type ClientGuardedRpcTag = keyof typeof CLIENT_GUARDED_RPC_SCOPES;
diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts
index b2cb2e902d8e..760995b38f92 100644
--- a/packages/contracts/src/git.ts
+++ b/packages/contracts/src/git.ts
@@ -132,6 +132,11 @@ export const GitRunStackedActionInput = Schema.Struct({
filePaths: Schema.optional(
Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)),
),
+ /**
+ * Branch a newly created pull request targets, instead of the one recorded
+ * for the branch or the repository default. Task graphs use it to stack PRs.
+ */
+ baseBranch: Schema.optional(TrimmedNonEmptyStringSchema),
/** The thread the action runs beside; a pull request it creates is linked to it. */
threadId: Schema.optional(ThreadId),
projectId: Schema.optional(ProjectId),
diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts
index 600d503ade39..63288d0f7de9 100644
--- a/packages/contracts/src/index.ts
+++ b/packages/contracts/src/index.ts
@@ -59,6 +59,7 @@ export * from "./previewAutomation.ts";
export * from "./resourceTelemetry.ts";
export * from "./usage.ts";
export * from "./scheduledTask.ts";
+export * from "./taskGraph.ts";
export * from "./worktreeMcp.ts";
export * from "./resourceTelemetry.ts";
export * from "./rpc.ts";
diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts
index 61304c52f860..e880ad91ca0b 100644
--- a/packages/contracts/src/rpc.ts
+++ b/packages/contracts/src/rpc.ts
@@ -347,6 +347,21 @@ import {
ScheduledTaskUpsertInput,
ScheduledTaskMutationResult,
} from "./scheduledTask.ts";
+import {
+ TaskGraphCreateInput,
+ TaskGraphEditInput,
+ TaskGraphError,
+ TaskGraphListResult,
+ TaskGraphPeerAddInput,
+ TaskGraphPeerGrant,
+ TaskGraphPeerGrantInput,
+ TaskGraphPeerListResult,
+ TaskGraphPeerSetWeightInput,
+ TaskGraphPeerTargetInput,
+ TaskGraphResult,
+ TaskGraphSubscribeInput,
+ TaskGraphTargetInput,
+} from "./taskGraph.ts";
import { SecretRequestAnswerInput, SecretRequestError } from "./secretRequest.ts";
import {
ProjectCloneActionInput,
@@ -523,6 +538,18 @@ export const WS_METHODS = {
scheduledTasksListWebhookDeliveries: "scheduledTasks.listWebhookDeliveries",
scheduledTasksGetWebhookDelivery: "scheduledTasks.getWebhookDelivery",
+ // Task graphs
+ taskGraphsSubscribe: "taskGraphs.subscribe",
+ taskGraphsCreate: "taskGraphs.create",
+ taskGraphsEdit: "taskGraphs.edit",
+ taskGraphsRun: "taskGraphs.run",
+ taskGraphsCancel: "taskGraphs.cancel",
+ taskGraphPeersSubscribe: "taskGraphPeers.subscribe",
+ taskGraphPeersAdd: "taskGraphPeers.add",
+ taskGraphPeersRemove: "taskGraphPeers.remove",
+ taskGraphPeersSetWeight: "taskGraphPeers.setWeight",
+ taskGraphPeersIssueGrant: "taskGraphPeers.issueGrant",
+
// Cloud environment methods
cloudGetRelayClientStatus: "cloud.getRelayClientStatus",
cloudInstallRelayClient: "cloud.installRelayClient",
@@ -1821,6 +1848,69 @@ const WsScheduledTasksRotateWebhookTokenRpc = Rpc.make(
},
);
+/** Streams a thread's task graphs: one snapshot on subscribe, then a fresh list after every change. */
+const WsTaskGraphsSubscribeRpc = Rpc.make(WS_METHODS.taskGraphsSubscribe, {
+ payload: TaskGraphSubscribeInput,
+ success: TaskGraphListResult,
+ error: Schema.Union([TaskGraphError, EnvironmentAuthorizationError]),
+ stream: true,
+});
+
+const WsTaskGraphsCreateRpc = Rpc.make(WS_METHODS.taskGraphsCreate, {
+ payload: TaskGraphCreateInput,
+ success: TaskGraphResult,
+ error: Schema.Union([TaskGraphError, EnvironmentAuthorizationError]),
+});
+
+const WsTaskGraphsEditRpc = Rpc.make(WS_METHODS.taskGraphsEdit, {
+ payload: TaskGraphEditInput,
+ success: TaskGraphResult,
+ error: Schema.Union([TaskGraphError, EnvironmentAuthorizationError]),
+});
+
+const WsTaskGraphsRunRpc = Rpc.make(WS_METHODS.taskGraphsRun, {
+ payload: TaskGraphTargetInput,
+ success: TaskGraphResult,
+ error: Schema.Union([TaskGraphError, EnvironmentAuthorizationError]),
+});
+
+const WsTaskGraphsCancelRpc = Rpc.make(WS_METHODS.taskGraphsCancel, {
+ payload: TaskGraphTargetInput,
+ success: TaskGraphResult,
+ error: Schema.Union([TaskGraphError, EnvironmentAuthorizationError]),
+});
+
+const WsTaskGraphPeersSubscribeRpc = Rpc.make(WS_METHODS.taskGraphPeersSubscribe, {
+ payload: Schema.Struct({}),
+ success: TaskGraphPeerListResult,
+ error: Schema.Union([TaskGraphError, EnvironmentAuthorizationError]),
+ stream: true,
+});
+
+const WsTaskGraphPeersAddRpc = Rpc.make(WS_METHODS.taskGraphPeersAdd, {
+ payload: TaskGraphPeerAddInput,
+ success: TaskGraphPeerListResult,
+ error: Schema.Union([TaskGraphError, EnvironmentAuthorizationError]),
+});
+
+const WsTaskGraphPeersRemoveRpc = Rpc.make(WS_METHODS.taskGraphPeersRemove, {
+ payload: TaskGraphPeerTargetInput,
+ success: TaskGraphPeerListResult,
+ error: Schema.Union([TaskGraphError, EnvironmentAuthorizationError]),
+});
+
+const WsTaskGraphPeersSetWeightRpc = Rpc.make(WS_METHODS.taskGraphPeersSetWeight, {
+ payload: TaskGraphPeerSetWeightInput,
+ success: TaskGraphPeerListResult,
+ error: Schema.Union([TaskGraphError, EnvironmentAuthorizationError]),
+});
+
+const WsTaskGraphPeersIssueGrantRpc = Rpc.make(WS_METHODS.taskGraphPeersIssueGrant, {
+ payload: TaskGraphPeerGrantInput,
+ success: TaskGraphPeerGrant,
+ error: Schema.Union([TaskGraphError, EnvironmentAuthorizationError]),
+});
+
const WsSecretsAnswerRequestRpc = Rpc.make(WS_METHODS.secretsAnswerRequest, {
payload: SecretRequestAnswerInput,
error: Schema.Union([SecretRequestError, EnvironmentAuthorizationError]),
@@ -1927,6 +2017,16 @@ export const WsRpcGroup = RpcGroup.make(
WsServerRefreshUsageRatesRpc,
WsServerSignalProcessRpc,
WsScheduledTasksListRpc,
+ WsTaskGraphsSubscribeRpc,
+ WsTaskGraphsCreateRpc,
+ WsTaskGraphsEditRpc,
+ WsTaskGraphsRunRpc,
+ WsTaskGraphsCancelRpc,
+ WsTaskGraphPeersSubscribeRpc,
+ WsTaskGraphPeersAddRpc,
+ WsTaskGraphPeersRemoveRpc,
+ WsTaskGraphPeersSetWeightRpc,
+ WsTaskGraphPeersIssueGrantRpc,
WsScheduledTasksSubscribeRpc,
WsScheduledTasksUpsertRpc,
WsScheduledTasksSetEnabledRpc,
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 2384e17c07f2..c187e721c8e2 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -1063,6 +1063,10 @@ export const StorageCleanupSettings = Schema.Struct({
});
export type StorageCleanupSettings = typeof StorageCleanupSettings.Type;
+export const TaskGraphMaxConcurrentNodes = Schema.Int.check(
+ Schema.isBetween({ minimum: 1, maximum: 16 }),
+);
+
export const ServerSettings = Schema.Struct({
// Null means no client has migrated this preference yet; an empty value is
// authoritative so another device cannot resurrect cleared preferences.
@@ -1090,6 +1094,16 @@ export const ServerSettings = Schema.Struct({
Schema.withDecodingDefault(Effect.succeed("paragraph" as const)),
),
enableProviderUpdateChecks: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
+ /**
+ * Whether a task graph an agent proposes starts at once. Off leaves it as a
+ * draft for the user to edit and run. The agent may override this per graph
+ * when the user's wording asks for one or the other.
+ */
+ taskGraphAutoRun: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
+ /** Most task graph nodes running at once on this environment. */
+ taskGraphMaxConcurrentNodes: TaskGraphMaxConcurrentNodes.pipe(
+ Schema.withDecodingDefault(Effect.succeed(4)),
+ ),
// Retain the update-era key; recovery now needs an environment-owned opt-in.
continueThreadsAfterServerUpdate: Schema.Boolean.pipe(
Schema.withDecodingDefault(Effect.succeed(false)),
@@ -1477,6 +1491,8 @@ export const ServerSettingsPatch = Schema.Struct({
// Server settings
responseStreamingMode: Schema.optionalKey(ResponseStreamingMode),
enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean),
+ taskGraphAutoRun: Schema.optionalKey(Schema.Boolean),
+ taskGraphMaxConcurrentNodes: Schema.optionalKey(TaskGraphMaxConcurrentNodes),
continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean),
enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean),
projectAgentBrowserAccessOverrides: Schema.optionalKey(
diff --git a/packages/contracts/src/taskGraph.ts b/packages/contracts/src/taskGraph.ts
new file mode 100644
index 000000000000..346528eaa045
--- /dev/null
+++ b/packages/contracts/src/taskGraph.ts
@@ -0,0 +1,289 @@
+import * as Effect from "effect/Effect";
+import * as Schema from "effect/Schema";
+
+import {
+ EnvironmentId,
+ IsoDateTime,
+ ProjectId,
+ TaskGraphId,
+ ThreadId,
+ TrimmedNonEmptyString,
+} from "./baseSchemas.ts";
+import type { AuthEnvironmentScope } from "./auth.ts";
+import { ModelSelection } from "./modelSelection.ts";
+
+/** Most nodes one graph may hold. A graph is a plan for a turn, not a backlog. */
+export const MAX_TASK_GRAPH_NODES = 48;
+
+/** A node's key within its graph, chosen by whoever writes the graph, such as `audit-auth`. */
+export const TaskGraphNodeKey = TrimmedNonEmptyString.check(
+ Schema.isPattern(/^[a-z0-9][a-z0-9-]{0,47}$/),
+).annotate({
+ description: "Lowercase letters, digits and dashes, up to 48 characters, such as 'audit-auth'.",
+});
+export type TaskGraphNodeKey = typeof TaskGraphNodeKey.Type;
+
+/**
+ * - `pending`: waiting for its dependencies, or for the graph to run.
+ * - `running`: its thread has a turn in progress.
+ * - `waiting`: held until `waitUntil`: a scheduled start, or a usage limit reset. A node
+ * with a thread was stopped by the limit and continues on that thread at the reset.
+ * - `delivering`: the turn finished and the branch is being committed, pushed and opened as a PR.
+ * - `skipped`: a dependency failed or was cancelled, so it never started.
+ */
+export const TaskGraphNodeStatus = Schema.Literals([
+ "pending",
+ "waiting",
+ "running",
+ "delivering",
+ "succeeded",
+ "failed",
+ "cancelled",
+ "skipped",
+]);
+export type TaskGraphNodeStatus = typeof TaskGraphNodeStatus.Type;
+
+export const TaskGraphStatus = Schema.Literals([
+ "draft",
+ "running",
+ "succeeded",
+ "failed",
+ "cancelled",
+]);
+export type TaskGraphStatus = typeof TaskGraphStatus.Type;
+
+const nodeFields = {
+ title: TrimmedNonEmptyString.check(Schema.isMaxLength(120)),
+ prompt: TrimmedNonEmptyString.check(Schema.isMaxLength(20_000)),
+ dependsOn: Schema.Array(TaskGraphNodeKey).annotate({
+ description:
+ "Keys of nodes that must succeed first. A node with several dependencies starts from the first one's branch and merges the others in.",
+ }),
+};
+
+/**
+ * Where a node works:
+ * - `worktree`: its own new worktree and branch.
+ * - `dependency`: continues in its first dependency's worktree and branch, so
+ * several nodes can build one branch in turn. Runs on that node's machine.
+ * - `root`: the project folder itself, with no branch, commit or pull request.
+ * For read-only work such as reviews.
+ */
+export const TaskGraphNodeWorkspace = Schema.Literals(["worktree", "dependency", "root"]);
+export type TaskGraphNodeWorkspace = typeof TaskGraphNodeWorkspace.Type;
+
+export const TaskGraphNodeWaitReason = Schema.Literals(["scheduled", "usage_limit"]);
+export type TaskGraphNodeWaitReason = typeof TaskGraphNodeWaitReason.Type;
+
+export const TaskGraphNodeInput = Schema.Struct({
+ key: TaskGraphNodeKey,
+ ...nodeFields,
+ pullRequest: Schema.optional(Schema.Boolean).annotate({
+ description:
+ "Commit, push and open a pull request for this node's branch when it succeeds. Defaults to true for nodes nothing depends on.",
+ }),
+ modelSelection: Schema.optional(ModelSelection).annotate({
+ description: "Provider and model for this node. Defaults to the graph's thread.",
+ }),
+ environmentId: Schema.optional(Schema.NullOr(EnvironmentId)).annotate({
+ description:
+ "Machine to run on, from task_graph_list's machines. Null or omitted balances across this machine and its paired peers.",
+ }),
+ startAt: Schema.optional(Schema.NullOr(IsoDateTime)).annotate({
+ description:
+ "Earliest time the node may start, as an ISO timestamp such as 2026-10-11T02:00:00Z. Omit to start as soon as its dependencies succeed.",
+ }),
+ workspace: Schema.optional(TaskGraphNodeWorkspace).annotate({
+ description:
+ "'worktree' (default): its own new worktree and branch. 'dependency': continue in the first dependency's worktree and branch, so one branch is built by several nodes in turn. 'root': the project folder, no branch, commit or PR; for read-only work such as reviews.",
+ }),
+});
+export type TaskGraphNodeInput = typeof TaskGraphNodeInput.Type;
+
+export const TaskGraphNodePullRequest = Schema.Struct({
+ status: Schema.Literals(["opened", "failed"]),
+ url: Schema.NullOr(Schema.String),
+ error: Schema.NullOr(Schema.String),
+});
+export type TaskGraphNodePullRequest = typeof TaskGraphNodePullRequest.Type;
+
+export const TaskGraphNode = Schema.Struct({
+ key: TaskGraphNodeKey,
+ ...nodeFields,
+ /** Null opens a pull request only when no other node depends on this one. */
+ pullRequest: Schema.NullOr(Schema.Boolean),
+ modelSelection: Schema.NullOr(ModelSelection),
+ environmentId: Schema.NullOr(EnvironmentId),
+ workspace: TaskGraphNodeWorkspace.pipe(
+ Schema.withDecodingDefault(Effect.succeed("worktree" as const)),
+ ),
+ /** Earliest start; null starts once dependencies succeed. */
+ startAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))),
+ status: TaskGraphNodeStatus,
+ /** While `waiting`: when the wait ends, and why. */
+ waitUntil: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))),
+ waitReason: Schema.NullOr(TaskGraphNodeWaitReason).pipe(
+ Schema.withDecodingDefault(Effect.succeed(null)),
+ ),
+ /** Where the node ran or runs; null until it starts. */
+ assignedEnvironmentId: Schema.NullOr(EnvironmentId),
+ threadId: Schema.NullOr(ThreadId),
+ branch: Schema.NullOr(Schema.String),
+ /** Where the node worked on its machine; a `dependency` node continues there. */
+ worktreePath: Schema.NullOr(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed(null))),
+ /** The end of the node's last reply, handed to the nodes that depend on it. */
+ summary: Schema.NullOr(Schema.String),
+ error: Schema.NullOr(Schema.String),
+ pullRequestResult: Schema.NullOr(TaskGraphNodePullRequest),
+ startedAt: Schema.NullOr(IsoDateTime),
+ completedAt: Schema.NullOr(IsoDateTime),
+});
+export type TaskGraphNode = typeof TaskGraphNode.Type;
+
+export const TaskGraph = Schema.Struct({
+ id: TaskGraphId,
+ projectId: ProjectId,
+ /** The thread that proposed the graph; it hears back when the graph ends. */
+ threadId: ThreadId,
+ title: TrimmedNonEmptyString,
+ /** Ref that nodes without dependencies branch from. */
+ baseRef: TrimmedNonEmptyString,
+ /** The proposing thread's model when the graph was made; nodes without their own use it. */
+ modelSelection: Schema.NullOr(ModelSelection).pipe(
+ Schema.withDecodingDefault(Effect.succeed(null)),
+ ),
+ status: TaskGraphStatus,
+ nodes: Schema.Array(TaskGraphNode),
+ createdAt: IsoDateTime,
+ updatedAt: IsoDateTime,
+});
+export type TaskGraph = typeof TaskGraph.Type;
+
+/**
+ * One change to a graph. Agents send these through MCP and the editor sends
+ * them over RPC, so both see the same rules. Only pending nodes can be edited
+ * or removed; `cancel_branch` cuts a node and everything below it, running or
+ * not, and `retry_node` reopens a node that did not succeed.
+ */
+export const TaskGraphEdit = Schema.Union([
+ Schema.Struct({ type: Schema.Literal("add_node"), node: TaskGraphNodeInput }),
+ Schema.Struct({
+ type: Schema.Literal("update_node"),
+ key: TaskGraphNodeKey,
+ title: Schema.optional(nodeFields.title),
+ prompt: Schema.optional(nodeFields.prompt),
+ dependsOn: Schema.optional(nodeFields.dependsOn),
+ pullRequest: Schema.optional(Schema.NullOr(Schema.Boolean)),
+ modelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
+ environmentId: Schema.optional(Schema.NullOr(EnvironmentId)),
+ workspace: Schema.optional(TaskGraphNodeWorkspace),
+ startAt: Schema.optional(Schema.NullOr(IsoDateTime)),
+ }),
+ Schema.Struct({ type: Schema.Literal("remove_node"), key: TaskGraphNodeKey }),
+ Schema.Struct({ type: Schema.Literal("cancel_branch"), key: TaskGraphNodeKey }),
+ Schema.Struct({ type: Schema.Literal("retry_node"), key: TaskGraphNodeKey }),
+]).annotate({
+ description:
+ "Pass an object with type add_node, update_node, remove_node, cancel_branch or retry_node.",
+});
+export type TaskGraphEdit = typeof TaskGraphEdit.Type;
+
+export const TaskGraphCreateInput = Schema.Struct({
+ threadId: ThreadId,
+ title: TrimmedNonEmptyString.check(Schema.isMaxLength(120)),
+ nodes: Schema.Array(TaskGraphNodeInput).check(
+ Schema.isMinLength(1),
+ Schema.isMaxLength(MAX_TASK_GRAPH_NODES),
+ ),
+ baseRef: Schema.optional(TrimmedNonEmptyString),
+ /** Run at once, or leave a draft. Omit to follow the environment's `taskGraphAutoRun`. */
+ run: Schema.optional(Schema.Boolean),
+});
+export type TaskGraphCreateInput = typeof TaskGraphCreateInput.Type;
+
+export const TaskGraphSubscribeInput = Schema.Struct({ threadId: ThreadId });
+export type TaskGraphSubscribeInput = typeof TaskGraphSubscribeInput.Type;
+
+export const TaskGraphListResult = Schema.Struct({ graphs: Schema.Array(TaskGraph) });
+export type TaskGraphListResult = typeof TaskGraphListResult.Type;
+
+export const TaskGraphEditInput = Schema.Struct({
+ graphId: TaskGraphId,
+ edits: Schema.Array(TaskGraphEdit).check(Schema.isMinLength(1)),
+});
+export type TaskGraphEditInput = typeof TaskGraphEditInput.Type;
+
+export const TaskGraphTargetInput = Schema.Struct({ graphId: TaskGraphId });
+export type TaskGraphTargetInput = typeof TaskGraphTargetInput.Type;
+
+export const TaskGraphResult = Schema.Struct({ graph: TaskGraph });
+export type TaskGraphResult = typeof TaskGraphResult.Type;
+
+export class TaskGraphError extends Schema.TaggedError()("TaskGraphError", {
+ message: Schema.String,
+ graphId: Schema.optional(TaskGraphId),
+ cause: Schema.optional(Schema.Defect()),
+}) {}
+
+/**
+ * Another T3 Code environment this one may run task graph nodes on. This
+ * server holds a narrowly scoped session on the peer: it can start, watch and
+ * stop threads and push their branches, nothing else.
+ */
+export const TaskGraphPeer = Schema.Struct({
+ environmentId: EnvironmentId,
+ label: TrimmedNonEmptyString,
+ httpBaseUrl: TrimmedNonEmptyString,
+ /** 0 never places nodes there automatically; same scale as client load balancing. */
+ weight: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 })),
+ status: Schema.Literals(["connecting", "connected", "unreachable", "unauthorized"]),
+ error: Schema.NullOr(Schema.String),
+ addedAt: IsoDateTime,
+});
+export type TaskGraphPeer = typeof TaskGraphPeer.Type;
+
+export const TaskGraphPeerListResult = Schema.Struct({ peers: Schema.Array(TaskGraphPeer) });
+export type TaskGraphPeerListResult = typeof TaskGraphPeerListResult.Type;
+
+export const TaskGraphPeerAddInput = Schema.Struct({
+ /** A pairing link from the peer, such as https://host/pair#token=ABCD. */
+ pairingUrl: TrimmedNonEmptyString,
+ label: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(80))),
+});
+export type TaskGraphPeerAddInput = typeof TaskGraphPeerAddInput.Type;
+
+export const TaskGraphPeerTargetInput = Schema.Struct({ environmentId: EnvironmentId });
+export type TaskGraphPeerTargetInput = typeof TaskGraphPeerTargetInput.Type;
+
+export const TaskGraphPeerSetWeightInput = Schema.Struct({
+ environmentId: EnvironmentId,
+ weight: TaskGraphPeer.fields.weight,
+});
+export type TaskGraphPeerSetWeightInput = typeof TaskGraphPeerSetWeightInput.Type;
+
+/**
+ * What a peer session may do: start, watch and stop threads and push their
+ * branches. Not files, terminals, settings or access.
+ */
+export const TASK_GRAPH_PEER_SCOPES = [
+ "orchestration:read",
+ "orchestration:operate",
+ "source-control:write",
+] as const satisfies ReadonlyArray;
+
+export const TaskGraphPeerGrantInput = Schema.Struct({
+ /** The machine that will redeem the grant, shown in this machine's connected clients. */
+ label: TrimmedNonEmptyString.check(Schema.isMaxLength(80)),
+});
+export type TaskGraphPeerGrantInput = typeof TaskGraphPeerGrantInput.Type;
+
+/**
+ * A short-lived pairing credential limited to `TASK_GRAPH_PEER_SCOPES`. A
+ * client connected to two machines asks one for a grant and hands it to the
+ * other, which also works when the first is only reachable through T3 Connect.
+ */
+export const TaskGraphPeerGrant = Schema.Struct({
+ credential: TrimmedNonEmptyString,
+ expiresAt: IsoDateTime,
+});
+export type TaskGraphPeerGrant = typeof TaskGraphPeerGrant.Type;
diff --git a/packages/shared/package.json b/packages/shared/package.json
index 4ccc2b691023..9bcaf381d8d4 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -435,6 +435,14 @@
"types": "./src/markdownListIndentation.ts",
"import": "./src/markdownListIndentation.ts"
},
+ "./loadBalancing": {
+ "types": "./src/loadBalancing.ts",
+ "import": "./src/loadBalancing.ts"
+ },
+ "./taskGraph": {
+ "types": "./src/taskGraph.ts",
+ "import": "./src/taskGraph.ts"
+ },
"./proposedPlanText": {
"types": "./src/proposedPlanText.ts",
"import": "./src/proposedPlanText.ts"
diff --git a/packages/shared/src/loadBalancing.ts b/packages/shared/src/loadBalancing.ts
new file mode 100644
index 000000000000..414b4c7c1ff7
--- /dev/null
+++ b/packages/shared/src/loadBalancing.ts
@@ -0,0 +1,43 @@
+import type { HostResourcesSnapshot } from "@t3tools/contracts";
+
+/**
+ * Picks the machine with the most free capacity, scaled by the user's weight.
+ * Callers supply only connected machines hosting the project and selected provider.
+ */
+export function chooseLoadBalancedEnvironment(
+ candidates: ReadonlyArray<{
+ environmentId: string;
+ resources: HostResourcesSnapshot | null;
+ /** Client receipt time avoids comparing clocks on different machines. */
+ receivedAt?: number;
+ weight: number;
+ }>,
+ now: number,
+): string | null {
+ let selected: string | null = null;
+ let bestScore = 0;
+ for (const { environmentId, resources, receivedAt, weight } of candidates) {
+ const sampledAt = receivedAt ?? resources?.sampledAt ?? 0;
+ if (
+ !resources ||
+ !Number.isFinite(weight) ||
+ weight <= 0 ||
+ now - sampledAt > 15_000 ||
+ sampledAt > now + 5_000 ||
+ resources.cpuUtilization === null ||
+ resources.cpuUtilization >= 0.95 ||
+ resources.totalMemoryBytes <= 0 ||
+ resources.cpuCount <= 0
+ ) {
+ continue;
+ }
+ const memoryAvailable = resources.availableMemoryBytes / resources.totalMemoryBytes;
+ if (memoryAvailable <= 0.05) continue;
+ const score = weight * resources.cpuCount * (1 - resources.cpuUtilization) * memoryAvailable;
+ if (score > bestScore) {
+ selected = environmentId;
+ bestScore = score;
+ }
+ }
+ return selected;
+}
diff --git a/packages/shared/src/taskGraph.test.ts b/packages/shared/src/taskGraph.test.ts
new file mode 100644
index 000000000000..f18beae633f6
--- /dev/null
+++ b/packages/shared/src/taskGraph.test.ts
@@ -0,0 +1,307 @@
+import type { TaskGraphNode, TaskGraphNodeInput } from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ applyTaskGraphEdits,
+ buildTaskGraphNodePrompt,
+ deriveTaskGraphStatus,
+ newTaskGraphNode,
+ readyTaskGraphNodes,
+ resumeTaskGraphNode,
+ skipUnreachableTaskGraphNodes,
+ taskGraphLayers,
+ taskGraphNodeOpensPullRequest,
+ taskGraphNodeSharesPullRequestBranch,
+ taskGraphPullRequestBase,
+ validateTaskGraphNodes,
+} from "./taskGraph.ts";
+
+const NOW = "2026-10-10T00:00:00.000Z";
+
+const input = (key: string, dependsOn: ReadonlyArray = []): TaskGraphNodeInput =>
+ ({ key, title: key, prompt: `do ${key}`, dependsOn }) as TaskGraphNodeInput;
+
+const graph = (...inputs: ReadonlyArray) => inputs.map(newTaskGraphNode);
+
+const withStatus = (
+ nodes: ReadonlyArray,
+ statuses: Record,
+) => nodes.map((node) => ({ ...node, status: statuses[node.key] ?? node.status }));
+
+// a -> (b, c) -> d: the fan-out and merge shape from the security audit example.
+const diamond = () =>
+ graph(input("a"), input("b", ["a"]), input("c", ["a"]), input("d", ["b", "c"]));
+
+describe("validateTaskGraphNodes", () => {
+ it("accepts a diamond", () => {
+ expect(validateTaskGraphNodes(diamond())).toBeNull();
+ });
+
+ it("rejects cycles, unknown dependencies and duplicate keys", () => {
+ expect(validateTaskGraphNodes(graph(input("a", ["b"]), input("b", ["a"])))).toMatch(/cycle/);
+ expect(validateTaskGraphNodes(graph(input("a", ["missing"])))).toMatch(/not in the graph/);
+ expect(validateTaskGraphNodes(graph(input("a"), input("a")))).toMatch(/used twice/);
+ });
+});
+
+describe("scheduling", () => {
+ it("releases a node only once every dependency succeeded", () => {
+ const nodes = diamond();
+ expect(readyTaskGraphNodes(nodes, NOW).map((node) => node.key)).toEqual(["a"]);
+ const afterB = withStatus(nodes, { a: "succeeded", b: "succeeded", c: "running" });
+ expect(readyTaskGraphNodes(afterB, NOW)).toEqual([]);
+ const afterC = withStatus(afterB, { c: "succeeded" });
+ expect(readyTaskGraphNodes(afterC, NOW).map((node) => node.key)).toEqual(["d"]);
+ });
+
+ it("skips everything below a failed node", () => {
+ const nodes = withStatus(diamond(), { a: "failed" });
+ const skipped = skipUnreachableTaskGraphNodes(nodes, NOW);
+ expect(skipped.map((node) => node.status)).toEqual(["failed", "skipped", "skipped", "skipped"]);
+ expect(deriveTaskGraphStatus("running", skipped)).toBe("failed");
+ });
+
+ it("keeps a draft a draft and derives the end state", () => {
+ expect(deriveTaskGraphStatus("draft", diamond())).toBe("draft");
+ expect(deriveTaskGraphStatus("running", diamond())).toBe("running");
+ const done = withStatus(diamond(), {
+ a: "succeeded",
+ b: "succeeded",
+ c: "succeeded",
+ d: "succeeded",
+ });
+ expect(deriveTaskGraphStatus("running", done)).toBe("succeeded");
+ });
+
+ it("keeps a cancelled graph cancelled even when a node failed", () => {
+ const ended = withStatus(diamond(), {
+ a: "succeeded",
+ b: "failed",
+ c: "cancelled",
+ d: "cancelled",
+ });
+ expect(deriveTaskGraphStatus("running", ended)).toBe("failed");
+ expect(deriveTaskGraphStatus("cancelled", ended)).toBe("cancelled");
+ });
+
+ it("opens pull requests from the ends of the tree by default", () => {
+ const nodes = diamond();
+ expect(nodes.map((node) => taskGraphNodeOpensPullRequest(nodes, node))).toEqual([
+ false,
+ false,
+ false,
+ true,
+ ]);
+ });
+
+ it("knows when a node continues a branch that already has a pull request", () => {
+ const nodes = graph(
+ { ...input("a"), pullRequest: true },
+ { ...input("a-tests", ["a"]), workspace: "dependency" },
+ { ...input("a-docs", ["a-tests"]), workspace: "dependency" },
+ input("b", ["a"]),
+ );
+ const shares = (key: string) =>
+ taskGraphNodeSharesPullRequestBranch(
+ nodes,
+ nodes.find((node) => node.key === key)!,
+ );
+ expect(["a", "a-tests", "a-docs", "b"].map(shares)).toEqual([false, true, true, false]);
+ });
+
+ it("lays nodes out by depth", () => {
+ expect(taskGraphLayers(diamond()).map((layer) => layer.map((node) => node.key))).toEqual([
+ ["a"],
+ ["b", "c"],
+ ["d"],
+ ]);
+ });
+});
+
+describe("applyTaskGraphEdits", () => {
+ it("cancels a branch and everything under it, leaving finished work alone", () => {
+ const nodes = withStatus(diamond(), { a: "succeeded", b: "running" });
+ const result = applyTaskGraphEdits(nodes, [{ type: "cancel_branch", key: "b" }], NOW);
+ expect(result.ok && result.nodes.map((node) => node.status)).toEqual([
+ "succeeded",
+ "cancelled",
+ "pending",
+ "cancelled",
+ ]);
+ });
+
+ it("retries a node and reopens what was skipped below it", () => {
+ const nodes = skipUnreachableTaskGraphNodes(withStatus(diamond(), { a: "failed" }), NOW);
+ const result = applyTaskGraphEdits(nodes, [{ type: "retry_node", key: "a" }], NOW);
+ expect(result.ok && result.nodes.every((node) => node.status === "pending")).toBe(true);
+ });
+
+ it("removes a pending node and its edges", () => {
+ const result = applyTaskGraphEdits(diamond(), [{ type: "remove_node", key: "c" }], NOW);
+ expect(result.ok && result.nodes.find((node) => node.key === "d")?.dependsOn).toEqual(["b"]);
+ });
+
+ it("refuses to edit a started node and applies nothing from a failing batch", () => {
+ const nodes = withStatus(diamond(), { a: "running" });
+ const result = applyTaskGraphEdits(
+ nodes,
+ [
+ { type: "add_node", node: input("e", ["d"]) },
+ { type: "update_node", key: "a", prompt: "changed" },
+ ],
+ NOW,
+ );
+ expect(result.ok).toBe(false);
+ });
+
+ it("rejects an edit that would create a cycle", () => {
+ const result = applyTaskGraphEdits(
+ diamond(),
+ [{ type: "update_node", key: "a", dependsOn: ["d"] }],
+ NOW,
+ );
+ expect(result).toMatchObject({ ok: false, error: expect.stringMatching(/cycle/) });
+ });
+});
+
+describe("buildTaskGraphNodePrompt", () => {
+ it("hands a merge node its dependencies' results and branches to merge", () => {
+ const nodes = diamond().map((node) =>
+ node.key === "b" || node.key === "c"
+ ? {
+ ...node,
+ status: "succeeded" as const,
+ branch: `t3/${node.key}`,
+ summary: `${node.key} done`,
+ }
+ : node,
+ );
+ const prompt = buildTaskGraphNodePrompt({ title: "Audit", nodes }, nodes[3]!);
+ expect(prompt).toContain("- t3/c (from 'c')");
+ expect(prompt).not.toContain("- t3/b");
+ expect(prompt).toContain("b done");
+ expect(prompt).toContain("do d");
+ });
+
+ it("never asks a project-folder node to merge branches", () => {
+ const nodes = diamond().map((node) =>
+ node.key === "b" || node.key === "c"
+ ? { ...node, status: "succeeded" as const, branch: `t3/${node.key}` }
+ : node.key === "d"
+ ? { ...node, workspace: "root" as const }
+ : node,
+ );
+ const prompt = buildTaskGraphNodePrompt({ title: "Audit", nodes }, nodes[3]!);
+ expect(prompt).not.toContain("git merge");
+ expect(prompt).toContain("read-only");
+ });
+});
+
+describe("resumeTaskGraphNode", () => {
+ it("runs a failed node again on its thread and reopens what was skipped below it", () => {
+ const failed = skipUnreachableTaskGraphNodes(
+ withStatus(diamond(), { a: "succeeded", b: "failed", c: "succeeded" }).map((node) =>
+ node.key === "b" ? { ...node, threadId: "thread-b" as never, error: "boom" } : node,
+ ),
+ NOW,
+ );
+ expect(failed.find((node) => node.key === "d")?.status).toBe("skipped");
+
+ const resumed = resumeTaskGraphNode(failed, "b")!;
+ const b = resumed.find((node) => node.key === "b")!;
+ expect([b.status, b.threadId, b.error]).toEqual(["running", "thread-b", null]);
+ expect(resumed.find((node) => node.key === "d")?.status).toBe("pending");
+ });
+
+ it("leaves nodes that did not fail alone", () => {
+ expect(resumeTaskGraphNode(withStatus(diamond(), { a: "succeeded" }), "a")).toBeNull();
+ });
+});
+
+describe("workspaces and pull request bases", () => {
+ // a -> b -> c, each on its own branch once it has run.
+ const chain = (overrides: Record> = {}) =>
+ graph(input("a"), input("b", ["a"]), input("c", ["b"])).map((node) => ({
+ ...node,
+ branch: `t3/${node.key}`,
+ ...overrides[node.key],
+ }));
+
+ it("points a lone branch-end PR at the graph base so it carries all the work", () => {
+ const nodes = chain();
+ expect(taskGraphPullRequestBase({ nodes, baseRef: "main" }, nodes[2]!)).toBe("main");
+ // A thread with no branch leaves the PR base to the repository default.
+ expect(taskGraphPullRequestBase({ nodes, baseRef: "HEAD" }, nodes[2]!)).toBeNull();
+ });
+
+ it("stacks PRs when inner nodes open their own", () => {
+ const nodes = chain({ a: { pullRequest: true }, b: { pullRequest: true } });
+ const base = (key: string) =>
+ taskGraphPullRequestBase(
+ { nodes, baseRef: "main" },
+ nodes.find((node) => node.key === key)!,
+ );
+ expect([base("a"), base("b"), base("c")]).toEqual(["main", "t3/a", "t3/b"]);
+ });
+
+ it("skips the nodes sharing a branch when finding the layer below", () => {
+ const nodes = chain({
+ a: { pullRequest: true },
+ c: { workspace: "dependency", branch: "t3/b" },
+ });
+ expect(taskGraphPullRequestBase({ nodes, baseRef: "main" }, nodes[2]!)).toBe("t3/a");
+ });
+
+ it("never opens a PR from the project folder", () => {
+ const nodes = graph({ ...input("review"), workspace: "root", pullRequest: true });
+ expect(taskGraphNodeOpensPullRequest(nodes, nodes[0]!)).toBe(false);
+ });
+
+ it("checks that a continued worktree exists and is continued only once", () => {
+ expect(validateTaskGraphNodes(graph({ ...input("a"), workspace: "dependency" }))).toMatch(
+ /depends on nothing/,
+ );
+ expect(
+ validateTaskGraphNodes(
+ graph(
+ input("a"),
+ { ...input("b", ["a"]), workspace: "dependency" },
+ { ...input("c", ["a"]), workspace: "dependency" },
+ ),
+ ),
+ ).toMatch(/Only one node/);
+ });
+});
+
+describe("waiting nodes", () => {
+ it("holds a node that has not started until its wait ends", () => {
+ const later = "2026-10-10T06:00:00.000Z";
+ const nodes = graph(input("a")).map((node) => ({
+ ...node,
+ status: "waiting" as const,
+ waitUntil: later,
+ waitReason: "scheduled" as const,
+ }));
+ expect(readyTaskGraphNodes(nodes, NOW)).toEqual([]);
+ expect(readyTaskGraphNodes(nodes, later).map((node) => node.key)).toEqual(["a"]);
+ });
+
+ it("lets a waiting node be edited, and starts the wait over", () => {
+ const nodes = graph(input("a")).map((node) => ({
+ ...node,
+ status: "waiting" as const,
+ waitUntil: NOW,
+ waitReason: "usage_limit" as const,
+ }));
+ const result = applyTaskGraphEdits(
+ nodes,
+ [{ type: "update_node", key: "a", startAt: "2026-10-11T00:00:00.000Z" }],
+ NOW,
+ );
+ expect(result.ok && result.nodes[0]).toMatchObject({
+ status: "pending",
+ waitUntil: null,
+ startAt: "2026-10-11T00:00:00.000Z",
+ });
+ });
+});
diff --git a/packages/shared/src/taskGraph.ts b/packages/shared/src/taskGraph.ts
new file mode 100644
index 000000000000..8cbf98736518
--- /dev/null
+++ b/packages/shared/src/taskGraph.ts
@@ -0,0 +1,469 @@
+import {
+ MAX_TASK_GRAPH_NODES,
+ type TaskGraph,
+ type TaskGraphEdit,
+ type TaskGraphNode,
+ type TaskGraphNodeInput,
+ type TaskGraphNodeStatus,
+ type TaskGraphStatus,
+} from "@t3tools/contracts";
+
+/**
+ * Pure task graph rules shared by the server, which owns graphs, and clients,
+ * which check an edit before sending it. Nothing here does I/O.
+ */
+
+type Nodes = ReadonlyArray;
+
+export type TaskGraphEditResult =
+ | { readonly ok: true; readonly nodes: Nodes }
+ | { readonly ok: false; readonly error: string };
+
+const TERMINAL_NODE_STATUSES: ReadonlySet = new Set([
+ "succeeded",
+ "failed",
+ "cancelled",
+ "skipped",
+]);
+
+export const isTerminalTaskGraphNodeStatus = (status: TaskGraphNodeStatus): boolean =>
+ TERMINAL_NODE_STATUSES.has(status);
+
+export const isActiveTaskGraphNodeStatus = (status: TaskGraphNodeStatus): boolean =>
+ status === "running" || status === "delivering";
+
+/** Not started yet: pending, or waiting for its start time or a reset before its first run. */
+export const isUnstartedTaskGraphNode = (node: Pick) =>
+ node.status === "pending" || (node.status === "waiting" && node.threadId === null);
+
+export function newTaskGraphNode(input: TaskGraphNodeInput): TaskGraphNode {
+ return {
+ key: input.key,
+ title: input.title,
+ prompt: input.prompt,
+ dependsOn: [...new Set(input.dependsOn)],
+ pullRequest: input.pullRequest ?? null,
+ modelSelection: input.modelSelection ?? null,
+ environmentId: input.environmentId ?? null,
+ workspace: input.workspace ?? "worktree",
+ startAt: input.startAt ?? null,
+ waitUntil: null,
+ waitReason: null,
+ status: "pending",
+ assignedEnvironmentId: null,
+ threadId: null,
+ branch: null,
+ worktreePath: null,
+ summary: null,
+ error: null,
+ pullRequestResult: null,
+ startedAt: null,
+ completedAt: null,
+ };
+}
+
+/** A reason the nodes do not form a valid graph, or null. */
+export function validateTaskGraphNodes(nodes: Nodes): string | null {
+ if (nodes.length === 0) return "A task graph needs at least one node.";
+ if (nodes.length > MAX_TASK_GRAPH_NODES) {
+ return `A task graph holds at most ${MAX_TASK_GRAPH_NODES} nodes.`;
+ }
+ const keys = new Set();
+ for (const node of nodes) {
+ if (keys.has(node.key)) return `Node key '${node.key}' is used twice.`;
+ keys.add(node.key);
+ }
+ for (const node of nodes) {
+ for (const dependency of node.dependsOn) {
+ if (dependency === node.key) return `Node '${node.key}' depends on itself.`;
+ if (!keys.has(dependency)) {
+ return `Node '${node.key}' depends on '${dependency}', which is not in the graph.`;
+ }
+ }
+ }
+ const continued = new Set();
+ for (const node of nodes) {
+ if (node.workspace !== "dependency") continue;
+ const [first] = node.dependsOn;
+ if (first === undefined) {
+ return `Node '${node.key}' continues its dependency's worktree but depends on nothing.`;
+ }
+ if (nodes.find((other) => other.key === first)?.workspace === "root") {
+ return `Node '${node.key}' cannot continue '${first}', which has no worktree.`;
+ }
+ if (continued.has(first)) {
+ return `Only one node can continue in '${first}'s worktree; give the others their own.`;
+ }
+ continued.add(first);
+ }
+ const cycle = findCycle(nodes);
+ return cycle === null ? null : `Dependencies form a cycle: ${cycle.join(" -> ")}.`;
+}
+
+function findCycle(nodes: Nodes): ReadonlyArray | null {
+ const byKey = new Map(nodes.map((node) => [node.key, node]));
+ const state = new Map();
+ const path: string[] = [];
+ const visit = (key: string): ReadonlyArray | null => {
+ const seen = state.get(key);
+ if (seen === "done") return null;
+ if (seen === "visiting") return [...path.slice(path.indexOf(key)), key];
+ state.set(key, "visiting");
+ path.push(key);
+ for (const dependency of byKey.get(key)?.dependsOn ?? []) {
+ const cycle = visit(dependency);
+ if (cycle !== null) return cycle;
+ }
+ path.pop();
+ state.set(key, "done");
+ return null;
+ };
+ for (const node of nodes) {
+ const cycle = visit(node.key);
+ if (cycle !== null) return cycle;
+ }
+ return null;
+}
+
+/** Keys of every node below `key`, not including `key`. */
+export function taskGraphDescendants(nodes: Nodes, key: string): ReadonlySet {
+ const found = new Set();
+ const queue = [key];
+ while (queue.length > 0) {
+ const current = queue.shift()!;
+ for (const node of nodes) {
+ if (node.dependsOn.includes(current) && !found.has(node.key)) {
+ found.add(node.key);
+ queue.push(node.key);
+ }
+ }
+ }
+ return found;
+}
+
+/** Whether the node opens a pull request when it succeeds. */
+export function taskGraphNodeOpensPullRequest(nodes: Nodes, node: TaskGraphNode): boolean {
+ if (node.workspace === "root") return false;
+ return node.pullRequest ?? !nodes.some((other) => other.dependsOn.includes(node.key));
+}
+
+/**
+ * Whether the node continues, through a chain of shared worktrees, a branch an
+ * earlier node opened a pull request from. Its commits must then be pushed, or
+ * that PR never gets them while later layers stacked on it do.
+ */
+export function taskGraphNodeSharesPullRequestBranch(nodes: Nodes, node: TaskGraphNode): boolean {
+ const byKey = new Map(nodes.map((candidate) => [candidate.key, candidate]));
+ const seen = new Set();
+ let current = node;
+ while (current.workspace === "dependency" && !seen.has(current.key)) {
+ seen.add(current.key);
+ const dependency = byKey.get(current.dependsOn[0] ?? "");
+ if (dependency === undefined) return false;
+ if (taskGraphNodeOpensPullRequest(nodes, dependency)) return true;
+ current = dependency;
+ }
+ return false;
+}
+
+/**
+ * The branch a node's pull request targets. Walking up first dependencies past
+ * the nodes sharing its branch, the first ancestor with a pull request of its
+ * own is the layer below it in a stack; with none, the PR carries everything
+ * since the graph's base. So branch ends alone give one PR each against the
+ * base, and PRs on inner nodes too give a stack. Null when that base is "HEAD"
+ * (a thread with no branch): it is no branch to target, so git picks the
+ * repository's default branch.
+ */
+export function taskGraphPullRequestBase(
+ graph: Pick,
+ node: TaskGraphNode,
+): string | null {
+ const byKey = new Map(graph.nodes.map((candidate) => [candidate.key, candidate]));
+ let sharesBranch = node.workspace === "dependency";
+ let current = byKey.get(node.dependsOn[0] ?? "");
+ const seen = new Set([node.key]);
+ while (current !== undefined && !seen.has(current.key)) {
+ seen.add(current.key);
+ if (
+ !sharesBranch &&
+ current.branch !== null &&
+ taskGraphNodeOpensPullRequest(graph.nodes, current)
+ ) {
+ return current.branch;
+ }
+ if (current.workspace === "root") sharesBranch = false;
+ else if (sharesBranch) sharesBranch = current.workspace === "dependency";
+ current = byKey.get(current.dependsOn[0] ?? "");
+ }
+ return graph.baseRef === "HEAD" ? null : graph.baseRef;
+}
+
+const reopened = (node: TaskGraphNode): TaskGraphNode => ({
+ ...node,
+ status: "pending",
+ waitUntil: null,
+ waitReason: null,
+ assignedEnvironmentId: null,
+ threadId: null,
+ branch: null,
+ worktreePath: null,
+ summary: null,
+ error: null,
+ pullRequestResult: null,
+ startedAt: null,
+ completedAt: null,
+});
+
+function applyEdit(nodes: Nodes, edit: TaskGraphEdit, now: string): TaskGraphEditResult {
+ const target = "key" in edit ? nodes.find((node) => node.key === edit.key) : undefined;
+ if (edit.type !== "add_node" && target === undefined) {
+ return { ok: false, error: `No node '${edit.key}' in the graph.` };
+ }
+ switch (edit.type) {
+ case "add_node":
+ return { ok: true, nodes: [...nodes, newTaskGraphNode(edit.node)] };
+ case "update_node": {
+ if (!isUnstartedTaskGraphNode(target!)) {
+ return { ok: false, error: `Node '${edit.key}' has started and can no longer be edited.` };
+ }
+ const { type: _type, key: _key, ...fields } = edit;
+ const updated: TaskGraphNode = {
+ ...target!,
+ ...Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined)),
+ ...(fields.dependsOn === undefined ? {} : { dependsOn: [...new Set(fields.dependsOn)] }),
+ // An edited node is weighed again from scratch, start time included.
+ status: "pending",
+ waitUntil: null,
+ waitReason: null,
+ };
+ return { ok: true, nodes: nodes.map((node) => (node.key === edit.key ? updated : node)) };
+ }
+ case "remove_node":
+ if (!isUnstartedTaskGraphNode(target!)) {
+ return {
+ ok: false,
+ error: `Node '${edit.key}' has started. Use cancel_branch to stop it instead.`,
+ };
+ }
+ return {
+ ok: true,
+ nodes: nodes
+ .filter((node) => node.key !== edit.key)
+ .map((node) =>
+ node.dependsOn.includes(edit.key)
+ ? { ...node, dependsOn: node.dependsOn.filter((key) => key !== edit.key) }
+ : node,
+ ),
+ };
+ case "cancel_branch": {
+ const cut = new Set(taskGraphDescendants(nodes, edit.key));
+ cut.add(edit.key);
+ return {
+ ok: true,
+ nodes: nodes.map((node) =>
+ cut.has(node.key) && !isTerminalTaskGraphNodeStatus(node.status)
+ ? { ...node, status: "cancelled", error: "Cancelled.", completedAt: now }
+ : node,
+ ),
+ };
+ }
+ case "retry_node": {
+ if (target!.status === "succeeded" || !isTerminalTaskGraphNodeStatus(target!.status)) {
+ return { ok: false, error: `Node '${edit.key}' has not failed or been cancelled.` };
+ }
+ const below = taskGraphDescendants(nodes, edit.key);
+ return {
+ ok: true,
+ nodes: nodes.map((node) =>
+ node.key === edit.key ||
+ (below.has(node.key) && (node.status === "skipped" || node.status === "cancelled"))
+ ? reopened(node)
+ : node,
+ ),
+ };
+ }
+ }
+}
+
+/**
+ * A failed or cancelled node whose thread started working again, because
+ * someone continued it there. The node runs again on the same thread, and what
+ * was skipped because of it waits for it again. Null when the node is not one
+ * that can resume.
+ */
+export function resumeTaskGraphNode(nodes: Nodes, key: string): Nodes | null {
+ const target = nodes.find((node) => node.key === key);
+ if (target === undefined || (target.status !== "failed" && target.status !== "cancelled")) {
+ return null;
+ }
+ const below = taskGraphDescendants(nodes, key);
+ return nodes.map((node) =>
+ node.key === key
+ ? { ...node, status: "running", error: null, pullRequestResult: null, completedAt: null }
+ : below.has(node.key) && node.status === "skipped"
+ ? reopened(node)
+ : node,
+ );
+}
+
+/** Applies edits in order. Either all apply and the result is a valid graph, or none do. */
+export function applyTaskGraphEdits(
+ nodes: Nodes,
+ edits: ReadonlyArray,
+ now: string,
+): TaskGraphEditResult {
+ let current = nodes;
+ for (const edit of edits) {
+ const result = applyEdit(current, edit, now);
+ if (!result.ok) return result;
+ current = result.nodes;
+ }
+ const error = validateTaskGraphNodes(current);
+ return error === null ? { ok: true, nodes: current } : { ok: false, error };
+}
+
+/**
+ * Marks pending nodes whose dependencies can no longer succeed as skipped,
+ * repeating until nothing changes so a skip carries all the way down.
+ */
+export function skipUnreachableTaskGraphNodes(nodes: Nodes, now: string): Nodes {
+ let current = nodes;
+ for (;;) {
+ const status = new Map(current.map((node) => [node.key, node.status]));
+ let changed = false;
+ current = current.map((node) => {
+ if (!isUnstartedTaskGraphNode(node)) return node;
+ const blocked = node.dependsOn.find((key) => {
+ const dependency = status.get(key);
+ return dependency === "failed" || dependency === "cancelled" || dependency === "skipped";
+ });
+ if (blocked === undefined) return node;
+ changed = true;
+ return {
+ ...node,
+ status: "skipped",
+ error: `Skipped because '${blocked}' did not succeed.`,
+ completedAt: now,
+ };
+ });
+ if (!changed) return current;
+ }
+}
+
+/**
+ * Nodes that could start now: not started, every dependency succeeded, and
+ * any wait over. A pending node with a future `startAt` is still returned; the
+ * caller moves it to `waiting` until then.
+ */
+export function readyTaskGraphNodes(nodes: Nodes, now: string): ReadonlyArray {
+ const succeeded = new Set(
+ nodes.filter((node) => node.status === "succeeded").map((node) => node.key),
+ );
+ const nowMs = Date.parse(now);
+ return nodes.filter(
+ (node) =>
+ isUnstartedTaskGraphNode(node) &&
+ (node.status === "pending" ||
+ node.waitUntil === null ||
+ Date.parse(node.waitUntil) <= nowMs) &&
+ node.dependsOn.every((key) => succeeded.has(key)),
+ );
+}
+
+/** The graph status its nodes imply. A draft stays a draft until it is run. */
+export function deriveTaskGraphStatus(current: TaskGraphStatus, nodes: Nodes): TaskGraphStatus {
+ // Cancelling is final, even with failed nodes, so nothing continued later reopens the graph.
+ if (current === "draft" || current === "cancelled") return current;
+ if (nodes.some((node) => !isTerminalTaskGraphNodeStatus(node.status))) return "running";
+ if (nodes.some((node) => node.status === "failed")) return "failed";
+ if (nodes.every((node) => node.status === "succeeded")) return "succeeded";
+ return "cancelled";
+}
+
+/** Nodes grouped by depth: layer 0 has no dependencies, layer n depends on layer n-1 at most. */
+export function taskGraphLayers(nodes: Nodes): ReadonlyArray> {
+ const byKey = new Map(nodes.map((node) => [node.key, node]));
+ const depth = new Map();
+ const depthOf = (node: TaskGraphNode, guard: number): number => {
+ const known = depth.get(node.key);
+ if (known !== undefined) return known;
+ if (guard > nodes.length) return 0;
+ const value = Math.max(
+ -1,
+ ...node.dependsOn.flatMap((key) => {
+ const dependency = byKey.get(key);
+ return dependency === undefined ? [] : [depthOf(dependency, guard + 1)];
+ }),
+ );
+ depth.set(node.key, value + 1);
+ return value + 1;
+ };
+ const layers: TaskGraphNode[][] = [];
+ for (const node of nodes) {
+ const index = depthOf(node, 0);
+ (layers[index] ??= []).push(node);
+ }
+ return layers.filter((layer) => layer !== undefined);
+}
+
+const SUMMARY_LIMIT = 4_000;
+
+/** Trims a node's last reply to what its dependents are handed. */
+export function taskGraphNodeSummary(text: string): string {
+ const trimmed = text.trim();
+ return trimmed.length <= SUMMARY_LIMIT ? trimmed : `…${trimmed.slice(-SUMMARY_LIMIT)}`;
+}
+
+/**
+ * The message a node's thread starts with: where it sits in the graph, what
+ * its dependencies reported, and which branches to merge before working.
+ */
+export function buildTaskGraphNodePrompt(
+ graph: Pick,
+ node: TaskGraphNode,
+): string {
+ const dependencies = node.dependsOn.flatMap((key) => {
+ const dependency = graph.nodes.find((candidate) => candidate.key === key);
+ return dependency === undefined ? [] : [dependency];
+ });
+ const sections = [
+ `You are running node '${node.key}' of the task graph "${graph.title}". Other agents run the other nodes in their own worktrees.`,
+ ];
+ const [, ...toMerge] = dependencies;
+ // A project-folder node is read-only, so it never merges branches into the shared checkout.
+ if (toMerge.length > 0 && node.workspace !== "root") {
+ const branches = toMerge.flatMap((dependency) =>
+ dependency.branch === null ? [] : [`- ${dependency.branch} (from '${dependency.key}')`],
+ );
+ if (branches.length > 0) {
+ sections.push(
+ [
+ "Your worktree starts from the first dependency's branch. Before anything else, merge these branches into it with `git merge` (if one is missing locally, `git fetch origin ` and merge `origin/`), resolve any conflicts so both sides' intent survives, and commit the merge:",
+ ...branches,
+ ].join("\n"),
+ );
+ }
+ }
+ for (const dependency of dependencies) {
+ if (dependency.summary === null) continue;
+ sections.push(
+ `Result of '${dependency.key}' (${dependency.title}):\n\n${dependency.summary}\n `,
+ );
+ }
+ if (node.workspace === "dependency" && dependencies[0] !== undefined) {
+ sections.push(
+ `You continue in '${dependencies[0].key}'s worktree, on its branch; its work is already there. Build on it rather than starting over.`,
+ );
+ }
+ if (node.workspace === "root") {
+ sections.push(
+ "You work in the project folder itself, shared with other work, not in a worktree of your own. Treat it as read-only: nothing you change is committed, and other agents may be reading it.",
+ );
+ }
+ sections.push(`Your task:\n${node.prompt}`);
+ sections.push(
+ "Finish with a short summary of what you changed and anything a later step must know. Do not open a pull request yourself; T3 Code handles that when the graph calls for one.",
+ );
+ return sections.join("\n\n");
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7e6efe075030..a1b992722228 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -754,6 +754,9 @@ importers:
'@clerk/react':
specifier: 6.16.1
version: 6.16.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@dagrejs/dagre':
+ specifier: ^3.1.1
+ version: 3.1.1
'@daypicker/react':
specifier: ^10.0.1
version: 10.0.1(@types/react@19.3.0)(react@19.2.6)
@@ -859,6 +862,9 @@ importers:
'@tiptap/starter-kit':
specifier: ^3.31.3
version: 3.31.3
+ '@xyflow/react':
+ specifier: ^12.12.0
+ version: 12.12.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -2938,6 +2944,12 @@ packages:
resolution: {integrity: sha512-CJQR4ocRFm74N1miSqSSrhWyEbvTRIJGyMfHQvZlcBIYzLMXCVPmb1bV82L4NJ8mG9TyvbckEjERjbnzpG5akQ==}
engines: {node: '>=22.13'}
+ '@dagrejs/dagre@3.1.1':
+ resolution: {integrity: sha512-zroZB1dFOFiGgv4Xcrn1DckB1o4aOikPqD2NDQPV0WM//CXGcS6xiD0rNkqHmw6FEg4tabt4nxPLwgCWT+Vb2A==}
+
+ '@dagrejs/graphlib@4.0.5':
+ resolution: {integrity: sha512-7xrBTqIts3o+PMUZX97wSc+7TUbW+/rULzGNCTP6yooNVDXbzw4Wutg/H/xOutTB/c/k0YqOAavgPh4/Zk9PFA==}
+
'@date-fns/tz@1.5.0':
resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==}
@@ -6791,6 +6803,22 @@ packages:
resolution: {integrity: sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==}
engines: {node: '>=14.6'}
+ '@xyflow/react@12.12.0':
+ resolution: {integrity: sha512-74oXI0Rgm1Eu33cHQAd74J6oVgCH9mbFyTVQbsGgp2kmWwh0HdtBr2VP04z8mbPD+M/xO69nqG5dDEt7/EN2rQ==}
+ peerDependencies:
+ '@types/react': '>=17'
+ '@types/react-dom': '>=17'
+ react: '>=17'
+ react-dom: '>=17'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@xyflow/system@0.0.83':
+ resolution: {integrity: sha512-a//eDjez3WZF47aaPTfVUDD0OINOq+WVwBZCRTqG+m0a5euX8EwlJ4jMY1Wxz0Ocj4xOeekJ70V22Pq0ll8RJw==}
+
'@yuku-codegen/binding-android-arm64@0.9.5':
resolution: {integrity: sha512-jrOY5WM+AaAqkv51fHP1x28ifto4WgcZVzodLUyMU1jMWn5Sq+VciRdk/n8E0Ey5w4p4cWWE+m5GfXWOYh7Kzw==}
cpu: [arm64]
@@ -7550,6 +7578,9 @@ packages:
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
+ classcat@5.0.5:
+ resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
+
cli-cursor@2.1.0:
resolution: {integrity: sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==}
engines: {node: '>=4'}
@@ -12348,6 +12379,21 @@ packages:
zod@4.6.5:
resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==}
+ zustand@4.5.7:
+ resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
+ engines: {node: '>=12.7.0'}
+ peerDependencies:
+ '@types/react': '>=16.8'
+ immer: '>=9.0.6'
+ react: '>=16.8'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ immer:
+ optional: true
+ react:
+ optional: true
+
zustand@5.0.14:
resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==}
engines: {node: '>=12.20.0'}
@@ -13692,6 +13738,12 @@ snapshots:
'@cursor/sdk-linux-x64': 1.0.35
'@cursor/sdk-win32-x64': 1.0.35
+ '@dagrejs/dagre@3.1.1':
+ dependencies:
+ '@dagrejs/graphlib': 4.0.5
+
+ '@dagrejs/graphlib@4.0.5': {}
+
'@date-fns/tz@1.5.0': {}
'@daypicker/react@10.0.1(@types/react@19.3.0)(react@19.2.6)':
@@ -17363,6 +17415,31 @@ snapshots:
'@xmldom/xmldom@0.9.12': {}
+ '@xyflow/react@12.12.0(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
+ dependencies:
+ '@xyflow/system': 0.0.83
+ classcat: 5.0.5
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+ zustand: 4.5.7(@types/react@19.3.0)(react@19.2.6)
+ optionalDependencies:
+ '@types/react': 19.3.0
+ '@types/react-dom': 19.2.3(@types/react@19.3.0)
+ transitivePeerDependencies:
+ - immer
+
+ '@xyflow/system@0.0.83':
+ dependencies:
+ '@types/d3-drag': 3.0.7
+ '@types/d3-interpolate': 3.0.4
+ '@types/d3-selection': 3.0.12
+ '@types/d3-transition': 3.0.9
+ '@types/d3-zoom': 3.0.9
+ d3-drag: 3.0.0
+ d3-interpolate: 3.0.1
+ d3-selection: 3.0.0
+ d3-zoom: 3.0.0
+
'@yuku-codegen/binding-android-arm64@0.9.5':
optional: true
@@ -18149,6 +18226,8 @@ snapshots:
dependencies:
clsx: 2.1.1
+ classcat@5.0.5: {}
+
cli-cursor@2.1.0:
dependencies:
restore-cursor: 2.0.0
@@ -23793,6 +23872,13 @@ snapshots:
zod@4.6.5: {}
+ zustand@4.5.7(@types/react@19.3.0)(react@19.2.6):
+ dependencies:
+ use-sync-external-store: 1.6.0(react@19.2.6)
+ optionalDependencies:
+ '@types/react': 19.3.0
+ react: 19.2.6
+
zustand@5.0.14(@types/react@19.3.0)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)):
optionalDependencies:
'@types/react': 19.3.0
diff --git a/vite.config.ts b/vite.config.ts
index 2020589ec0a9..8c7de820ff03 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -385,6 +385,7 @@ export default defineConfig({
"apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx",
"apps/mobile/src/features/threads/ThreadQueueControl.tsx",
"apps/mobile/src/features/threads/ThreadAgentsSheet.tsx",
+ "apps/mobile/src/features/threads/TaskGraphSheet.tsx",
"apps/mobile/src/features/review/ReviewCommentCard.tsx",
"apps/mobile/src/features/threads/ThreadSettingsSheet.tsx",
"apps/mobile/src/features/threads/git/GitOverviewSheet.tsx",