diff --git a/.agents/skills/ship/SKILL.md b/.agents/skills/ship/SKILL.md index 9a9a7ec82..4492dfb56 100644 --- a/.agents/skills/ship/SKILL.md +++ b/.agents/skills/ship/SKILL.md @@ -67,6 +67,23 @@ only user-visible output is the per-iteration status line and the final summary. ## ADE deltas to the playbook +**Every push restarts the review bots — Greptile especially.** Pushing a new +commit re-triggers Greptile and Codex from scratch; an in-progress Greptile +review (`Greptile Review` status stuck `pending`/`IN_PROGRESS`, often 15-25 min) +is *cancelled and restarted* by the next push, so a rapid fix-every-iteration +cadence means Greptile never actually lands a re-review. Consequences: +- **Batch all fixes for an iteration into ONE push**, then genuinely wait for + Greptile to reach a terminal state before pushing again. Do not push a follow-up + while its status check is still `pending` — you'll just reset its ~20-min clock. +- Codex re-reviews fast (~3-5 min) and tends to surface the *next* instance of a + bug class each round (e.g. you pinned 2 of 4 cleanups → it flags the other 2). + **Sweep the whole class in one iteration** (every cleanup pinned, every mutating + call guarded) so you don't trade N fast Codex rounds for N Greptile restarts. +- When deciding to merge: a perpetually-restarted Greptile that never completed + on the latest commit is not a "still reviewing" signal to wait on forever — it's + a signal you pushed too often. Once Codex is clean and CI is green on a commit + you have NOT pushed over, let Greptile finish that commit, then merge. + **Rebase only on real conflicts.** `behindMain` alone does NOT trigger a rebase. Only rebase/merge `main` when there is an actual conflict (`mergeStateStatus` shows the PR is dirty/conflicting). If the branch is merely behind but cleanly diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 4a39bd6dc..b95bc571e 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -1163,7 +1163,7 @@ declare global { reparent: (args: ReparentLaneArgs) => Promise; updateAppearance: (args: UpdateLaneAppearanceArgs) => Promise; archive: (args: ArchiveLaneArgs) => Promise; - delete: (args: DeleteLaneArgs) => Promise; + delete: (args: DeleteLaneArgs, pin?: OpenProjectBinding | null) => Promise; cancelDelete: (args: { laneId: string; }) => Promise<{ cancelled: boolean; reason?: string }>; @@ -1323,7 +1323,7 @@ declare global { modelCatalog: (args?: AgentChatModelCatalogArgs) => Promise; archive: (args: AgentChatArchiveArgs) => Promise; unarchive: (args: AgentChatArchiveArgs) => Promise; - delete: (args: AgentChatDeleteArgs) => Promise; + delete: (args: AgentChatDeleteArgs, pin?: OpenProjectBinding | null) => Promise; updateSession: ( args: AgentChatUpdateSessionArgs, ) => Promise; @@ -1616,7 +1616,7 @@ declare global { cols: number; rows: number; }) => Promise; - dispose: (args: { ptyId: string; sessionId?: string }) => Promise; + dispose: (args: { ptyId: string; sessionId?: string }, pin?: OpenProjectBinding | null) => Promise; setDataSubscriptions: (args: { ptyIds: string[] }) => Promise; onData: (cb: (ev: PtyDataEvent) => void) => () => void; onExit: (cb: (ev: PtyExitEvent) => void) => () => void; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 988d3a0a9..6bfd221e2 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1345,6 +1345,37 @@ async function callProjectRuntimeActionStrictOr( return localRuntime.handled ? localRuntime.result : local(); } +// Route a runtime action to an EXPLICIT project binding, bypassing the mutable +// module-level `currentProjectBinding` and the project-transition guard. The +// target runtime is addressed directly by id/projectId (remote) or rootPath +// (local), exactly as the bound helpers do — the only difference is the binding +// is supplied by the caller instead of resolved from global state. Used to pin +// in-flight work (e.g. draft-launch rollback) to the project that started it so +// a concurrent project switch cannot misroute the call to the now-active +// project. Callers only pass a pin for explicitly-targeted, intentional work, +// so the transition guard (which protects the ambiguous *active* binding) does +// not apply. +async function callPinnedRuntimeAction( + pin: OpenProjectBinding, + domain: string, + action: string, + request: Omit = {}, +): Promise { + if (pin.kind === "remote") { + const response = (await ipcRenderer.invoke(IPC.remoteRuntimeCallAction, { + id: pin.targetId, + projectId: pin.projectId, + request: { domain, action, ...request }, + })) as RemoteRuntimeActionResult; + return response.result as T; + } + const response = (await ipcRenderer.invoke(IPC.localRuntimeCallAction, { + rootPath: pin.rootPath, + request: { domain, action, ...request }, + })) as RemoteRuntimeActionResult; + return response.result as T; +} + function callPrReadRuntimeActionOr( action: string, request: Omit, @@ -4424,11 +4455,15 @@ contextBridge.exposeInMainWorld("ade", { ); clearGitReadCaches(); }, - delete: async (args: DeleteLaneArgs): Promise => { + delete: async (args: DeleteLaneArgs, pin?: OpenProjectBinding | null): Promise => { clearGitReadCaches(); - await callProjectRuntimeActionOr("lane", "delete", { args }, () => - ipcRenderer.invoke(IPC.lanesDelete, args), - ); + if (pin) { + await callPinnedRuntimeAction(pin, "lane", "delete", { args }); + } else { + await callProjectRuntimeActionOr("lane", "delete", { args }, () => + ipcRenderer.invoke(IPC.lanesDelete, args), + ); + } clearGitReadCaches(); }, cancelDelete: async (args: { @@ -5208,14 +5243,18 @@ contextBridge.exposeInMainWorld("ade", { await ipcRenderer.invoke(IPC.agentChatUnarchive, args); agentChatSummaryCache.clear(); }, - delete: async (args: AgentChatDeleteArgs): Promise => { + delete: async (args: AgentChatDeleteArgs, pin?: OpenProjectBinding | null): Promise => { agentChatSummaryCache.clear(); - const runtime = await callProjectRuntimeActionIfBound( - "chat", - "deleteSession", - { args }, - ); - if (!runtime.handled) await ipcRenderer.invoke(IPC.agentChatDelete, args); + if (pin) { + await callPinnedRuntimeAction(pin, "chat", "deleteSession", { args }); + } else { + const runtime = await callProjectRuntimeActionIfBound( + "chat", + "deleteSession", + { args }, + ); + if (!runtime.handled) await ipcRenderer.invoke(IPC.agentChatDelete, args); + } agentChatSummaryCache.clear(); }, updateSession: async ( @@ -6113,7 +6152,10 @@ contextBridge.exposeInMainWorld("ade", { dispose: async (arg: { ptyId: string; sessionId?: string; - }): Promise => { + }, pin?: OpenProjectBinding | null): Promise => { + if (pin) { + return callPinnedRuntimeAction(pin, "pty", "dispose", { args: arg }); + } const runtime = await callProjectRuntimeActionIfBound( "pty", "dispose", diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 501436083..5f30efa0a 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -3654,7 +3654,9 @@ describe("AgentChatPane submit recovery", () => { }); }); expect(send).not.toHaveBeenCalled(); - expect(deleteChat).toHaveBeenCalledWith({ sessionId: "created-session" }); + // Orchestrator lead rollback is pinned to the originating project's binding + // (null in the default test store). + expect(deleteChat).toHaveBeenCalledWith({ sessionId: "created-session" }, null); expect(await screen.findByText("Orchestration bundle could not be allocated: disk full")).toBeTruthy(); expect(warnSpy).toHaveBeenCalled(); } finally { @@ -3744,8 +3746,10 @@ describe("AgentChatPane submit recovery", () => { sessionId: "created-session", text: "This first send will fail.", })); - expect(deleteChat).toHaveBeenCalledWith({ sessionId: "created-session" }); - expect(deleteLane).toHaveBeenCalledWith({ laneId: "lane-created", force: true }); + // Rollback is pinned to the originating project's binding (null here in + // the default test store) so a concurrent project switch can't misroute it. + expect(deleteChat).toHaveBeenCalledWith({ sessionId: "created-session" }, null); + expect(deleteLane).toHaveBeenCalledWith({ laneId: "lane-created", force: true }, null); expect(onSessionCreated).not.toHaveBeenCalled(); }); }); @@ -3825,13 +3829,122 @@ describe("AgentChatPane submit recovery", () => { fireEvent.click(await screen.findByRole("button", { name: "Send" })); await waitFor(() => { - expect(deleteLane).toHaveBeenCalledWith({ laneId: "lane-created", force: true }); + expect(deleteLane).toHaveBeenCalledWith({ laneId: "lane-created", force: true }, null); expect(deleteChat).not.toHaveBeenCalled(); expect(send).not.toHaveBeenCalled(); expect(onSessionCreated).not.toHaveBeenCalled(); }); }); + it("aborts an auto-create launch before creating a lane when the project changes mid-naming", async () => { + const { createLane, suggestLaneName, deleteLane } = installAdeMocks({ sessions: [] }); + let resolveName!: (name: string) => void; + suggestLaneName.mockImplementation(() => new Promise((resolve) => { + resolveName = resolve; + })); + // The originating project's binding is captured when the launch starts. + useAppStore.setState({ + projectBinding: { + kind: "local", + key: "local:/tmp/project-under-test", + rootPath: "/tmp/project-under-test", + displayName: "project-under-test", + } as any, + }); + + renderAutoCreateDraftPane(); + + const modelTrigger = await screen.findByRole("button", { name: /^Select model/ }); + const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; + fireEvent.pointerDown(modelTrigger, { button: 0 }); + fireEvent.click(modelTrigger); + fireEvent.click(await screen.findByRole("tab", { name: /^OpenAI$/i })); + await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); + + fireEvent.click(await screen.findByRole("button", { name: "Select lane" })); + fireEvent.click(await screen.findByRole("button", { name: /Auto-create lane/i })); + + const textbox = await screen.findByRole("textbox"); + fireEvent.change(textbox, { target: { value: "Switch projects mid-launch." } }); + fireEvent.click(await screen.findByRole("button", { name: "Send" })); + + await waitFor(() => expect(suggestLaneName).toHaveBeenCalledTimes(1)); + + // Switch the active project to a different one, then let lane naming resolve. + // selectActiveProjectRoot reads project.rootPath for local bindings, so the + // scope key (and thus the status banner) stays addressable while only the + // binding key drifts. + await act(async () => { + useAppStore.setState({ + projectBinding: { + kind: "local", + key: "local:/tmp/other-project", + rootPath: "/tmp/other-project", + displayName: "other-project", + } as any, + }); + resolveName("would-be-lane"); + await Promise.resolve(); + }); + + // After the switch the pane renders the new project's scope, so assert the + // failed job directly in the (root) store, under the originating scope. + await waitFor(() => { + const jobs = Object.values(useAppStore.getState().draftLaunchJobsByScope).flat(); + const failed = jobs.find((job) => job.status === "failed"); + expect(failed?.error).toMatch(/Project changed/i); + }); + // The guard fired before the irreversible mutation: no lane was created in + // the now-active project, and there was nothing to roll back. + expect(createLane).not.toHaveBeenCalled(); + expect(deleteLane).not.toHaveBeenCalled(); + }); + + it("pins the rollback delete to the originating project's binding", async () => { + const binding = { + kind: "local" as const, + key: "local:/tmp/project-under-test", + rootPath: "/tmp/project-under-test", + displayName: "project-under-test", + }; + const { createLane, suggestLaneName, deleteLane } = installAdeMocks({ + sessions: [], + sendError: new Error("send failed"), + }); + suggestLaneName.mockResolvedValue("pinned-rollback-lane"); + createLane.mockResolvedValue({ + id: "lane-created", + name: "pinned-rollback-lane", + laneType: "worktree", + branchRef: "refs/heads/pinned-rollback-lane", + worktreePath: "/tmp/project-under-test/pinned-rollback-lane", + parentLaneId: "lane-primary", + }); + useAppStore.setState({ projectBinding: binding as any }); + + renderAutoCreateDraftPane(); + + const modelTrigger = await screen.findByRole("button", { name: /^Select model/ }); + const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; + fireEvent.pointerDown(modelTrigger, { button: 0 }); + fireEvent.click(modelTrigger); + fireEvent.click(await screen.findByRole("tab", { name: /^OpenAI$/i })); + await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); + + fireEvent.click(await screen.findByRole("button", { name: "Select lane" })); + fireEvent.click(await screen.findByRole("button", { name: /Auto-create lane/i })); + + const textbox = await screen.findByRole("textbox"); + fireEvent.change(textbox, { target: { value: "Roll back to the right project." } }); + fireEvent.click(await screen.findByRole("button", { name: "Send" })); + + await waitFor(() => { + // Same project throughout, so the lane is created, the send fails, and the + // rollback is routed at the captured binding (not the global one). + expect(deleteLane).toHaveBeenCalledWith({ laneId: "lane-created", force: true }, binding); + }); + }); + it("restores the Work draft bucket after remount with text, model, and attachment refs", async () => { installAdeMocks({ sessions: [] }); const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index bef730524..29dae6a81 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -42,6 +42,7 @@ import { type IosSimulatorDrawerMode, type LaneLinearIssue, type AiSettingsStatus, + type OpenProjectBinding, type TerminalSessionDetail, type TerminalToolType, } from "../../../shared/types"; @@ -125,7 +126,7 @@ import { ConfirmDialog, useConfirmDialog } from "../shared/InlineDialogs"; import { ChatActionsDrawerPanel, type ChatActionsTab } from "./ChatActionsDrawerPanel"; import { CodexPlanCard } from "./codex/CodexPlanCard"; import { ChatPrPane } from "./ChatPrPane"; -import { selectActiveProjectRoot, useAppStore } from "../../state/appStore"; +import { rootAppStoreApi, selectActiveProjectRoot, useAppStore, useRootAppStore } from "../../state/appStore"; import { buildChatAppearanceRootStyle } from "./chatAppearance"; import { copyLaunchPromptToClipboard } from "../../lib/launchPromptClipboard"; import { LaneAccentDot } from "../lanes/LaneAccentDot"; @@ -157,6 +158,8 @@ import { isDraftLaunchJobStale, isDraftLaunchJobTerminal, pruneDraftLaunchJobs, + withDraftLaunchTimeout, + LAUNCH_PROJECT_CHANGED_MESSAGE, type BackgroundLaunchNotice, type DraftLaunchJob, type DraftLaunchKind, @@ -2847,6 +2850,9 @@ export function AgentChatPane({ const projectRoot = useAppStore(selectActiveProjectRoot); const projectTransition = useAppStore((s) => s.projectTransition); const isRemoteProject = useAppStore((s) => s.projectBinding?.kind === "remote"); + // The originating project's binding, captured per launch to pin cleanup and + // detect a project switch mid-launch (see LAUNCH_PROJECT_CHANGED_MESSAGE). + const projectBinding = useAppStore((s) => s.projectBinding); const agentTurnCompletionSound = useAppStore((s) => s.agentTurnCompletionSound); const agentTurnCompletionSoundVolume = useAppStore((s) => s.agentTurnCompletionSoundVolume); const agentTurnCompletionSoundQuietWhenFocused = useAppStore((s) => s.agentTurnCompletionSoundQuietWhenFocused); @@ -2924,20 +2930,33 @@ export function AgentChatPane({ const draftLaunchJobsScopeKey = useMemo( () => [ "draft-launch-jobs", - projectRoot?.trim() || "project", + // Partition by the project BINDING, not just projectRoot: jobs now live in + // the shared root store, and two remote targets can have the same rootPath + // (e.g. /home/user/project on two machines). Without the binding key, + // switching remote A → B could surface A's ready/failed job against B. + projectBinding?.key ?? (projectRoot?.trim() || "project"), laneId ?? "no-lane", surfaceProfile, workDraftStorageKind, ].map(encodeURIComponent).join(":"), - [laneId, projectRoot, surfaceProfile, workDraftStorageKind], + [laneId, projectBinding?.key, projectRoot, surfaceProfile, workDraftStorageKind], ); - const draftLaunchJobs = useAppStore((s) => s.draftLaunchJobsByScope[draftLaunchJobsScopeKey] ?? EMPTY_DRAFT_LAUNCH_JOBS); - const setDraftLaunchJobsInStore = useAppStore((s) => s.setDraftLaunchJobs); + // Draft-launch job state lives in the ROOT store, not the project-scoped + // store. A launch can outlive the pane (and its project surface) that started + // it: switching to another remote project tears down the originating + // project's scoped store entirely (App.tsx mounts only the active remote + // surface), which would otherwise drop the in-flight job with no trace. The + // root store survives that teardown, so the job re-surfaces (and ready jobs + // auto-open / failures show a Restore) when the user returns. The scope key + // is already projectRoot-keyed, so jobs stay correctly partitioned per + // project. Reads (useRootAppStore / rootAppStoreApi.getState) and writes + // (rootAppStoreApi.getState().setDraftLaunchJobs) both target the root store. + const draftLaunchJobs = useRootAppStore((s) => s.draftLaunchJobsByScope[draftLaunchJobsScopeKey] ?? EMPTY_DRAFT_LAUNCH_JOBS); const setDraftLaunchJobs = useCallback(( next: DraftLaunchJob[] | ((prev: DraftLaunchJob[]) => DraftLaunchJob[]), ) => { - setDraftLaunchJobsInStore(draftLaunchJobsScopeKey, next); - }, [draftLaunchJobsScopeKey, setDraftLaunchJobsInStore]); + rootAppStoreApi.getState().setDraftLaunchJobs(draftLaunchJobsScopeKey, next); + }, [draftLaunchJobsScopeKey]); const hasActiveDraftLaunchJobs = useMemo( () => draftLaunchJobs.some((job) => !isDraftLaunchJobTerminal(job.status)), [draftLaunchJobs], @@ -6524,6 +6543,12 @@ export function AgentChatPane({ notify?: boolean; notifyOptions?: AgentChatSessionCreatedOptions; launchState?: DraftLaunchSnapshot; + // Draft launches pass a guard that throws if the originating project + // changed (or the launch timed out); checked before the inner + // orchestration mutation so a bundle is never allocated in the wrong project. + assertActive?: () => void; + // Originating project binding, used to pin the orchestrator lead rollback. + pin?: OpenProjectBinding | null; } = {}, ): Promise => { if (constrainedModelSelectionError) { @@ -6576,6 +6601,7 @@ export function AgentChatPane({ // first prompt so a half-created lead chat cannot start working. if (orchestratorEnabled) { try { + options.assertActive?.(); const runCreate = await window.ade.orchestration.runCreate({ laneId: targetLaneId, leadSessionId: created.id, @@ -6592,7 +6618,7 @@ export function AgentChatPane({ "[AgentChatPane] orchestration.runCreate failed; lead chat created without bundle", runCreateError, ); - await window.ade.agentChat.delete({ sessionId: created.id }).catch((cleanupError: unknown) => { + await window.ade.agentChat.delete({ sessionId: created.id }, options.pin).catch((cleanupError: unknown) => { console.warn("[AgentChatPane] orchestration lead cleanup failed", cleanupError); }); const message = runCreateError instanceof Error @@ -6791,7 +6817,8 @@ export function AgentChatPane({ }, [setDraftLaunchJobs]); const draftLaunchJobExists = useCallback((jobId: string) => { - return (useAppStore.getState().draftLaunchJobsByScope[draftLaunchJobsScopeKey] ?? EMPTY_DRAFT_LAUNCH_JOBS) + // Read from the ROOT store, matching where setDraftLaunchJobs writes. + return (rootAppStoreApi.getState().draftLaunchJobsByScope[draftLaunchJobsScopeKey] ?? EMPTY_DRAFT_LAUNCH_JOBS) .some((job) => job.id === jobId); }, [draftLaunchJobsScopeKey]); @@ -6870,6 +6897,8 @@ export function AgentChatPane({ onAutoCreateNameResolved?: () => void, onAutoCreateNameFallback?: (message: string) => void, onAutoCreateNameModelResolved?: (modelId: string) => void, + assertActive?: () => void, + pin?: OpenProjectBinding | null, ): Promise => { if (draftLaunchTargetIsAutoCreate) { if (!laneId) throw new Error("Select a lane before auto-creating a new lane."); @@ -6894,6 +6923,10 @@ export function AgentChatPane({ onFallback: onAutoCreateNameFallback, }); onAutoCreateNameResolved?.(); + // Guard before branch discovery too: git.fetch/listBranches run against the + // primary lane through the active binding, so a switch during naming must + // abort here rather than fetch/list refs in the now-active project. + assertActive?.(); const baseSource = effectiveNewLaneBaseSource(projectConfigSnapshot); const branches = await fetchNewLaneBaseBranches({ source: baseSource, @@ -6908,10 +6941,27 @@ export function AgentChatPane({ primaryBaseRef, }); const baseBranch = selectedBaseBranch; + // Last checkpoint before the irreversible mutation: if the user switched + // projects while the lane name was being generated, abort rather than + // create a lane in the wrong project. + assertActive?.(); const createdLane = await window.ade.lanes.create({ name: laneName, ...(baseBranch ? { baseBranch } : {}), }); + // lanes.create is not cancellable, so if the launch was aborted while it + // was in flight (timeout, or a project switch), the outer wait has already + // rejected with targetLane === null and will not roll this lane back. + // Clean it up here, pinned to the originating project, before returning. + try { + assertActive?.(); + } catch (abortError) { + await window.ade.lanes.delete({ laneId: createdLane.id, force: true }, pin).catch((cleanupError: unknown) => { + console.warn("draft launch lane cleanup after abort failed", cleanupError); + }); + await refreshLanesStore().catch(() => undefined); + throw abortError; + } await refreshLanesStore().catch((refreshError: unknown) => { console.warn("draft launch lane refresh failed", refreshError); }); @@ -6949,8 +6999,11 @@ export function AgentChatPane({ const cleanupDraftChatSession = useCallback(async ( session: AgentChatSession, targetLane: DraftLaunchLaneTarget, + pin?: OpenProjectBinding | null, ) => { - await window.ade.agentChat.delete({ sessionId: session.id }).catch((cleanupError: unknown) => { + // Pin the rollback to the project that owns the session so a concurrent + // project switch can't route the delete at the now-active project. + await window.ade.agentChat.delete({ sessionId: session.id }, pin).catch((cleanupError: unknown) => { console.warn("draft chat launch session cleanup failed", cleanupError); }); loadedHistoryRef.current.delete(session.id); @@ -6967,10 +7020,22 @@ export function AgentChatPane({ const startDraftChatLaunch = useCallback(async ( prepared: PreparedDraftLaunch, targetLane: DraftLaunchLaneTarget, + pin?: OpenProjectBinding | null, + assertActive?: () => void, ): Promise => { let createdSession: AgentChatSession | null = null; try { - createdSession = await createSessionForLane(targetLane.laneId, { select: false, launchState: prepared }); + // Re-assert immediately before each mutating call (not just once up the + // stack): both session creation and the prompt send resolve the project + // binding at call time, so a project switch or a timed-out launch between + // them must abort before the irreversible step rather than create/send in + // the wrong project. + assertActive?.(); + createdSession = await createSessionForLane(targetLane.laneId, { select: false, launchState: prepared, assertActive, pin }); + // Re-assert after creation: if the launch timed out (or the project + // switched) while the session was being created, abort now so the catch + // tears the session down rather than sending into the wrong project. + assertActive?.(); touchSession(createdSession.id); const sendInteractionMode = createdSession.provider === "claude" ? createdSession.interactionMode ?? prepared.interactionMode @@ -6986,6 +7051,9 @@ export function AgentChatPane({ interactionMode: sendInteractionMode, ...(createdSession.provider === "cursor" ? { runtime: "local" as const } : {}), }); + // If the launch was aborted while the prompt was in flight, tear the + // session down rather than leaving a started-but-orphaned chat. + assertActive?.(); notifySessionCreated(createdSession, { activate: false, source: "draft-launch", @@ -6996,7 +7064,7 @@ export function AgentChatPane({ }; } catch (launchError) { if (createdSession) { - await cleanupDraftChatSession(createdSession, targetLane); + await cleanupDraftChatSession(createdSession, targetLane, pin); } throw launchError; } @@ -7011,6 +7079,8 @@ export function AgentChatPane({ prepared: PreparedDraftLaunch, targetLane: DraftLaunchLaneTarget, mode: DraftLaunchMode, + assertActive?: () => void, + pin?: OpenProjectBinding | null, ): Promise => { if (!onLaunchCliSession) throw new Error("CLI sessions are not available from this surface."); if (!prepared.modelId) throw new Error("Select a model before launching a CLI session."); @@ -7060,6 +7130,9 @@ export function AgentChatPane({ const initialInputDelayMs = launch.initialInputDelayMs ?? ( initialInput && provider === "codex" && !codexUsesPromptArg ? 750 : undefined ); + // Final checkpoint before the PTY/session is spawned: abort if the project + // switched or the launch timed out while building the launch command. + assertActive?.(); const result = await onLaunchCliSession({ laneId: targetLane.laneId, profile: provider, @@ -7074,6 +7147,19 @@ export function AgentChatPane({ tracked: true, disposition: mode, }); + // The PTY spawn is not cancellable, so if the launch was aborted while it + // was starting (timeout or project switch), dispose the freshly-created + // session instead of leaving an orphaned terminal in the wrong project. + try { + assertActive?.(); + } catch (abortError) { + // Pin the dispose to the originating project so a concurrent switch can't + // route it at the now-active runtime (or throw under the transition guard). + await window.ade.pty.dispose({ ptyId: result.ptyId, sessionId: result.sessionId }, pin).catch((disposeError: unknown) => { + console.warn("draft cli launch pty cleanup failed", disposeError); + }); + throw abortError; + } return { sessionId: result.sessionId, draftKind: "cli", @@ -7112,6 +7198,33 @@ export function AgentChatPane({ draftLaunchInFlightKeysRef.current.add(requestKey); void copyPromptForLaunch(snapshot.text); + // Pin this launch to the project that started it. The chain runs detached + // from the pane's lifecycle, so if the user switches projects mid-launch we + // must (a) abort before any mutating call rather than create a lane/session + // in the now-active project, and (b) route rollback at the originating + // project. `launchBinding` is this pane's (project-scoped) binding; the root + // store's binding tracks whichever project is currently active. + // + // `launchTimedOut` covers the other abort source: withDraftLaunchTimeout + // rejects the renderer wait but cannot cancel the underlying (detached) IPC, + // so a timed-out step that keeps running must also be stopped before its + // next mutation. assertLaunchActive() is therefore called immediately before + // every irreversible call (lane create, session/PTY create, prompt send). + const launchBinding = projectBinding; + let launchTimedOut = false; + const assertLaunchActive = () => { + if (launchTimedOut) { + throw new Error("Draft launch aborted after timeout."); + } + const activeBinding = rootAppStoreApi.getState().projectBinding; + if (launchBinding && activeBinding?.key !== launchBinding.key) { + throw new Error(LAUNCH_PROJECT_CHANGED_MESSAGE); + } + }; + const markLaunchTimedOut = () => { + launchTimedOut = true; + }; + const jobId = createDraftLaunchJobId(); if (mode === "foreground") { latestForegroundDraftLaunchJobIdRef.current = jobId; @@ -7147,13 +7260,13 @@ export function AgentChatPane({ let targetLane: DraftLaunchLaneTarget | null = null; try { - targetLane = await resolveDraftLaunchLane(snapshot, () => { + targetLane = await withDraftLaunchTimeout(resolveDraftLaunchLane(snapshot, () => { patchDraftLaunchJob(jobId, { status: "creating-lane" }); }, (message) => { patchDraftLaunchJob(jobId, { warning: message }); }, (modelId) => { patchDraftLaunchJob(jobId, { namingModelId: modelId }); - }); + }, assertLaunchActive, launchBinding), "Lane setup", markLaunchTimedOut); patchDraftLaunchJob(jobId, { status: "starting-session", laneId: targetLane.laneId, @@ -7165,9 +7278,17 @@ export function AgentChatPane({ laneId: targetLane.laneId, laneName: targetLane.laneName, }); - const launched = kind === "chat" - ? await startDraftChatLaunch(prepared, targetLane) - : await startDraftCliLaunch(prepared, targetLane, mode); + // Re-check before starting the session: a switch during prepare must not + // start a session in the now-active project. The start functions also + // re-assert immediately before each of their own mutating calls. + assertLaunchActive(); + const launched = await withDraftLaunchTimeout( + kind === "chat" + ? startDraftChatLaunch(prepared, targetLane, launchBinding, assertLaunchActive) + : startDraftCliLaunch(prepared, targetLane, mode, assertLaunchActive, launchBinding), + "Session start", + markLaunchTimedOut, + ); invalidateSessionListCache(); invalidateAgentChatSessionListCache({ laneId: targetLane.laneId }); if (launched.draftKind === "chat" && targetLane.laneId === laneId) { @@ -7202,7 +7323,9 @@ export function AgentChatPane({ } } catch (launchError) { if (targetLane?.autoCreated) { - await window.ade.lanes.delete({ laneId: targetLane.laneId, force: true }).catch((cleanupError: unknown) => { + // Pin the rollback to the originating project so it deletes the lane we + // created, even if the active project has since changed. + await window.ade.lanes.delete({ laneId: targetLane.laneId, force: true }, launchBinding).catch((cleanupError: unknown) => { console.warn(`draft ${kind} launch lane cleanup failed`, cleanupError); }); await refreshLanesStore().catch(() => undefined); @@ -7235,6 +7358,7 @@ export function AgentChatPane({ patchDraftLaunchJob, parallelLaunchBusy, prepareDraftLaunchForSend, + projectBinding, projectTransitionBlocksChat, copyPromptForLaunch, refreshLanesStore, diff --git a/apps/desktop/src/renderer/lib/draftLaunchJobs.test.ts b/apps/desktop/src/renderer/lib/draftLaunchJobs.test.ts new file mode 100644 index 000000000..d8951fafe --- /dev/null +++ b/apps/desktop/src/renderer/lib/draftLaunchJobs.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + DRAFT_LAUNCH_TIMEOUT_MS, + MAX_DRAFT_LAUNCH_TERMINAL_JOBS, + isDraftLaunchJobTerminal, + pruneDraftLaunchJobs, + withDraftLaunchTimeout, + type DraftLaunchJob, + type DraftLaunchJobStatus, +} from "./draftLaunchJobs"; + +// pruneDraftLaunchJobs / the timeout helper only read a few fields, so a minimal +// partial job is sufficient and avoids constructing a full DraftLaunchSnapshot. +function makeJob(id: string, status: DraftLaunchJobStatus): DraftLaunchJob { + return { id, status, createdAtMs: 0 } as unknown as DraftLaunchJob; +} + +describe("withDraftLaunchTimeout", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("passes a resolved value straight through and clears its timer", async () => { + const promise = withDraftLaunchTimeout(Promise.resolve("ready"), "Lane setup"); + await expect(promise).resolves.toBe("ready"); + expect(vi.getTimerCount()).toBe(0); + }); + + it("propagates the original rejection rather than a timeout error", async () => { + const original = new Error("create failed"); + const promise = withDraftLaunchTimeout(Promise.reject(original), "Session start"); + await expect(promise).rejects.toBe(original); + expect(vi.getTimerCount()).toBe(0); + }); + + it("rejects with a labeled timeout error and fires onTimeout when the call never settles", async () => { + const onTimeout = vi.fn(); + const promise = withDraftLaunchTimeout(new Promise(() => {}), "Session start", onTimeout); + // Attach the rejection handler before advancing so the rejection is not unhandled. + const expectation = expect(promise).rejects.toThrow(/Session start timed out/); + await vi.advanceTimersByTimeAsync(DRAFT_LAUNCH_TIMEOUT_MS); + await expectation; + // onTimeout lets the caller raise its abort flag so the detached promise + // cannot perform a late irreversible step. + expect(onTimeout).toHaveBeenCalledTimes(1); + }); + + it("does not fire onTimeout when the call settles before the timeout", async () => { + const onTimeout = vi.fn(); + await expect( + withDraftLaunchTimeout(Promise.resolve("ok"), "Lane setup", onTimeout), + ).resolves.toBe("ok"); + expect(onTimeout).not.toHaveBeenCalled(); + }); +}); + +describe("pruneDraftLaunchJobs", () => { + it("never drops active (non-terminal) jobs, however many there are", () => { + // The durability of an in-flight launch depends on active jobs surviving + // every prune pass; only completed/failed notices are capped. + const active = Array.from({ length: MAX_DRAFT_LAUNCH_TERMINAL_JOBS + 5 }, (_, i) => + makeJob(`active-${i}`, "naming-lane"), + ); + const pruned = pruneDraftLaunchJobs(active); + expect(pruned).toHaveLength(active.length); + expect(pruned.every((job) => !isDraftLaunchJobTerminal(job.status))).toBe(true); + }); + + it("caps retained terminal notices while keeping every active job", () => { + const jobs = [ + makeJob("active-0", "sending-prompt"), + makeJob("active-1", "creating-lane"), + ...Array.from({ length: MAX_DRAFT_LAUNCH_TERMINAL_JOBS + 10 }, (_, i) => + makeJob(`done-${i}`, "ready"), + ), + ]; + const pruned = pruneDraftLaunchJobs(jobs); + const active = pruned.filter((job) => !isDraftLaunchJobTerminal(job.status)); + const terminal = pruned.filter((job) => isDraftLaunchJobTerminal(job.status)); + expect(active).toHaveLength(2); + expect(terminal.length).toBeLessThanOrEqual(MAX_DRAFT_LAUNCH_TERMINAL_JOBS); + }); +}); diff --git a/apps/desktop/src/renderer/lib/draftLaunchJobs.ts b/apps/desktop/src/renderer/lib/draftLaunchJobs.ts index 19971a8f9..a7c647c2b 100644 --- a/apps/desktop/src/renderer/lib/draftLaunchJobs.ts +++ b/apps/desktop/src/renderer/lib/draftLaunchJobs.ts @@ -19,6 +19,42 @@ import type { export const MAX_DRAFT_LAUNCH_TERMINAL_JOBS = 8; export const DRAFT_LAUNCH_JOB_STALE_AFTER_MS = 2 * 60 * 1000; +// Hard ceiling on how long a single draft launch may run before we fail it. +// Without this, a remote runtime call that neither resolves nor rejects (e.g. a +// connection dropped mid-switch) would wedge the job in a non-terminal state +// forever, blocking re-submission of the same draft. +export const DRAFT_LAUNCH_TIMEOUT_MS = 90 * 1000; + +// Thrown when the active project changes out from under an in-flight draft +// launch. The launch captures the originating project's binding when it starts +// and aborts before any mutating runtime call (lane create / session start) if +// the active project has drifted, so it can never create a lane or session in +// the wrong project. The job surfaces as a Restorable failure. +export const LAUNCH_PROJECT_CHANGED_MESSAGE = + "Project changed before the launch finished — it was not started. Use Restore to run it in the current project."; + +// Reject `promise` if it has not settled within DRAFT_LAUNCH_TIMEOUT_MS. The +// underlying runtime call is not cancellable (Electron IPC), so on timeout it +// keeps running detached — `onTimeout` lets the caller raise an abort flag that +// its per-mutation guard checks, so the detached promise cannot perform a late +// irreversible step (create a lane/session) after the job is already failed. +export function withDraftLaunchTimeout( + promise: Promise, + label: string, + onTimeout?: () => void, +): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + onTimeout?.(); + reject(new Error(`${label} timed out. The runtime may have disconnected — use Restore to try again.`)); + }, DRAFT_LAUNCH_TIMEOUT_MS); + promise.then( + (value) => { clearTimeout(timer); resolve(value); }, + (error: unknown) => { clearTimeout(timer); reject(error); }, + ); + }); +} + export type NativeControlState = { interactionMode: AgentChatInteractionMode; claudePermissionMode: AgentChatClaudePermissionMode; diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 9ea52d9cf..6d1bc9419 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -45,11 +45,11 @@ machinery layered on top. | `apps/desktop/src/shared/chatTranscript.ts` | Pure JSON-lines parser for `AgentChatEventEnvelope` values. Used by both the main process and the renderer. | | `apps/desktop/src/shared/chatSubagents.ts` | Cross-target subagent helpers: `buildSubagentPaneRows`, `selectedSubagentSnapshot`, `subagentIndexForPaneLine`, `subagentPaneSelectableLineOffsets`, `buildSubagentTranscriptEvents`, `isLifecycleEventForSnapshot`, plus the `latestPlan` derivation. Both the desktop `ChatSubagentsPanel` and the `apps/ade-cli/src/tuiClient/subagentPane.ts` / `chatInfo.ts` modules re-export from here so the desktop pane and the terminal TUI render the same roster, transcript filter, and plan summary. | | `apps/desktop/src/shared/types/chat.ts` | All chat types: `AgentChatSession`, `AgentChatEvent` union, `AgentChatEventHistorySnapshot` (with optional `sessionFound` for stale-session detection), Codex goal/token-usage DTOs, typed Codex goal control args, permission modes, pending input, completion reports, `PARALLEL_CHAT_MAX_ATTACHMENTS`, and parallel launch state DTOs. `AgentChatSessionSummary.linearIssueLinks?: SessionLinearIssueLink[]` carries the Linear issues attached to the session (chat or CLI), populated from `session_linear_issues` independent of any lane link. | -| `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` | Top-level renderer surface: state derivation, IPC wiring, composer mount, message-list mount, End/Delete chat controls in the header, parallel multi-model lane launch orchestration, transient-lane cleanup, and multi-lane deep-link navigation. Renders the inline `InlineQuestionRequestCard` (in `AgentChatMessageList`) when the active pending input is a question/structured-question. Resolves the surface accent colour through `providerChatAccent(provider)` so Claude/Codex/Cursor stay visually consistent regardless of model variant; the question/plan cards inherit that same `--chat-accent`. Visible Work grid tiles flush user/lifecycle/live events immediately and poll-recover active transcripts when IPC misses an event, even when the tile is not focused. Event-history snapshots with `sessionFound: false` clear stale locked-pane state instead of rendering a dead transcript. Draft chats scope their last-launch config by project/lane/surface/draft-kind and mark local model/reasoning/permission edits as touched so late lane-session hydration cannot overwrite the user's draft selection; composer text is also keyed by the real session id or the lane draft key (`draft:`) so switching draft lanes does not leak text through a shared null session key. During project transitions the pane blocks send/model/permission mutations and shows a "Project is switching..." composer placeholder so chat calls do not hit the wrong runtime binding. On macOS, polls `ade.iosSimulator.getStatus` and renders the iOS Simulator drawer toggle in the header when the platform is supported (see [iOS Simulator feature](../ios-simulator/README.md)); selecting elements inside the drawer flows back through the pane as `IosElementContextItem` chips on the composer. Polls `ade.appControl.getStatus` and exposes the App Control drawer toggle when the platform is supported, mounting `ChatAppControlPanel`; selections become `AppControlContextItem` chips + attachments on the composer. See [App Control](../computer-use/app-control.md). When mounted as a Work tile (`SessionSurface` passes `hideLaneToolDrawers={true}`) the iOS, App Control, and chat terminal drawer toggles are suppressed because the Work right-edge sidebar owns those lane-scoped drawers; hidden lane-tool mode also skips App Control status polling and terminal listing. Remote-bound panes further defer local-only App Control / proof snapshot polling until the matching drawer is open, delay unfinished parallel-launch cleanup recovery briefly after mount, cache chat-session lists and slash-command catalogs by active project root, and avoid mount-time session-delta fetches until a remote turn completes. The pane still listens on `ade:agent-chat:add-attachment` / `add-ios-context` / `add-app-control-context` / `add-builtin-browser-context` / `insert-draft` window events so selections from the sidebar flow into the active chat composer; event handlers match on either `sessionId` (for active sessions) or `draftTargetId` (for unsaved draft composers when `draftContextTargetId` is set), enabling the Work sidebar to insert context into a draft composer before a chat session exists. Work-tab CLI launches pass the active lane worktree into the shared launcher so the spawned CLI sees lane-aware Agent Skill roots. Work CLI launches intentionally skip the direct-argv path: the pane drops `command` / `args` from the `onLaunchPtySession` payload and always sends `startupCommand` plus `workCliStartupDelayMs = 180` so the spawned shell can finish drawing its prompt before the CLI invocation is typed in (see [pty-and-processes.md](../terminals-and-sessions/pty-and-processes.md#create-flow-createargs) for how `ptyService.create` consumes the delay). The `onLaunchCliSession` prop is typed as `(args: WorkPtyLaunchArgs) => Promise` and passes `disposition` matching the draft launch mode so background CLI launches do not steal focus. Internal draft launch state is structured through `DraftLaunchMode`, `DraftLaunchKind`, `DraftLaunchLaneTarget`, `StartedDraftLaunch`, and `DraftLaunchJob`. Each draft launch creates a `DraftLaunchJob` that tracks multi-step progress through a state machine (`naming-lane` -> `creating-lane` -> `starting-session` -> `sending-prompt` -> `ready` | `failed`) and stores it in `appStore.draftLaunchJobsByScope` keyed by project, lane, surface profile, and Work draft kind so loading/error strips survive pane remounts without leaking into another lane pane. The composer is cleared optimistically when the job starts rather than after it finishes; active jobs remain visible while terminal rows are pruned by scope. The pane renders status strips with Open/Restore for ready/failed jobs, Dismiss for terminal jobs, and a hide-status escape hatch for stale active jobs. Failed jobs offer a Restore button that merges the snapshot back into the composer (merging attachments and context items by identity rather than replacing). `clearDraftLaunchComposer` resets the draft, attachments, and context items after a successful launch. `DraftLaunchJob` carries `draftKind` so the dismissible job strip's "Open" action restores the correct Work draft kind (chat vs. CLI). Proof remains chat-scoped and stays on the chat header. | +| `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` | Top-level renderer surface: state derivation, IPC wiring, composer mount, message-list mount, End/Delete chat controls in the header, parallel multi-model lane launch orchestration, transient-lane cleanup, and multi-lane deep-link navigation. Renders the inline `InlineQuestionRequestCard` (in `AgentChatMessageList`) when the active pending input is a question/structured-question. Resolves the surface accent colour through `providerChatAccent(provider)` so Claude/Codex/Cursor stay visually consistent regardless of model variant; the question/plan cards inherit that same `--chat-accent`. Visible Work grid tiles flush user/lifecycle/live events immediately and poll-recover active transcripts when IPC misses an event, even when the tile is not focused. Event-history snapshots with `sessionFound: false` clear stale locked-pane state instead of rendering a dead transcript. Draft chats scope their last-launch config by project/lane/surface/draft-kind and mark local model/reasoning/permission edits as touched so late lane-session hydration cannot overwrite the user's draft selection; composer text is also keyed by the real session id or the lane draft key (`draft:`) so switching draft lanes does not leak text through a shared null session key. During project transitions the pane blocks send/model/permission mutations and shows a "Project is switching..." composer placeholder so chat calls do not hit the wrong runtime binding. On macOS, polls `ade.iosSimulator.getStatus` and renders the iOS Simulator drawer toggle in the header when the platform is supported (see [iOS Simulator feature](../ios-simulator/README.md)); selecting elements inside the drawer flows back through the pane as `IosElementContextItem` chips on the composer. Polls `ade.appControl.getStatus` and exposes the App Control drawer toggle when the platform is supported, mounting `ChatAppControlPanel`; selections become `AppControlContextItem` chips + attachments on the composer. See [App Control](../computer-use/app-control.md). When mounted as a Work tile (`SessionSurface` passes `hideLaneToolDrawers={true}`) the iOS, App Control, and chat terminal drawer toggles are suppressed because the Work right-edge sidebar owns those lane-scoped drawers; hidden lane-tool mode also skips App Control status polling and terminal listing. Remote-bound panes further defer local-only App Control / proof snapshot polling until the matching drawer is open, delay unfinished parallel-launch cleanup recovery briefly after mount, cache chat-session lists and slash-command catalogs by active project root, and avoid mount-time session-delta fetches until a remote turn completes. The pane still listens on `ade:agent-chat:add-attachment` / `add-ios-context` / `add-app-control-context` / `add-builtin-browser-context` / `insert-draft` window events so selections from the sidebar flow into the active chat composer; event handlers match on either `sessionId` (for active sessions) or `draftTargetId` (for unsaved draft composers when `draftContextTargetId` is set), enabling the Work sidebar to insert context into a draft composer before a chat session exists. Work-tab CLI launches pass the active lane worktree into the shared launcher so the spawned CLI sees lane-aware Agent Skill roots. Work CLI launches intentionally skip the direct-argv path: the pane drops `command` / `args` from the `onLaunchPtySession` payload and always sends `startupCommand` plus `workCliStartupDelayMs = 180` so the spawned shell can finish drawing its prompt before the CLI invocation is typed in (see [pty-and-processes.md](../terminals-and-sessions/pty-and-processes.md#create-flow-createargs) for how `ptyService.create` consumes the delay). The `onLaunchCliSession` prop is typed as `(args: WorkPtyLaunchArgs) => Promise` and passes `disposition` matching the draft launch mode so background CLI launches do not steal focus. Internal draft launch state is structured through `DraftLaunchMode`, `DraftLaunchKind`, `DraftLaunchLaneTarget`, `StartedDraftLaunch`, and `DraftLaunchJob`. Each draft launch creates a `DraftLaunchJob` that tracks multi-step progress through a state machine (`naming-lane` -> `creating-lane` -> `starting-session` -> `sending-prompt` -> `ready` | `failed`) and stores it in the **root** store's `draftLaunchJobsByScope` (read via `useRootAppStore` / `rootAppStoreApi.getState()`) keyed by project root, lane, surface profile, and Work draft kind so loading/error strips survive pane remounts — and a remote project switch that tears down the originating per-project store — without leaking into another lane pane. The detached launch chain captures the originating `OpenProjectBinding`, re-checks it before each mutating step (aborts with `LAUNCH_PROJECT_CHANGED_MESSAGE` if the active project drifted), pins rollback (`lanes.delete` / `agentChat.delete` with a `pin`) to that binding, and caps each step with `withDraftLaunchTimeout` (90 s). The composer is cleared optimistically when the job starts rather than after it finishes; active jobs remain visible while terminal rows are pruned by scope. The pane renders status strips with Open/Restore for ready/failed jobs, Dismiss for terminal jobs, and a hide-status escape hatch for stale active jobs. Failed jobs offer a Restore button that merges the snapshot back into the composer (merging attachments and context items by identity rather than replacing). `clearDraftLaunchComposer` resets the draft, attachments, and context items after a successful launch. `DraftLaunchJob` carries `draftKind` so the dismissible job strip's "Open" action restores the correct Work draft kind (chat vs. CLI). Proof remains chat-scoped and stays on the chat header. | | `apps/desktop/src/renderer/lib/agentChatSessionListCache.ts` | Short-lived renderer cache for `ade.agentChat.list`, keyed by active project root, lane, automation, and archive flags. Mutations invalidate by project/lane so remote Work panes do not fan out repeated list calls while still refreshing immediately after create/archive/delete. | | `apps/desktop/src/renderer/lib/agentChatSlashCommandsCache.ts` | Short-lived renderer cache for `ade.agentChat.slashCommands`, keyed by project root plus session id or lane/provider. System notices can force-refresh the selected session's commands. | -| `apps/desktop/src/renderer/lib/draftLaunchJobs.ts` | Shared renderer helper for Work draft-launch job DTOs and pruning. Owns `NativeControlState`, `DraftLaunchSnapshot`, `PreparedDraftLaunch`, `DraftLaunchJobStatus`, `DraftLaunchJob`, `isDraftLaunchJobTerminal`, `isDraftLaunchJobStale`, and `pruneDraftLaunchJobs`; active jobs are kept ahead of terminal rows, with terminal rows filling the remaining retained slots and at least one terminal row retained alongside active jobs. | -| `apps/desktop/src/renderer/state/appStore.ts` | Shared renderer state store. Besides project/lane/work selection, it persists user preferences such as `launchPromptClipboardEnabled` and `launchPromptClipboardNoticeEnabled`, mirrors them into per-project stores, and owns `draftLaunchJobsByScope` for Work draft launch status strips. | +| `apps/desktop/src/renderer/lib/draftLaunchJobs.ts` | Shared renderer helper for Work draft-launch job DTOs and pruning. Owns `NativeControlState`, `DraftLaunchSnapshot`, `PreparedDraftLaunch`, `DraftLaunchJobStatus`, `DraftLaunchJob`, `isDraftLaunchJobTerminal`, `isDraftLaunchJobStale`, and `pruneDraftLaunchJobs`; active jobs are kept ahead of terminal rows, with terminal rows filling the remaining retained slots and at least one terminal row retained alongside active jobs. Also owns the launch durability constants/helpers: `DRAFT_LAUNCH_TIMEOUT_MS` (90 s) + `withDraftLaunchTimeout(promise, label)` (rejects a launch step whose runtime call never settles; the underlying IPC is not cancellable, so on timeout it keeps running detached and the timeout only unwedges the renderer-side job) and `LAUNCH_PROJECT_CHANGED_MESSAGE` (the abort error thrown when the active project drifts mid-launch). | +| `apps/desktop/src/renderer/state/appStore.ts` | Shared renderer state store. Besides project/lane/work selection, it persists user preferences such as `launchPromptClipboardEnabled` and `launchPromptClipboardNoticeEnabled`, mirrors them into per-project stores, and owns `draftLaunchJobsByScope` (+ `setDraftLaunchJobs`) for Work draft launch status strips. This lives in the **root** store (not the per-project store) on purpose: an in-flight draft launch must survive a remote project switch that destroys the originating per-project store; `AgentChatPane` reads it via `useRootAppStore` / `rootAppStoreApi.getState()`. | | `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx` | Virtualized transcript renderer. Coalesces resize / measurement updates and, while sticky-to-bottom is active, follows height changes across multiple animation frames so streamed output and late row measurements do not leave the user above the newest message. Programmatic scroll writes are tracked by target scroll position, not a stale counter, so browser-coalesced scroll events do not swallow the next real user gesture. Codex goal lifecycle events render as compact user-facing rows (`Goal set`, `Goal paused`, `Goal cleared`) instead of raw JSON-RPC/status wording. | | `apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx` | Git / PR quick-action toolbar above the composer. If the lane already has a linked PR, the PR button opens that PR; otherwise it routes to the PR workspace with a create-PR handoff (`create=1&sourceLaneId=&target=primary`). | | `apps/desktop/src/renderer/lib/visualContextFormatting.ts` | Serializes iOS, App Control, built-in browser, and attachment context into prompt text. | @@ -171,11 +171,35 @@ render them, but neither one *runs* them. `creating-lane` -> `starting-session` -> `sending-prompt` -> `ready` | `failed`; auto-created lanes enter `naming-lane` while the prompt-derived branch name is being resolved, then `creating-lane` - while the lane row/worktree is created. Jobs live in - `appStore.draftLaunchJobsByScope`, scoped by project, lane, surface - profile, and Work draft kind, so a new chat pane or remount does not + while the lane row/worktree is created. Jobs live in the **root** + `appStore.draftLaunchJobsByScope` (read via `useRootAppStore` / + `rootAppStoreApi.getState()`, not the per-project store), scoped by + project root, lane, surface profile, and Work draft kind. The root + store is used deliberately: a launch can outlive the pane (and its + project surface) that started it — switching to another remote project + tears down the originating project's per-project store entirely, which + would otherwise drop the in-flight job with no trace. Living in the + root store lets the job re-surface (ready jobs auto-open, failures show + Restore) when the user returns, while the project-root-keyed scope keeps + jobs partitioned per project, so a new chat pane or remount does not drop loading/error state and another lane pane does not inherit the - strip. The composer is cleared optimistically at job creation rather + strip. **Project-switch safety:** the launch chain runs detached from + the pane lifecycle, so it captures the originating project's + `OpenProjectBinding` up front and re-checks before every mutating step + (lane create, session start) that the active project has not drifted — + if it has, it aborts with `LAUNCH_PROJECT_CHANGED_MESSAGE` rather than + create a lane/session in the now-active project, surfacing the job as a + Restorable failure. Rollback of a partially-created launch (the + auto-created lane via `lanes.delete`, the created chat session via + `agentChat.delete`) is **pinned** to that captured binding (passed as + the optional `pin` arg → `callPinnedRuntimeAction`, see [Remote runtime + internal architecture](../remote-runtime/internal-architecture.md#local-runtime-routing)) + so cleanup deletes the rows it created even after a concurrent project + switch. A `DRAFT_LAUNCH_TIMEOUT_MS = 90 s` ceiling (via + `withDraftLaunchTimeout`) fails the job if a runtime call neither + resolves nor rejects — e.g. a connection dropped mid-switch — so a + wedged remote call cannot block re-submitting the same draft. The + composer is cleared optimistically at job creation rather than after the async flow completes, so users can begin composing the next prompt immediately. Active jobs remain visible; terminal rows are pruned per scope while keeping at least one diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index 6ec070bfd..858bf2dae 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -11,8 +11,8 @@ stream plus session metadata. | Path | Role | |---|---| -| `AgentChatPane.tsx` | Top-level pane; IPC wiring, session state, presentation profile resolution, lane navigation, parallel launch orchestration, mounting of sub-panels and composer. Visible Work grid tiles flush user/lifecycle/live events immediately and poll-recover active transcripts so inactive-but-visible tiles stay current. Draft chats preserve user-touched model/reasoning/permission controls across late lane-session hydration, and composer text is keyed by session id or lane draft key so switching draft lanes does not reuse another draft's text. Accepts an optional `draftContextTargetId` prop so the Work sidebar can target an unsaved draft composer for context insertions (attachments, iOS/App Control/browser selections, draft text) even before a chat session exists; window event handlers match on either `sessionId` or `draftTargetId`. When auto-creating a lane the draft resolves the primary lane for the `onLaneChange` callback so the sidebar lane context stays in sync. Composer draft state (text, model, reasoning, attachments, context items) is persisted to `localStorage` under the `ade.chat.composerDraft.v1` key family and restored on scope change through `ComposerDraftStorageSnapshot`. Draft launches are tracked through store-backed `DraftLaunchJob` state machines with multi-step progress (`naming-lane` -> `creating-lane` -> `starting-session` -> `sending-prompt` -> `ready` / `failed`); the composer is cleared optimistically at job start, stale active rows gain a hide-status escape hatch, and the `DraftLaunchSnapshot` captures the full control state so the async launch uses frozen settings. | -| `apps/desktop/src/renderer/lib/draftLaunchJobs.ts` | Pure helper for Work draft-launch job DTOs, terminal/stale-state detection, and pruning. The list keeps active rows ahead of terminal rows, fills remaining retained slots with terminal rows, and keeps at least one terminal row alongside active jobs. | +| `AgentChatPane.tsx` | Top-level pane; IPC wiring, session state, presentation profile resolution, lane navigation, parallel launch orchestration, mounting of sub-panels and composer. Visible Work grid tiles flush user/lifecycle/live events immediately and poll-recover active transcripts so inactive-but-visible tiles stay current. Draft chats preserve user-touched model/reasoning/permission controls across late lane-session hydration, and composer text is keyed by session id or lane draft key so switching draft lanes does not reuse another draft's text. Accepts an optional `draftContextTargetId` prop so the Work sidebar can target an unsaved draft composer for context insertions (attachments, iOS/App Control/browser selections, draft text) even before a chat session exists; window event handlers match on either `sessionId` or `draftTargetId`. When auto-creating a lane the draft resolves the primary lane for the `onLaneChange` callback so the sidebar lane context stays in sync. Composer draft state (text, model, reasoning, attachments, context items) is persisted to `localStorage` under the `ade.chat.composerDraft.v1` key family and restored on scope change through `ComposerDraftStorageSnapshot`. Draft launches are tracked through **root**-store-backed `DraftLaunchJob` state machines with multi-step progress (`naming-lane` -> `creating-lane` -> `starting-session` -> `sending-prompt` -> `ready` / `failed`); jobs live in the root store (not the per-project store) so an in-flight launch survives a remote project switch that tears down the originating project surface. The detached launch chain captures the originating `OpenProjectBinding`, aborts before any mutating call if the active project drifts (`LAUNCH_PROJECT_CHANGED_MESSAGE`), pins rollback to that binding, and caps each step at 90 s (`withDraftLaunchTimeout`). The composer is cleared optimistically at job start, stale active rows gain a hide-status escape hatch, and the `DraftLaunchSnapshot` captures the full control state so the async launch uses frozen settings. | +| `apps/desktop/src/renderer/lib/draftLaunchJobs.ts` | Pure helper for Work draft-launch job DTOs, terminal/stale-state detection, and pruning. The list keeps active rows ahead of terminal rows, fills remaining retained slots with terminal rows, and keeps at least one terminal row alongside active jobs. Also owns the durability constants/helpers: `DRAFT_LAUNCH_TIMEOUT_MS` (90 s) + `withDraftLaunchTimeout` (fails a step whose runtime call never settles; the underlying IPC is not cancellable, so it keeps running detached and the timeout only unwedges the renderer-side job) and `LAUNCH_PROJECT_CHANGED_MESSAGE` (thrown when the active project drifts mid-launch). | | `AgentChatMessageList.tsx` | Virtualized message list (`@tanstack/react-virtual`). Renders transcript rows and turn dividers, and keeps sticky-bottom sessions pinned across streamed row growth and late virtual-height measurements. Plan-approval rows with non-empty body text render a scrollable markdown block (capped at `360px`) beneath the header so the user can review plan content inline. Codex goal lifecycle rows use user-facing text such as `Goal set`, `Goal paused`, and `Goal cleared`. | | `AgentChatComposer.tsx` | Text input, attachments, model selector, permission controls, slash commands, pending-input answering, voice-dictation target registration, and parallel model-slot controls. Launch-prompt clipboard reminder text is controlled by `launchPromptClipboardNoticeEnabled`, separate from the `launchPromptClipboardEnabled` copy behavior. | | `VoiceDictationButton.tsx`, `apps/desktop/src/renderer/services/globalVoiceRecorder.ts`, `apps/desktop/src/renderer/components/voice/*` | Desktop dictation UI and recorder. The module-level recorder owns mic capture across navigation, writes live state to the root app store, transcribes via `window.ade.transcription`, inserts cleaned text into the registered composer, and always copies the cleaned transcript to the clipboard. The header indicator and composer pill render the same recording state. | @@ -627,15 +627,21 @@ These modules are pure and unit-testable: ## Fragile and tricky wiring - **Draft launch job lifecycle.** `DraftLaunchJob` tracks multi-step - async launches and is stored in `appStore.draftLaunchJobsByScope` - rather than local pane state. The composer is cleared immediately when - the job starts, not when it finishes. Auto-created lanes begin at - `naming-lane`, show the naming model, switch to `creating-lane` after - the suggested branch name resolves or after the deterministic fallback - wins, then move through session start and prompt send. If - the launch fails, the Restore action merges the snapshot back via - `restoreDraftLaunchSnapshot`, which appends rather than replaces - existing draft text and merges context items by id. + async launches and is stored in the **root** store's + `draftLaunchJobsByScope` (read/written via `useRootAppStore` / + `rootAppStoreApi.getState().setDraftLaunchJobs`) rather than the + per-project store or local pane state. This is load-bearing: a launch + routinely outlives the pane that started it, and switching to another + remote project tears down the originating project's scoped store + entirely — keeping the job in the root store is what lets it re-surface + (and ready jobs auto-open / failures show Restore) when the user + returns. The composer is cleared immediately when the job starts, not + when it finishes. Auto-created lanes begin at `naming-lane`, show the + naming model, switch to `creating-lane` after the suggested branch name + resolves or after the deterministic fallback wins, then move through + session start and prompt send. If the launch fails, the Restore action + merges the snapshot back via `restoreDraftLaunchSnapshot`, which appends + rather than replaces existing draft text and merges context items by id. `isDraftLaunchJobStale` makes an active row hideable after the stale threshold so a hung IPC call cannot leave a permanent status strip. `latestForegroundDraftLaunchJobIdRef` prevents stale foreground jobs @@ -644,6 +650,26 @@ These modules are pure and unit-testable: (model, reasoning, execution mode, native controls) so `createSessionForLane` receives a `launchState` that overrides the live composer state during the async gap. +- **Draft launch project-switch safety.** Because the launch chain is + detached from the pane lifecycle, it must never act on the wrong + project. It captures the originating project's `OpenProjectBinding` + (`launchBinding`) at the start and calls `assertLaunchProjectActive()` + before every irreversible step — before `lanes.create` (inside + `resolveDraftLaunchLane`) and again before starting the session — + comparing `launchBinding.key` against the **root** store's current + `projectBinding.key`; a mismatch throws `LAUNCH_PROJECT_CHANGED_MESSAGE` + and the job becomes a Restorable failure. Rollback of a + partially-created launch is pinned to `launchBinding`: + `window.ade.lanes.delete(..., launchBinding)` and + `window.ade.agentChat.delete(..., pin)` pass the binding as the + optional `pin` arg so cleanup deletes the lane/session it created even + after the active project changed (the preload routes a `pin` through + `callPinnedRuntimeAction` — see [Remote runtime internal + architecture](../remote-runtime/internal-architecture.md#local-runtime-routing)). + Each step is also wrapped in `withDraftLaunchTimeout` so a runtime call + that neither resolves nor rejects (`DRAFT_LAUNCH_TIMEOUT_MS = 90 s`) + fails the job instead of wedging it in a non-terminal state and + blocking re-submission. - **Composer draft persistence.** `ComposerDraftStorageSnapshot` is persisted to `localStorage` on every draft/model/attachment change and restored on scope switch. `composerDraftHydratingRef` suppresses diff --git a/docs/features/remote-runtime/internal-architecture.md b/docs/features/remote-runtime/internal-architecture.md index a737c79c4..e392fbe84 100644 --- a/docs/features/remote-runtime/internal-architecture.md +++ b/docs/features/remote-runtime/internal-architecture.md @@ -107,6 +107,8 @@ The sync command registry labels descriptors as `runtime` or `project` scope. Pr Local desktop windows go through the runtime binding. `callProjectRuntimeActionOr` and `callProjectRuntimeSyncOr` in `apps/desktop/src/preload/preload.ts` call the active local or remote runtime when a binding exists; legacy Electron IPC handlers are used only when no runtime route is bound or for desktop-only side effects. File actions are strict once a local or remote runtime is bound, which prevents a failed runtime-bound file write/read from being retried against the desktop's local filesystem when the bound project is owned by a daemon or remote host. Usage and budget reads use the remote runtime only for remote-bound windows; local-bound windows keep using desktop usage IPC. During `project.switchToPath`, preload temporarily binds local runtime calls to the requested root and main-process `runtimeBridge.ts` honors the explicit `rootPath` over the window session binding for local action, sync, and event-stream calls. During `remoteRuntime.openProject`, preload clears the binding while the switch is in flight; mutating runtime actions and mutating sync calls fail with the "Project is switching" message instead of refreshing or writing through a stale binding, while read-only project calls can wait for the active remote open and retry against the new binding. +`callPinnedRuntimeAction(pin, domain, action, request)` is the explicit-binding escape hatch alongside the binding-resolving helpers. Instead of reading the mutable module-level `currentProjectBinding`, it routes against a caller-supplied `OpenProjectBinding` — addressing a remote runtime by `targetId`/`projectId` or a local runtime by `rootPath` directly — and bypasses the project-transition guard. It exists for in-flight work that must stay pinned to the project that started it even if the active project changes mid-flight: the originating binding is captured up front and the call cannot be misrouted to the now-active project. `lanes.delete` and `agentChat.delete` accept an optional `pin?: OpenProjectBinding | null` second argument that routes through this helper when present (used by draft-launch rollback — see [Chat](../chat/README.md)); when `pin` is absent they fall back to the binding-resolving path. The transition guard is skipped deliberately because a pin is only passed for explicitly-targeted, intentional cleanup, not for ambiguous active-binding calls. + Lane preview reads are also binding-aware. For remote bindings, `proxyGetPreviewInfo` is resolved on the remote runtime, then `remoteRuntime.ensurePortForward` creates or reuses a local `127.0.0.1:` TCP forward to the remote preview port and rewrites the preview URL before returning it to the renderer. The runtime path covers: