From b8da359aa0cddb402e17394d8d90ab06d28f4d33 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 11:12:39 +0000 Subject: [PATCH 01/25] =?UTF-8?q?=F0=9F=A4=96=20feat:=20define=20shared=20?= =?UTF-8?q?desktop=20task=20bindings=20and=20service=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task schema and persistence tests pass; desktop coordinator and lifecycle implementations follow in scoped integration commits. --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high`_ --- src/common/orpc/schemas/api.ts | 3 + src/common/orpc/schemas/workspace.ts | 5 ++ src/common/schemas/project.ts | 5 ++ src/common/utils/tools/toolDefinitions.ts | 34 ++++++++++ src/node/config.test.ts | 31 +++++++++ src/node/config/index.ts | 4 ++ src/node/services/di/layers/core.ts | 17 ++++- src/node/services/di/layers/desktop.ts | 4 +- src/node/services/di/tags.ts | 7 ++ src/node/services/tools/task.test.ts | 81 +++++++++++++++++++++++ src/node/services/tools/task.ts | 13 ++++ 11 files changed, 200 insertions(+), 4 deletions(-) diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 8a87a56e0f..e67fb42d53 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -2076,6 +2076,7 @@ export const tasks = { .object({ parentWorkspaceId: z.string(), kind: z.literal("agent"), + desktop: z.enum(["shared", "isolated"]).optional(), agentId: AgentIdSchema.optional(), /** @deprecated Legacy alias for agentId (kept for downgrade compatibility). */ agentType: z.string().min(1).optional(), @@ -2102,6 +2103,7 @@ export const tasks = { taskId: z.string(), kind: z.literal("agent"), status: z.enum(["queued", "starting", "running"]), + desktopOwnerWorkspaceId: z.string().optional(), }), z.string() ), @@ -3167,6 +3169,7 @@ const DesktopCapabilitySchema = z.discriminatedUnion("available", [ width: z.number(), height: z.number(), sessionId: z.string(), + sharedDesktop: z.object({ ownerWorkspaceId: z.string(), ownerName: z.string() }).optional(), }), z.object({ available: z.literal(false), diff --git a/src/common/orpc/schemas/workspace.ts b/src/common/orpc/schemas/workspace.ts index 7e820ab2b0..6596c26790 100644 --- a/src/common/orpc/schemas/workspace.ts +++ b/src/common/orpc/schemas/workspace.ts @@ -239,6 +239,11 @@ export const WorkspaceMetadataSchema = z.object({ description: "Trunk branch used to create/init this agent task workspace (used for restart-safe init on queued tasks).", }), + // Delegation changes the operator, not the computer; checkout isolation is independent. + taskDesktopOwnerWorkspaceId: z.string().optional().meta({ + description: + "Ancestor owning the shared desktop. Absent means this workspace owns its desktop.", + }), taskIsolation: z.enum(["fork", "none"]).optional().meta({ description: 'Workspace isolation for an agent task. "none" shares an ancestor checkout and must never be treated as an independently managed worktree.', diff --git a/src/common/schemas/project.ts b/src/common/schemas/project.ts index ca76676bcc..6833874723 100644 --- a/src/common/schemas/project.ts +++ b/src/common/schemas/project.ts @@ -223,6 +223,11 @@ export const WorkspaceConfigSchema = z.object({ description: "Trunk branch used to create/init this agent task workspace (used for restart-safe init on queued tasks).", }), + // Delegation changes the operator, not the computer; checkout isolation is independent. + taskDesktopOwnerWorkspaceId: z.string().optional().meta({ + description: + "Ancestor owning the shared desktop. Absent means this workspace owns its desktop.", + }), taskIsolation: z .enum(["fork", "none"]) .optional() diff --git a/src/common/utils/tools/toolDefinitions.ts b/src/common/utils/tools/toolDefinitions.ts index c58a08bf54..3e0cfe5cd1 100644 --- a/src/common/utils/tools/toolDefinitions.ts +++ b/src/common/utils/tools/toolDefinitions.ts @@ -371,6 +371,7 @@ function refineTaskToolAgentArgs( subagent_type?: string | null; prompt: string; n?: number | null; + desktop?: "shared" | "isolated" | null; workspace?: { mode?: "new" | "fork" | "existing" | null; workspaceId?: string | null } | null; }, ctx: z.RefinementCtx @@ -382,6 +383,13 @@ function refineTaskToolAgentArgs( if (kind === "workspace") { // Workspace tasks accept agentId (agent mode for the launched turn, e.g. "plan") but keep // rejecting the deprecated sub-agent alias subagent_type. + if (args.desktop != null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Workspace tasks do not accept desktop targeting", + path: ["desktop"], + }); + } if (hasSubagentType) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -422,6 +430,19 @@ function refineTaskToolAgentArgs( return; } + if ( + (args.n ?? 1) > 1 && + (args.desktop === "shared" || + (args.desktop == null && (args.agentId ?? args.subagent_type) === "desktop")) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'Shared desktop tasks cannot use n > 1. Request desktop: "isolated" for parallel GUI work.', + path: ["n"], + }); + } + // GPT models often send both fields with identical values — allow that. // Only reject when they conflict, since the handler silently prefers agentId. if (hasAgentId && hasSubagentType && args.agentId !== args.subagent_type) { @@ -435,6 +456,15 @@ function refineTaskToolAgentArgs( } const taskToolBaseShape = { + desktop: z + .enum(["shared", "isolated"]) + .nullish() + .describe( + 'Desktop target for sub-agents, independent of checkout isolation. "shared" uses the caller\'s desktop; ' + + '"isolated" starts a separate desktop. Defaults to shared for agentId="desktop", isolated otherwise. ' + + "Only one active shared child can control desktop tools; n > 1 requires isolation. " + + "Does not exclude human viewer input, shell tools, or external CDP clients." + ), kind: WorkspaceTaskKindSchema.nullish().describe( 'Task kind. Omit or use "subagent" for the existing child-workspace sub-agent flow; use "workspace" to start a normal full workspace turn.' ), @@ -503,6 +533,7 @@ const TaskToolSpawnedTaskSchema = z workspaceId: z.string().optional(), modelString: z.string().optional(), thinkingLevel: TaskThinkingLevelSchema.optional(), + desktopOwnerWorkspaceId: z.string().optional(), }) .strict(); @@ -521,6 +552,7 @@ const TaskToolCompletedReportSchema = z finalMessageRef: WorkspaceTurnFinalMessageRefSchema.optional(), modelString: z.string().optional(), thinkingLevel: TaskThinkingLevelSchema.optional(), + desktopOwnerWorkspaceId: z.string().optional(), }) .strict(); @@ -535,6 +567,7 @@ export const TaskToolQueuedResultSchema = z reports: z.array(TaskToolCompletedReportSchema).min(1).optional(), modelString: z.string().optional(), thinkingLevel: TaskThinkingLevelSchema.optional(), + desktopOwnerWorkspaceId: z.string().optional(), note: z .string() .min(1) @@ -573,6 +606,7 @@ export const TaskToolCompletedResultSchema = z reports: z.array(TaskToolCompletedReportSchema).min(1).optional(), modelString: z.string().optional(), thinkingLevel: TaskThinkingLevelSchema.optional(), + desktopOwnerWorkspaceId: z.string().optional(), /** * Follow-up context the caller needs alongside the terminal report — e.g. * that the caller's previously tracked handle was quietly superseded by diff --git a/src/node/config.test.ts b/src/node/config.test.ts index 4f9a56a8a1..14c00471c3 100644 --- a/src/node/config.test.ts +++ b/src/node/config.test.ts @@ -2634,6 +2634,37 @@ describe("Config", () => { expect(workspace.name).toBe("feature-branch"); }); + it.each(["owner", undefined])( + "preserves desktop ownership through metadata read/write (%s)", + async (owner) => { + const projectPath = path.join(tempDir, "project"); + await config.editConfig((cfg) => { + cfg.projects.set(projectPath, { + workspaces: [ + { + id: "child", + name: "child", + path: projectPath, + createdAt: "2025-01-01T00:00:00.000Z", + runtimeConfig: { type: "local" }, + parentWorkspaceId: "owner", + agentId: "desktop", + taskDesktopOwnerWorkspaceId: owner, + }, + ], + }); + return cfg; + }); + const reloaded = new Config(tempDir); + const [metadata] = await reloaded.getAllWorkspaceMetadata(); + expect(metadata.taskDesktopOwnerWorkspaceId).toBe(owner); + await reloaded.addWorkspace(projectPath, { ...metadata, title: "Renamed operator" }); + const [saved] = await new Config(tempDir).getAllWorkspaceMetadata(); + expect(saved.title).toBe("Renamed operator"); + expect(saved.taskDesktopOwnerWorkspaceId).toBe(owner); + } + ); + it("defaults sparse persisted heartbeat intervals in workspace metadata", async () => { const projectPath = "/fake/project"; const workspacePath = path.join(config.srcDir, "project", "heartbeat-sparse"); diff --git a/src/node/config/index.ts b/src/node/config/index.ts index 5912473785..49f4b18812 100644 --- a/src/node/config/index.ts +++ b/src/node/config/index.ts @@ -3218,6 +3218,7 @@ export class Config { taskPrompt: workspace.taskPrompt, taskTrunkBranch: workspace.taskTrunkBranch, taskIsolation: workspace.taskIsolation, + taskDesktopOwnerWorkspaceId: workspace.taskDesktopOwnerWorkspaceId, taskSticky: workspace.taskSticky, taskExecutionId: workspace.taskExecutionId, taskExecutionStatus: workspace.taskExecutionStatus, @@ -3531,6 +3532,7 @@ export class Config { taskPrompt: workspace.taskPrompt, taskTrunkBranch: workspace.taskTrunkBranch, taskIsolation: workspace.taskIsolation, + taskDesktopOwnerWorkspaceId: workspace.taskDesktopOwnerWorkspaceId, taskSticky: workspace.taskSticky, taskExecutionId: workspace.taskExecutionId, taskExecutionStatus: workspace.taskExecutionStatus, @@ -3607,6 +3609,7 @@ export class Config { taskPrompt: workspace.taskPrompt, taskTrunkBranch: workspace.taskTrunkBranch, taskIsolation: workspace.taskIsolation, + taskDesktopOwnerWorkspaceId: workspace.taskDesktopOwnerWorkspaceId, taskSticky: workspace.taskSticky, taskExecutionId: workspace.taskExecutionId, taskExecutionStatus: workspace.taskExecutionStatus, @@ -3704,6 +3707,7 @@ export class Config { taskPrompt: metadata.taskPrompt, taskTrunkBranch: metadata.taskTrunkBranch, taskIsolation: metadata.taskIsolation, + taskDesktopOwnerWorkspaceId: metadata.taskDesktopOwnerWorkspaceId, taskSticky: metadata.taskSticky, taskExecutionId: metadata.taskExecutionId, taskExecutionStatus: metadata.taskExecutionStatus, diff --git a/src/node/services/di/layers/core.ts b/src/node/services/di/layers/core.ts index 22f2699c13..c17c94d44b 100644 --- a/src/node/services/di/layers/core.ts +++ b/src/node/services/di/layers/core.ts @@ -35,6 +35,7 @@ import { SessionUsage, StreamManagerTag, Task, + DesktopInputCoordinatorTag, TerminalAttentionStoreTag, TurnRequestBuilderBindingsTag, Workspace, @@ -58,6 +59,7 @@ import { MemoryService } from "@/node/services/memoryService"; import { ProviderService } from "@/node/services/providerService"; import { SessionUsageService } from "@/node/services/sessionUsageService"; import { StreamManager } from "@/node/services/streamManager"; +import { DesktopInputCoordinator } from "@/node/services/desktop/DesktopInputCoordinator"; import { TaskService } from "@/node/services/taskService"; import { TerminalAttentionStore } from "@/node/services/terminalAttentionStore"; import type { TurnRequestBuilderBindings } from "@/node/services/turnRequestBuilder"; @@ -171,6 +173,11 @@ export const MemoryLive = Layer.effect( }) ); +export const DesktopInputCoordinatorLive = Layer.effect( + DesktopInputCoordinatorTag, + Effect.map(ConfigTag, (config) => new DesktopInputCoordinator(config)) +); + export const TerminalAttentionStoreLive = Layer.effect( TerminalAttentionStoreTag, Effect.map(ConfigTag, (config) => new TerminalAttentionStore(config)) @@ -380,7 +387,8 @@ export const WorkspaceLive = Layer.effect( opts.sessionTimingService, yield* StreamManagerTag, yield* SecretsStoreTag, - yield* ProvidersConfigStoreTag + yield* ProvidersConfigStoreTag, + yield* DesktopInputCoordinatorTag ); }) ); @@ -403,7 +411,8 @@ export const TaskLive = Layer.effect( yield* SessionUsage, yield* WorkspaceGoal, yield* SecretsStoreTag, - yield* TerminalAttentionStoreTag + yield* TerminalAttentionStoreTag, + yield* DesktopInputCoordinatorTag ); }) ); @@ -423,7 +432,8 @@ export const WorkspaceTurnManagerLive = Layer.effect( yield* InitStateManagerTag, yield* Task, yield* TerminalAttentionStoreTag, - yield* StreamManagerTag + yield* StreamManagerTag, + yield* DesktopInputCoordinatorTag ); }) ); @@ -582,6 +592,7 @@ const S1 = Layer.mergeAll( ExtensionMetadataLive, MemoryLive, TerminalAttentionStoreLive, + DesktopInputCoordinatorLive, IdleDispatcherLive, TurnRequestBuilderBindingsLive ); diff --git a/src/node/services/di/layers/desktop.ts b/src/node/services/di/layers/desktop.ts index e4537cb50a..ed9a30b848 100644 --- a/src/node/services/di/layers/desktop.ts +++ b/src/node/services/di/layers/desktop.ts @@ -67,6 +67,7 @@ import { CopilotOauth, DesktopBridgeServerTag, DesktopSessionManagerTag, + DesktopInputCoordinatorTag, DesktopTokenManagerTag, DevTools, Editor, @@ -286,10 +287,11 @@ export const BrowserLive: Layer.Layer = Layer.eff export const DesktopBridgeLive: Layer.Layer< DesktopBridgeTags, never, - ConfigTag | Experiments | Workspace + ConfigTag | Experiments | Workspace | DesktopInputCoordinatorTag > = Layer.effectContext( Effect.gen(function* () { const desktopSessionManager = new DesktopSessionManager({ + inputCoordinator: yield* DesktopInputCoordinatorTag, config: yield* ConfigTag, experimentsService: yield* Experiments, workspaceService: yield* Workspace, diff --git a/src/node/services/di/tags.ts b/src/node/services/di/tags.ts index 761dbe6dcb..f1f797a842 100644 --- a/src/node/services/di/tags.ts +++ b/src/node/services/di/tags.ts @@ -35,6 +35,7 @@ import type { CoderService } from "@/node/services/coderService"; import type { CodexOauthService } from "@/node/services/codexOauthService"; import type { CopilotOauthService } from "@/node/services/copilotOauthService"; import type { DesktopBridgeServer } from "@/node/services/desktop/DesktopBridgeServer"; +import type { DesktopInputCoordinator } from "@/node/services/desktop/DesktopInputCoordinator"; import type { DesktopSessionManager } from "@/node/services/desktop/DesktopSessionManager"; import type { DesktopTokenManager } from "@/node/services/desktop/DesktopTokenManager"; import type { DevToolsService } from "@/node/services/devToolsService"; @@ -152,6 +153,11 @@ export class MemoryConsolidation extends Context.Service< MemoryConsolidation, MemoryConsolidationService >()("xum/MemoryConsolidation") {} +/** Shared input gates are available before task/workspace construction (no desktop-session dependency). */ +export class DesktopInputCoordinatorTag extends Context.Service< + DesktopInputCoordinatorTag, + DesktopInputCoordinator +>()("xum/DesktopInputCoordinator") {} /** Terminal attention records; built by the core graph for Task/TurnManager (not a `CoreServices` field). */ export class TerminalAttentionStoreTag extends Context.Service< TerminalAttentionStoreTag, @@ -318,6 +324,7 @@ export type CoreTags = | Memory | MemoryMeta | MemoryConsolidation + | DesktopInputCoordinatorTag | TerminalAttentionStoreTag | TurnRequestBuilderBindingsTag; diff --git a/src/node/services/tools/task.test.ts b/src/node/services/tools/task.test.ts index 9baaeabef1..1ccb2d3eac 100644 --- a/src/node/services/tools/task.test.ts +++ b/src/node/services/tools/task.test.ts @@ -372,6 +372,87 @@ describe("task tool", () => { expect(silent.note ?? "").not.toContain("wst_previous_turn"); }); + it.each([ + { kind: "workspace", desktop: "shared" }, + { kind: "workspace", desktop: "isolated" }, + { agentId: "desktop", n: 2 }, + { agentId: "desktop", desktop: null, n: 2 }, + { agentId: "custom", desktop: "shared", n: 2 }, + ])("rejects invalid desktop delegation before creating any work: %j", async (args) => { + using tempDir = new TestTempDir("test-desktop-task-refusal"); + const create = mock(() => Ok({ taskId: "unexpected", kind: "agent", status: "running" })); + const createWorkspaceTurn = mock(() => Promise.resolve(Err("unexpected"))); + const baseConfig = createTestToolConfig(tempDir.path); + const tool = createTaskTool({ + ...baseConfig, + taskService: { create } as unknown as TaskService, + workspaceTurnManager: { + createWorkspaceTurn, + } as unknown as NonNullable, + }); + await expect( + tool.execute!({ ...args, prompt: "test", title: "Operator" }, mockToolCallOptions) + ).rejects.toThrow("task tool input validation failed"); + expect(create).not.toHaveBeenCalled(); + expect(createWorkspaceTurn).not.toHaveBeenCalled(); + }); + + it.each([true, false])( + "preserves the resolved desktop in task results (background=%s)", + async (background) => { + using tempDir = new TestTempDir("test-desktop-task-target"); + const create = mock((_: { desktop?: string; isolation?: string }) => + Ok({ + taskId: "child", + kind: "agent" as const, + status: "running" as const, + desktopOwnerWorkspaceId: "ancestor", + }) + ); + const taskService = { + create, + waitForAgentReport: () => Promise.resolve({ reportMarkdown: "done" }), + } as unknown as TaskService; + const tool = createTaskTool({ ...createTestToolConfig(tempDir.path), taskService }); + const result = await tool.execute!( + { + agentId: "custom", + desktop: "shared", + isolation: "none", + prompt: "test", + title: "Operator", + run_in_background: background, + }, + mockToolCallOptions + ); + expect(create.mock.calls[0]?.[0]).toMatchObject({ desktop: "shared", isolation: "none" }); + expect(result).toMatchObject({ desktopOwnerWorkspaceId: "ancestor" }); + } + ); + + it("allows explicitly isolated desktop groups", async () => { + using tempDir = new TestTempDir("test-isolated-desktop-group"); + const create = mock(() => + Ok({ taskId: "child", kind: "agent" as const, status: "running" as const }) + ); + const tool = createTaskTool({ + ...createTestToolConfig(tempDir.path), + taskService: { create } as unknown as TaskService, + }); + await tool.execute!( + { + agentId: "desktop", + desktop: "isolated", + n: 2, + prompt: "test", + title: "Operator", + run_in_background: true, + }, + mockToolCallOptions + ); + expect(create).toHaveBeenCalledTimes(2); + }); + it("forwards isolation to taskService.create", async () => { using tempDir = new TestTempDir("test-task-tool-isolation-passthrough"); const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "parent-workspace" }); diff --git a/src/node/services/tools/task.ts b/src/node/services/tools/task.ts index b9d41e18e6..ca9d17ed83 100644 --- a/src/node/services/tools/task.ts +++ b/src/node/services/tools/task.ts @@ -151,6 +151,7 @@ interface SpawnedTaskInfo { status: "queued" | "starting" | "running"; modelString?: string; thinkingLevel?: ThinkingLevel; + desktopOwnerWorkspaceId?: string; } interface PendingTaskInfo { @@ -158,6 +159,7 @@ interface PendingTaskInfo { status: "queued" | "starting" | "running" | "completed" | "interrupted"; modelString?: string; thinkingLevel?: ThinkingLevel; + desktopOwnerWorkspaceId?: string; } interface CompletedTaskInfo { @@ -169,6 +171,7 @@ interface CompletedTaskInfo { agentType: string; modelString?: string; thinkingLevel?: ThinkingLevel; + desktopOwnerWorkspaceId?: string; } type ForegroundWaitOutcome = @@ -224,6 +227,7 @@ function serializeCompletedReport(report: CompletedTaskInfo) { agentType: report.agentType, modelString: report.modelString, thinkingLevel: report.thinkingLevel, + desktopOwnerWorkspaceId: report.desktopOwnerWorkspaceId, }; } @@ -277,6 +281,7 @@ function buildPendingTaskResult(params: { taskId: task.taskId, modelString: task.modelString, thinkingLevel: task.thinkingLevel, + desktopOwnerWorkspaceId: task.desktopOwnerWorkspaceId, note: params.note, }; } @@ -289,6 +294,7 @@ function buildPendingTaskResult(params: { status: task.status, modelString: task.modelString, thinkingLevel: task.thinkingLevel, + desktopOwnerWorkspaceId: task.desktopOwnerWorkspaceId, })), note: params.note, ...(serializedReports ? { reports: serializedReports } : {}), @@ -311,6 +317,7 @@ function buildCompletedTaskResult(params: { agentType: report.agentType, modelString: report.modelString, thinkingLevel: report.thinkingLevel, + desktopOwnerWorkspaceId: report.desktopOwnerWorkspaceId, }; } @@ -337,6 +344,7 @@ function normalizePendingTaskStatuses(params: { status: "completed", modelString: completedReport.modelString ?? createdTask.modelString, thinkingLevel: completedReport.thinkingLevel ?? createdTask.thinkingLevel, + desktopOwnerWorkspaceId: createdTask.desktopOwnerWorkspaceId, }; } @@ -354,6 +362,7 @@ function normalizePendingTaskStatuses(params: { : "running", modelString: createdTask.modelString, thinkingLevel: createdTask.thinkingLevel, + desktopOwnerWorkspaceId: createdTask.desktopOwnerWorkspaceId, }; }); } @@ -401,6 +410,7 @@ export const createTaskTool: ToolFactory = (config: ToolConfiguration) => { model, thinking, isolation, + desktop, workspace, } = validatedArgs; @@ -572,6 +582,7 @@ export const createTaskTool: ToolFactory = (config: ToolConfiguration) => { ? { thinkingLevel: aiOverrides.thinkingLevel } : {}), ...(isolation != null ? { isolation } : {}), + ...(desktop != null ? { desktop } : {}), ...(parentRuntimeAiSettings != null ? { parentRuntimeAiSettings } : {}), // Background launches are non-blocking with terminal wake-up; foreground/default block. attentionPolicy: run_in_background ? "notify_on_terminal" : "blocking_until_terminal", @@ -608,6 +619,7 @@ export const createTaskTool: ToolFactory = (config: ToolConfiguration) => { status: created.data.status, modelString: created.data.modelString, thinkingLevel: created.data.thinkingLevel, + desktopOwnerWorkspaceId: created.data.desktopOwnerWorkspaceId, } satisfies SpawnedTaskInfo; createdTasks.push(task); @@ -654,6 +666,7 @@ export const createTaskTool: ToolFactory = (config: ToolConfiguration) => { // auto-handoffs to exec rewrites its task settings after launch. modelString: report.model ?? createdTask.modelString, thinkingLevel: report.thinkingLevel ?? createdTask.thinkingLevel, + desktopOwnerWorkspaceId: createdTask.desktopOwnerWorkspaceId, } satisfies CompletedTaskInfo, }; } catch (error: unknown) { From 9c195fabcdf276331379faca895870623dd23a8f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 11:17:49 +0000 Subject: [PATCH 02/25] =?UTF-8?q?=F0=9F=A4=96=20feat:=20share=20desktop=20?= =?UTF-8?q?sessions=20with=20coordinated=20task=20input?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve persisted desktop owners from current ancestry and serialize durable admission with input. Keep sessions owner-keyed and tokens requester-bound. Validation: 49 desktop tests and touched-file formatting pass. Typecheck and typed lint await the parent-owned taskDesktopOwnerWorkspaceId schema; typecheck also exposes the existing workspaceService.test.ts Config stub error. --- src/common/types/desktop.ts | 8 +- .../desktop/DesktopBridgeServer.test.ts | 56 ++++ .../services/desktop/DesktopBridgeServer.ts | 13 + .../desktop/DesktopInputCoordinator.test.ts | 267 ++++++++++++++++++ .../desktop/DesktopInputCoordinator.ts | 171 +++++++++++ .../desktop/DesktopSessionManager.test.ts | 254 ++++++++++++++++- .../services/desktop/DesktopSessionManager.ts | 182 ++++++------ .../services/desktop/DesktopTokenManager.ts | 2 + .../services/desktop/desktopOperations.ts | 17 +- 9 files changed, 868 insertions(+), 102 deletions(-) create mode 100644 src/node/services/desktop/DesktopInputCoordinator.test.ts create mode 100644 src/node/services/desktop/DesktopInputCoordinator.ts diff --git a/src/common/types/desktop.ts b/src/common/types/desktop.ts index 7ffb1f8e32..86547584a5 100644 --- a/src/common/types/desktop.ts +++ b/src/common/types/desktop.ts @@ -13,7 +13,13 @@ export type DesktopPrereqStatus = /** Capability check result for a workspace's desktop support. */ export type DesktopCapability = - | { available: true; width: number; height: number; sessionId: string } + | { + available: true; + width: number; + height: number; + sessionId: string; + sharedDesktop?: { ownerWorkspaceId: string; ownerName: string }; + } | { available: false; reason: diff --git a/src/node/services/desktop/DesktopBridgeServer.test.ts b/src/node/services/desktop/DesktopBridgeServer.test.ts index 04eba579b3..c4ec0976a1 100644 --- a/src/node/services/desktop/DesktopBridgeServer.test.ts +++ b/src/node/services/desktop/DesktopBridgeServer.test.ts @@ -3,6 +3,7 @@ import * as net from "node:net"; import { describe, expect, mock, spyOn, test } from "bun:test"; import { WebSocket, type RawData } from "ws"; import { DesktopBridgeServer } from "./DesktopBridgeServer"; +import { DesktopTokenManager } from "./DesktopTokenManager"; const VALID_TOKEN = "valid-token"; const VALID_WORKSPACE_ID = "workspace-1"; @@ -304,6 +305,61 @@ async function waitForTcpData(socket: net.Socket, timeoutMs = 2_000): Promise { + test("shared tokens authorize the requester and bind its owner's session", async () => { + const tcpHarness = await listenTcpServer(); + const tokens = new DesktopTokenManager(); + const token = tokens.mint("child", "owner-session"); + const getLiveSessionConnection = mock((workspaceId: string) => + workspaceId === "child" ? { sessionId: "owner-session", vncPort: tcpHarness.port } : null + ); + const bridgeServer = new DesktopBridgeServer({ + desktopTokenManager: tokens, + desktopSessionManager: { getLiveSessionConnection }, + }); + const upgradeHarness = await listenUpgradeServer(bridgeServer); + let ws: WebSocket | null = null; + try { + ws = new WebSocket(`ws://127.0.0.1:${upgradeHarness.port}/?token=${token}&workspaceId=owner`); + await waitForWebSocketOpen(ws); + const tcpSocket = await tcpHarness.connectionPromise; + ws.send(Buffer.from([1, 2, 3])); + expect(await waitForTcpData(tcpSocket)).toEqual(Buffer.from([1, 2, 3])); + expect(getLiveSessionConnection.mock.calls.map((call) => call[0])).toEqual([ + "child", + "child", + ]); + const replay = new WebSocket(`ws://127.0.0.1:${upgradeHarness.port}/?token=${token}`); + expect((await waitForWebSocketClose(replay)).code).toBe(4001); + } finally { + if (ws) await closeWebSocket(ws); + tokens.dispose(); + await upgradeHarness.close(); + await bridgeServer.stop(); + await tcpHarness.close(); + } + }); + + test("refuses a requester whose target disappears while connecting to VNC", async () => { + const tcpHarness = await listenTcpServer(); + let checks = 0; + const bridgeServer = createBridgeServer({ + getLiveSessionConnection: () => { + checks += 1; + return checks === 1 ? { sessionId: VALID_SESSION_ID, vncPort: tcpHarness.port } : null; + }, + }); + const upgradeHarness = await listenUpgradeServer(bridgeServer); + try { + const ws = new WebSocket(`ws://127.0.0.1:${upgradeHarness.port}/?token=${VALID_TOKEN}`); + expect((await waitForWebSocketClose(ws)).code).toBe(4002); + expect(checks).toBe(2); + } finally { + await upgradeHarness.close(); + await bridgeServer.stop(); + await tcpHarness.close(); + } + }); + test("handleUpgrade bridges binary traffic when mounted on an external HTTP server", async () => { const tcpHarness = await listenTcpServer(); const bridgeServer = createBridgeServer({ diff --git a/src/node/services/desktop/DesktopBridgeServer.ts b/src/node/services/desktop/DesktopBridgeServer.ts index 598199f87d..ec13e2ce1d 100644 --- a/src/node/services/desktop/DesktopBridgeServer.ts +++ b/src/node/services/desktop/DesktopBridgeServer.ts @@ -214,6 +214,19 @@ export class DesktopBridgeServer { try { const tcp = await this.connectToVnc(liveSession.vncPort); + // Tokens name the requester, not the owner: revalidate the current relationship after + // connecting too, so an archive/removal during TCP setup cannot attach a stale borrower. + const currentSession = this.desktopSessionManager.getLiveSessionConnection( + payload.workspaceId + ); + if ( + currentSession?.sessionId !== payload.sessionId || + currentSession.vncPort !== liveSession.vncPort + ) { + tcp.destroy(); + closeWebSocket(ws, MISSING_SESSION_CLOSE_CODE, "session unavailable"); + return; + } const pair: BridgePair = { ws, tcp, closed: false }; this.attachBridgeListeners(pair, payload.workspaceId, liveSession.sessionId); this.activePairs.add(pair); diff --git a/src/node/services/desktop/DesktopInputCoordinator.test.ts b/src/node/services/desktop/DesktopInputCoordinator.test.ts new file mode 100644 index 0000000000..5f41ae8dbe --- /dev/null +++ b/src/node/services/desktop/DesktopInputCoordinator.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, test } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { Workspace } from "@/common/types/project"; +import { Config } from "@/node/config"; +import { DesktopInputCoordinator } from "./DesktopInputCoordinator"; + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function workspace(id: string, fields: Partial = {}): Workspace { + return { id, name: id, path: `/tmp/desktop-coordinator/${id}`, ...fields }; +} + +const owner = workspace("owner"); +const borrower = (id: string, fields: Partial = {}) => + workspace(id, { + parentWorkspaceId: "owner", + taskDesktopOwnerWorkspaceId: "owner", + taskStatus: "reported", + ...fields, + }); + +async function withCoordinator( + run: ( + coordinator: DesktopInputCoordinator, + write: (workspaces: Workspace[]) => Promise + ) => Promise +) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "desktop-coordinator-")); + const config = new Config(root); + const write = async (workspaces: Workspace[]) => { + await config.editConfig((current) => { + current.projects.set("/tmp/desktop-coordinator", { workspaces }); + return current; + }); + }; + try { + await write([owner, borrower("child")]); + await run(new DesktopInputCoordinator(config), write); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +} + +describe("DesktopInputCoordinator", () => { + test("resolves flattened ancestry and leaves legacy children isolated", async () => { + await withCoordinator(async (coordinator, write) => { + await write([ + owner, + borrower("child"), + borrower("nested", { parentWorkspaceId: "child" }), + workspace("legacy", { + parentWorkspaceId: "child", + runtimeConfig: { type: "worktree", srcBaseDir: "/tmp" }, + }), + ]); + expect(coordinator.resolveTarget("nested")).toEqual({ + ownerWorkspaceId: "owner", + ownerName: "owner", + }); + expect(coordinator.resolveTarget("legacy").ownerWorkspaceId).toBe("legacy"); + await write([{ ...owner, name: "renamed" }, borrower("child")]); + expect(coordinator.resolveTarget("child").ownerName).toBe("renamed"); + }); + }); + + test("rejects missing, unrelated, cyclic, chained, archived, and unsupported targets", async () => { + await withCoordinator(async (coordinator, write) => { + const invalid: Array<{ entries: Workspace[]; message: string }> = [ + { entries: [borrower("child")], message: "not found" }, + { entries: [owner], message: "not found" }, + { + entries: [owner, borrower("child", { parentWorkspaceId: undefined })], + message: "not an ancestor", + }, + { + entries: [{ ...owner, parentWorkspaceId: "child" }, borrower("child")], + message: "cycle", + }, + { + entries: [owner, borrower("child", { parentWorkspaceId: "missing" })], + message: "ancestor workspace not found", + }, + { + entries: [{ ...owner, taskDesktopOwnerWorkspaceId: "other" }, borrower("child")], + message: "itself be bound", + }, + { + entries: [owner, borrower("child", { taskDesktopOwnerWorkspaceId: "child" })], + message: "itself be bound", + }, + { + entries: [owner, borrower("child", { taskDesktopOwnerWorkspaceId: "" })], + message: "Invalid desktop owner", + }, + { + entries: [{ ...owner, archivedAt: "2026-09-01T00:00:00Z" }, borrower("child")], + message: "archived", + }, + { + entries: [owner, borrower("child", { archivedAt: "2026-09-01T00:00:00Z" })], + message: "archived", + }, + { + entries: [ + { ...owner, runtimeConfig: { type: "ssh", host: "host", srcBaseDir: "/tmp" } }, + borrower("child"), + ], + message: "Unsupported desktop runtime", + }, + { + entries: [ + owner, + borrower("child", { runtimeConfig: { type: "docker", image: "image" } }), + ], + message: "Unsupported desktop runtime", + }, + ]; + for (const { entries, message } of invalid) { + await write(entries); + expect(() => coordinator.resolveTarget("child")).toThrow(message); + } + }); + }); + + test("only the single active borrower can input and either lifecycle status claims control", async () => { + await withCoordinator(async (coordinator, write) => { + const states: Array> = [ + ...(["queued", "starting", "running", "awaiting_report"] as const).map((taskStatus) => ({ + taskStatus, + })), + ...(["queued", "starting", "running"] as const).map((taskExecutionStatus) => ({ + taskExecutionStatus, + })), + ]; + for (const state of states) { + await write([owner, borrower("child", state), borrower("other")]); + expect(await coordinator.withInput("child", () => Promise.resolve("input"))).toBe("input"); + expect(coordinator.withInput("owner", () => Promise.resolve())).rejects.toThrow( + "controlled by" + ); + expect(coordinator.withInput("other", () => Promise.resolve())).rejects.toThrow( + "controlled by" + ); + } + await write([owner, borrower("child")]); + expect(coordinator.withInput("child", () => Promise.resolve())).rejects.toThrow("not active"); + expect(await coordinator.withInput("owner", () => Promise.resolve("input"))).toBe("input"); + await write([ + owner, + borrower("child", { taskStatus: "running" }), + borrower("other", { taskExecutionStatus: "running" }), + ]); + for (const id of ["owner", "child", "other"]) { + expect(coordinator.withInput(id, () => Promise.resolve())).rejects.toThrow( + "multiple active" + ); + } + }); + }); + + test("an open input holds admission, then persisted admission blocks later owner input", async () => { + await withCoordinator(async (coordinator, write) => { + const entered = deferred(); + const release = deferred(); + let admitted = false; + const input = coordinator.withInput("owner", async () => { + entered.resolve(); + await release.promise; + }); + await entered.promise; + const admission = coordinator.withAdmission("child", async () => { + admitted = true; + await write([owner, borrower("child", { taskStatus: "running" })]); + }); + const nextInput = coordinator.withInput("owner", () => Promise.resolve()); + const rejectedInput = nextInput.catch((error: unknown) => error); + expect(admitted).toBe(false); + release.resolve(); + await Promise.all([input, admission]); + expect(String(await rejectedInput)).toContain("controlled by"); + expect(admitted).toBe(true); + }); + }); + + test("overlapping reservations observe persisted winners and permit the same borrower", async () => { + await withCoordinator(async (coordinator, write) => { + const entered = deferred(); + const release = deferred(); + let losingCallback = false; + const first = coordinator.withReservation("owner", "first", async () => { + entered.resolve(); + await release.promise; + await write([owner, borrower("first", { taskStatus: "queued" })]); + }); + await entered.promise; + const second = coordinator.withReservation("owner", "second", () => { + losingCallback = true; + return Promise.resolve(); + }); + const rejected = second.catch((error: unknown) => error); + release.resolve(); + await first; + expect(String(await rejected)).toContain("controlled by"); + expect(losingCallback).toBe(false); + expect( + await coordinator.withReservation("owner", "first", () => Promise.resolve("same")) + ).toBe("same"); + }); + }); + + test("revalidates queued operations and releases the gate after failures", async () => { + await withCoordinator(async (coordinator, write) => { + const entered = deferred(); + const release = deferred(); + const first = coordinator.withInput("owner", async () => { + entered.resolve(); + await release.promise; + throw new Error("input failed"); + }); + const failed = first.catch((error: unknown) => error); + await entered.promise; + const admission = coordinator.withAdmission("child", () => Promise.resolve()); + const rejected = admission.catch((error: unknown) => error); + await write([owner]); + release.resolve(); + expect(String(await failed)).toContain("input failed"); + expect(String(await rejected)).toContain("not found"); + expect(await coordinator.withInput("owner", () => Promise.resolve("released"))).toBe( + "released" + ); + }); + }); + + test("isolated remote admissions are unchanged and unrelated owners do not block", async () => { + await withCoordinator(async (coordinator, write) => { + await write([ + owner, + workspace("remote", { runtimeConfig: { type: "ssh", host: "host", srcBaseDir: "/tmp" } }), + workspace("other"), + ]); + const entered = deferred(); + const release = deferred(); + const input = coordinator.withInput("owner", async () => { + entered.resolve(); + await release.promise; + }); + await entered.promise; + try { + expect(await coordinator.withAdmission("remote", () => Promise.resolve("admitted"))).toBe( + "admitted" + ); + expect(await coordinator.withInput("other", () => Promise.resolve("input"))).toBe("input"); + } finally { + release.resolve(); + await input; + } + }); + }); +}); diff --git a/src/node/services/desktop/DesktopInputCoordinator.ts b/src/node/services/desktop/DesktopInputCoordinator.ts new file mode 100644 index 0000000000..134d8fbf76 --- /dev/null +++ b/src/node/services/desktop/DesktopInputCoordinator.ts @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import type { ProjectsConfig, Workspace } from "@/common/types/project"; +import { isWorkspaceArchived } from "@/common/utils/archive"; +import type { Config } from "@/node/config"; +import { findWorkspaceEntry } from "@/node/services/taskUtils"; +import { MutexMap } from "@/node/utils/concurrency/mutexMap"; + +export interface DesktopTarget { + ownerWorkspaceId: string; + ownerName: string; +} + +export class UnsupportedDesktopRuntimeError extends Error {} + +/** + * Delegation changes the operator, not the computer; checkout isolation is separate. + * The gate covers input and durable admission together. Config task statuses are the + * only ownership ledger, so completion/restart never depends on an in-memory lease. + */ +export class DesktopInputCoordinator { + private readonly gates = new MutexMap(); + + constructor(private readonly config: Config) {} + + resolveTarget(workspaceId: string): DesktopTarget { + return this.resolveFromConfig(this.config.loadConfigOrDefault(), workspaceId); + } + + async withReservation( + ownerWorkspaceId: string, + borrowerWorkspaceId: string, + reserve: () => Promise + ): Promise { + assert(borrowerWorkspaceId.length > 0, "Desktop reservation requires a borrower ID"); + return this.gates.withLock(ownerWorkspaceId, async () => { + const config = this.config.loadConfigOrDefault(); + const owner = this.resolveFromConfig(config, ownerWorkspaceId); + if (owner.ownerWorkspaceId !== ownerWorkspaceId || ownerWorkspaceId === borrowerWorkspaceId) { + throw new Error("Desktop reservation requires an unbound owner and a distinct borrower"); + } + this.assertController(config, ownerWorkspaceId, borrowerWorkspaceId, false); + return reserve(); + }); + } + + async withAdmission(workspaceId: string, admit: () => Promise): Promise { + const entry = findWorkspaceEntry(this.config.loadConfigOrDefault(), workspaceId); + // Non-desktop/legacy tasks retain their existing admission behavior, including remote runtimes. + if (entry?.workspace.taskDesktopOwnerWorkspaceId === undefined) return admit(); + const target = this.resolveTarget(workspaceId); + return this.gates.withLock(target.ownerWorkspaceId, async () => { + const config = this.config.loadConfigOrDefault(); + this.assertSameTarget(config, workspaceId, target.ownerWorkspaceId); + this.assertController(config, target.ownerWorkspaceId, workspaceId, false); + return admit(); + }); + } + + async withInput(workspaceId: string, run: () => Promise): Promise { + const target = this.resolveTarget(workspaceId); + return this.gates.withLock(target.ownerWorkspaceId, async () => { + const config = this.config.loadConfigOrDefault(); + this.assertSameTarget(config, workspaceId, target.ownerWorkspaceId); + this.assertController(config, target.ownerWorkspaceId, workspaceId, true); + return run(); + }); + } + + private assertSameTarget(config: ProjectsConfig, workspaceId: string, ownerWorkspaceId: string) { + if (this.resolveFromConfig(config, workspaceId).ownerWorkspaceId !== ownerWorkspaceId) { + throw new Error(`Desktop target changed for workspace ${workspaceId}`); + } + } + + private assertController( + config: ProjectsConfig, + ownerWorkspaceId: string, + workspaceId: string, + requireActive: boolean + ): void { + const activeBorrowers: string[] = []; + for (const project of config.projects.values()) { + for (const workspace of project.workspaces) { + if ( + workspace.taskDesktopOwnerWorkspaceId !== ownerWorkspaceId || + !this.isActive(workspace) + ) { + continue; + } + if (!workspace.id) throw new Error("Active desktop borrower is missing its workspace ID"); + this.resolveFromConfig(config, workspace.id); + activeBorrowers.push(workspace.id); + } + } + if (activeBorrowers.length > 1) { + throw new Error(`Desktop ${ownerWorkspaceId} has multiple active borrowers`); + } + const activeBorrower = activeBorrowers[0]; + if (activeBorrower !== undefined && activeBorrower !== workspaceId) { + throw new Error( + `Desktop ${ownerWorkspaceId} is controlled by active borrower ${activeBorrower}` + ); + } + if (requireActive && workspaceId !== ownerWorkspaceId && activeBorrower !== workspaceId) { + throw new Error(`Desktop borrower ${workspaceId} is not active`); + } + } + + private isActive(workspace: Workspace): boolean { + return ( + workspace.taskStatus === "queued" || + workspace.taskStatus === "starting" || + workspace.taskStatus === "running" || + workspace.taskStatus === "awaiting_report" || + workspace.taskExecutionStatus === "queued" || + workspace.taskExecutionStatus === "starting" || + workspace.taskExecutionStatus === "running" + ); + } + + private resolveFromConfig(config: ProjectsConfig, workspaceId: string): DesktopTarget { + assert(workspaceId.length > 0, "Desktop target requires a workspace ID"); + const requester = this.requireWorkspace(config, workspaceId); + const ownerWorkspaceId = requester.taskDesktopOwnerWorkspaceId ?? workspaceId; + // Null/empty bindings are corruption, not an invitation to silently allocate another desktop. + if ( + requester.taskDesktopOwnerWorkspaceId !== undefined && + (typeof requester.taskDesktopOwnerWorkspaceId !== "string" || ownerWorkspaceId.length === 0) + ) { + throw new Error(`Invalid desktop owner for workspace ${workspaceId}`); + } + const owner = this.requireWorkspace(config, ownerWorkspaceId); + if (owner.taskDesktopOwnerWorkspaceId !== undefined) { + throw new Error(`Desktop owner ${ownerWorkspaceId} must not itself be bound`); + } + if (requester.taskDesktopOwnerWorkspaceId !== undefined) { + const visited = new Set([workspaceId]); + let parentId = requester.parentWorkspaceId; + let foundOwner = false; + while (parentId !== undefined) { + if (visited.has(parentId)) throw new Error(`Desktop ancestry cycle at ${parentId}`); + visited.add(parentId); + const parent = findWorkspaceEntry(config, parentId)?.workspace; + if (!parent) throw new Error(`Desktop ancestor workspace not found: ${parentId}`); + if (parentId === ownerWorkspaceId) foundOwner = true; + parentId = parent.parentWorkspaceId; + } + if (!foundOwner) { + throw new Error(`Desktop owner ${ownerWorkspaceId} is not an ancestor of ${workspaceId}`); + } + } + return { ownerWorkspaceId, ownerName: owner.name ?? ownerWorkspaceId }; + } + + private requireWorkspace(config: ProjectsConfig, workspaceId: string): Workspace { + const workspace = findWorkspaceEntry(config, workspaceId)?.workspace; + if (!workspace) throw new Error(`Desktop workspace not found: ${workspaceId}`); + if (isWorkspaceArchived(workspace.archivedAt, workspace.unarchivedAt)) { + throw new Error( + `Workspace is archived: ${workspaceId}. Unarchive it before using a desktop.` + ); + } + const runtime = workspace.runtimeConfig?.type; + if (runtime !== undefined && runtime !== "local" && runtime !== "worktree") { + throw new UnsupportedDesktopRuntimeError( + `Unsupported desktop runtime for ${workspaceId}: ${runtime}` + ); + } + return workspace; + } +} diff --git a/src/node/services/desktop/DesktopSessionManager.test.ts b/src/node/services/desktop/DesktopSessionManager.test.ts index 4be8079680..9cfb2cd04f 100644 --- a/src/node/services/desktop/DesktopSessionManager.test.ts +++ b/src/node/services/desktop/DesktopSessionManager.test.ts @@ -1,7 +1,12 @@ import * as fs from "fs/promises"; import * as os from "os"; import * as path from "path"; -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; +import type { Workspace } from "@/common/types/project"; +import { DESKTOP_DEFAULTS } from "@/common/constants/desktop"; +import { PortableDesktopSession } from "./PortableDesktopSession"; +import { DesktopTokenManager } from "./DesktopTokenManager"; +import { getDesktopBootstrap } from "./desktopOperations"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import { Config } from "@/node/config"; import { ExperimentsService } from "@/node/services/experimentsService"; @@ -85,6 +90,44 @@ async function withDesktopManagerHarness( const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); try { + await config.editConfig((current) => { + current.projects.set("/tmp/project-1", { + workspaces: [ + "platform", + "missing-binary", + "local", + "reuse", + "archiving", + "dead", + "close-one", + "close-two", + "action", + ] + .map( + (suffix): Workspace => ({ + id: `workspace-${suffix}`, + name: `workspace-${suffix}`, + path: `/tmp/project-1/workspace-${suffix}`, + runtimeConfig: { type: "local" }, + }) + ) + .concat([ + { + id: "workspace-ssh", + name: "workspace-ssh", + path: "/tmp/project-1/ssh", + runtimeConfig: { type: "ssh", host: "example.com", srcBaseDir: "~/mux" }, + }, + { + id: "workspace-worktree", + name: "workspace-worktree", + path: "/tmp/project-1/worktree", + runtimeConfig: { type: "worktree", srcBaseDir: "/tmp/worktrees" }, + }, + ] as Workspace[]), + }); + return current; + }); await run({ tempDir, config, originalPath }); } finally { process.env.PATH = originalPath; @@ -293,7 +336,216 @@ function assertPortableDesktopRecordedCommands( } } +async function registerSharedWorkspaces(config: Config): Promise { + await config.editConfig((current) => { + const project = current.projects.get("/tmp/project-1"); + if (!project) throw new Error("Missing test project"); + project.workspaces.push( + { id: "owner", name: "owner-name", path: "/tmp/project-1/owner" }, + { + id: "child", + name: "child", + path: "/tmp/project-1/child", + parentWorkspaceId: "owner", + taskDesktopOwnerWorkspaceId: "owner", + taskStatus: "running", + }, + { + id: "isolated", + name: "isolated", + path: "/tmp/project-1/isolated", + parentWorkspaceId: "owner", + } + ); + return current; + }); +} + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + describe("DesktopSessionManager", () => { + test("shares startup, screenshots, actions and bootstrap while legacy children stay isolated", async () => { + await withDesktopManagerHarness(async ({ tempDir, config }) => { + if (process.platform === "win32") return; + await registerSharedWorkspaces(config); + const actionRecordPath = path.join(tempDir, "shared-actions.json"); + await installPortableDesktopShim({ + rootDir: tempDir, + config: { + startupInfo: createStartupInfo({ display: 20, vncPort: 5910, geometry: "1024x768" }), + actionRecordPath, + }, + }); + process.env.PATH = ""; + const manager = new DesktopSessionManager({ + config, + experimentsService: createExperimentsService(true), + workspaceService: createWorkspaceService(() => Promise.resolve(null)), + }); + const tokens = new DesktopTokenManager(); + const start = spyOn(PortableDesktopSession.prototype, "start"); + const serverService = { + getServerInfo: () => ({ + baseUrl: "http://127.0.0.1:1234", + token: "test", + bindHost: "127.0.0.1", + port: 1234, + networkBaseUrls: [], + }), + }; + try { + const [parentSession, childSession] = await Promise.all([ + manager.ensureStarted("owner"), + manager.ensureStarted("child"), + ]); + expect(childSession).toBe(parentSession); + expect(start).toHaveBeenCalledTimes(1); + expect(manager.has("child")).toBe(false); + expect(await manager.screenshot("child")).toEqual(await manager.screenshot("owner")); + expect(await manager.action("child", "key_press", { key: "Return" })).toEqual({ + success: true, + }); + expect(manager.action("owner", "key_press", { key: "Return" })).rejects.toThrow( + "controlled by" + ); + const recorded: unknown = JSON.parse(await fs.readFile(actionRecordPath, "utf8")); + assertPortableDesktopRecordedCommands(recorded); + expect(recorded.length).toBe(1); + expect(recorded[0]?.stateFile).toContain("owner"); + await manager.close("child"); + expect(parentSession.isAlive()).toBe(true); + + const isolated = await manager.ensureStarted("isolated"); + expect(isolated).not.toBe(parentSession); + expect(start).toHaveBeenCalledTimes(2); + const bootstrap = await getDesktopBootstrap( + { desktopSessionManager: manager, desktopTokenManager: tokens, serverService }, + "child" + ); + expect(bootstrap.capability.available).toBe(true); + if (!bootstrap.capability.available || !bootstrap.token) + throw new Error("Expected bootstrap"); + expect(bootstrap.capability.sharedDesktop).toEqual({ + ownerWorkspaceId: "owner", + ownerName: "owner-name", + }); + const ownerSessionId = parentSession.getSessionInfo().sessionId; + if (!ownerSessionId) throw new Error("Expected owner session ID"); + expect(tokens.validate(bootstrap.token)).toEqual({ + workspaceId: "child", + sessionId: ownerSessionId, + }); + expect(tokens.validate(bootstrap.token)).toBeNull(); + expect(manager.getLiveSessionConnection("child")).toEqual( + manager.getLiveSessionConnection("owner") + ); + + await config.editConfig((current) => { + const child = current.projects + .get("/tmp/project-1") + ?.workspaces.find((entry) => entry.id === "child"); + if (!child) throw new Error("Missing child"); + child.archivedAt = "2026-09-01T00:00:00Z"; + return current; + }); + expect(manager.getLiveSessionConnection("child")).toBeNull(); + expect(manager.getLiveSessionConnection("owner")).not.toBeNull(); + expect(await manager.getCapability("child")).toEqual({ + available: false, + reason: "startup_failed", + }); + } finally { + start.mockRestore(); + tokens.dispose(); + await manager.closeAll(); + } + }); + }); + + for (const archivedId of ["owner", "child"]) { + test(`rechecks ${archivedId} after a shared startup without leaking or closing another owner's session`, async () => { + await withDesktopManagerHarness(async ({ tempDir, config }) => { + if (process.platform === "win32") return; + await registerSharedWorkspaces(config); + await installPortableDesktopShim({ + rootDir: tempDir, + config: { + startupInfo: createStartupInfo({ display: 21, vncPort: 5911, geometry: "1024x768" }), + }, + }); + process.env.PATH = ""; + const manager = new DesktopSessionManager({ + config, + experimentsService: createExperimentsService(true), + workspaceService: createWorkspaceService(() => Promise.resolve(null)), + }); + const started = deferred(); + const release = deferred(); + // eslint-disable-next-line @typescript-eslint/unbound-method -- call below supplies the session under test. + const originalStart = PortableDesktopSession.prototype.start; + const start = spyOn(PortableDesktopSession.prototype, "start").mockImplementation( + async function (this: PortableDesktopSession) { + await originalStart.call(this); + started.resolve(); + await release.promise; + } + ); + const startup = manager.ensureStarted("child").catch((error: unknown) => error); + try { + await started.promise; + expect(manager.has("owner")).toBe(true); + expect(manager.has("child")).toBe(false); + await config.editConfig((current) => { + const entry = current.projects + .get("/tmp/project-1") + ?.workspaces.find((entry) => entry.id === archivedId); + if (!entry) throw new Error("Missing workspace"); + entry.archivedAt = "2026-09-01T00:00:00Z"; + return current; + }); + release.resolve(); + expect(String(await startup)).toContain("archived"); + expect(manager.has("owner")).toBe(archivedId !== "owner"); + expect(manager.getLiveSessionConnection("child")).toBeNull(); + if (archivedId === "owner") { + expect( + await fs.readdir( + path.join(tempDir, "cache", DESKTOP_DEFAULTS.CACHE_DIR_NAME, "sessions") + ) + ).toEqual([]); + } + } finally { + release.resolve(); + await startup; + start.mockRestore(); + await manager.closeAll(); + } + }); + }); + } + + test("rejects requester and owner archive guards before shared startup", async () => { + await withDesktopManagerHarness(async ({ config }) => { + await registerSharedWorkspaces(config); + const manager = new DesktopSessionManager({ + config, + experimentsService: createExperimentsService(true), + workspaceService: createWorkspaceService(() => Promise.resolve(null)), + }); + for (const id of ["owner", "child"]) { + manager.setWorkspaceArchiveGuard((candidate) => candidate === id); + expect(manager.ensureStarted("child")).rejects.toThrow("being archived"); + expect(manager.has("owner")).toBe(false); + } + }); + }); + test("reports machine-level prereqs without consulting workspace metadata when the binary is missing", async () => { await withDesktopManagerHarness(async ({ config }) => { process.env.PATH = ""; diff --git a/src/node/services/desktop/DesktopSessionManager.ts b/src/node/services/desktop/DesktopSessionManager.ts index 0c85cf8b2e..1286b58881 100644 --- a/src/node/services/desktop/DesktopSessionManager.ts +++ b/src/node/services/desktop/DesktopSessionManager.ts @@ -1,6 +1,4 @@ import { DESKTOP_DEFAULTS } from "@/common/constants/desktop"; -import { isWorkspaceArchived } from "@/common/utils/archive"; -import { findWorkspaceEntry } from "@/node/services/taskUtils"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import type { DesktopActionResult, @@ -9,13 +7,12 @@ import type { DesktopPrereqStatus, DesktopScreenshotResult, } from "@/common/types/desktop"; -import { parseRuntimeModeAndHost } from "@/common/types/runtime"; -import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import type { Config } from "@/node/config"; import type { ExperimentsService } from "@/node/services/experimentsService"; import { log } from "@/node/services/log"; import assert from "node:assert/strict"; import type { WorkspaceService } from "@/node/services/workspaceService"; +import { DesktopInputCoordinator, UnsupportedDesktopRuntimeError } from "./DesktopInputCoordinator"; import { PortableDesktopBinaryNotFoundError, PortableDesktopSession, @@ -24,6 +21,7 @@ import { export class DesktopSessionManager { private readonly sessions = new Map(); private readonly startupPromises = new Map>(); + private readonly inputCoordinator: DesktopInputCoordinator; private workspaceArchiveGuard: ((workspaceId: string) => boolean) | undefined; /** @@ -37,44 +35,25 @@ export class DesktopSessionManager { this.workspaceArchiveGuard = guard; } - private isArchivedNow(workspaceId: string): boolean { - const workspaceEntry = findWorkspaceEntry(this.deps.config.loadConfigOrDefault(), workspaceId); - return ( - workspaceEntry != null && - isWorkspaceArchived( - workspaceEntry.workspace.archivedAt, - workspaceEntry.workspace.unarchivedAt - ) - ); - } - constructor( private readonly deps: { config: Config; experimentsService: ExperimentsService; workspaceService: WorkspaceService; + inputCoordinator?: DesktopInputCoordinator; } - ) {} - - private parseWorkspaceRuntime(metadata: FrontendWorkspaceMetadata) { - const runtimeConfig = metadata.runtimeConfig; - - switch (runtimeConfig.type) { - case "local": - return parseRuntimeModeAndHost("srcBaseDir" in runtimeConfig ? "worktree" : "local"); - case "worktree": - return parseRuntimeModeAndHost("worktree"); - case "ssh": - return parseRuntimeModeAndHost(`ssh ${runtimeConfig.host}`); - case "docker": - return parseRuntimeModeAndHost(`docker ${runtimeConfig.image}`); - case "devcontainer": - return parseRuntimeModeAndHost( - runtimeConfig.configPath.length > 0 - ? `devcontainer ${runtimeConfig.configPath}` - : "devcontainer" - ); + ) { + this.inputCoordinator = deps.inputCoordinator ?? new DesktopInputCoordinator(deps.config); + } + + resolveTarget(workspaceId: string) { + const target = this.inputCoordinator.resolveTarget(workspaceId); + for (const id of new Set([workspaceId, target.ownerWorkspaceId])) { + if (this.workspaceArchiveGuard?.(id) === true) { + throw new Error(`Workspace is being archived: ${id}. Unarchive it before using a desktop.`); + } } + return target; } getPrereqStatus(): DesktopPrereqStatus { @@ -104,63 +83,57 @@ export class DesktopSessionManager { } } - async getCapability(workspaceId: string): Promise { - if (!this.deps.experimentsService.isExperimentEnabled(EXPERIMENT_IDS.PORTABLE_DESKTOP)) { - return { available: false, reason: "disabled" }; - } - - const workspaceInfo = await this.deps.workspaceService.getInfo(workspaceId); - if (!workspaceInfo) { - log.error("PortableDesktop capability check failed because workspace metadata was missing", { - workspaceId, - }); - return { available: false, reason: "startup_failed" }; - } + getCapability(workspaceId: string): Promise { + return Promise.resolve().then(() => { + if (!this.deps.experimentsService.isExperimentEnabled(EXPERIMENT_IDS.PORTABLE_DESKTOP)) { + return { available: false, reason: "disabled" }; + } - const parsedRuntime = this.parseWorkspaceRuntime(workspaceInfo); - if ( - parsedRuntime?.mode === "ssh" || - parsedRuntime?.mode === "docker" || - parsedRuntime?.mode === "devcontainer" - ) { - return { available: false, reason: "unsupported_runtime" }; - } + let target; + try { + target = this.resolveTarget(workspaceId); + } catch (error) { + log.debug("PortableDesktop target unavailable", { workspaceId, error }); + return { + available: false, + reason: + error instanceof UnsupportedDesktopRuntimeError + ? "unsupported_runtime" + : "startup_failed", + }; + } - const prereqStatus = this.getPrereqStatus(); - if (!prereqStatus.available) { - return prereqStatus; - } + const prereqStatus = this.getPrereqStatus(); + if (!prereqStatus.available) { + return prereqStatus; + } - // Capability checks are used for agent listing and tool gating, so they must not - // start a long-lived desktop session just to report whether PortableDesktop exists. - return { - available: true, - width: DESKTOP_DEFAULTS.WIDTH, - height: DESKTOP_DEFAULTS.HEIGHT, - sessionId: `desktop:${workspaceId}`, - }; + // Capability checks are used for agent listing and tool gating, so they must not + // start a long-lived desktop session just to report whether PortableDesktop exists. + return { + available: true, + width: DESKTOP_DEFAULTS.WIDTH, + height: DESKTOP_DEFAULTS.HEIGHT, + sessionId: `desktop:${target.ownerWorkspaceId}`, + ...(target.ownerWorkspaceId !== workspaceId ? { sharedDesktop: target } : {}), + }; + }); } async ensureStarted(workspaceId: string): Promise { - // Archive admission pairing: this check shares the synchronous block that registers the - // startup promise below (no awaits in between), so an archive gate armed first refuses - // this startup while a startup registered first is observed by the gate via has(). Without - // it, a startup entering between the gate's has() check and archivedAt persisting would - // publish a live desktop session into the hidden workspace. - if (this.workspaceArchiveGuard?.(workspaceId) === true) { - throw new Error( - `Workspace is being archived: ${workspaceId}. Unarchive it before starting a desktop session.` - ); - } - // Archived workspaces must not accrue hidden live activity: archive stops desktop - // sessions, so admitting a new one afterwards would leave one running in a workspace - // the UI no longer surfaces. Unarchive first. - if (this.isArchivedNow(workspaceId)) { - throw new Error( - `Workspace is archived: ${workspaceId}. Unarchive it before starting a desktop session.` - ); + const target = this.resolveTarget(workspaceId); + // Reserve the owner startup synchronously with both archive guards; has() stays owner-keyed. + const session = await this.ensureOwnerStarted(target.ownerWorkspaceId); + // A requester may disappear/archive while joining somebody else's startup. Reject that + // request without closing the owner's desktop, which other requesters can still use. + if (this.resolveTarget(workspaceId).ownerWorkspaceId !== target.ownerWorkspaceId) { + throw new Error(`Desktop target changed while starting for workspace ${workspaceId}`); } + return session; + } + private async ensureOwnerStarted(workspaceId: string): Promise { + this.resolveTarget(workspaceId); const existingSession = this.sessions.get(workspaceId); if (existingSession?.isAlive()) { return existingSession; @@ -193,15 +166,15 @@ export class DesktopSessionManager { await session.close(); throw new Error(`PortableDesktop startup for workspace ${workspaceId} was superseded`); } - // Post-start recheck: a user-driven archive (which force-closes rather than refuses) - // may have run its close() snapshot while start() was awaiting — that close only - // terminates tracked sessions, so publishing now would leave a hidden desktop session - // in the archived workspace. Close the just-started session instead of registering it. - if (this.workspaceArchiveGuard?.(workspaceId) === true || this.isArchivedNow(workspaceId)) { + // A user archive can persist while startup awaits; never publish a hidden session. + try { + const target = this.resolveTarget(workspaceId); + if (target.ownerWorkspaceId !== workspaceId) { + throw new Error(`Desktop owner changed while starting: ${workspaceId}`); + } + } catch (error) { await session.close(); - throw new Error( - `Workspace was archived while the desktop session was starting: ${workspaceId}.` - ); + throw error; } this.sessions.set(workspaceId, session); return session; @@ -223,7 +196,11 @@ export class DesktopSessionManager { } async screenshot(workspaceId: string): Promise { + const target = this.resolveTarget(workspaceId); const session = await this.ensureStarted(workspaceId); + if (this.resolveTarget(workspaceId).ownerWorkspaceId !== target.ownerWorkspaceId) { + throw new Error(`Desktop target changed before screenshot for workspace ${workspaceId}`); + } return session.screenshot(); } @@ -232,8 +209,14 @@ export class DesktopSessionManager { actionType: DesktopActionType, params: Record ): Promise { + const target = this.resolveTarget(workspaceId); const session = await this.ensureStarted(workspaceId); - return session.action(actionType, params); + return this.inputCoordinator.withInput(workspaceId, () => { + if (this.resolveTarget(workspaceId).ownerWorkspaceId !== target.ownerWorkspaceId) { + throw new Error(`Desktop target changed before input for workspace ${workspaceId}`); + } + return session.action(actionType, params); + }); } /** Whether a live desktop session exists for this workspace. */ @@ -293,8 +276,15 @@ export class DesktopSessionManager { * Used by DesktopBridgeServer to resolve token→VNC-port mappings. */ getLiveSessionConnection(workspaceId: string): { sessionId: string; vncPort: number } | null { - const session = this.sessions.get(workspaceId); - if (!session) { + let ownerWorkspaceId: string; + try { + ownerWorkspaceId = this.resolveTarget(workspaceId).ownerWorkspaceId; + } catch (error) { + log.debug("Desktop bridge target unavailable", { workspaceId, error }); + return null; + } + const session = this.sessions.get(ownerWorkspaceId); + if (!session?.isAlive()) { return null; } @@ -308,7 +298,7 @@ export class DesktopSessionManager { } return { - sessionId: sessionInfo.sessionId ?? `desktop:${workspaceId}`, + sessionId: sessionInfo.sessionId ?? `desktop:${ownerWorkspaceId}`, vncPort: sessionInfo.vncPort, }; } diff --git a/src/node/services/desktop/DesktopTokenManager.ts b/src/node/services/desktop/DesktopTokenManager.ts index a69b0997f7..b3debfbb7e 100644 --- a/src/node/services/desktop/DesktopTokenManager.ts +++ b/src/node/services/desktop/DesktopTokenManager.ts @@ -4,6 +4,8 @@ import { assert } from "@/common/utils/assert"; import { log } from "@/node/services/log"; interface TokenRecord { + // Keep the requester even for a shared desktop: the bridge must revalidate its + // current relationship to the owner, not authorize it as the owner directly. workspaceId: string; sessionId: string; expiresAtMs: number; diff --git a/src/node/services/desktop/desktopOperations.ts b/src/node/services/desktop/desktopOperations.ts index 8808dea96a..c0f8b8cf59 100644 --- a/src/node/services/desktop/desktopOperations.ts +++ b/src/node/services/desktop/desktopOperations.ts @@ -2,10 +2,14 @@ import type { ORPCContext } from "@/node/orpc/context"; import { DESKTOP_WS_PATH } from "@/node/orpc/wsPaths"; import { log } from "@/node/services/log"; -type DesktopContext = Pick< - ORPCContext, - "desktopSessionManager" | "desktopTokenManager" | "serverService" ->; +interface DesktopContext { + desktopSessionManager: Pick< + ORPCContext["desktopSessionManager"], + "getCapability" | "ensureStarted" | "resolveTarget" + >; + desktopTokenManager: Pick; + serverService: Pick; +} export async function getDesktopBootstrap(context: DesktopContext, workspaceId: string) { const capability = await context.desktopSessionManager.getCapability(workspaceId); @@ -17,12 +21,17 @@ export async function getDesktopBootstrap(context: DesktopContext, workspaceId: } try { const session = await context.desktopSessionManager.ensureStarted(workspaceId); + const target = context.desktopSessionManager.resolveTarget(workspaceId); + if (target.ownerWorkspaceId !== (capability.sharedDesktop?.ownerWorkspaceId ?? workspaceId)) { + throw new Error(`Desktop target changed during bootstrap for workspace ${workspaceId}`); + } const sessionInfo = session.getSessionInfo(); const startedCapability = { available: true as const, width: sessionInfo.width, height: sessionInfo.height, sessionId: sessionInfo.sessionId ?? capability.sessionId, + ...(target.ownerWorkspaceId !== workspaceId ? { sharedDesktop: target } : {}), }; return { capability: startedCapability, From 6eb8712d9aeda6bce88565382c312d61235739e6 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 11:21:42 +0000 Subject: [PATCH 03/25] =?UTF-8?q?=F0=9F=A4=96=20feat:=20coordinate=20mixed?= =?UTF-8?q?-owner=20desktop=20reservations=20atomically?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Acquire distinct owner gates in sorted order and validate the full batch before its durable admission callback. Reject conflicting borrowers within one batch and delegate singleton reservations to the same path. Validation with parent schema dependencies: 51 desktop tests, touched-file ESLint/formatting, and main TypeScript config pass. Full project typecheck has only the existing workspaceService.test.ts Config stub error. --- .../desktop/DesktopInputCoordinator.test.ts | 96 +++++++++++++++++++ .../desktop/DesktopInputCoordinator.ts | 47 +++++++-- 2 files changed, 135 insertions(+), 8 deletions(-) diff --git a/src/node/services/desktop/DesktopInputCoordinator.test.ts b/src/node/services/desktop/DesktopInputCoordinator.test.ts index 5f41ae8dbe..02271a87d5 100644 --- a/src/node/services/desktop/DesktopInputCoordinator.test.ts +++ b/src/node/services/desktop/DesktopInputCoordinator.test.ts @@ -216,6 +216,102 @@ describe("DesktopInputCoordinator", () => { }); }); + test("mixed-owner batches lock in stable order and validate every owner before admission", async () => { + await withCoordinator(async (coordinator, write) => { + const otherOwner = workspace("other-owner"); + const otherChild = borrower("other-child", { + parentWorkspaceId: "other-owner", + taskDesktopOwnerWorkspaceId: "other-owner", + }); + await write([owner, otherOwner, borrower("child"), otherChild]); + const reservations = [ + { ownerWorkspaceId: "owner", borrowerWorkspaceId: "child" }, + { ownerWorkspaceId: "other-owner", borrowerWorkspaceId: "other-child" }, + ]; + // Both calls begin before either owns its second gate: opposite input ordering must + // not let each batch hold one owner's gate while waiting forever for the other. + expect( + await Promise.all([ + coordinator.withReservations(reservations, () => Promise.resolve("forward")), + coordinator.withReservations([...reservations].reverse(), () => + Promise.resolve("reverse") + ), + ]) + ).toEqual(["forward", "reverse"]); + const entered = deferred(); + const release = deferred(); + const first = coordinator.withReservations(reservations, async () => { + entered.resolve(); + await release.promise; + await write([ + owner, + otherOwner, + borrower("child", { taskStatus: "queued" }), + { ...otherChild, taskStatus: "queued" }, + ]); + }); + await entered.promise; + let secondEntered = false; + const second = coordinator.withReservations([...reservations].reverse(), () => { + secondEntered = true; + return Promise.resolve("same borrowers"); + }); + const input = coordinator + .withInput("other-owner", () => Promise.resolve()) + .catch((error: unknown) => error); + expect(secondEntered).toBe(false); + release.resolve(); + await first; + expect(await second).toBe("same borrowers"); + expect(String(await input)).toContain("controlled by"); + + let admitted = false; + expect( + coordinator.withReservations( + [ + { ownerWorkspaceId: "owner", borrowerWorkspaceId: "child" }, + { ownerWorkspaceId: "other-owner", borrowerWorkspaceId: "competitor" }, + ], + () => { + admitted = true; + return Promise.resolve(); + } + ) + ).rejects.toThrow("controlled by"); + expect(admitted).toBe(false); + }); + }); + + test("rejects conflicting batch owners before callbacks and deduplicates identical reservations", async () => { + await withCoordinator(async (coordinator) => { + let admissions = 0; + const reserve = () => { + admissions += 1; + return Promise.resolve(admissions); + }; + expect( + coordinator.withReservations( + [ + { ownerWorkspaceId: "owner", borrowerWorkspaceId: "child" }, + { ownerWorkspaceId: "owner", borrowerWorkspaceId: "other" }, + ], + reserve + ) + ).rejects.toThrow("multiple borrowers in one batch"); + expect(admissions).toBe(0); + expect( + await coordinator.withReservations( + [ + { ownerWorkspaceId: "owner", borrowerWorkspaceId: "child" }, + { ownerWorkspaceId: "owner", borrowerWorkspaceId: "child" }, + ], + reserve + ) + ).toBe(1); + expect(await coordinator.withReservations([], reserve)).toBe(2); + }); + }); + test("revalidates queued operations and releases the gate after failures", async () => { await withCoordinator(async (coordinator, write) => { const entered = deferred(); diff --git a/src/node/services/desktop/DesktopInputCoordinator.ts b/src/node/services/desktop/DesktopInputCoordinator.ts index 134d8fbf76..b006621bb1 100644 --- a/src/node/services/desktop/DesktopInputCoordinator.ts +++ b/src/node/services/desktop/DesktopInputCoordinator.ts @@ -26,21 +26,52 @@ export class DesktopInputCoordinator { return this.resolveFromConfig(this.config.loadConfigOrDefault(), workspaceId); } - async withReservation( + withReservation( ownerWorkspaceId: string, borrowerWorkspaceId: string, reserve: () => Promise ): Promise { - assert(borrowerWorkspaceId.length > 0, "Desktop reservation requires a borrower ID"); - return this.gates.withLock(ownerWorkspaceId, async () => { + return this.withReservations([{ ownerWorkspaceId, borrowerWorkspaceId }], reserve); + } + + async withReservations( + reservations: ReadonlyArray<{ ownerWorkspaceId: string; borrowerWorkspaceId: string }>, + reserve: () => Promise + ): Promise { + const borrowers = new Map(); + for (const { ownerWorkspaceId, borrowerWorkspaceId } of reservations) { + assert(ownerWorkspaceId.length > 0, "Desktop reservation requires an owner ID"); + assert(borrowerWorkspaceId.length > 0, "Desktop reservation requires a borrower ID"); + const existing = borrowers.get(ownerWorkspaceId); + if (existing !== undefined && existing !== borrowerWorkspaceId) { + throw new Error(`Desktop ${ownerWorkspaceId} cannot have multiple borrowers in one batch`); + } + borrowers.set(ownerWorkspaceId, borrowerWorkspaceId); + } + if (borrowers.size === 0) return reserve(); + + // Mixed-owner batches need one atomic admission window. Stable ordering avoids deadlock + // between concurrent batches without recursively acquiring the same owner's gate. + const ownerIds = [...borrowers.keys()].sort(); + const lockNext = (index: number): Promise => { + const ownerId = ownerIds[index]; + if (ownerId !== undefined) { + return this.gates.withLock(ownerId, () => lockNext(index + 1)); + } const config = this.config.loadConfigOrDefault(); - const owner = this.resolveFromConfig(config, ownerWorkspaceId); - if (owner.ownerWorkspaceId !== ownerWorkspaceId || ownerWorkspaceId === borrowerWorkspaceId) { - throw new Error("Desktop reservation requires an unbound owner and a distinct borrower"); + for (const [ownerWorkspaceId, borrowerWorkspaceId] of borrowers) { + const owner = this.resolveFromConfig(config, ownerWorkspaceId); + if ( + owner.ownerWorkspaceId !== ownerWorkspaceId || + ownerWorkspaceId === borrowerWorkspaceId + ) { + throw new Error("Desktop reservation requires an unbound owner and a distinct borrower"); + } + this.assertController(config, ownerWorkspaceId, borrowerWorkspaceId, false); } - this.assertController(config, ownerWorkspaceId, borrowerWorkspaceId, false); return reserve(); - }); + }; + return lockNext(0); } async withAdmission(workspaceId: string, admit: () => Promise): Promise { From 35dc51ed7491ae21c02f2b4ff4171c7bf54a91ea Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 11:21:26 +0000 Subject: [PATCH 04/25] =?UTF-8?q?=F0=9F=A4=96=20feat:=20show=20shared=20de?= =?UTF-8?q?sktop=20targets=20in=20the=20viewer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep shared-target metadata tied to the caller bootstrap, clear it on connection teardown, and remount viewers on workspace switches. Add full-app responsive stories, binding lifecycle tests, and bound-desktop agent guidance. --- .storybook/main.ts | 4 + .../features/desktop/DesktopPanel.test.tsx | 143 ++++++++++++++ src/browser/features/desktop/DesktopPanel.tsx | 12 +- .../features/desktop/useDesktopConnection.ts | 8 + src/browser/stories/App.desktop.stories.tsx | 179 ++++++++++++++++++ src/browser/stories/mocks/desktopRfb.ts | 33 ++++ src/node/builtinAgents/desktop.md | 11 +- 7 files changed, 386 insertions(+), 4 deletions(-) create mode 100644 src/browser/features/desktop/DesktopPanel.test.tsx create mode 100644 src/browser/stories/App.desktop.stories.tsx create mode 100644 src/browser/stories/mocks/desktopRfb.ts diff --git a/.storybook/main.ts b/.storybook/main.ts index 2e7c2a738b..5ce57dcc59 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -21,6 +21,10 @@ const config: StorybookConfig = { // src/version.ts existing in the local workspace. resolve: { alias: [ + { + find: "@novnc/novnc/lib/rfb", + replacement: path.join(process.cwd(), "src/browser/stories/mocks/desktopRfb.ts"), + }, { find: "@/version", replacement: path.join(process.cwd(), "src/browser/stories/mocks/version.ts"), diff --git a/src/browser/features/desktop/DesktopPanel.test.tsx b/src/browser/features/desktop/DesktopPanel.test.tsx new file mode 100644 index 0000000000..eac4703f3c --- /dev/null +++ b/src/browser/features/desktop/DesktopPanel.test.tsx @@ -0,0 +1,143 @@ +import { act, cleanup, render, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { GlobalWindow } from "happy-dom"; +import type { APIClient } from "@/browser/contexts/API"; + +const getBootstrap = mock(); +void mock.module("@/browser/contexts/API", () => ({ + useAPI: () => ({ api: { desktop: { getBootstrap } } }), +})); + +class FakeRfb extends EventTarget { + static instances: FakeRfb[] = []; + disconnected = false; + constructor( + _container: HTMLElement, + readonly url: string + ) { + super(); + FakeRfb.instances.push(this); + queueMicrotask(() => this.dispatchEvent(new Event("connect"))); + } + disconnect() { + this.disconnected = true; + } +} +void mock.module("@novnc/novnc/lib/rfb", () => ({ default: FakeRfb })); + +import { DesktopPanel } from "./DesktopPanel"; + +type Bootstrap = Awaited>; +const ownCapability = { available: true as const, width: 1280, height: 720, sessionId: "session" }; +const sharedBootstrap: Bootstrap = { + capability: { + ...ownCapability, + sharedDesktop: { ownerWorkspaceId: "owner", ownerName: "Original desktop" }, + }, + bridgePath: "/desktop/ws/caller", + token: "caller-token", +}; + +async function connectedViewer() { + await waitFor(() => expect(FakeRfb.instances.length).toBeGreaterThan(0)); + return FakeRfb.instances.at(-1)!; +} + +describe("DesktopPanel binding", () => { + let originalWindow: typeof globalThis.window; + let originalDocument: typeof globalThis.document; + + beforeEach(() => { + originalWindow = globalThis.window; + originalDocument = globalThis.document; + globalThis.window = new GlobalWindow({ url: "http://localhost" }) as unknown as Window & + typeof globalThis; + globalThis.document = window.document; + FakeRfb.instances = []; + getBootstrap.mockReset(); + getBootstrap.mockResolvedValue(sharedBootstrap); + }); + + afterEach(() => { + cleanup(); + globalThis.window = originalWindow; + globalThis.document = originalDocument; + }); + + test("shows bootstrap binding while connecting with the caller's bridge and token", async () => { + const view = render(); + const viewer = await connectedViewer(); + expect(getBootstrap).toHaveBeenCalledWith({ workspaceId: "caller" }); + expect(getBootstrap).not.toHaveBeenCalledWith({ workspaceId: "owner" }); + expect(viewer.url).toBe("ws://localhost/desktop/ws/caller?token=caller-token"); + expect(view.getByText(/Original desktop/)).toBeTruthy(); + }); + + test("does not show a shared target for an independent desktop", async () => { + getBootstrap.mockResolvedValue({ ...sharedBootstrap, capability: ownCapability }); + const view = render(); + await connectedViewer(); + expect(view.queryByText(/Original desktop/)).toBeNull(); + }); + + test("clears the binding after security failure and keeps it cleared when retry bootstrap fails", async () => { + const view = render(); + const viewer = await connectedViewer(); + act(() => { + viewer.dispatchEvent( + new CustomEvent("securityfailure", { detail: { status: 1, reason: "expired token" } }) + ); + }); + expect(view.queryByText(/Original desktop/)).toBeNull(); + expect(viewer.disconnected).toBe(true); + getBootstrap.mockRejectedValueOnce(new Error("binding removed")); + act(() => view.getByRole("button", { name: "Retry" }).click()); + await waitFor(() => expect(getBootstrap).toHaveBeenCalledTimes(2)); + expect(view.queryByText(/Original desktop/)).toBeNull(); + }); + + test("clears disconnected target metadata before a reconnect gets a new bootstrap", async () => { + const view = render(); + const viewer = await connectedViewer(); + act(() => { + viewer.dispatchEvent(new CustomEvent("disconnect", { detail: { clean: false } })); + }); + expect(view.queryByText(/Original desktop/)).toBeNull(); + expect(viewer.disconnected).toBe(true); + }); + + test("disposes the previous binding and bootstraps the newly selected workspace", async () => { + const view = render(); + const previousViewer = await connectedViewer(); + getBootstrap.mockResolvedValue({ + capability: ownCapability, + bridgePath: "/desktop/ws/isolated", + token: "isolated-token", + }); + view.rerender(); + expect(view.queryByText(/Original desktop/)).toBeNull(); + expect(previousViewer.disconnected).toBe(true); + await waitFor(() => expect(FakeRfb.instances).toHaveLength(2)); + expect(getBootstrap).toHaveBeenLastCalledWith({ workspaceId: "isolated" }); + expect(FakeRfb.instances[1].url).toBe( + "ws://localhost/desktop/ws/isolated?token=isolated-token" + ); + expect(view.queryByText(/Original desktop/)).toBeNull(); + }); + + test("ignores a late bootstrap from the workspace that was switched away from", async () => { + const pending = Promise.withResolvers(); + getBootstrap.mockReturnValueOnce(pending.promise); + const view = render(); + getBootstrap.mockResolvedValue({ ...sharedBootstrap, capability: ownCapability }); + view.rerender(); + await connectedViewer(); + await act(async () => { + pending.resolve(sharedBootstrap); + await pending.promise; + }); + expect(FakeRfb.instances).toHaveLength(1); + expect(view.queryByText(/Original desktop/)).toBeNull(); + expect(getBootstrap).toHaveBeenLastCalledWith({ workspaceId: "isolated" }); + }); +}); diff --git a/src/browser/features/desktop/DesktopPanel.tsx b/src/browser/features/desktop/DesktopPanel.tsx index 05801e6861..978e4d9d0c 100644 --- a/src/browser/features/desktop/DesktopPanel.tsx +++ b/src/browser/features/desktop/DesktopPanel.tsx @@ -81,6 +81,11 @@ function StatusOverlay(props: { desktop: UseDesktopConnectionResult }) { } export function DesktopPanel(props: { workspaceId: string }) { + // A workspace switch must dispose the old viewer, token, and shared-target label together. + return ; +} + +function WorkspaceDesktopPanel(props: { workspaceId: string }) { const desktop = useDesktopConnection(props.workspaceId); useEffect(() => { @@ -90,7 +95,12 @@ export function DesktopPanel(props: { workspaceId: string }) { }, []); return ( -
+
+ {desktop.sharedDesktop && ( +
+ Shared desktop · {desktop.sharedDesktop.ownerName} +
+ )} {desktop.state === "connected" ? null : }
void; width: number; height: number; + sharedDesktop: Extract["sharedDesktop"] | null; } type DesktopUnavailableReason = Extract["reason"]; @@ -108,6 +109,8 @@ export function useDesktopConnection(workspaceId: string): UseDesktopConnectionR const [reason, setReason] = useState(null); const [width, setWidth] = useState(DESKTOP_DEFAULTS.WIDTH); const [height, setHeight] = useState(DESKTOP_DEFAULTS.HEIGHT); + const [sharedDesktop, setSharedDesktop] = + useState(null); const rfbRef = useRef(null); const containerRef = useRef(null); @@ -131,6 +134,7 @@ export function useDesktopConnection(workspaceId: string): UseDesktopConnectionR }; const disconnectCurrentRfb = () => { + setSharedDesktop(null); const currentRfb = rfbRef.current; rfbRef.current = null; if (!currentRfb) { @@ -199,6 +203,8 @@ export function useDesktopConnection(workspaceId: string): UseDesktopConnectionR setState("checking"); try { + // Shared-target metadata is display-only: the caller's bootstrap/token preserves the + // backend's authorization and binding checks; never bootstrap the owner directly. const result = await api.desktop.getBootstrap({ workspaceId }); if (generationRef.current !== generation || isDisposedRef.current) { return; @@ -290,6 +296,7 @@ export function useDesktopConnection(workspaceId: string): UseDesktopConnectionR rfb.addEventListener("disconnect", handleDisconnect); rfb.addEventListener("securityfailure", handleSecurityFailure); rfbRef.current = rfb; + setSharedDesktop(result.capability.sharedDesktop ?? null); setState("connecting"); } catch (error) { if (generationRef.current !== generation || isDisposedRef.current) { @@ -326,5 +333,6 @@ export function useDesktopConnection(workspaceId: string): UseDesktopConnectionR disconnect: disconnectHandleRef.current, width, height, + sharedDesktop, }; } diff --git a/src/browser/stories/App.desktop.stories.tsx b/src/browser/stories/App.desktop.stories.tsx new file mode 100644 index 0000000000..ea39bbe512 --- /dev/null +++ b/src/browser/stories/App.desktop.stories.tsx @@ -0,0 +1,179 @@ +import { expect, fn, userEvent, waitFor, within } from "@storybook/test"; +import type { APIClient } from "@/browser/contexts/API"; +import { updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { EXPERIMENT_IDS, getExperimentKey } from "@/common/constants/experiments"; +import { + getRightSidebarLayoutKey, + LEFT_SIDEBAR_COLLAPSED_KEY, + RIGHT_SIDEBAR_TAB_KEY, + RIGHT_SIDEBAR_WIDTH_KEY, +} from "@/common/constants/storage"; +import { appMeta, AppWithMocks, type AppStory } from "./meta"; +import { setupSimpleChatStory } from "./helpers/chatSetup"; +import { expandProjects, expandRightSidebar } from "./helpers/uiState"; +import { createWorkspace } from "./mocks/workspaces"; +import DesktopRfb from "./mocks/desktopRfb"; +import { blurActiveElement, waitForChatInputAutofocusDone } from "./storyPlayHelpers"; + +const CALLER_ID = "desktop-shared-agent"; +const OWNER_ID = "desktop-owner"; +const OWNER_NAME = "release-validation-with-an-intentionally-long-workspace-name"; +const isolatedWorkspace = createWorkspace({ + id: "desktop-isolated-agent", + name: "isolated-agent", + projectName: "desktop-demo", +}); +const capability = { available: true as const, width: 1280, height: 720, sessionId: "session" }; + +const getBootstrap = fn(({ workspaceId }) => + Promise.resolve({ + capability: { + ...capability, + ...(workspaceId === CALLER_ID + ? { sharedDesktop: { ownerWorkspaceId: OWNER_ID, ownerName: OWNER_NAME } } + : {}), + }, + bridgePath: `/desktop/ws/${workspaceId}`, + token: `token-for-${workspaceId}`, + }) +); + +function setupDesktopStory(phone = false): APIClient { + getBootstrap.mockClear(); + DesktopRfb.instances = []; + const client = setupSimpleChatStory({ + workspaceId: CALLER_ID, + workspaceName: "shared-agent", + projectName: "desktop-demo", + messages: [], + additionalWorkspaces: [isolatedWorkspace], + }); + // Only bootstrap carries the binding; a capability probe must not determine the viewer's label. + client.desktop = { + getPrereqStatus: () => Promise.resolve({ available: true }), + getCapability: () => Promise.resolve(capability), + getBootstrap, + }; + updatePersistedState(getExperimentKey(EXPERIMENT_IDS.PORTABLE_DESKTOP), true); + updatePersistedState(LEFT_SIDEBAR_COLLAPSED_KEY, phone); + updatePersistedState(RIGHT_SIDEBAR_TAB_KEY, "desktop"); + updatePersistedState(RIGHT_SIDEBAR_WIDTH_KEY, 320); + for (const workspaceId of [CALLER_ID, isolatedWorkspace.id]) { + updatePersistedState(getRightSidebarLayoutKey(workspaceId), undefined); + } + expandProjects([isolatedWorkspace.projectPath]); + expandRightSidebar(); + return client; +} + +async function expectCallerConnection( + canvasElement: HTMLElement, + workspaceId: string, + visible = true +) { + const canvas = within(canvasElement); + await waitFor(async () => { + const preview = canvas.getByLabelText("Desktop session preview"); + if (visible) await expect(preview).toBeVisible(); + else await expect(preview).not.toBeVisible(); + await expect(getBootstrap).toHaveBeenLastCalledWith({ workspaceId }); + const viewer = DesktopRfb.instances.at(-1); + if (!viewer) throw new Error("Desktop viewer did not connect"); + const url = new URL(viewer.url); + await expect(url.pathname).toBe(`/desktop/ws/${workspaceId}`); + await expect(url.searchParams.get("token")).toBe(`token-for-${workspaceId}`); + await expect(viewer.viewOnly).toBe(false); + }); + await expect(getBootstrap).not.toHaveBeenCalledWith({ workspaceId: OWNER_ID }); +} + +export default { + ...appMeta, + title: "App/Desktop", + beforeEach: () => () => { + for (const viewer of DesktopRfb.instances) viewer.disconnect(); + DesktopRfb.instances = []; + updatePersistedState(getExperimentKey(EXPERIMENT_IDS.PORTABLE_DESKTOP), undefined); + }, +}; + +export const SharedBinding: AppStory = { + globals: { viewport: { value: "desktopBindingWide", isRotated: false } }, + parameters: { + ...appMeta.parameters, + viewport: { + options: { + desktopBindingWide: { + name: "Desktop (1900px)", + styles: { width: "1900px", height: "1000px" }, + type: "desktop", + }, + }, + }, + pixel: { matrix: { themes: ["dark", "light"], viewports: ["desktop"] } }, + }, + render: () => setupDesktopStory()} />, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expectCallerConnection(canvasElement, CALLER_ID); + await expect(canvas.getByText(`Shared desktop · ${OWNER_NAME}`)).toBeVisible(); + const sharedViewer = DesktopRfb.instances.at(-1)!; + + await userEvent.click(canvas.getByText(isolatedWorkspace.name, { exact: true })); + await expectCallerConnection(canvasElement, isolatedWorkspace.id); + await expect(canvas.queryByText(`Shared desktop · ${OWNER_NAME}`)).not.toBeInTheDocument(); + await expect(sharedViewer.disconnected).toBe(true); + + await userEvent.click(canvas.getByText("shared-agent", { exact: true })); + await expectCallerConnection(canvasElement, CALLER_ID); + const label = canvas.getByText(`Shared desktop · ${OWNER_NAME}`); + await expect(label).toBeVisible(); + await expect(label.scrollWidth).toBeGreaterThan(label.clientWidth); + await expect(getComputedStyle(label).textOverflow).toBe("ellipsis"); + await expect(label.getBoundingClientRect().width).toBeLessThanOrEqual(320); + await waitForChatInputAutofocusDone(canvasElement); + blurActiveElement(); + }, +}; + +export const SharedBindingPhone: AppStory = { + globals: { viewport: { value: "desktopBindingPhone", isRotated: false } }, + parameters: { + ...appMeta.parameters, + viewport: { + options: { + desktopBindingPhone: { + name: "Phone (390px)", + styles: { width: "390px", height: "844px" }, + type: "mobile", + }, + }, + }, + pixel: { matrix: { themes: ["dark", "light"], viewports: ["phone"] } }, + }, + // The test-runner ignores viewport globals; the wrapper exercises the same narrow container. + render: () => ( +
+ setupDesktopStory(true)} /> +
+ ), + play: async ({ canvasElement }) => { + // The app intentionally hides Workspace insights below its minimum usable width, even + // for fine pointers. Keep that mobile behavior rather than forcing a visible test-only panel. + await expectCallerConnection(canvasElement, CALLER_ID, false); + await waitFor(async () => { + await expect( + within(canvasElement).getByText(`Shared desktop · ${OWNER_NAME}`) + ).not.toBeVisible(); + const sidebar = canvasElement.querySelector('[aria-label="Workspace insights"]'); + if (!sidebar) throw new Error("Missing workspace insights sidebar"); + await expect(getComputedStyle(sidebar).display).toBe("none"); + }); + const frame = canvasElement.firstElementChild; + if (!(frame instanceof HTMLElement)) throw new Error("Missing phone frame"); + await expect(frame.getBoundingClientRect().width).toBe(390); + await expect(frame.scrollWidth).toBeLessThanOrEqual(390); + await waitForChatInputAutofocusDone(canvasElement); + blurActiveElement(); + }, +}; diff --git a/src/browser/stories/mocks/desktopRfb.ts b/src/browser/stories/mocks/desktopRfb.ts new file mode 100644 index 0000000000..43704050e3 --- /dev/null +++ b/src/browser/stories/mocks/desktopRfb.ts @@ -0,0 +1,33 @@ +/** Storybook-only noVNC transport: exercise real bootstrap wiring without a live desktop server. */ +export default class DesktopRfb extends EventTarget { + static instances: DesktopRfb[] = []; + readonly url: string; + readonly preview: HTMLDivElement; + scaleViewport = false; + resizeSession = false; + viewOnly = false; + disconnected = false; + + constructor(container: HTMLElement, url: string) { + super(); + this.url = url; + this.preview = document.createElement("div"); + this.preview.setAttribute("role", "img"); + this.preview.setAttribute("aria-label", "Desktop session preview"); + this.preview.className = + "bg-surface-primary text-muted-foreground flex h-full items-center justify-center text-sm"; + this.preview.textContent = "Desktop session preview"; + container.append(this.preview); + DesktopRfb.instances.push(this); + queueMicrotask(() => { + if (!this.disconnected) { + this.dispatchEvent(new Event("connect")); + } + }); + } + + disconnect() { + this.disconnected = true; + this.preview.remove(); + } +} diff --git a/src/node/builtinAgents/desktop.md b/src/node/builtinAgents/desktop.md index 30d2f6fa10..386f4a8f48 100644 --- a/src/node/builtinAgents/desktop.md +++ b/src/node/builtinAgents/desktop.md @@ -9,9 +9,11 @@ subagent: append_prompt: | You are a desktop automation sub-agent running in a child workspace. - - Your job: interact with the desktop GUI via screenshot-driven automation. + - Your job: interact with the bound desktop GUI via screenshot-driven automation. + - By default this is the caller's desktop, not a fresh desktop in your checkout. For independent GUI testing, the caller must request task desktop: "isolated"; checkout isolation is separate. - Always take a screenshot before starting a GUI interaction sequence. - - Follow the grounding loop: screenshot → identify target → act → screenshot to verify. + - Follow the grounding loop: screenshot → identify target → act → screenshot to verify. Run dependent screenshots and actions sequentially, never in parallel. + - Other Mux desktop tools may be excluded during an action, but humans in noVNC, shell commands, and CDP can still change the desktop. Re-ground on fresh screenshots. - After completing the task, summarize the outcome in your final assistant message with only the result plus selected evidence (e.g., a final screenshot path). - Do not expand scope beyond the delegated desktop task. @@ -50,6 +52,9 @@ tools: You are a desktop automation agent. +- **Bound desktop:** Desktop tools use the desktop bound to this agent. New desktop agents share the caller's desktop by default; `task` with `desktop: "isolated"` requests an independent desktop for separate GUI testing. Repository checkout isolation does not select the desktop. +- **Sequential steps:** Run dependent screenshots and actions one at a time. Mux desktop-tool input exclusion does not lock out humans using noVNC, shell commands, or CDP; never assume exclusive control of the GUI. +- **Scope:** Change only what the delegated desktop task requires, preserving unrelated windows and user state. - **Screenshot-first rule:** Always take a `desktop_screenshot` before beginning any GUI interaction loop. Never act on stale visual state. - **Grounding loop:** Follow `screenshot → identify target coordinates → act (click/type/drag) → screenshot to verify` for each major interaction. Every major interaction step should end with a screenshot to verify the expected result. - **Coordinate precision:** Use screenshot analysis to identify precise pixel coordinates for clicks, drags, and other positional actions. Account for window position, display scaling, and DPI before acting. @@ -60,4 +65,4 @@ You are a desktop automation agent. - If an unexpected dialog or popup appears, take another screenshot and adapt to the new state. - **Scrolling:** Use `desktop_scroll` to navigate within windows, then take a screenshot after scrolling to verify the new content is visible. - **Error recovery:** If an action does not produce the expected result, take another screenshot, reassess the current state, and retry with adjusted coordinates. -- **Reporting:** When complete, summarize only the outcome and key evidence back to the parent agent, such as the final screenshot confirming success. Do not send raw coordinate logs. +- **Reporting:** When complete, identify the actual desktop changed (shared caller or explicitly isolated), summarize the outcome, and provide key evidence such as a final screenshot. Do not infer that a checkout change proves which desktop changed, and do not send raw coordinate logs. From 925d244eb2b0cd3231ad5fc1f752222f691b1c48 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 11:31:11 +0000 Subject: [PATCH 05/25] =?UTF-8?q?=F0=9F=A4=96=20feat:=20reserve=20shared?= =?UTF-8?q?=20desktops=20across=20task=20lifecycle=20transitions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/services/taskService.test.ts | 280 +++++++- src/node/services/taskService.ts | 680 +++++++++++------- src/node/services/taskWorkspaceSeam.ts | 7 +- src/node/services/workspaceService.test.ts | 40 +- src/node/services/workspaceService.ts | 74 +- .../services/workspaceTurnManager.test.ts | 211 ++++-- src/node/services/workspaceTurnManager.ts | 150 +++- 7 files changed, 1051 insertions(+), 391 deletions(-) diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 29715309dd..7feac8058c 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -1,3 +1,4 @@ +import { DesktopInputCoordinator } from "@/node/services/desktop/DesktopInputCoordinator"; import { SecretsStore } from "@/node/config"; import * as path from "path"; import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from "bun:test"; @@ -199,6 +200,7 @@ function createTaskServiceHarness( initStateManager?: InitStateManager; sessionUsageService?: SessionUsageService; workspaceGoalService?: WorkspaceGoalService; + desktopInputCoordinator?: DesktopInputCoordinator; } ): { historyService: HistoryService; @@ -229,7 +231,8 @@ function createTaskServiceHarness( overrides?.sessionUsageService, overrides?.workspaceGoalService, new SecretsStore(config.rootDir), - terminalAttentionStore + terminalAttentionStore, + overrides?.desktopInputCoordinator ); const workspaceTurnManager = new WorkspaceTurnManager( config, @@ -239,7 +242,8 @@ function createTaskServiceHarness( initStateManager, taskService, terminalAttentionStore, - streamManager + streamManager, + overrides?.desktopInputCoordinator ); taskService.setWorkspaceTurnManager(workspaceTurnManager); const managerInternals = workspaceTurnManager as unknown as { @@ -4007,6 +4011,278 @@ describe("TaskService", () => { expect(workflowOwned.success).toBe(true); }); + test.each(["single", "batch"] as const)( + "shared desktop best-of %s refuses before creation side effects", + async (mode) => { + const config = await createTestConfig(rootDir); + const { taskService, aiService } = createTaskServiceHarness(config); + const metadata = spyOn(aiService, "getWorkspaceMetadata"); + const args = { + parentWorkspaceId: "missing-parent", + kind: "agent" as const, + agentId: "desktop", + prompt: "Inspect the desktop", + title: "Inspector", + bestOf: { groupId: "group", index: 0, total: 2 }, + }; + const result = + mode === "single" ? await taskService.create(args) : await taskService.createMany([args]); + expect(result.success).toBe(false); + expect(metadata).not.toHaveBeenCalled(); + expect(config.loadConfigOrDefault().projects.size).toBe(0); + } + ); + + test("shared desktop batch rejects competing children before reservation callbacks", async () => { + const config = await createTestConfig(rootDir); + const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); + const { taskService } = createTaskServiceHarness(config); + const onTaskReserved = mock(() => undefined); + const fork = spyOn(forkOrchestrator, "orchestrateFork"); + try { + const result = await taskService.createMany( + ["one", "two"].map((prompt) => ({ + parentWorkspaceId: parentId, + kind: "agent" as const, + agentId: "explore", + prompt, + title: prompt, + desktop: "shared" as const, + })), + { onTaskReserved } + ); + expect(result.success).toBe(false); + expect(onTaskReserved).not.toHaveBeenCalled(); + expect(fork).not.toHaveBeenCalled(); + expect(config.loadConfigOrDefault().projects.get(projectPath)?.workspaces).toHaveLength(1); + } finally { + fork.mockRestore(); + } + }); + + test("shared desktop batch preserves distinct owners through queued reservation", async () => { + const config = await createTestConfig(rootDir); + const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); + await config.editConfig((cfg) => { + cfg.taskSettings = testTaskSettings(1, 3); + const project = cfg.projects.get(projectPath)!; + project.workspaces.push( + { ...project.workspaces[0], id: "second-parent", name: "second-parent" }, + projectWorkspace(projectPath, "busy", "busy", { + parentWorkspaceId: parentId, + taskStatus: "running", + agentId: "explore", + }) + ); + return cfg; + }); + const { taskService } = createTaskServiceHarness(config); + const owners = [parentId, "second-parent"]; + const result = await taskService.createMany( + owners.map((owner) => ({ + parentWorkspaceId: owner, + kind: "agent" as const, + agentId: "explore", + prompt: "Inspect", + title: "Inspector", + desktop: "shared" as const, + })) + ); + assert(result.success); + expect(result.data.map((task) => task.status)).toEqual(["queued", "queued"]); + expect( + result.data.map( + (task) => findWorkspaceInConfig(config, task.taskId)?.taskDesktopOwnerWorkspaceId + ) + ).toEqual(owners); + }); + + test.each([undefined, "isolated"] as const)( + "desktop specialist queued binding respects explicit override %s", + async (desktop) => { + const config = await createTestConfig(rootDir); + const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); + await config.editConfig((cfg) => { + cfg.taskSettings = testTaskSettings(1, 3); + cfg.projects.get(projectPath)!.workspaces.push( + projectWorkspace(projectPath, "busy", "busy", { + parentWorkspaceId: parentId, + agentId: "explore", + taskStatus: "running", + runtimeConfig: { type: "local" }, + }) + ); + return cfg; + }); + const { taskService } = createTaskServiceHarness(config); + const result = await createAgentTask(taskService, parentId, "Inspect", { + agentId: " Desktop ", + desktop, + }); + assert(result.success); + expect(result.data.status).toBe("queued"); + expect(findWorkspaceInConfig(config, result.data.taskId)?.taskDesktopOwnerWorkspaceId).toBe( + desktop === "isolated" ? undefined : parentId + ); + } + ); + + test("shared desktop creation waits for open input then releases its gate before sending", async () => { + const config = await createTestConfig(rootDir); + const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); + const desktop = new DesktopInputCoordinator(config); + let releaseInput!: () => void; + const inputReleased = new Promise((resolve) => { + releaseInput = resolve; + }); + let inputStarted!: () => void; + const inputEntered = new Promise((resolve) => { + inputStarted = resolve; + }); + let reservationStarted!: () => void; + const reservationEntered = new Promise((resolve) => { + reservationStarted = resolve; + }); + const reserve = desktop.withReservation.bind(desktop); + const reservationSpy = spyOn(desktop, "withReservation").mockImplementation( + (owner, borrower, persist) => { + reservationStarted(); + return reserve(owner, borrower, persist); + } + ); + const { workspaceService, sendMessage } = createWorkspaceServiceMocks(); + sendMessage.mockImplementation((id: string) => + desktop.withInput(id, () => Promise.resolve(Ok(undefined))) + ); + const { taskService } = createTaskServiceHarness(config, { + workspaceService, + desktopInputCoordinator: desktop, + }); + const input = desktop.withInput(parentId, async () => { + inputStarted(); + await inputReleased; + }); + try { + await inputEntered; + const creation = createAgentTask(taskService, parentId, "Inspect", { desktop: "shared" }); + await reservationEntered; + expect(config.loadConfigOrDefault().projects.get(projectPath)?.workspaces).toHaveLength(1); + expect(sendMessage).not.toHaveBeenCalled(); + releaseInput(); + await input; + const result = await creation; + assert(result.success); + expect(sendMessage).toHaveBeenCalledTimes(1); + const failure = await desktop + .withInput(parentId, () => Promise.resolve()) + .then( + () => null, + (error: unknown) => error + ); + expect(failure).toBeInstanceOf(Error); + await taskService.editWorkspaceEntry(result.data.taskId, (workspace) => { + workspace.taskStatus = "interrupted"; + }); + await desktop.withInput(parentId, () => Promise.resolve()); + } finally { + releaseInput(); + await input; + reservationSpy.mockRestore(); + } + }); + + test.each(["queued", "starting", "running", "awaiting_report"] as const)( + "shared desktop startup recovery refuses a missing owner from %s", + async (taskStatus) => { + const config = await createTestConfig(rootDir); + const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); + await config.editConfig((cfg) => { + cfg.projects.get(projectPath)!.workspaces.push( + projectWorkspace(projectPath, "child", "child", { + parentWorkspaceId: parentId, + taskStatus, + agentId: "explore", + agentType: "explore", + runtimeConfig: { type: "local" }, + taskDesktopOwnerWorkspaceId: "deleted", + taskPrompt: "Inspect", + taskModelString: "anthropic:claude-opus-4-6", + }) + ); + return cfg; + }); + const { workspaceService, sendMessage } = createWorkspaceServiceMocks(); + const { taskService } = createTaskServiceHarness(config, { workspaceService }); + await taskService.recoverInterruptedTasks(); + expect(sendMessage).not.toHaveBeenCalled(); + expect(findWorkspaceInConfig(config, "child")?.taskStatus).toBe("interrupted"); + expect(findWorkspaceInConfig(config, "child")?.taskDesktopOwnerWorkspaceId).toBe("deleted"); + } + ); + + test("shared desktop failed launch releases the owner for the next child", async () => { + const config = await createTestConfig(rootDir); + const { parentId } = await saveLocalParentWorkspace(config, rootDir); + const { workspaceService, sendMessage } = createWorkspaceServiceMocks(); + sendMessage.mockResolvedValueOnce(Err("launch refused")); + const { taskService } = createTaskServiceHarness(config, { workspaceService }); + const failed = await createAgentTask(taskService, parentId, "First", { desktop: "shared" }); + expect(failed.success).toBe(false); + const next = await createAgentTask(taskService, parentId, "Second", { desktop: "shared" }); + assert(next.success); + expect(findWorkspaceInConfig(config, next.data.taskId)?.taskDesktopOwnerWorkspaceId).toBe( + parentId + ); + const fork = spyOn(forkOrchestrator, "orchestrateFork"); + try { + const competing = await createAgentTask(taskService, parentId, "Third", { + desktop: "shared", + }); + expect(competing.success).toBe(false); + expect(fork).not.toHaveBeenCalled(); + expect(sendMessage).toHaveBeenCalledTimes(2); + } finally { + fork.mockRestore(); + } + }); + + test.each(["reported", "interrupted"] as const)( + "shared desktop %s resume preserves binding and refuses a competing controller", + async (taskStatus) => { + const config = await createTestConfig(rootDir); + const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); + await config.editConfig((cfg) => { + cfg.projects.get(projectPath)!.workspaces.push( + ...["child", "competitor"].map((id) => + projectWorkspace(projectPath, id, id, { + parentWorkspaceId: parentId, + taskStatus: id === "child" ? taskStatus : "running", + agentId: "explore", + runtimeConfig: { type: "local" }, + taskDesktopOwnerWorkspaceId: parentId, + }) + ) + ); + return cfg; + }); + const { taskService } = createTaskServiceHarness(config); + const failure = await taskService.markInterruptedTaskRunning("child").then( + () => null, + (error: unknown) => error + ); + expect(failure).toBeInstanceOf(Error); + expect(findWorkspaceInConfig(config, "child")?.taskStatus).toBe(taskStatus); + await taskService.editWorkspaceEntry("competitor", (workspace) => { + workspace.taskStatus = "interrupted"; + }); + expect(await taskService.markInterruptedTaskRunning("child")).toBe(true); + expect(await taskService.markInterruptedTaskRunning("child")).toBe(false); + expect(findWorkspaceInConfig(config, "child")?.taskDesktopOwnerWorkspaceId).toBe(parentId); + await taskService.restoreInterruptedTaskAfterResumeFailure("child", taskStatus); + expect(findWorkspaceInConfig(config, "child")?.taskStatus).toBe(taskStatus); + } + ); + test("createMany reserves admitted tasks as starting and over-capacity tasks as queued", async () => { const config = await createTestConfig(rootDir); stubStableIds(config, ["aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc"], "dddddddddd"); diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index b4d5ab7706..ddfd2b39bb 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -1,3 +1,4 @@ +import { DesktopInputCoordinator } from "@/node/services/desktop/DesktopInputCoordinator"; import { randomUUID } from "node:crypto"; import assert from "node:assert/strict"; import * as path from "node:path"; @@ -414,6 +415,7 @@ interface TaskLaunchPlan { experiments?: TaskCreateArgs["experiments"]; onRefusal?: TaskCreateArgs["onRefusal"]; attentionPolicy?: TaskCreateArgs["attentionPolicy"]; + taskDesktopOwnerWorkspaceId?: string; } interface TaskCreateManyOptions { @@ -1795,7 +1797,8 @@ export class TaskService implements AgentTaskIntegration { private readonly sessionUsageService?: SessionUsageService, private readonly workspaceGoalService?: WorkspaceGoalService, private readonly secretsStore: SecretsStore = new SecretsStore(config.rootDir), - terminalAttentionStore?: TerminalAttentionStore + terminalAttentionStore?: TerminalAttentionStore, + private readonly desktopInputCoordinator = new DesktopInputCoordinator(config) ) { this.agentPeerMessageBroker = new AgentPeerMessageBroker(workspaceService); this.terminalAttentionStore = terminalAttentionStore ?? new TerminalAttentionStore(config); @@ -2308,22 +2311,25 @@ export class TaskService implements AgentTaskIntegration { }); } - await this.config.editConfig((config) => { - for (const task of staleStartingTasks) { - assert(task.id != null && task.id.length > 0, "stale starting task id is required"); - const recovery = recoveries.get(task.id); - assert(recovery != null, "stale starting task recovery is required"); - const entry = findWorkspaceEntry(config, task.id); - if (!entry) continue; - entry.workspace.taskStatus = recovery.status; - if (recovery.acceptedPrompt) { - // The initial prompt is already durable in chat history; clearing taskPrompt makes the - // queued recovery path resume that accepted turn instead of appending a duplicate user turn. - entry.workspace.taskPrompt = undefined; - } + for (const task of staleStartingTasks) { + assert(task.id != null && task.id.length > 0, "stale starting task id is required"); + const recovery = recoveries.get(task.id); + assert(recovery != null, "stale starting task recovery is required"); + try { + await this.editActiveWorkspaceEntry( + task.id, + (workspace) => { + if (workspace.taskStatus !== "starting") return; + workspace.taskStatus = recovery.status; + // History already owns accepted prompts; do not duplicate them on restart. + if (recovery.acceptedPrompt) workspace.taskPrompt = undefined; + }, + { allowMissing: true } + ); + } catch (error) { + await this.markTaskLaunchFailed(task.id, getErrorMessage(error)); } - return config; - }); + } log.info("[startup] Recovered stale starting agent tasks", { count: staleStartingTasks.length, acceptedPromptCount: [...recoveries.values()].filter((recovery) => recovery.acceptedPrompt) @@ -2383,6 +2389,7 @@ export class TaskService implements AgentTaskIntegration { for (const task of awaitingReportTasks) { if (!task.id) continue; + if (!(await this.admitTaskDesktopRecovery(task.id))) continue; if ( await this.interruptTaskRecoveryForInactiveWorkflowOwner( @@ -2421,6 +2428,7 @@ export class TaskService implements AgentTaskIntegration { for (const task of runningTasks) { if (!task.id) continue; + if (!(await this.admitTaskDesktopRecovery(task.id))) continue; if ( await this.interruptTaskRecoveryForInactiveWorkflowOwner( task.id, @@ -2759,6 +2767,36 @@ export class TaskService implements AgentTaskIntegration { }; } + private resolveTaskDesktopOwner(args: TaskCreateArgs, agentId: string): string | undefined { + const desktop = args.desktop ?? (agentId === "desktop" ? "shared" : "isolated"); + if (desktop !== "shared") return undefined; + if (args.bestOf != null && args.bestOf.total > 1) { + throw new Error("Shared desktop tasks cannot use best-of groups; use desktop: isolated"); + } + return this.desktopInputCoordinator.resolveTarget(args.parentWorkspaceId).ownerWorkspaceId; + } + + private async admitTaskDesktopRecovery(taskId: string): Promise { + try { + await this.desktopInputCoordinator.withAdmission(taskId, () => Promise.resolve(undefined)); + return true; + } catch (error) { + await this.markTaskLaunchFailed(taskId, getErrorMessage(error)); + return false; + } + } + + private async editActiveWorkspaceEntry( + workspaceId: string, + updater: (workspace: WorkspaceConfigEntry) => void, + options?: { allowMissing?: boolean } + ): Promise { + // Admission protects only persistence. Never hold the desktop gate across nested sends. + return await this.desktopInputCoordinator.withAdmission(workspaceId, () => + this.editWorkspaceEntry(workspaceId, updater, options) + ); + } + async createMany( argsList: TaskCreateArgs[], options: TaskCreateManyOptions = {} @@ -2811,6 +2849,12 @@ export class TaskService implements AgentTaskIntegration { } const agentId = parsedAgentId.data; const agentType = agentId; + let taskDesktopOwnerWorkspaceId: string | undefined; + try { + taskDesktopOwnerWorkspaceId = this.resolveTaskDesktopOwner(args, agentId); + } catch (error) { + return Err(getErrorMessage(error)); + } let normalizedBestOf: TaskCreateArgs["bestOf"]; const bestOf = args.bestOf; @@ -3045,6 +3089,7 @@ export class TaskService implements AgentTaskIntegration { experiments: args.experiments, onRefusal: args.onRefusal, attentionPolicy: args.attentionPolicy, + taskDesktopOwnerWorkspaceId, status, ...(sharedWorkspacePath != null ? { sharedWorkspacePath } : {}), // Real branch checked out in the parent's checkout: persisted as taskTrunkBranch and used @@ -3064,71 +3109,90 @@ export class TaskService implements AgentTaskIntegration { }); } - for (const [index, result] of results.entries()) { - // Workflow callers durably checkpoint returned task IDs before task records are persisted. - // If config persistence fails afterward, replay sees a started step whose task is not found - // and restarts it instead of duplicating an already-launched child after a crash. - await options.onTaskReserved?.(index, result); - } + try { + await this.desktopInputCoordinator.withReservations( + plans.flatMap((plan) => + plan.taskDesktopOwnerWorkspaceId == null + ? [] + : [ + { + ownerWorkspaceId: plan.taskDesktopOwnerWorkspaceId, + borrowerWorkspaceId: plan.taskId, + }, + ] + ), + async () => { + for (const [index, result] of results.entries()) { + // Workflow callers durably checkpoint returned task IDs before task records are persisted. + // If config persistence fails afterward, replay sees a started step whose task is not found + // and restarts it instead of duplicating an already-launched child after a crash. + await options.onTaskReserved?.(index, result); + } - await this.config.editConfig((config) => { - for (const plan of plans) { - const runtime = createRuntimeForWorkspace({ - runtimeConfig: plan.taskRuntimeConfig, - projectPath: plan.parentMeta.projectPath, - name: plan.parentMeta.name, - }); - const workspacePath = - plan.sharedWorkspacePath ?? - runtime.getWorkspacePath(plan.parentMeta.projectPath, plan.workspaceName); - const trunkBranch = - coerceNonEmptyString(plan.preferredTrunkBranch) ?? - coerceNonEmptyString(plan.parentMeta.name); - if (!trunkBranch) { - throw new Error("Task.createMany: parent workspace name missing"); - } - let projectConfig = config.projects.get(plan.configProjectPath); - if (!projectConfig) { - projectConfig = { workspaces: [] }; - config.projects.set(plan.configProjectPath, projectConfig); + await this.config.editConfig((config) => { + for (const plan of plans) { + const runtime = createRuntimeForWorkspace({ + runtimeConfig: plan.taskRuntimeConfig, + projectPath: plan.parentMeta.projectPath, + name: plan.parentMeta.name, + }); + const workspacePath = + plan.sharedWorkspacePath ?? + runtime.getWorkspacePath(plan.parentMeta.projectPath, plan.workspaceName); + const trunkBranch = + coerceNonEmptyString(plan.preferredTrunkBranch) ?? + coerceNonEmptyString(plan.parentMeta.name); + if (!trunkBranch) { + throw new Error("Task.createMany: parent workspace name missing"); + } + let projectConfig = config.projects.get(plan.configProjectPath); + if (!projectConfig) { + projectConfig = { workspaces: [] }; + config.projects.set(plan.configProjectPath, projectConfig); + } + projectConfig.workspaces.push({ + kind: plan.workspaceKind, + path: workspacePath, + id: plan.taskId, + name: plan.workspaceName, + title: plan.title, + createdAt: plan.createdAt, + runtimeConfig: plan.taskRuntimeConfig, + aiSettings: + plan.effectiveThinkingLevel !== undefined + ? { + model: plan.canonicalModel, + thinkingLevel: plan.effectiveThinkingLevel, + ...(plan.effectiveReasoningMode != null + ? { reasoningMode: plan.effectiveReasoningMode } + : {}), + } + : undefined, + parentWorkspaceId: plan.parentWorkspaceId, + agentId: plan.agentId, + agentType: plan.agentType, + workflowTask: plan.workflowTask, + bestOf: plan.bestOf, + taskStatus: plan.status, + taskPrompt: plan.start.kind === "sendMessage" ? plan.start.prompt : undefined, + taskTrunkBranch: trunkBranch, + taskModelString: plan.taskModelString, + taskThinkingLevel: plan.effectiveThinkingLevel, + taskOnRefusal: plan.onRefusal, + taskExperiments: withLegacyPtcExclusiveMirror(plan.experiments), + taskIsolation: plan.sharedWorkspacePath != null ? "none" : undefined, + taskAttentionPolicy: plan.attentionPolicy, + taskDesktopOwnerWorkspaceId: plan.taskDesktopOwnerWorkspaceId, + projects: plan.parentMeta.projects, + }); + } + return config; + }); } - projectConfig.workspaces.push({ - kind: plan.workspaceKind, - path: workspacePath, - id: plan.taskId, - name: plan.workspaceName, - title: plan.title, - createdAt: plan.createdAt, - runtimeConfig: plan.taskRuntimeConfig, - aiSettings: - plan.effectiveThinkingLevel !== undefined - ? { - model: plan.canonicalModel, - thinkingLevel: plan.effectiveThinkingLevel, - ...(plan.effectiveReasoningMode != null - ? { reasoningMode: plan.effectiveReasoningMode } - : {}), - } - : undefined, - parentWorkspaceId: plan.parentWorkspaceId, - agentId: plan.agentId, - agentType: plan.agentType, - workflowTask: plan.workflowTask, - bestOf: plan.bestOf, - taskStatus: plan.status, - taskPrompt: plan.start.kind === "sendMessage" ? plan.start.prompt : undefined, - taskTrunkBranch: trunkBranch, - taskModelString: plan.taskModelString, - taskThinkingLevel: plan.effectiveThinkingLevel, - taskOnRefusal: plan.onRefusal, - taskExperiments: withLegacyPtcExclusiveMirror(plan.experiments), - taskIsolation: plan.sharedWorkspacePath != null ? "none" : undefined, - taskAttentionPolicy: plan.attentionPolicy, - projects: plan.parentMeta.projects, - }); - } - return config; - }); + ); + } catch (error) { + return Err(getErrorMessage(error)); + } for (const result of results) { await this.emitWorkspaceMetadata(result.taskId); @@ -3364,6 +3428,9 @@ export class TaskService implements AgentTaskIntegration { return; } + // Revalidate persisted bindings on restart before materializing a checkout or starting init. + if (!(await this.admitTaskDesktopRecovery(plan.taskId))) return; + // isolation: "none" tasks were queued pointing at the parent's checkout. When that checkout // still exists, materialization reuses it (no fork); if it disappeared, materialization falls // back to forking a real workspace and the shared flag must be cleared below. @@ -3659,6 +3726,20 @@ export class TaskService implements AgentTaskIntegration { const agentId = parsedAgentId.data; const agentType = agentId; // Legacy alias for on-disk compatibility. + let taskDesktopOwnerWorkspaceId: string | undefined; + try { + taskDesktopOwnerWorkspaceId = this.resolveTaskDesktopOwner(args, agentId); + } catch (error) { + return Err(getErrorMessage(error)); + } + const reserveDesktop = (reserve: () => Promise): Promise => + taskDesktopOwnerWorkspaceId == null + ? reserve() + : this.desktopInputCoordinator.withReservation( + taskDesktopOwnerWorkspaceId, + taskId, + reserve + ); await using _lock = await this.mutex.acquire(); @@ -3925,44 +4006,53 @@ export class TaskService implements AgentTaskIntegration { workspacePath, }); - await this.config.editConfig((config) => { - let projectConfig = config.projects.get(configProjectPath); - if (!projectConfig) { - projectConfig = { workspaces: [] }; - config.projects.set(configProjectPath, projectConfig); - } + try { + await reserveDesktop(async () => { + await this.config.editConfig((config) => { + let projectConfig = config.projects.get(configProjectPath); + if (!projectConfig) { + projectConfig = { workspaces: [] }; + config.projects.set(configProjectPath, projectConfig); + } - projectConfig.workspaces.push({ - kind: parentIsScratch ? "scratch" : undefined, - path: workspacePath, - id: taskId, - name: workspaceName, - title: args.title, - createdAt, - runtimeConfig: taskRuntimeConfig, - aiSettings: { - model: canonicalModel, - thinkingLevel: effectiveThinkingLevel, - ...(effectiveReasoningMode != null ? { reasoningMode: effectiveReasoningMode } : {}), - }, - parentWorkspaceId, - agentId, - agentType, - workflowTask: args.workflowTask, - bestOf: normalizedBestOf, - taskStatus: "queued", - taskPrompt: prompt, - taskTrunkBranch: trunkBranch, - taskModelString, - taskThinkingLevel: effectiveThinkingLevel, - taskOnRefusal: args.onRefusal, - taskExperiments: withLegacyPtcExclusiveMirror(args.experiments), - taskIsolation: useSharedWorkspace ? "none" : undefined, - taskAttentionPolicy: args.attentionPolicy, - projects: parentMeta.projects, + projectConfig.workspaces.push({ + kind: parentIsScratch ? "scratch" : undefined, + path: workspacePath, + id: taskId, + name: workspaceName, + title: args.title, + createdAt, + runtimeConfig: taskRuntimeConfig, + aiSettings: { + model: canonicalModel, + thinkingLevel: effectiveThinkingLevel, + ...(effectiveReasoningMode != null + ? { reasoningMode: effectiveReasoningMode } + : {}), + }, + parentWorkspaceId, + agentId, + agentType, + workflowTask: args.workflowTask, + bestOf: normalizedBestOf, + taskStatus: "queued", + taskPrompt: prompt, + taskTrunkBranch: trunkBranch, + taskModelString, + taskThinkingLevel: effectiveThinkingLevel, + taskOnRefusal: args.onRefusal, + taskExperiments: withLegacyPtcExclusiveMirror(args.experiments), + taskIsolation: useSharedWorkspace ? "none" : undefined, + taskAttentionPolicy: args.attentionPolicy, + taskDesktopOwnerWorkspaceId, + projects: parentMeta.projects, + }); + return config; + }); }); - return config; - }); + } catch (error) { + return Err(getErrorMessage(error)); + } // Emit metadata update so the UI sees the workspace immediately. await this.emitWorkspaceMetadata(taskId); @@ -3987,152 +4077,169 @@ export class TaskService implements AgentTaskIntegration { }); } - const initLogger = this.startWorkspaceInit(taskId, parentMeta.projectPath); - - let workspacePath: string; - let trunkBranch: string; - let forkedRuntimeConfig: RuntimeConfig; - let runtimeForTaskWorkspace: Runtime; - let forkedFromSource: boolean; - let inheritedProjects: ProjectRef[] | undefined; + const materialize = async () => { + const initLogger = this.startWorkspaceInit(taskId, parentMeta.projectPath); + + let workspacePath: string; + let trunkBranch: string; + let forkedRuntimeConfig: RuntimeConfig; + let runtimeForTaskWorkspace: Runtime; + let forkedFromSource: boolean; + let inheritedProjects: ProjectRef[] | undefined; + + if (useSharedWorkspace) { + // isolation: "none" — run the sub-agent directly in the parent workspace's checkout instead + // of forking. Mirrors local-runtime semantics for worktree/SSH so read-only analysis (or + // prompt-isolated work) skips the fork + init overhead and sees the parent's uncommitted work. + // + // SAFETY: the task still gets a unique workspace name, and workspace deletion is keyed on that + // name (runtime.deleteWorkspace(projectPath, name)), so removing this task never deletes the + // shared parent checkout. workspaceService.remove additionally skips physical deletion for + // tasks persisted with taskIsolation === "none". + workspacePath = parentWorkspacePath; + trunkBranch = parentBranchName ?? "main"; + forkedRuntimeConfig = parentRuntimeConfig; + forkedFromSource = false; + inheritedProjects = parentMeta.projects; + // Build the runtime with the child's identity but the parent's checkout path. Worktree/SSH + // runtimes honor this persisted path override (see *Runtime.getWorkspacePath), so cwd + // resolution and ensureReady land in the shared parent checkout instead of a name-derived + // directory that was never created. This mirrors the runtime rebuilt from the persisted entry. + runtimeForTaskWorkspace = createRuntimeForWorkspace({ + runtimeConfig: parentRuntimeConfig, + projectPath: parentMeta.projectPath, + name: workspaceName, + namedWorkspacePath: parentWorkspacePath, + }); + initLogger.logStep("Sharing parent workspace (isolation: none) — skipping fork and init"); + initLogger.logComplete(0); + } else { + // Note: Local project-dir runtimes share the same directory (unsafe by design). + // For worktree/ssh runtimes we attempt a fork first; otherwise fall back to createWorkspace. + const forkResult = await orchestrateFork({ + sourceRuntime: runtime, + projectPath: parentMeta.projectPath, + sourceWorkspaceName: parentMeta.name, + newWorkspaceName: workspaceName, + initLogger, + config: this.config, + sourceWorkspaceId: parentWorkspaceId, + sourceRuntimeConfig: parentRuntimeConfig, + parentMetadata: parentMeta, + allowCreateFallback: true, + // Create-fallback base when the fork cannot detect a source branch — a shared parent's + // synthetic name never names a real branch, so supply the actual checked-out branch. + // Gated to shared parents to keep the existing branch-discovery fallback otherwise. + ...(parentIsSharedTask && parentBranchName != null + ? { preferredTrunkBranch: parentBranchName } + : {}), + trusted: + this.config.loadConfigOrDefault().projects.get(configProjectPath)?.trusted ?? false, + multiProjectExperimentEnabled: this.workspaceService.isExperimentEnabled( + EXPERIMENT_IDS.MULTI_PROJECT_WORKSPACES + ), + }); - if (useSharedWorkspace) { - // isolation: "none" — run the sub-agent directly in the parent workspace's checkout instead - // of forking. Mirrors local-runtime semantics for worktree/SSH so read-only analysis (or - // prompt-isolated work) skips the fork + init overhead and sees the parent's uncommitted work. - // - // SAFETY: the task still gets a unique workspace name, and workspace deletion is keyed on that - // name (runtime.deleteWorkspace(projectPath, name)), so removing this task never deletes the - // shared parent checkout. workspaceService.remove additionally skips physical deletion for - // tasks persisted with taskIsolation === "none". - workspacePath = parentWorkspacePath; - trunkBranch = parentBranchName ?? "main"; - forkedRuntimeConfig = parentRuntimeConfig; - forkedFromSource = false; - inheritedProjects = parentMeta.projects; - // Build the runtime with the child's identity but the parent's checkout path. Worktree/SSH - // runtimes honor this persisted path override (see *Runtime.getWorkspacePath), so cwd - // resolution and ensureReady land in the shared parent checkout instead of a name-derived - // directory that was never created. This mirrors the runtime rebuilt from the persisted entry. - runtimeForTaskWorkspace = createRuntimeForWorkspace({ - runtimeConfig: parentRuntimeConfig, - projectPath: parentMeta.projectPath, - name: workspaceName, - namedWorkspacePath: parentWorkspacePath, - }); - initLogger.logStep("Sharing parent workspace (isolation: none) — skipping fork and init"); - initLogger.logComplete(0); - } else { - // Note: Local project-dir runtimes share the same directory (unsafe by design). - // For worktree/ssh runtimes we attempt a fork first; otherwise fall back to createWorkspace. - const forkResult = await orchestrateFork({ - sourceRuntime: runtime, - projectPath: parentMeta.projectPath, - sourceWorkspaceName: parentMeta.name, - newWorkspaceName: workspaceName, - initLogger, - config: this.config, - sourceWorkspaceId: parentWorkspaceId, - sourceRuntimeConfig: parentRuntimeConfig, - parentMetadata: parentMeta, - allowCreateFallback: true, - // Create-fallback base when the fork cannot detect a source branch — a shared parent's - // synthetic name never names a real branch, so supply the actual checked-out branch. - // Gated to shared parents to keep the existing branch-discovery fallback otherwise. - ...(parentIsSharedTask && parentBranchName != null - ? { preferredTrunkBranch: parentBranchName } - : {}), - trusted: - this.config.loadConfigOrDefault().projects.get(configProjectPath)?.trusted ?? false, - multiProjectExperimentEnabled: this.workspaceService.isExperimentEnabled( - EXPERIMENT_IDS.MULTI_PROJECT_WORKSPACES - ), - }); + if (forkResult.success && forkResult.data.sourceRuntimeConfigUpdate) { + await this.config.updateWorkspaceMetadata(parentWorkspaceId, { + runtimeConfig: forkResult.data.sourceRuntimeConfigUpdate, + }); + // Ensure UI gets the updated runtimeConfig for the parent workspace. + await this.emitWorkspaceMetadata(parentWorkspaceId); + } - if (forkResult.success && forkResult.data.sourceRuntimeConfigUpdate) { - await this.config.updateWorkspaceMetadata(parentWorkspaceId, { - runtimeConfig: forkResult.data.sourceRuntimeConfigUpdate, - }); - // Ensure UI gets the updated runtimeConfig for the parent workspace. - await this.emitWorkspaceMetadata(parentWorkspaceId); - } + if (!forkResult.success) { + initLogger.logComplete(-1); + return Err(`Task fork failed: ${forkResult.error}`); + } - if (!forkResult.success) { - initLogger.logComplete(-1); - return Err(`Task fork failed: ${forkResult.error}`); + workspacePath = forkResult.data.workspacePath; + trunkBranch = forkResult.data.trunkBranch; + forkedRuntimeConfig = forkResult.data.forkedRuntimeConfig; + runtimeForTaskWorkspace = forkResult.data.targetRuntime; + forkedFromSource = forkResult.data.forkedFromSource; + inheritedProjects = forkResult.data.projects; } - workspacePath = forkResult.data.workspacePath; - trunkBranch = forkResult.data.trunkBranch; - forkedRuntimeConfig = forkResult.data.forkedRuntimeConfig; - runtimeForTaskWorkspace = forkResult.data.targetRuntime; - forkedFromSource = forkResult.data.forkedFromSource; - inheritedProjects = forkResult.data.projects; - } + // Multi-project forks need per-project secrets for each runtime's init hook. + this.configureMultiProjectRuntimeEnvResolver(runtimeForTaskWorkspace); - // Multi-project forks need per-project secrets for each runtime's init hook. - this.configureMultiProjectRuntimeEnvResolver(runtimeForTaskWorkspace); + const taskBaseCommitShaByProjectPath = await readTaskBaseCommitShaByProjectPath({ + workspaceId: taskId, + workspaceName, + workspacePath, + runtimeConfig: forkedRuntimeConfig, + projectPath: parentMeta.projectPath, + projectName: parentMeta.projectName, + projects: inheritedProjects, + runtime: runtimeForTaskWorkspace, + }); + const taskBaseCommitSha = taskBaseCommitShaByProjectPath[parentMeta.projectPath]; - const taskBaseCommitShaByProjectPath = await readTaskBaseCommitShaByProjectPath({ - workspaceId: taskId, - workspaceName, - workspacePath, - runtimeConfig: forkedRuntimeConfig, - projectPath: parentMeta.projectPath, - projectName: parentMeta.projectName, - projects: inheritedProjects, - runtime: runtimeForTaskWorkspace, - }); - const taskBaseCommitSha = taskBaseCommitShaByProjectPath[parentMeta.projectPath]; + taskQueueDebug("TaskService.create started (workspace created)", { + taskId, + workspaceName, + workspacePath, + trunkBranch, + forkSuccess: forkedFromSource, + }); - taskQueueDebug("TaskService.create started (workspace created)", { - taskId, - workspaceName, - workspacePath, - trunkBranch, - forkSuccess: forkedFromSource, - }); + // Persist workspace entry before starting work so it's durable across crashes. + await this.config.editConfig((config) => { + let projectConfig = config.projects.get(configProjectPath); + if (!projectConfig) { + projectConfig = { workspaces: [] }; + config.projects.set(configProjectPath, projectConfig); + } - // Persist workspace entry before starting work so it's durable across crashes. - await this.config.editConfig((config) => { - let projectConfig = config.projects.get(configProjectPath); - if (!projectConfig) { - projectConfig = { workspaces: [] }; - config.projects.set(configProjectPath, projectConfig); - } + projectConfig.workspaces.push({ + kind: parentIsScratch ? "scratch" : undefined, + path: workspacePath, + id: taskId, + name: workspaceName, + title: args.title, + createdAt, + runtimeConfig: forkedRuntimeConfig, + aiSettings: { + model: canonicalModel, + thinkingLevel: effectiveThinkingLevel, + ...(effectiveReasoningMode != null ? { reasoningMode: effectiveReasoningMode } : {}), + }, + agentId, + parentWorkspaceId, + agentType, + workflowTask: args.workflowTask, + bestOf: normalizedBestOf, + taskStatus: "running", + taskTrunkBranch: trunkBranch, + taskBaseCommitSha: taskBaseCommitSha ?? undefined, + taskBaseCommitShaByProjectPath, + taskModelString, + taskThinkingLevel: effectiveThinkingLevel, + taskOnRefusal: args.onRefusal, + taskExperiments: withLegacyPtcExclusiveMirror(args.experiments), + taskIsolation: useSharedWorkspace ? "none" : undefined, + taskAttentionPolicy: args.attentionPolicy, + taskDesktopOwnerWorkspaceId, + projects: inheritedProjects, + }); + return config; + }); - projectConfig.workspaces.push({ - kind: parentIsScratch ? "scratch" : undefined, - path: workspacePath, - id: taskId, - name: workspaceName, - title: args.title, - createdAt, - runtimeConfig: forkedRuntimeConfig, - aiSettings: { - model: canonicalModel, - thinkingLevel: effectiveThinkingLevel, - ...(effectiveReasoningMode != null ? { reasoningMode: effectiveReasoningMode } : {}), - }, - agentId, - parentWorkspaceId, - agentType, - workflowTask: args.workflowTask, - bestOf: normalizedBestOf, - taskStatus: "running", - taskTrunkBranch: trunkBranch, - taskBaseCommitSha: taskBaseCommitSha ?? undefined, - taskBaseCommitShaByProjectPath, - taskModelString, - taskThinkingLevel: effectiveThinkingLevel, - taskOnRefusal: args.onRefusal, - taskExperiments: withLegacyPtcExclusiveMirror(args.experiments), - taskIsolation: useSharedWorkspace ? "none" : undefined, - taskAttentionPolicy: args.attentionPolicy, - projects: inheritedProjects, + return Ok({ + initLogger, + workspacePath, + trunkBranch, + forkedRuntimeConfig, + runtimeForTaskWorkspace, }); - return config; - }); + }; + const materialized = await reserveDesktop(materialize).catch((error: unknown) => + Err(getErrorMessage(error)) + ); + if (!materialized.success) return materialized; + const { initLogger, workspacePath, trunkBranch, forkedRuntimeConfig, runtimeForTaskWorkspace } = + materialized.data; if (!useSharedWorkspace) { // SECURITY: this checkout materialized outside the host's create/fork paths, so @@ -4198,18 +4305,20 @@ export class TaskService implements AgentTaskIntegration { } // Start immediately (counts towards parallel limit). - const sendResult = await this.workspaceService.sendMessage( - taskId, - prompt, - { - model: taskModelString, - agentId, - thinkingLevel: effectiveThinkingLevel, - reasoningMode: effectiveReasoningMode, - experiments: args.experiments, - }, - { agentInitiated: true } - ); + const sendResult = await this.workspaceService + .sendMessage( + taskId, + prompt, + { + model: taskModelString, + agentId, + thinkingLevel: effectiveThinkingLevel, + reasoningMode: effectiveReasoningMode, + experiments: args.experiments, + }, + { agentInitiated: true } + ) + .catch((error: unknown) => Err(getErrorMessage(error))); if (!sendResult.success) { const message = typeof sendResult.error === "string" @@ -4487,7 +4596,7 @@ export class TaskService implements AgentTaskIntegration { } const guidanceId = randomUUID(); - await this.editWorkspaceEntry( + await this.editActiveWorkspaceEntry( taskId, (workspace) => { workspace.taskPendingGuidance = [ @@ -8150,7 +8259,7 @@ export class TaskService implements AgentTaskIntegration { const tokens = entry.workspace.taskTimeoutFinalizationTokens ?? []; const alreadyPrompted = tokens.includes(options.finalizationToken); if (!alreadyPrompted) { - await this.editWorkspaceEntry( + await this.editActiveWorkspaceEntry( taskId, (workspace) => { workspace.taskStatus = "awaiting_report"; @@ -8197,7 +8306,7 @@ export class TaskService implements AgentTaskIntegration { if (hasCompletedAgentReport(entry.workspace) || this.completedReportsByTaskId.has(taskId)) { return; } - await this.editWorkspaceEntry( + await this.editActiveWorkspaceEntry( taskId, (workspace) => { const existing = workspace.taskTimeoutFinalizationTokens ?? []; @@ -10161,9 +10270,14 @@ export class TaskService implements AgentTaskIntegration { // relaunched task's persisted aiSettings. normalizeSelectedModel(task.taskModelString ?? defaultModel); const createdAt = task.createdAt ?? getIsoNow(); - await this.editWorkspaceEntry(taskId, (workspace) => { - workspace.taskStatus = "starting"; - }); + try { + await this.editActiveWorkspaceEntry(taskId, (workspace) => { + workspace.taskStatus = "starting"; + }); + } catch (error) { + await this.markTaskLaunchFailed(taskId, getErrorMessage(error)); + continue; + } reservedSlots += 1; plans.push({ @@ -10281,6 +10395,8 @@ export class TaskService implements AgentTaskIntegration { /** * If a preserved descendant task workspace was previously interrupted and the user manually * resumes it, restore taskStatus=running so stream-end finalization can proceed normally. + * Shared-desktop reported tasks also need this durable reservation for direct sends that have + * no workspace-turn handle. Their original binding is never inferred again on reawakening. * * Returns true only when a state transition happened. */ @@ -10292,19 +10408,28 @@ export class TaskService implements AgentTaskIntegration { if (!entryAtStart?.workspace.parentWorkspaceId) { return false; } - if (entryAtStart.workspace.taskStatus !== "interrupted") { + if ( + entryAtStart.workspace.taskStatus !== "interrupted" && + !( + entryAtStart.workspace.taskStatus === "reported" && + entryAtStart.workspace.taskDesktopOwnerWorkspaceId != null + ) + ) { return false; } let transitionedToRunning = false; - await this.editWorkspaceEntry( + await this.editActiveWorkspaceEntry( workspaceId, (ws) => { // Only descendant task workspaces have task lifecycle status. if (!ws.parentWorkspaceId) { return; } - if (ws.taskStatus !== "interrupted") { + if ( + ws.taskStatus !== "interrupted" && + !(ws.taskStatus === "reported" && ws.taskDesktopOwnerWorkspaceId != null) + ) { return; } @@ -10332,7 +10457,10 @@ export class TaskService implements AgentTaskIntegration { * Revert a pre-stream interrupted->running transition when send/resume fails to start * or complete. This preserves fail-fast interrupted semantics for task_await. */ - async restoreInterruptedTaskAfterResumeFailure(workspaceId: string): Promise { + async restoreInterruptedTaskAfterResumeFailure( + workspaceId: string, + previousStatus?: AgentTaskStatus | null + ): Promise { assert( workspaceId.length > 0, "restoreInterruptedTaskAfterResumeFailure: workspaceId must be non-empty" @@ -10351,8 +10479,8 @@ export class TaskService implements AgentTaskIntegration { } parentWorkspaceId = ws.parentWorkspaceId; - ws.taskStatus = "interrupted"; - ws.reportedAt = undefined; + ws.taskStatus = previousStatus === "reported" ? "reported" : "interrupted"; + if (previousStatus !== "reported") ws.reportedAt = undefined; revertedToInterrupted = true; }, { allowMissing: true } @@ -11478,7 +11606,7 @@ export class TaskService implements AgentTaskIntegration { } if (planSummary == null) { - await this.editWorkspaceEntry( + await this.editActiveWorkspaceEntry( args.workspaceId, (workspace) => { workspace.taskStatus = "awaiting_report"; @@ -12152,7 +12280,7 @@ export class TaskService implements AgentTaskIntegration { }; } - await this.editWorkspaceEntry( + await this.editActiveWorkspaceEntry( childWorkspaceId, (ws) => { ws.taskStatus = "awaiting_report"; diff --git a/src/node/services/taskWorkspaceSeam.ts b/src/node/services/taskWorkspaceSeam.ts index ecd8810187..9c687545bb 100644 --- a/src/node/services/taskWorkspaceSeam.ts +++ b/src/node/services/taskWorkspaceSeam.ts @@ -90,6 +90,8 @@ export interface TaskCreateArgs { * "fork" (isolated copy) when omitted. Ignored (treated as "fork") on unsupported runtimes. */ isolation?: TaskIsolation; + /** Desktop sharing is independent of checkout isolation. */ + desktop?: "shared" | "isolated"; parentRuntimeAiSettings?: { modelString?: string; thinkingLevel?: ThinkingLevel }; /** * Model-refusal policy persisted on the child workspace. "fail" opts the task @@ -519,7 +521,10 @@ export interface AgentTaskIntegration { resetAutoResumeCount(workspaceId: string): void; backgroundForegroundWaitsForWorkspace(workspaceId: string): number; markInterruptedTaskRunning(workspaceId: string): Promise; - restoreInterruptedTaskAfterResumeFailure(workspaceId: string): Promise; + restoreInterruptedTaskAfterResumeFailure( + workspaceId: string, + previousStatus?: AgentTaskStatus | null + ): Promise; markParentWorkspaceInterrupted(workspaceId: string): void; latchHardInterruptCascade(workspaceId: string): (() => void) | undefined; terminateAllDescendantAgentTasks( diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 12e71b83f3..50e3376a12 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -9206,6 +9206,31 @@ describe("WorkspaceService sendMessage status clearing", () => { } }); + test.each(["sendMessage", "resumeStream"] as const)( + "%s refuses the stream when desktop task admission fails", + async (operation) => { + fakeSession.isBusy.mockReturnValue(false); + const restoreInterruptedTaskAfterResumeFailure = mock(() => Promise.resolve()); + workspaceService.setAgentTaskIntegration( + makeAgentTaskIntegrationFake({ + markInterruptedTaskRunning: mock(() => + Promise.reject(new Error("Desktop is controlled by another child")) + ), + restoreInterruptedTaskAfterResumeFailure, + }) + ); + const options = { model: "openai:gpt-4o-mini", agentId: "exec" }; + const result = + operation === "sendMessage" + ? await workspaceService.sendMessage("test-workspace", "hello", options) + : await workspaceService.resumeStream("test-workspace", options); + expect(result.success).toBe(false); + expect(fakeSession.sendMessage).not.toHaveBeenCalled(); + expect(fakeSession.resumeStream).not.toHaveBeenCalled(); + expect(restoreInterruptedTaskAfterResumeFailure).not.toHaveBeenCalled(); + } + ); + // Send outcome drives interrupted-task rollback: a successful send keeps the // restored running status; a failed or thrown send rolls it back. test.each([ @@ -9241,7 +9266,10 @@ describe("WorkspaceService sendMessage status clearing", () => { if (expectSuccess) { expect(restoreInterruptedTaskAfterResumeFailure).not.toHaveBeenCalled(); } else { - expect(restoreInterruptedTaskAfterResumeFailure).toHaveBeenCalledWith("test-workspace"); + expect(restoreInterruptedTaskAfterResumeFailure).toHaveBeenCalledWith( + "test-workspace", + undefined + ); } }); @@ -9287,7 +9315,10 @@ describe("WorkspaceService sendMessage status clearing", () => { expect(markInterruptedTaskRunning).toHaveBeenCalledWith("test-workspace"); await startupFailureHandled.promise; - expect(restoreInterruptedTaskAfterResumeFailure).toHaveBeenCalledWith("test-workspace"); + expect(restoreInterruptedTaskAfterResumeFailure).toHaveBeenCalledWith( + "test-workspace", + undefined + ); }); // Resume outcome drives interrupted-task rollback: only a resume that actually @@ -9325,7 +9356,10 @@ describe("WorkspaceService sendMessage status clearing", () => { if (resumeOutcome === "started") { expect(restoreInterruptedTaskAfterResumeFailure).not.toHaveBeenCalled(); } else { - expect(restoreInterruptedTaskAfterResumeFailure).toHaveBeenCalledWith("test-workspace"); + expect(restoreInterruptedTaskAfterResumeFailure).toHaveBeenCalledWith( + "test-workspace", + undefined + ); } }); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index c39d187e7d..fcb8c337aa 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -1,3 +1,4 @@ +import { DesktopInputCoordinator } from "@/node/services/desktop/DesktopInputCoordinator"; import * as path from "path"; import { TASK_TERMINATION_STOP_STREAM_TIMEOUT_MS } from "@/constants/terminationTimeouts"; import { raceWithAbortAndTimeout } from "@/node/utils/concurrency/withTimeout"; @@ -2350,7 +2351,8 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { private readonly secretsStore: Pick = new SecretsStore( config.rootDir ), - private readonly providersConfigStore = new ProvidersConfigStore(config.rootDir) + private readonly providersConfigStore = new ProvidersConfigStore(config.rootDir), + private readonly desktopInputCoordinator = new DesktopInputCoordinator(config) ) { super(); this.bashMonitorRegistryStore = new BashMonitorRegistryStore(config); @@ -10672,6 +10674,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { const authoredAtMs = Date.now(); let resumedInterruptedTask = false; + let previousTaskStatus: ReturnType; let claimedAutoTitle = false; try { // Block streaming while workspace is being renamed to prevent path conflicts @@ -11125,23 +11128,28 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { // config read inside markInterruptedTaskRunning would otherwise be flipped straight back // to running — after which every later probe sees an active status and admits the very // turn the stop was meant to prevent. + if ( + findWorkspaceEntry(this.config.loadConfigOrDefault(), workspaceId)?.workspace + .taskDesktopOwnerWorkspaceId !== undefined + ) { + await this.desktopInputCoordinator.withAdmission(workspaceId, () => + Promise.resolve(undefined) + ); + } if (internal?.admissionStale == null) { - try { - resumedInterruptedTask = - (await this.agentTaskIntegration?.markInterruptedTaskRunning(workspaceId)) ?? false; - } catch (error: unknown) { - log.error("Failed to restore interrupted task status before sendMessage", { - workspaceId, - error, - }); - } + previousTaskStatus = this.agentTaskIntegration?.getAgentTaskStatus(workspaceId); + resumedInterruptedTask = + (await this.agentTaskIntegration?.markInterruptedTaskRunning(workspaceId)) ?? false; } const continuationSendState = getContinuationSendState(); const onAcceptedPreStreamFailure = async (error: SendMessageError) => { if (resumedInterruptedTask && normalizedOptions?.editMessageId) { try { - await this.agentTaskIntegration?.restoreInterruptedTaskAfterResumeFailure(workspaceId); + await this.agentTaskIntegration?.restoreInterruptedTaskAfterResumeFailure( + workspaceId, + previousTaskStatus + ); } catch (restoreError: unknown) { log.error( "Failed to restore interrupted task status after accepted edit startup failure", @@ -11218,7 +11226,10 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { if (resumedInterruptedTask) { try { - await this.agentTaskIntegration?.restoreInterruptedTaskAfterResumeFailure(workspaceId); + await this.agentTaskIntegration?.restoreInterruptedTaskAfterResumeFailure( + workspaceId, + previousTaskStatus + ); } catch (error: unknown) { log.error("Failed to restore interrupted task status after sendMessage failure", { workspaceId, @@ -11253,7 +11264,10 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { if (resumedInterruptedTask) { try { - await this.agentTaskIntegration?.restoreInterruptedTaskAfterResumeFailure(workspaceId); + await this.agentTaskIntegration?.restoreInterruptedTaskAfterResumeFailure( + workspaceId, + previousTaskStatus + ); } catch (restoreError: unknown) { log.error("Failed to restore interrupted task status after sendMessage throw", { workspaceId, @@ -11288,6 +11302,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { internal?: { allowQueuedAgentTask?: boolean; agentInitiated?: boolean } ): Promise> { let resumedInterruptedTask = false; + let previousTaskStatus: ReturnType; try { // Block streaming while workspace is being renamed to prevent path conflicts if (this.renamingWorkspaces.has(workspaceId)) { @@ -11421,15 +11436,17 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { // Non-destructive interrupt cascades preserve descendant task workspaces with // taskStatus=interrupted. Transition before stream start so task orchestration stream-end // handling does not early-return on interrupted status. - try { - resumedInterruptedTask = - (await this.agentTaskIntegration?.markInterruptedTaskRunning(workspaceId)) ?? false; - } catch (error: unknown) { - log.error("Failed to restore interrupted task status before resumeStream", { - workspaceId, - error, - }); + if ( + findWorkspaceEntry(this.config.loadConfigOrDefault(), workspaceId)?.workspace + .taskDesktopOwnerWorkspaceId !== undefined + ) { + await this.desktopInputCoordinator.withAdmission(workspaceId, () => + Promise.resolve(undefined) + ); } + previousTaskStatus = this.agentTaskIntegration?.getAgentTaskStatus(workspaceId); + resumedInterruptedTask = + (await this.agentTaskIntegration?.markInterruptedTaskRunning(workspaceId)) ?? false; // Codex P1 (PRRT_kwDOPxxmWM6cSREO): resumeStream runs its own async // admission (a second pricing gate) during which the session still @@ -11450,7 +11467,10 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { }); if (resumedInterruptedTask) { try { - await this.agentTaskIntegration?.restoreInterruptedTaskAfterResumeFailure(workspaceId); + await this.agentTaskIntegration?.restoreInterruptedTaskAfterResumeFailure( + workspaceId, + previousTaskStatus + ); } catch (error: unknown) { log.error("Failed to restore interrupted task status after resumeStream failure", { workspaceId, @@ -11466,7 +11486,10 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { if (!result.data.started) { if (resumedInterruptedTask) { try { - await this.agentTaskIntegration?.restoreInterruptedTaskAfterResumeFailure(workspaceId); + await this.agentTaskIntegration?.restoreInterruptedTaskAfterResumeFailure( + workspaceId, + previousTaskStatus + ); } catch (error: unknown) { log.error("Failed to restore interrupted task status after no-op resumeStream", { workspaceId, @@ -11481,7 +11504,10 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { } catch (error) { if (resumedInterruptedTask) { try { - await this.agentTaskIntegration?.restoreInterruptedTaskAfterResumeFailure(workspaceId); + await this.agentTaskIntegration?.restoreInterruptedTaskAfterResumeFailure( + workspaceId, + previousTaskStatus + ); } catch (restoreError: unknown) { log.error("Failed to restore interrupted task status after resumeStream throw", { workspaceId, diff --git a/src/node/services/workspaceTurnManager.test.ts b/src/node/services/workspaceTurnManager.test.ts index 20588945ad..327ca44584 100644 --- a/src/node/services/workspaceTurnManager.test.ts +++ b/src/node/services/workspaceTurnManager.test.ts @@ -308,6 +308,77 @@ describe("WorkspaceTurnManager", () => { await fsPromises.rm(rootDir, { recursive: true, force: true }); }); + test("shared desktop execution mirror rejects stale active and terminal callbacks", async () => { + const config = await createTestConfig(rootDir); + const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); + await config.editConfig((cfg) => { + cfg.projects.get(projectPath)!.workspaces.push( + projectWorkspace(projectPath, "child", "child", { + parentWorkspaceId: parentId, + taskStatus: "reported", + runtimeConfig: { type: "local" }, + taskDesktopOwnerWorkspaceId: parentId, + taskExecutionId: "new", + taskExecutionStatus: "running", + }) + ); + return cfg; + }); + const { taskService } = createWorkspaceTurnManagerHarness(config); + const internals = taskService as unknown as { + activeWorkspaceTurnHandleByWorkspaceId: Map< + string, + { handleId: string; ownerWorkspaceId: string; accepted: boolean } + >; + }; + internals.activeWorkspaceTurnHandleByWorkspaceId.set("child", { + handleId: "new", + ownerWorkspaceId: parentId, + accepted: true, + }); + for (const status of ["queued", "starting", "running", "completed", null] as const) { + await taskService.updateAgentTaskExecutionState("child", "old", status); + expect(findWorkspaceInConfig(config, "child")?.taskExecutionId).toBe("new"); + expect(findWorkspaceInConfig(config, "child")?.taskExecutionStatus).toBe("running"); + } + await taskService.updateAgentTaskExecutionState("child", "new", "completed"); + await taskService.updateAgentTaskExecutionState("child", "old", "running"); + await taskService.updateAgentTaskExecutionState("child", "new", "running"); + expect(findWorkspaceInConfig(config, "child")?.taskExecutionStatus).toBe("completed"); + }); + + test("shared desktop active mirror refuses a missing target or competing child", async () => { + const config = await createTestConfig(rootDir); + const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); + await config.editConfig((cfg) => { + cfg.projects.get(projectPath)!.workspaces.push( + ...["missing", "child", "competitor"].map((id) => + projectWorkspace(projectPath, id, id, { + parentWorkspaceId: parentId, + agentId: "explore", + taskStatus: id === "competitor" ? "running" : "reported", + runtimeConfig: { type: "local" }, + taskDesktopOwnerWorkspaceId: id === "missing" ? "deleted" : parentId, + taskExecutionId: id, + taskExecutionStatus: "completed", + }) + ) + ); + return cfg; + }); + const { taskService } = createWorkspaceTurnManagerHarness(config); + // Exercise admission itself deterministically; startup normalization may settle dead streams. + for (const id of ["missing", "child"]) { + const failure = await taskService.updateAgentTaskExecutionState(id, id, "running").then( + () => null, + (error: unknown) => error + ); + expect(failure).toBeInstanceOf(Error); + expect(findWorkspaceInConfig(config, id)?.taskExecutionStatus).toBe("completed"); + } + expect(taskService.getLiveWorkspaceTurnRegistration("child")).toBeUndefined(); + }); + async function startWorkspaceTurnForTest( options: { stableIds?: string[]; @@ -3151,67 +3222,93 @@ describe("WorkspaceTurnManager", () => { expect(sendMessage).toHaveBeenCalledTimes(2); }); - test("internal workspace-turn execution can continue a reported descendant agent workspace", async () => { - const config = await createTestConfig(rootDir); - stubStableIds(config, ["followuphandle", "followupturn"]); - const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); - const childWorkspaceId = "reported-child-workspace"; - await config.editConfig((cfg) => { - const project = cfg.projects.get(projectPath); - assert(project, "test project must exist"); - project.workspaces.push({ - path: path.join(projectPath, "reported-child"), - id: childWorkspaceId, - name: "agent_explore_reported_child", - parentWorkspaceId: parentId, - agentType: "explore", - taskStatus: "reported", - reportedAt: "2026-06-19T00:00:00.000Z", - aiSettingsByAgent: { - explore: { model: "anthropic:claude-sonnet-4-6", thinkingLevel: "medium" }, - }, - taskModelString: "openai:gpt-5.2", - taskThinkingLevel: "high", - runtimeConfig: { type: "local" }, + test.each(["isolated", "shared", "busy"] as const)( + "internal workspace-turn reported child continuation (%s desktop)", + async (desktop) => { + const config = await createTestConfig(rootDir); + stubStableIds(config, ["followuphandle", "followupturn"]); + const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); + const childWorkspaceId = "reported-child-workspace"; + await config.editConfig((cfg) => { + const project = cfg.projects.get(projectPath); + assert(project, "test project must exist"); + project.workspaces.push({ + path: path.join(projectPath, "reported-child"), + id: childWorkspaceId, + name: "agent_explore_reported_child", + parentWorkspaceId: parentId, + agentType: "explore", + taskStatus: "reported", + taskDesktopOwnerWorkspaceId: desktop === "isolated" ? undefined : parentId, + reportedAt: "2026-06-19T00:00:00.000Z", + aiSettingsByAgent: { + explore: { model: "anthropic:claude-sonnet-4-6", thinkingLevel: "medium" }, + }, + taskModelString: "openai:gpt-5.2", + taskThinkingLevel: "high", + runtimeConfig: { type: "local" }, + }); + if (desktop === "busy") { + project.workspaces.push( + projectWorkspace(projectPath, "competitor", "competitor", { + parentWorkspaceId: parentId, + agentId: "explore", + taskStatus: "running", + taskDesktopOwnerWorkspaceId: parentId, + runtimeConfig: { type: "local" }, + }) + ); + } + return cfg; }); - return cfg; - }); - const sendMessage = mock(async (...args: unknown[]): Promise> => { - const internal = args[3] as { onAccepted?: () => Promise | void } | undefined; - await internal?.onAccepted?.(); - return Ok(undefined); - }); - const { workspaceService } = createWorkspaceServiceMocks({ sendMessage }); - const { taskService } = createWorkspaceTurnManagerHarness(config, { workspaceService }); + const sendMessage = mock(async (...args: unknown[]): Promise> => { + const internal = args[3] as { onAccepted?: () => Promise | void } | undefined; + await internal?.onAccepted?.(); + return Ok(undefined); + }); + const { workspaceService } = createWorkspaceServiceMocks({ sendMessage }); + const { taskService } = createWorkspaceTurnManagerHarness(config, { workspaceService }); - const result = await taskService.createWorkspaceTurn({ - ownerWorkspaceId: parentId, - prompt: "Investigate the follow-up root cause", - title: "Continue reported child", - allowAgentWorkspace: true, - workspace: { mode: "existing", workspaceId: childWorkspaceId }, - }); + const result = await taskService.createWorkspaceTurn({ + ownerWorkspaceId: parentId, + prompt: "Investigate the follow-up root cause", + title: "Continue reported child", + allowAgentWorkspace: true, + workspace: { mode: "existing", workspaceId: childWorkspaceId }, + }); - expect(result).toEqual( - Ok({ - taskId: "wst_followuphandle", - kind: "workspace_turn", - status: "running", - workspaceId: childWorkspaceId, - }) - ); - expect(sendMessage).toHaveBeenCalledWith( - childWorkspaceId, - "Investigate the follow-up root cause", - expect.objectContaining({ - model: "anthropic:claude-sonnet-4-6", - agentId: "explore", - thinkingLevel: "medium", - }), - expect.objectContaining({ requireIdle: true }) - ); - }); + if (desktop === "busy") { + expect(result.success).toBe(false); + expect(sendMessage).not.toHaveBeenCalled(); + expect(findWorkspaceInConfig(config, childWorkspaceId)?.taskExecutionId).toBeUndefined(); + expect(await new TaskHandleStore(config).listAllWorkspaceTurns()).toHaveLength(0); + return; + } + expect(findWorkspaceInConfig(config, childWorkspaceId)?.taskDesktopOwnerWorkspaceId).toBe( + desktop === "isolated" ? undefined : parentId + ); + expect(findWorkspaceInConfig(config, childWorkspaceId)?.taskExecutionStatus).toBe("running"); + expect(result).toEqual( + Ok({ + taskId: "wst_followuphandle", + kind: "workspace_turn", + status: "running", + workspaceId: childWorkspaceId, + }) + ); + expect(sendMessage).toHaveBeenCalledWith( + childWorkspaceId, + "Investigate the follow-up root cause", + expect.objectContaining({ + model: "anthropic:claude-sonnet-4-6", + agentId: "explore", + thinkingLevel: "medium", + }), + expect.objectContaining({ requireIdle: true }) + ); + } + ); test("late direct-parent snapshot consumption suppresses duplicate continuation delivery", async () => { const config = await createTestConfig(rootDir); diff --git a/src/node/services/workspaceTurnManager.ts b/src/node/services/workspaceTurnManager.ts index b74f3b9d6e..b44603aae1 100644 --- a/src/node/services/workspaceTurnManager.ts +++ b/src/node/services/workspaceTurnManager.ts @@ -1,3 +1,4 @@ +import { DesktopInputCoordinator } from "@/node/services/desktop/DesktopInputCoordinator"; import assert from "node:assert/strict"; import { MutexMap } from "@/node/utils/concurrency/mutexMap"; import { type Config } from "@/node/config"; @@ -453,7 +454,8 @@ export class WorkspaceTurnManager { private readonly initStateManager: InitStateManager, private readonly taskHost: WorkspaceTurnManagerHost, private readonly terminalAttentionStore: TerminalAttentionStore, - private readonly streamManager?: StreamManager + private readonly streamManager?: StreamManager, + private readonly desktopInputCoordinator = new DesktopInputCoordinator(config) ) { this.taskHandleStore = new TaskHandleStore(config); } @@ -1309,25 +1311,47 @@ export class WorkspaceTurnManager { ownerWorkspaceId === targetWorkspaceId ? [targetWorkspaceId] : [ownerWorkspaceId, targetWorkspaceId].sort(); + let persistedHandle = false; const persisted = await this.withWorkspaceLifecycleLockKeys( lifecycleLockKeys, async (): Promise<"persisted" | "target_archived" | "owner_archived"> => { if (isArchivedInConfig(targetWorkspaceId)) return "target_archived"; if (isArchivedInConfig(ownerWorkspaceId)) return "owner_archived"; - await this.taskHandleStore.upsertWorkspaceTurn(record); - if (record.status !== "queued") { - this.activeWorkspaceTurnHandleByWorkspaceId.set(targetWorkspaceId, { - handleId, - ownerWorkspaceId, - // Reservation only: the sendMessage below may still fail requireIdle or be canceled - // pre-admission. Peer-send admission must not treat this entry as live until - // markWorkspaceTurnAccepted flips it. - accepted: false, - }); - } - return "persisted"; + return await this.desktopInputCoordinator.withAdmission(targetWorkspaceId, async () => { + await this.taskHandleStore.upsertWorkspaceTurn(record); + persistedHandle = true; + if (record.status !== "queued") { + this.activeWorkspaceTurnHandleByWorkspaceId.set(targetWorkspaceId, { + handleId, + ownerWorkspaceId, + // Reservation only: the sendMessage below may still fail requireIdle or be canceled + // pre-admission. Peer-send admission must not treat this entry as live until + // markWorkspaceTurnAccepted flips it. + accepted: false, + }); + } + if (targetIsAgentWorkspace) { + await this.persistAgentTaskExecutionState( + targetWorkspaceId, + handleId, + record.status, + true + ); + } + return "persisted" as const; + }); } - ); + ).catch((error: unknown) => ({ error: getErrorMessage(error) })); + if (typeof persisted === "object") { + if (persistedHandle) { + await this.settleWorkspaceTurn({ + record, + next: { ...record, status: "error", updatedAt: getIsoNow(), error: persisted.error }, + waiterSettlement: { status: "error", error: new Error(persisted.error) }, + }); + } + return Err(persisted.error); + } if (persisted === "target_archived") { return Err("Task.createWorkspaceTurn: target workspace was archived during turn creation"); } @@ -1354,10 +1378,6 @@ export class WorkspaceTurnManager { } return Err("Task.createWorkspaceTurn: owner workspace was archived during turn creation"); } - if (targetIsAgentWorkspace) { - await this.updateAgentTaskExecutionState(targetWorkspaceId, handleId, record.status); - } - if (agentValidationError != null) { // Deferred post-create validation failure: the record above keeps the created // workspace owner-owned (retryable via mode="existing"); settle the handle as a @@ -1390,6 +1410,12 @@ export class WorkspaceTurnManager { if (this.isTerminalWorkspaceTurnStatus(current.status)) { throw new Error(current.error ?? "Workspace turn was canceled before stream start"); } + if (targetIsAgentWorkspace) { + const claimed = await this.desktopInputCoordinator.withAdmission(targetWorkspaceId, () => + this.persistAgentTaskExecutionState(targetWorkspaceId, handleId, "running", true) + ); + if (!claimed) throw new Error("Workspace turn was superseded before stream start"); + } if (current.status !== "running") { await this.taskHandleStore.upsertWorkspaceTurn({ ...current, @@ -1398,7 +1424,6 @@ export class WorkspaceTurnManager { }); } if (targetIsAgentWorkspace) { - await this.updateAgentTaskExecutionState(targetWorkspaceId, handleId, "running"); // A stopped queued child keeps its only copy of the initial brief in taskPrompt. Once the // continuation accepts the replayed prompt, history owns that brief and the config copy can go. await this.taskHost.editWorkspaceEntry( @@ -2869,6 +2894,15 @@ export class WorkspaceTurnManager { ? { deferredMessageIds: options.deferredMessageIds } : {}), }; + // Re-admission can fail if another child now controls this desktop. Do not revive the + // handle, erase terminal attention, or register it live until its durable mirror reserves it. + const taskEntry = findWorkspaceEntry(this.config.loadConfigOrDefault(), record.workspaceId); + if (taskEntry?.workspace.parentWorkspaceId != null) { + const claimed = await this.desktopInputCoordinator.withAdmission(record.workspaceId, () => + this.persistAgentTaskExecutionState(record.workspaceId, record.handleId, "running", true) + ); + if (!claimed) return current; + } delete next.error; // The revived turn's next terminal transition is a new outcome; re-arm its wake-up. // The notification tombstone must go too: enqueueIfAbsent would otherwise treat the @@ -4830,15 +4864,41 @@ export class WorkspaceTurnManager { continue; } - await this.taskHost.editWorkspaceEntry( - task.id, - (workspace) => { - workspace.taskExecutionId = normalized.handleId; - workspace.taskExecutionStatus = normalized.status; - }, - { allowMissing: true } - ); - await this.taskHost.emitWorkspaceMetadata(task.id); + if (isActiveWorkspaceTurnTaskStatus(normalized.status)) { + const taskId = task.id; + let claimed: boolean; + try { + claimed = await this.desktopInputCoordinator.withAdmission(taskId, () => + this.persistAgentTaskExecutionState( + taskId, + normalized.handleId, + normalized.status, + true + ) + ); + } catch (error) { + // Both durable activity sources must settle: leaving the pre-restart execution mirror + // active would reserve the desktop even after TaskService interrupts the task. + const message = getErrorMessage(error); + await this.settleWorkspaceTurn({ + record: normalized, + next: { ...normalized, status: "error", updatedAt: getIsoNow(), error: message }, + waiterSettlement: { status: "error", error: new Error(message) }, + }); + continue; + } + if (!claimed) continue; + } else { + await this.taskHost.editWorkspaceEntry( + task.id, + (workspace) => { + workspace.taskExecutionId = normalized.handleId; + workspace.taskExecutionStatus = normalized.status; + }, + { allowMissing: true } + ); + await this.taskHost.emitWorkspaceMetadata(task.id); + } if (isActiveWorkspaceTurnTaskStatus(normalized.status)) { this.activeWorkspaceTurnHandleByWorkspaceId.set(task.id, { handleId: normalized.handleId, @@ -4866,10 +4926,26 @@ export class WorkspaceTurnManager { handleId: string, status: WorkspaceTurnTaskStatus | null ): Promise { + if (status != null && isActiveWorkspaceTurnTaskStatus(status)) { + await this.desktopInputCoordinator.withAdmission(workspaceId, () => + this.persistAgentTaskExecutionState(workspaceId, handleId, status) + ); + } else { + await this.persistAgentTaskExecutionState(workspaceId, handleId, status); + } + } + + private async persistAgentTaskExecutionState( + workspaceId: string, + handleId: string, + status: WorkspaceTurnTaskStatus | null, + allowNewExecution = false + ): Promise { // editWorkspaceEntry reports `updated` for a mere existing workspace, so a queued/stale // handle B settling must not count as settlement for the DIFFERENT live handle A the mirror // points at — track whether the matching mirror was actually mutated. let settledMatchingMirror = false; + let claimedActiveMirror = false; const updated = await this.taskHost.editWorkspaceEntry( workspaceId, (workspace) => { @@ -4882,6 +4958,23 @@ export class WorkspaceTurnManager { return; } if (isActiveWorkspaceTurnTaskStatus(status)) { + const live = this.activeWorkspaceTurnHandleByWorkspaceId.get(workspaceId); + // A delayed acceptance/recovery callback must not steal a newer continuation's mirror. + if (live != null && live.handleId !== handleId) return; + if (live == null) { + if ( + !allowNewExecution && + (workspace.taskExecutionId !== handleId || + !isActiveWorkspaceTurnTaskStatus(workspace.taskExecutionStatus)) + ) + return; + if ( + workspace.taskExecutionId !== handleId && + isActiveWorkspaceTurnTaskStatus(workspace.taskExecutionStatus) + ) + return; + } + claimedActiveMirror = true; workspace.taskExecutionId = handleId; workspace.taskExecutionStatus = status; return; @@ -4916,5 +5009,6 @@ export class WorkspaceTurnManager { } await this.taskHost.emitWorkspaceMetadata(workspaceId); } + return claimedActiveMirror || settledMatchingMirror; } } From 631eeadcd51dc2d732981d02055ed9f1d970da73 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 11:37:19 +0000 Subject: [PATCH 06/25] =?UTF-8?q?=F0=9F=A4=96=20fix:=20preserve=20newer=20?= =?UTF-8?q?execution=20recovery=20over=20stale=20active=20mirrors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/services/taskService.test.ts | 87 ++++++++++++----------- src/node/services/workspaceTurnManager.ts | 12 +++- 2 files changed, 54 insertions(+), 45 deletions(-) diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 7feac8058c..3be6fbd5f5 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -24359,52 +24359,55 @@ describe("TaskService", () => { expect(findWorkspaceInConfig(config, childTaskId)?.taskExecutionStatus).toBe("running"); }); - test("initialize prefers a newer unreferenced execution over a stale child pointer", async () => { - const config = await createTestConfig(rootDir); - const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); - const childTaskId = "child-newer-execution"; - await config.editConfig((cfg) => { - const project = cfg.projects.get(projectPath); - assert(project, "test project must exist"); - project.workspaces.push( - projectWorkspace(projectPath, "child-newer", childTaskId, { - parentWorkspaceId: parentId, - agentId: "explore", - agentType: "explore", - taskStatus: "reported", - reportedAt: "2026-08-10T00:00:00.000Z", - title: "React lifecycle expert", - taskExecutionId: "wst_old", - taskExecutionStatus: "completed", + test.each(["completed", "running"] as const)( + "initialize prefers a newer unreferenced execution over a stale %s child pointer", + async (previousStatus) => { + const config = await createTestConfig(rootDir); + const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); + const childTaskId = "child-newer-execution"; + await config.editConfig((cfg) => { + const project = cfg.projects.get(projectPath); + assert(project, "test project must exist"); + project.workspaces.push( + projectWorkspace(projectPath, "child-newer", childTaskId, { + parentWorkspaceId: parentId, + agentId: "explore", + agentType: "explore", + taskStatus: "reported", + reportedAt: "2026-08-10T00:00:00.000Z", + title: "React lifecycle expert", + taskExecutionId: "wst_old", + taskExecutionStatus: previousStatus, + }) + ); + return cfg; + }); + const isStreaming = mock((workspaceId: string) => workspaceId === childTaskId); + const { aiService } = createAIServiceMocks(config, { isStreaming }); + const { taskService } = createTaskServiceHarness(config, { aiService }); + const taskHandleStore = (taskService as unknown as { taskHandleStore: TaskHandleStore }) + .taskHandleStore; + await taskHandleStore.upsertWorkspaceTurn( + workspaceTurnRecord(parentId, childTaskId, "wst_old", previousStatus, { + turnId: "turn-old", + createdAt: "2026-08-10T00:00:01.000Z", + updatedAt: "2026-08-10T00:00:02.000Z", + }) + ); + await taskHandleStore.upsertWorkspaceTurn( + workspaceTurnRecord(parentId, childTaskId, "wst_new", "running", { + turnId: "turn-new", + createdAt: "2026-08-10T00:00:03.000Z", + updatedAt: "2026-08-10T00:00:04.000Z", }) ); - return cfg; - }); - const isStreaming = mock((workspaceId: string) => workspaceId === childTaskId); - const { aiService } = createAIServiceMocks(config, { isStreaming }); - const { taskService } = createTaskServiceHarness(config, { aiService }); - const taskHandleStore = (taskService as unknown as { taskHandleStore: TaskHandleStore }) - .taskHandleStore; - await taskHandleStore.upsertWorkspaceTurn( - workspaceTurnRecord(parentId, childTaskId, "wst_old", "completed", { - turnId: "turn-old", - createdAt: "2026-08-10T00:00:01.000Z", - updatedAt: "2026-08-10T00:00:02.000Z", - }) - ); - await taskHandleStore.upsertWorkspaceTurn( - workspaceTurnRecord(parentId, childTaskId, "wst_new", "running", { - turnId: "turn-new", - createdAt: "2026-08-10T00:00:03.000Z", - updatedAt: "2026-08-10T00:00:04.000Z", - }) - ); - await taskService.initialize(); + await taskService.initialize(); - expect(findWorkspaceInConfig(config, childTaskId)?.taskExecutionId).toBe("wst_new"); - expect(findWorkspaceInConfig(config, childTaskId)?.taskExecutionStatus).toBe("running"); - }); + expect(findWorkspaceInConfig(config, childTaskId)?.taskExecutionId).toBe("wst_new"); + expect(findWorkspaceInConfig(config, childTaskId)?.taskExecutionStatus).toBe("running"); + } + ); test("initialize ignores parseable non-ISO timestamps when selecting the latest handle", async () => { const config = await createTestConfig(rootDir); diff --git a/src/node/services/workspaceTurnManager.ts b/src/node/services/workspaceTurnManager.ts index b44603aae1..0df5024a79 100644 --- a/src/node/services/workspaceTurnManager.ts +++ b/src/node/services/workspaceTurnManager.ts @@ -4873,7 +4873,8 @@ export class WorkspaceTurnManager { taskId, normalized.handleId, normalized.status, - true + true, + task.taskExecutionId ) ); } catch (error) { @@ -4939,7 +4940,8 @@ export class WorkspaceTurnManager { workspaceId: string, handleId: string, status: WorkspaceTurnTaskStatus | null, - allowNewExecution = false + allowNewExecution = false, + reconciledPreviousExecutionId?: string ): Promise { // editWorkspaceEntry reports `updated` for a mere existing workspace, so a queued/stale // handle B settling must not count as settlement for the DIFFERENT live handle A the mirror @@ -4970,7 +4972,11 @@ export class WorkspaceTurnManager { return; if ( workspace.taskExecutionId !== handleId && - isActiveWorkspaceTurnTaskStatus(workspace.taskExecutionStatus) + isActiveWorkspaceTurnTaskStatus(workspace.taskExecutionStatus) && + // Startup's timestamp-selected successor may replace the pointer it actually read, + // but never a different continuation published while reconciliation was suspended. + (reconciledPreviousExecutionId == null || + workspace.taskExecutionId !== reconciledPreviousExecutionId) ) return; } From f17d0ef01709b8b5b828ec779c2daeae2205969f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 11:46:11 +0000 Subject: [PATCH 07/25] =?UTF-8?q?=F0=9F=A4=96=20fix:=20propagate=20resolve?= =?UTF-8?q?d=20desktop=20targets=20through=20task=20results?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regenerate bundled task and agent guidance. Focused desktop lifecycle tests, lint, formatting and main-process typecheck pass. Full typecheck remains blocked by the existing getSessionDir Config test fixture. --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high`_ --- docs/agents/index.mdx | 11 ++++++++--- docs/hooks/tools.mdx | 3 ++- .../agentSkills/builtInSkillContent.generated.ts | 14 ++++++++++---- src/node/services/taskService.test.ts | 4 ++++ src/node/services/taskService.ts | 4 ++++ src/node/services/tools/task.test.ts | 10 ++++++---- 6 files changed, 34 insertions(+), 12 deletions(-) diff --git a/docs/agents/index.mdx b/docs/agents/index.mdx index db0e6b1a30..583e32777b 100644 --- a/docs/agents/index.mdx +++ b/docs/agents/index.mdx @@ -495,9 +495,11 @@ subagent: append_prompt: | You are a desktop automation sub-agent running in a child workspace. - - Your job: interact with the desktop GUI via screenshot-driven automation. + - Your job: interact with the bound desktop GUI via screenshot-driven automation. + - By default this is the caller's desktop, not a fresh desktop in your checkout. For independent GUI testing, the caller must request task desktop: "isolated"; checkout isolation is separate. - Always take a screenshot before starting a GUI interaction sequence. - - Follow the grounding loop: screenshot → identify target → act → screenshot to verify. + - Follow the grounding loop: screenshot → identify target → act → screenshot to verify. Run dependent screenshots and actions sequentially, never in parallel. + - Other Mux desktop tools may be excluded during an action, but humans in noVNC, shell commands, and CDP can still change the desktop. Re-ground on fresh screenshots. - After completing the task, summarize the outcome in your final assistant message with only the result plus selected evidence (e.g., a final screenshot path). - Do not expand scope beyond the delegated desktop task. @@ -536,6 +538,9 @@ tools: You are a desktop automation agent. +- **Bound desktop:** Desktop tools use the desktop bound to this agent. New desktop agents share the caller's desktop by default; `task` with `desktop: "isolated"` requests an independent desktop for separate GUI testing. Repository checkout isolation does not select the desktop. +- **Sequential steps:** Run dependent screenshots and actions one at a time. Mux desktop-tool input exclusion does not lock out humans using noVNC, shell commands, or CDP; never assume exclusive control of the GUI. +- **Scope:** Change only what the delegated desktop task requires, preserving unrelated windows and user state. - **Screenshot-first rule:** Always take a `desktop_screenshot` before beginning any GUI interaction loop. Never act on stale visual state. - **Grounding loop:** Follow `screenshot → identify target coordinates → act (click/type/drag) → screenshot to verify` for each major interaction. Every major interaction step should end with a screenshot to verify the expected result. - **Coordinate precision:** Use screenshot analysis to identify precise pixel coordinates for clicks, drags, and other positional actions. Account for window position, display scaling, and DPI before acting. @@ -546,7 +551,7 @@ You are a desktop automation agent. - If an unexpected dialog or popup appears, take another screenshot and adapt to the new state. - **Scrolling:** Use `desktop_scroll` to navigate within windows, then take a screenshot after scrolling to verify the new content is visible. - **Error recovery:** If an action does not produce the expected result, take another screenshot, reassess the current state, and retry with adjusted coordinates. -- **Reporting:** When complete, summarize only the outcome and key evidence back to the parent agent, such as the final screenshot confirming success. Do not send raw coordinate logs. +- **Reporting:** When complete, identify the actual desktop changed (shared caller or explicitly isolated), summarize the outcome, and provide key evidence such as a final screenshot. Do not infer that a checkout change proves which desktop changed, and do not send raw coordinate logs. ``` diff --git a/docs/hooks/tools.mdx b/docs/hooks/tools.mdx index 409ae835ad..893da213d5 100644 --- a/docs/hooks/tools.mdx +++ b/docs/hooks/tools.mdx @@ -670,11 +670,12 @@ If a value is too large for the environment, it may be omitted (not set). Xum al
-task (16) +task (17) | Env var | JSON path | Type | Description | | ---------------------------------------------- | ----------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `XUM_TOOL_INPUT_AGENT_ID` | `agentId` | string | — | +| `XUM_TOOL_INPUT_DESKTOP` | `desktop` | enum | Desktop target for sub-agents, independent of checkout isolation. "shared" uses the caller's desktop; "isolated" starts a separate desktop. Defaults to shared for agentId="desktop", isolated otherwise. Only one active shared child can control desktop tools; n > 1 requires isolation. Does not exclude human viewer input, shell tools, or external CDP clients. | | `XUM_TOOL_INPUT_ISOLATION` | `isolation` | enum | Workspace isolation for the sub-agent. "fork" (the default) runs it in an isolated copy of this workspace created from committed state. "none" runs it directly in this workspace's checkout, sharing the working tree (including uncommitted changes) and skipping the fork + init overhead. Use "none" only for read-only analysis (e.g. the explore agent) or when you instruct the sub-agent to avoid editing shared files, since it can otherwise modify the same files concurrently. Omit to fork. | | `XUM_TOOL_INPUT_KIND` | `kind` | enum | Task kind. Omit or use "subagent" for the existing child-workspace sub-agent flow; use "workspace" to start a normal full workspace turn. | | `XUM_TOOL_INPUT_MODEL` | `model` | string | Optional model override for the sub-agent, parsed with the same alias logic as the UI (an alias or a full 'provider:model' string). Omit this unless the user explicitly instructed a specific model — by default the sub-agent inherits the parent's model. Do not assume any particular model is available. | diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index f6ca94d410..fde853c992 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -2717,9 +2717,11 @@ export const BUILTIN_SKILL_FILES: Record> = { " append_prompt: |", " You are a desktop automation sub-agent running in a child workspace.", "", - " - Your job: interact with the desktop GUI via screenshot-driven automation.", + " - Your job: interact with the bound desktop GUI via screenshot-driven automation.", + ' - By default this is the caller\'s desktop, not a fresh desktop in your checkout. For independent GUI testing, the caller must request task desktop: "isolated"; checkout isolation is separate.', " - Always take a screenshot before starting a GUI interaction sequence.", - " - Follow the grounding loop: screenshot → identify target → act → screenshot to verify.", + " - Follow the grounding loop: screenshot → identify target → act → screenshot to verify. Run dependent screenshots and actions sequentially, never in parallel.", + " - Other Mux desktop tools may be excluded during an action, but humans in noVNC, shell commands, and CDP can still change the desktop. Re-ground on fresh screenshots.", " - After completing the task, summarize the outcome in your final assistant message with only", " the result plus selected evidence (e.g., a final screenshot path).", " - Do not expand scope beyond the delegated desktop task.", @@ -2758,6 +2760,9 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "You are a desktop automation agent.", "", + '- **Bound desktop:** Desktop tools use the desktop bound to this agent. New desktop agents share the caller\'s desktop by default; `task` with `desktop: "isolated"` requests an independent desktop for separate GUI testing. Repository checkout isolation does not select the desktop.', + "- **Sequential steps:** Run dependent screenshots and actions one at a time. Mux desktop-tool input exclusion does not lock out humans using noVNC, shell commands, or CDP; never assume exclusive control of the GUI.", + "- **Scope:** Change only what the delegated desktop task requires, preserving unrelated windows and user state.", "- **Screenshot-first rule:** Always take a `desktop_screenshot` before beginning any GUI interaction loop. Never act on stale visual state.", "- **Grounding loop:** Follow `screenshot → identify target coordinates → act (click/type/drag) → screenshot to verify` for each major interaction. Every major interaction step should end with a screenshot to verify the expected result.", "- **Coordinate precision:** Use screenshot analysis to identify precise pixel coordinates for clicks, drags, and other positional actions. Account for window position, display scaling, and DPI before acting.", @@ -2768,7 +2773,7 @@ export const BUILTIN_SKILL_FILES: Record> = { " - If an unexpected dialog or popup appears, take another screenshot and adapt to the new state.", "- **Scrolling:** Use `desktop_scroll` to navigate within windows, then take a screenshot after scrolling to verify the new content is visible.", "- **Error recovery:** If an action does not produce the expected result, take another screenshot, reassess the current state, and retry with adjusted coordinates.", - "- **Reporting:** When complete, summarize only the outcome and key evidence back to the parent agent, such as the final screenshot confirming success. Do not send raw coordinate logs.", + "- **Reporting:** When complete, identify the actual desktop changed (shared caller or explicitly isolated), summarize the outcome, and provide key evidence such as a final screenshot. Do not infer that a checkout change proves which desktop changed, and do not send raw coordinate logs.", "```", "", "", @@ -6290,11 +6295,12 @@ export const BUILTIN_SKILL_FILES: Record> = { "
", "", "
", - "task (16)", + "task (17)", "", "| Env var | JSON path | Type | Description |", "| ---------------------------------------------- | ----------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |", "| `XUM_TOOL_INPUT_AGENT_ID` | `agentId` | string | — |", + '| `XUM_TOOL_INPUT_DESKTOP` | `desktop` | enum | Desktop target for sub-agents, independent of checkout isolation. "shared" uses the caller\'s desktop; "isolated" starts a separate desktop. Defaults to shared for agentId="desktop", isolated otherwise. Only one active shared child can control desktop tools; n > 1 requires isolation. Does not exclude human viewer input, shell tools, or external CDP clients. |', '| `XUM_TOOL_INPUT_ISOLATION` | `isolation` | enum | Workspace isolation for the sub-agent. "fork" (the default) runs it in an isolated copy of this workspace created from committed state. "none" runs it directly in this workspace\'s checkout, sharing the working tree (including uncommitted changes) and skipping the fork + init overhead. Use "none" only for read-only analysis (e.g. the explore agent) or when you instruct the sub-agent to avoid editing shared files, since it can otherwise modify the same files concurrently. Omit to fork. |', '| `XUM_TOOL_INPUT_KIND` | `kind` | enum | Task kind. Omit or use "subagent" for the existing child-workspace sub-agent flow; use "workspace" to start a normal full workspace turn. |', "| `XUM_TOOL_INPUT_MODEL` | `model` | string | Optional model override for the sub-agent, parsed with the same alias logic as the UI (an alias or a full 'provider:model' string). Omit this unless the user explicitly instructed a specific model — by default the sub-agent inherits the parent's model. Do not assume any particular model is available. |", diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 3be6fbd5f5..1ab64b9461 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -4090,6 +4090,7 @@ describe("TaskService", () => { ); assert(result.success); expect(result.data.map((task) => task.status)).toEqual(["queued", "queued"]); + expect(result.data.map((task) => task.desktopOwnerWorkspaceId)).toEqual(owners); expect( result.data.map( (task) => findWorkspaceInConfig(config, task.taskId)?.taskDesktopOwnerWorkspaceId @@ -4124,6 +4125,9 @@ describe("TaskService", () => { expect(findWorkspaceInConfig(config, result.data.taskId)?.taskDesktopOwnerWorkspaceId).toBe( desktop === "isolated" ? undefined : parentId ); + expect(result.data.desktopOwnerWorkspaceId).toBe( + desktop === "isolated" ? result.data.taskId : parentId + ); } ); diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index ddfd2b39bb..25ce8ec583 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -380,6 +380,7 @@ function isAgentRunnableAsChild( } export interface TaskCreateResult { + desktopOwnerWorkspaceId?: string; taskId: string; kind: TaskKind; status: "queued" | "starting" | "running"; @@ -3106,6 +3107,7 @@ export class TaskService implements AgentTaskIntegration { status, modelString: taskModelString, thinkingLevel: effectiveThinkingLevel, + desktopOwnerWorkspaceId: taskDesktopOwnerWorkspaceId ?? taskId, }); } @@ -4074,6 +4076,7 @@ export class TaskService implements AgentTaskIntegration { status: "queued", modelString: taskModelString, thinkingLevel: effectiveThinkingLevel, + desktopOwnerWorkspaceId: taskDesktopOwnerWorkspaceId ?? taskId, }); } @@ -4340,6 +4343,7 @@ export class TaskService implements AgentTaskIntegration { status: "running", modelString: taskModelString, thinkingLevel: effectiveThinkingLevel, + desktopOwnerWorkspaceId: taskDesktopOwnerWorkspaceId ?? taskId, }); } diff --git a/src/node/services/tools/task.test.ts b/src/node/services/tools/task.test.ts index 1ccb2d3eac..aad9092273 100644 --- a/src/node/services/tools/task.test.ts +++ b/src/node/services/tools/task.test.ts @@ -390,9 +390,11 @@ describe("task tool", () => { createWorkspaceTurn, } as unknown as NonNullable, }); - await expect( - tool.execute!({ ...args, prompt: "test", title: "Operator" }, mockToolCallOptions) - ).rejects.toThrow("task tool input validation failed"); + await Promise.resolve( + expect( + tool.execute!({ ...args, prompt: "test", title: "Operator" }, mockToolCallOptions) + ).rejects.toThrow("task tool input validation failed") + ); expect(create).not.toHaveBeenCalled(); expect(createWorkspaceTurn).not.toHaveBeenCalled(); }); @@ -414,7 +416,7 @@ describe("task tool", () => { waitForAgentReport: () => Promise.resolve({ reportMarkdown: "done" }), } as unknown as TaskService; const tool = createTaskTool({ ...createTestToolConfig(tempDir.path), taskService }); - const result = await tool.execute!( + const result: unknown = await tool.execute!( { agentId: "custom", desktop: "shared", From 43189e39fa71dfca47191b663ead5e1c37ece410 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 11:44:56 +0000 Subject: [PATCH 08/25] =?UTF-8?q?=F0=9F=A4=96=20fix:=20roll=20back=20forke?= =?UTF-8?q?d=20task=20checkouts=20when=20post-fork=20persistence=20throws?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/services/taskService.test.ts | 72 +++++++++++++++++++++++++++ src/node/services/taskService.ts | 21 +++++++- 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 1ab64b9461..193a29f0e1 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -15682,6 +15682,78 @@ describe("TaskService", () => { expect(workspacePathExists).toBe(false); }, 20_000); + test("rolls back a forked checkout when persistence throws after the fork", async () => { + const config = await createTestConfig(rootDir); + const childTaskId = "cccccccccc"; + stubStableIds(config, [childTaskId, "dddddddddd"], childTaskId); + + const projectPath = await createTestProject(rootDir); + const runtimeConfig = { type: "worktree" as const, srcBaseDir: config.srcDir }; + const runtime = createRuntime(runtimeConfig, { projectPath }); + const parentName = "parent"; + const parentCreate = await runtime.createWorkspace({ + projectPath, + branchName: parentName, + trunkBranch: "main", + directoryName: parentName, + initLogger: createNullInitLogger(), + }); + expect(parentCreate.success).toBe(true); + const parentId = "1111111111"; + await saveWorkspaces( + config, + projectPath, + [ + { + path: runtime.getWorkspacePath(projectPath, parentName), + id: parentId, + name: parentName, + createdAt: new Date().toISOString(), + runtimeConfig, + }, + ], + testTaskSettings() + ); + const { workspaceService, sendMessage, discardExtensionMetadataEntry } = + createWorkspaceServiceMocks(); + const { taskService } = createTaskServiceHarness(config, { workspaceService }); + + // Deterministic post-fork failure: the transform that registers the child entry throws, so + // the checkout already exists on disk while nothing was persisted. + const editConfig = config.editConfig.bind(config); + const persistSpy = spyOn(config, "editConfig").mockImplementation((transform) => + editConfig((cfg) => { + const next = transform(cfg); + const registersChild = Array.from(next.projects.values()).some((project) => + project.workspaces.some((workspace) => workspace.id === childTaskId) + ); + if (registersChild) throw new Error("config persistence failed after fork"); + return next; + }) + ); + try { + const failed = await createAgentTask(taskService, parentId, "Inspect", { desktop: "shared" }); + expect(failed).toEqual(Err("config persistence failed after fork")); + } finally { + persistSpy.mockRestore(); + } + + expect(sendMessage).not.toHaveBeenCalled(); + expect(findWorkspaceInConfig(config, childTaskId)).toBeUndefined(); + expect(discardExtensionMetadataEntry).toHaveBeenCalledWith(childTaskId); + const forkedPath = runtime.getWorkspacePath(projectPath, `agent_explore_${childTaskId}`); + expect(existsSync(forkedPath)).toBe(false); + + // The owner's desktop is free again: the reservation never outlived the failed callback. + const next = await createAgentTask(taskService, parentId, "Inspect again", { + desktop: "shared", + }); + assert(next.success, "Expected the next shared desktop task to be admitted"); + expect(findWorkspaceInConfig(config, next.data.taskId)?.taskDesktopOwnerWorkspaceId).toBe( + parentId + ); + }, 20_000); + test("failed config deregistration during rollback does not tombstone the task's metadata", async () => { const config = await createTestConfig(rootDir); stubStableIds(config, ["bbbbbbbbbb"], "bbbbbbbbbb"); diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 25ce8ec583..f0b0c7ff7f 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -4080,6 +4080,9 @@ export class TaskService implements AgentTaskIntegration { }); } + // Set once a checkout exists for this task: a throw after that point (base-SHA read, config + // persistence) must roll the checkout back instead of leaking it like an unhandled rejection. + let materializedCheckout: { initLogger: InitLogger; runtime: Runtime } | undefined; const materialize = async () => { const initLogger = this.startWorkspaceInit(taskId, parentMeta.projectPath); @@ -4164,6 +4167,8 @@ export class TaskService implements AgentTaskIntegration { inheritedProjects = forkResult.data.projects; } + materializedCheckout = { initLogger, runtime: runtimeForTaskWorkspace }; + // Multi-project forks need per-project secrets for each runtime's init hook. this.configureMultiProjectRuntimeEnvResolver(runtimeForTaskWorkspace); @@ -4240,7 +4245,21 @@ export class TaskService implements AgentTaskIntegration { const materialized = await reserveDesktop(materialize).catch((error: unknown) => Err(getErrorMessage(error)) ); - if (!materialized.success) return materialized; + if (!materialized.success) { + if (materializedCheckout != null) { + // Runs after the desktop gate released: only the checkout and any persisted entry (which + // would otherwise hold the desktop reservation as a running child) need to go. + await this.rollbackFailedTaskCreate( + materializedCheckout.runtime, + parentMeta.projectPath, + workspaceName, + taskId, + { preservePhysicalWorkspace: useSharedWorkspace } + ); + materializedCheckout.initLogger.logComplete(-1); + } + return materialized; + } const { initLogger, workspacePath, trunkBranch, forkedRuntimeConfig, runtimeForTaskWorkspace } = materialized.data; From 64f8ad1fbf0c7ed94efbe4de9a9ad2e6a948cf7a Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 11:57:43 +0000 Subject: [PATCH 09/25] =?UTF-8?q?=F0=9F=A4=96=20fix:=20release=20a=20share?= =?UTF-8?q?d=20desktop=20when=20the=20user=20stops=20its=20child=20stream?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/services/taskService.test.ts | 70 +++++++++++++++++++++++++++ src/node/services/taskService.ts | 51 ++++++++++++++++++- 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 193a29f0e1..155f797a3a 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -4250,6 +4250,76 @@ describe("TaskService", () => { } }); + test.each([ + ["shared", "user", "interrupted"], + ["shared", "system", "running"], + ["isolated", "user", "running"], + ] as const)( + "%s desktop child %s stream abort leaves the task %s", + async (desktop, abortReason, expectedStatus) => { + const config = await createTestConfig(rootDir); + const { parentId } = await saveLocalParentWorkspace(config, rootDir); + const desktopCoordinator = new DesktopInputCoordinator(config); + // Drive the real aiService subscription so the abort flows through the event lock. + const listeners = new Map void>(); + const on = mock((event: string, handler: (payload: unknown) => void) => { + listeners.set(event, handler); + }); + const { aiService } = createAIServiceMocks(config, { on }); + const { workspaceService } = createWorkspaceServiceMocks(); + const { taskService } = createTaskServiceHarness(config, { + aiService, + workspaceService, + desktopInputCoordinator: desktopCoordinator, + }); + const created = await createAgentTask(taskService, parentId, "Inspect", { desktop }); + assert(created.success, "Expected the child task to start"); + const childId = created.data.taskId; + const ownerInput = () => + desktopCoordinator + .withInput(parentId, () => Promise.resolve("clicked")) + .then( + (value) => value, + (error: unknown) => (error instanceof Error ? error.message : String(error)) + ); + if (desktop === "shared") { + expect(await ownerInput()).toContain(`active borrower ${childId}`); + } + const waiter = taskService.waitForAgentReport(childId, { timeoutMs: 5_000 }).then( + () => "settled", + (error: unknown) => (error instanceof Error ? error.message : "?") + ); + + const onStreamAbort = listeners.get("stream-abort"); + assert(onStreamAbort, "TaskService must subscribe to stream-abort"); + onStreamAbort({ + type: "stream-abort", + workspaceId: childId, + messageId: "msg_1", + abortReason, + }); + + if (expectedStatus === "interrupted") { + await waitForWorkspaceTaskStatus(config, childId, "interrupted"); + // Clicking Stop in the child UI hands the desktop back to the owner immediately... + expect(await ownerInput()).toBe("clicked"); + expect(await waiter).toBe("Task interrupted"); + // ...and the paused child still reawakens onto the same desktop when the user resumes it. + expect(await taskService.markInterruptedTaskRunning(childId)).toBe(true); + expect(findWorkspaceInConfig(config, childId)?.taskDesktopOwnerWorkspaceId).toBe(parentId); + expect(await ownerInput()).toContain(`active borrower ${childId}`); + return; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(findWorkspaceInConfig(config, childId)?.taskStatus).toBe("running"); + if (desktop === "shared") { + expect(await ownerInput()).toContain(`active borrower ${childId}`); + } else { + expect(await ownerInput()).toBe("clicked"); + } + } + ); + test.each(["reported", "interrupted"] as const)( "shared desktop %s resume preserves binding and refuses a competing controller", async (taskStatus) => { diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index f0b0c7ff7f..9428a007d9 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -11185,7 +11185,56 @@ export class TaskService implements AgentTaskIntegration { } private async handleStreamAbort(event: StreamAbortEvent): Promise { - await this.getWorkspaceTurnManager().finalizeWorkspaceTurnFromStreamAbort(event); + if (await this.getWorkspaceTurnManager().finalizeWorkspaceTurnFromStreamAbort(event)) { + return; + } + if (event.abortReason === "user") { + await this.releaseSharedDesktopTaskOnUserStop(event.workspaceId); + } + } + + /** + * A user Stop on an ordinary child is a steerable pause: the task stays `running` so the + * user can resume it. A shared-desktop child, however, holds the owner's desktop through that + * `running` status (the config ledger is the only ownership source), so a paused child would + * block the owner until the parent's foreground wait timed out. Mirror task_stop instead: the + * durable `interrupted` status releases the desktop and fails the parent's wait fast, while a + * user resume re-admits the child onto the same desktop via markInterruptedTaskRunning. + */ + private async releaseSharedDesktopTaskOnUserStop(workspaceId: string): Promise { + const workspace = findWorkspaceEntry(this.config.loadConfigOrDefault(), workspaceId)?.workspace; + if (workspace?.parentWorkspaceId == null || workspace.taskDesktopOwnerWorkspaceId == null) { + return; + } + if (workspace.taskStatus !== "running" && workspace.taskStatus !== "awaiting_report") { + return; + } + // Stop-and-send-queued dispatches a new turn right after the abort; that turn keeps the + // desktop, so only a genuinely idle child releases it. + if ( + this.aiService.isStreaming(workspaceId) || + this.workspaceService.hasPendingQueuedOrPreparingTurn(workspaceId) + ) { + return; + } + let transitionedToInterrupted = false; + let parentWorkspaceId: string | undefined; + await this.editWorkspaceEntry( + workspaceId, + (ws) => { + if (ws.taskStatus !== "running" && ws.taskStatus !== "awaiting_report") return; + parentWorkspaceId = ws.parentWorkspaceId; + transitionedToInterrupted = this.applyInterruptedTaskStatus(ws) === "interrupted"; + }, + { allowMissing: true } + ); + if (!transitionedToInterrupted) { + return; + } + this.recordTaskInterrupted(workspaceId, parentWorkspaceId); + this.rejectWaiters(workspaceId, new Error("Task interrupted")); + await this.emitWorkspaceMetadata(workspaceId); + this.scheduleMaybeStartQueuedTasks(); } private async handleTaskStreamError(event: ErrorEvent): Promise { From 2329c86ef6590bd4dc24d709e3016d04bec2a5da Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 12:04:52 +0000 Subject: [PATCH 10/25] =?UTF-8?q?=F0=9F=A4=96=20fix:=20release=20a=20reawa?= =?UTF-8?q?kened=20shared=20desktop=20child=20on=20UI=20stop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/services/taskService.test.ts | 98 +++++++++++++++++++++++---- src/node/services/taskService.ts | 15 ++-- 2 files changed, 92 insertions(+), 21 deletions(-) diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 155f797a3a..375473fb95 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -4251,14 +4251,16 @@ describe("TaskService", () => { }); test.each([ - ["shared", "user", "interrupted"], - ["shared", "system", "running"], - ["isolated", "user", "running"], + ["initial", "shared", "user", "idle", "interrupted"], + ["initial", "shared", "system", "idle", "running"], + ["initial", "isolated", "user", "idle", "running"], + ["reawakened", "shared", "user", "idle", "interrupted"], + ["reawakened", "shared", "user", "pending successor", "running"], ] as const)( - "%s desktop child %s stream abort leaves the task %s", - async (desktop, abortReason, expectedStatus) => { + "%s %s desktop child %s stream abort while %s leaves the task %s", + async (run, desktop, abortReason, successor, expectedStatus) => { const config = await createTestConfig(rootDir); - const { parentId } = await saveLocalParentWorkspace(config, rootDir); + const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); const desktopCoordinator = new DesktopInputCoordinator(config); // Drive the real aiService subscription so the abort flows through the event lock. const listeners = new Map void>(); @@ -4266,15 +4268,79 @@ describe("TaskService", () => { listeners.set(event, handler); }); const { aiService } = createAIServiceMocks(config, { on }); - const { workspaceService } = createWorkspaceServiceMocks(); + // The real WorkspaceService restores a reawakened child to running before dispatching; + // the mock mirrors that and accepts the turn. + const serviceRef: { current?: TaskService } = {}; + const sendMessage = mock( + async ( + workspaceId: string, + _message: string, + _options: unknown, + internal?: { onAccepted?: () => Promise | void } + ): Promise> => { + await serviceRef.current?.markInterruptedTaskRunning(workspaceId); + await internal?.onAccepted?.(); + return Ok(undefined); + } + ); + const { workspaceService } = createWorkspaceServiceMocks({ + sendMessage, + hasPendingQueuedOrPreparingTurn: mock(() => successor === "pending successor"), + }); const { taskService } = createTaskServiceHarness(config, { aiService, workspaceService, desktopInputCoordinator: desktopCoordinator, }); - const created = await createAgentTask(taskService, parentId, "Inspect", { desktop }); - assert(created.success, "Expected the child task to start"); - const childId = created.data.taskId; + serviceRef.current = taskService; + + let childId: string; + let waiter: Promise; + if (run === "initial") { + const created = await createAgentTask(taskService, parentId, "Inspect", { desktop }); + assert(created.success, "Expected the child task to start"); + childId = created.data.taskId; + waiter = taskService.waitForAgentReport(childId, { timeoutMs: 5_000 }).then( + () => "settled", + (error: unknown) => (error instanceof Error ? error.message : "?") + ); + } else { + childId = "reported-child"; + await config.editConfig((cfg) => { + cfg.projects.get(projectPath)!.workspaces.push( + projectWorkspace(projectPath, childId, childId, { + parentWorkspaceId: parentId, + agentId: "explore", + agentType: "explore", + taskStatus: "reported", + reportedAt: "2026-09-01T00:00:00.000Z", + runtimeConfig: { type: "local" }, + taskDesktopOwnerWorkspaceId: parentId, + }) + ); + return cfg; + }); + const continuation = await workspaceTurnManagerFor(taskService).createWorkspaceTurn({ + ownerWorkspaceId: parentId, + prompt: "Continue on the same desktop", + title: "Reawaken", + allowAgentWorkspace: true, + workspace: { mode: "existing", workspaceId: childId }, + }); + assert(continuation.success, "Expected the reawakening turn to be admitted"); + expect(findWorkspaceInConfig(config, childId)?.taskStatus).toBe("running"); + expect(findWorkspaceInConfig(config, childId)?.taskExecutionStatus).toBe("running"); + waiter = workspaceTurnManagerFor(taskService) + .waitForWorkspaceTurn(continuation.data.taskId, { + requestingWorkspaceId: parentId, + ownerWorkspaceId: parentId, + timeoutMs: 5_000, + }) + .then( + () => "settled", + (error: unknown) => (error instanceof Error ? error.message : "?") + ); + } const ownerInput = () => desktopCoordinator .withInput(parentId, () => Promise.resolve("clicked")) @@ -4285,10 +4351,6 @@ describe("TaskService", () => { if (desktop === "shared") { expect(await ownerInput()).toContain(`active borrower ${childId}`); } - const waiter = taskService.waitForAgentReport(childId, { timeoutMs: 5_000 }).then( - () => "settled", - (error: unknown) => (error instanceof Error ? error.message : "?") - ); const onStreamAbort = listeners.get("stream-abort"); assert(onStreamAbort, "TaskService must subscribe to stream-abort"); @@ -4303,7 +4365,12 @@ describe("TaskService", () => { await waitForWorkspaceTaskStatus(config, childId, "interrupted"); // Clicking Stop in the child UI hands the desktop back to the owner immediately... expect(await ownerInput()).toBe("clicked"); - expect(await waiter).toBe("Task interrupted"); + expect(await waiter).toBe( + run === "initial" ? "Task interrupted" : "Workspace turn interrupted" + ); + if (run === "reawakened") { + expect(findWorkspaceInConfig(config, childId)?.taskExecutionStatus).toBe("interrupted"); + } // ...and the paused child still reawakens onto the same desktop when the user resumes it. expect(await taskService.markInterruptedTaskRunning(childId)).toBe(true); expect(findWorkspaceInConfig(config, childId)?.taskDesktopOwnerWorkspaceId).toBe(parentId); @@ -4313,6 +4380,7 @@ describe("TaskService", () => { await new Promise((resolve) => setTimeout(resolve, 20)); expect(findWorkspaceInConfig(config, childId)?.taskStatus).toBe("running"); if (desktop === "shared") { + // A pending successor turn (or a non-user abort) keeps the child in control. expect(await ownerInput()).toContain(`active borrower ${childId}`); } else { expect(await ownerInput()).toBe("clicked"); diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 9428a007d9..17777a0ba6 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -11185,9 +11185,10 @@ export class TaskService implements AgentTaskIntegration { } private async handleStreamAbort(event: StreamAbortEvent): Promise { - if (await this.getWorkspaceTurnManager().finalizeWorkspaceTurnFromStreamAbort(event)) { - return; - } + // Settles a continuation handle (execution mirror) first. A reawakened child is ALSO + // `running` in its stable status (markInterruptedTaskRunning), and the desktop ledger treats + // either active source as control, so the stable status must be released independently. + await this.getWorkspaceTurnManager().finalizeWorkspaceTurnFromStreamAbort(event); if (event.abortReason === "user") { await this.releaseSharedDesktopTaskOnUserStop(event.workspaceId); } @@ -11209,11 +11210,13 @@ export class TaskService implements AgentTaskIntegration { if (workspace.taskStatus !== "running" && workspace.taskStatus !== "awaiting_report") { return; } - // Stop-and-send-queued dispatches a new turn right after the abort; that turn keeps the - // desktop, so only a genuinely idle child releases it. + // Stop-and-send-queued dispatches a new turn right after the abort, and a newer continuation + // may already own the execution mirror; either keeps the desktop, so only a genuinely idle + // child releases it. if ( this.aiService.isStreaming(workspaceId) || - this.workspaceService.hasPendingQueuedOrPreparingTurn(workspaceId) + this.workspaceService.hasPendingQueuedOrPreparingTurn(workspaceId) || + isActiveWorkspaceTurnTaskStatus(workspace.taskExecutionStatus) ) { return; } From b68b4c8c0f402727429f32b1ce20fd77987f1034 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 12:00:13 +0000 Subject: [PATCH 11/25] =?UTF-8?q?=F0=9F=A4=96=20fix:=20await=20desktop=20c?= =?UTF-8?q?ommand=20termination=20before=20releasing=20input?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the action/screenshot timeout race with execFileAsync process-tree termination and its close-backed result. A SIGTERM-resistant input command can no longer outlive action completion and the desktop input gate. The deterministic regression failed with a live PID after action completion before the fix and passes after it. All 52 desktop tests, scoped lint and formatting, and main typecheck pass with parent schema dependencies applied. --- .../desktop/PortableDesktopSession.test.ts | 76 ++++++++++++++++++- .../desktop/PortableDesktopSession.ts | 18 +++-- 2 files changed, 88 insertions(+), 6 deletions(-) diff --git a/src/node/services/desktop/PortableDesktopSession.test.ts b/src/node/services/desktop/PortableDesktopSession.test.ts index ad5015bfd3..aca977d2b9 100644 --- a/src/node/services/desktop/PortableDesktopSession.test.ts +++ b/src/node/services/desktop/PortableDesktopSession.test.ts @@ -1,7 +1,8 @@ import * as fs from "fs/promises"; +import * as net from "node:net"; import * as os from "os"; import * as path from "path"; -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { DESKTOP_DEFAULTS } from "@/common/constants/desktop"; import type { DesktopActionResult, DesktopScreenshotResult } from "@/common/types/desktop"; import { execFileAsync } from "@/node/utils/disposableExec"; @@ -34,6 +35,7 @@ interface PortableDesktopShimConfig { screenshotResult?: DesktopScreenshotResult; actionResult?: DesktopActionResult; actionRecordPath?: string; + blockedActionPidPath?: string; } interface PortableDesktopHarness { @@ -211,6 +213,14 @@ switch (command) { break; } case "keyboard": { + if (config.blockedActionPidPath) { + process.on("SIGTERM", () => {}); + fs.writeFileSync(config.blockedActionPidPath + ".tmp", String(process.pid)); + fs.renameSync(config.blockedActionPidPath + ".tmp", config.blockedActionPidPath); + require("net").createConnection(config.blockedActionPidPath + ".sock").on("connect", function () { this.end(); }); + setInterval(() => {}, 1000); + break; + } assertActionSucceeds("keyboard"); const positionals = getPositionals(); appendActionRecord({ @@ -606,6 +616,70 @@ describe("PortableDesktopSession", () => { }); }); + test("action timeout waits for a SIGTERM-resistant command to exit", async () => { + await withPortableDesktopHarness(async ({ tempDir }) => { + if (process.platform === "win32") return; + const pidPath = path.join(tempDir, "blocked-action.pid"); + await writePortableDesktopShim({ + rootDir: tempDir, + installMode: "cache", + config: { + startupInfo: createStartupInfo({ display: 19, vncPort: 5900, geometry: "1024x768" }), + blockedActionPidPath: pidPath, + }, + }); + process.env.PATH = ""; + const session = new PortableDesktopSession({ + workspaceId: "workspace-action-timeout", + rootDir: tempDir, + }); + let markReady!: () => void; + const ready = new Promise((resolve) => { + markReady = resolve; + }); + const readinessServer = net.createServer((socket) => { + socket.end(); + markReady(); + }); + await new Promise((resolve, reject) => { + readinessServer.once("error", reject); + readinessServer.listen(pidPath + ".sock", resolve); + }); + let pid: number | undefined; + const timerSpy = spyOn(globalThis, "setTimeout"); + try { + await session.start(); + timerSpy.mockClear(); + const action = session.action("key_press", { key: "Return" }); + await ready; + const actionPid = Number(await fs.readFile(pidPath, "utf8")); + expect(Number.isInteger(actionPid) && actionPid > 0).toBe(true); + pid = actionPid; + expect(process.kill(actionPid, 0)).toBe(true); + // Fire the real timeout only after the shim has installed its SIGTERM handler. + // This tests the deadline path without racing process startup against a short timer. + const timeoutHandler = timerSpy.mock.calls.find( + ([, delay]) => delay === DESKTOP_DEFAULTS.ACTION_TIMEOUT_MS + )?.[0]; + if (typeof timeoutHandler !== "function") throw new Error("Missing action deadline"); + timeoutHandler(); + expect((await action).success).toBe(false); + expect(() => process.kill(actionPid, 0)).toThrow(); + } finally { + timerSpy.mockRestore(); + await new Promise((resolve) => readinessServer.close(() => resolve())); + if (pid !== undefined) { + try { + process.kill(pid, "SIGKILL"); + } catch (error) { + expect((error as NodeJS.ErrnoException).code).toBe("ESRCH"); + } + } + await session.close(); + } + }); + }); + test("runs action commands through the PortableDesktop binary", async () => { await withPortableDesktopHarness(async ({ tempDir }) => { if (process.platform === "win32") { diff --git a/src/node/services/desktop/PortableDesktopSession.ts b/src/node/services/desktop/PortableDesktopSession.ts index b1ff0e2ade..95ebea68bf 100644 --- a/src/node/services/desktop/PortableDesktopSession.ts +++ b/src/node/services/desktop/PortableDesktopSession.ts @@ -211,12 +211,20 @@ export class PortableDesktopSession { timeoutMs: number ): Promise<{ stdout: string; stderr: string }> { assert(this.binaryPath, "PortableDesktop binary path is unavailable before startup"); - using proc = execFileAsync(this.binaryPath, args); - return await withTimeout( - proc.result, + // Input ownership must not be released until a timed-out command and its descendants + // have stopped: a promise race plus synchronous disposal only sends SIGTERM. + using proc = execFileAsync(this.binaryPath, args, { timeoutMs, - `PortableDesktop ${commandLabel} timed out after ${timeoutMs}ms for workspace ${this.options.workspaceId}` - ); + killTreeOnTermination: true, + }); + try { + return await proc.result; + } catch (error) { + throw new Error( + `PortableDesktop ${commandLabel} failed for workspace ${this.options.workspaceId}: ${getErrorMessage(error)}`, + { cause: error } + ); + } } private getNumericActionParam(params: Record, name: string): string { From 9a9e1231bff3b2f7a6d231c1e776c30a38d2511c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 12:12:12 +0000 Subject: [PATCH 12/25] =?UTF-8?q?=F0=9F=A4=96=20fix:=20decide=20shared=20d?= =?UTF-8?q?esktop=20release=20inside=20the=20serialized=20config=20edit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/services/taskService.test.ts | 87 ++++++++++++++++++++++++++- src/node/services/taskService.ts | 33 +++++----- 2 files changed, 103 insertions(+), 17 deletions(-) diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 375473fb95..e90fa8383d 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -27,7 +27,7 @@ import { upsertSubagentFailureArtifact, } from "@/node/services/subagentFailureArtifacts"; import { sandboxHostService } from "@/node/services/sandbox/sandboxHostService"; -import { resolveWorkspaceModelFallbackChain } from "@/node/services/taskUtils"; +import { findWorkspaceEntry, resolveWorkspaceModelFallbackChain } from "@/node/services/taskUtils"; import { ExtensionMetadataService } from "@/node/services/ExtensionMetadataService"; import { SessionUsageService } from "@/node/services/sessionUsageService"; import { WorkspaceGoalService } from "@/node/services/workspaceGoalService"; @@ -4388,6 +4388,91 @@ describe("TaskService", () => { } ); + test.each(["successor mirror", "pending turn"] as const)( + "user stop release re-evaluates a %s published while its config edit was queued", + async (race) => { + const config = await createTestConfig(rootDir); + const { parentId } = await saveLocalParentWorkspace(config, rootDir); + const desktopCoordinator = new DesktopInputCoordinator(config); + const listeners = new Map void>(); + const on = mock((event: string, handler: (payload: unknown) => void) => { + listeners.set(event, handler); + }); + const { aiService } = createAIServiceMocks(config, { on }); + const pendingTurn = { value: false }; + const { workspaceService } = createWorkspaceServiceMocks({ + hasPendingQueuedOrPreparingTurn: mock(() => pendingTurn.value), + }); + const { taskService } = createTaskServiceHarness(config, { + aiService, + workspaceService, + desktopInputCoordinator: desktopCoordinator, + }); + const created = await createAgentTask(taskService, parentId, "Inspect", { + desktop: "shared", + }); + assert(created.success, "Expected the child task to start"); + const childId = created.data.taskId; + + // The release's edit is the first config edit after the abort. Publish the successor after + // that edit was scheduled but before its transform runs, mimicking a config-queue suspension. + const editConfig = config.editConfig.bind(config); + let releaseEditSettled!: () => void; + const releaseEdit = new Promise((resolve) => { + releaseEditSettled = resolve; + }); + let intercepted = false; + const editSpy = spyOn(config, "editConfig").mockImplementation(async (transform) => { + if (intercepted) return editConfig(transform); + intercepted = true; + if (race === "successor mirror") { + await editConfig((cfg) => { + const child = findWorkspaceEntry(cfg, childId)?.workspace; + assert(child, "child entry must exist"); + child.taskExecutionId = "wst_successor"; + child.taskExecutionStatus = "running"; + return cfg; + }); + } else { + pendingTurn.value = true; + } + try { + return await editConfig(transform); + } finally { + releaseEditSettled(); + } + }); + try { + const onStreamAbort = listeners.get("stream-abort"); + assert(onStreamAbort, "TaskService must subscribe to stream-abort"); + onStreamAbort({ + type: "stream-abort", + workspaceId: childId, + messageId: "msg_1", + abortReason: "user", + }); + await releaseEdit; + } finally { + editSpy.mockRestore(); + } + + // The stale abort must not clear the successor's control. + const child = findWorkspaceInConfig(config, childId); + expect(child?.taskStatus).toBe("running"); + if (race === "successor mirror") { + expect(child?.taskExecutionId).toBe("wst_successor"); + expect(child?.taskExecutionStatus).toBe("running"); + } + const ownerInput = await desktopCoordinator + .withInput(parentId, () => Promise.resolve("clicked")) + .then( + (value) => value, + (error: unknown) => (error instanceof Error ? error.message : String(error)) + ); + expect(ownerInput).toContain(`active borrower ${childId}`); + } + ); + test.each(["reported", "interrupted"] as const)( "shared desktop %s resume preserves binding and refuses a competing controller", async (taskStatus) => { diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 17777a0ba6..9fa2ba5dbc 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -11198,34 +11198,35 @@ export class TaskService implements AgentTaskIntegration { * A user Stop on an ordinary child is a steerable pause: the task stays `running` so the * user can resume it. A shared-desktop child, however, holds the owner's desktop through that * `running` status (the config ledger is the only ownership source), so a paused child would - * block the owner until the parent's foreground wait timed out. Mirror task_stop instead: the - * durable `interrupted` status releases the desktop and fails the parent's wait fast, while a - * user resume re-admits the child onto the same desktop via markInterruptedTaskRunning. + * block the owner indefinitely — a wait timeout must never release a still-active child, only + * an explicit stop may. Mirror task_stop instead: the durable `interrupted` status releases the + * desktop and fails the parent's wait fast, while a user resume re-admits the child onto the + * same desktop via markInterruptedTaskRunning. */ private async releaseSharedDesktopTaskOnUserStop(workspaceId: string): Promise { + // Cheap bound only; every release decision below is re-evaluated inside the serialized edit. const workspace = findWorkspaceEntry(this.config.loadConfigOrDefault(), workspaceId)?.workspace; if (workspace?.parentWorkspaceId == null || workspace.taskDesktopOwnerWorkspaceId == null) { return; } - if (workspace.taskStatus !== "running" && workspace.taskStatus !== "awaiting_report") { - return; - } - // Stop-and-send-queued dispatches a new turn right after the abort, and a newer continuation - // may already own the execution mirror; either keeps the desktop, so only a genuinely idle - // child releases it. - if ( - this.aiService.isStreaming(workspaceId) || - this.workspaceService.hasPendingQueuedOrPreparingTurn(workspaceId) || - isActiveWorkspaceTurnTaskStatus(workspace.taskExecutionStatus) - ) { - return; - } let transitionedToInterrupted = false; let parentWorkspaceId: string | undefined; await this.editWorkspaceEntry( workspaceId, (ws) => { + if (ws.taskDesktopOwnerWorkspaceId == null) return; if (ws.taskStatus !== "running" && ws.taskStatus !== "awaiting_report") return; + // Evaluated against the fresh config inside the FIFO config edit: a successor that + // claimed the execution mirror, queued a turn, or started streaming while this edit + // waited in the queue keeps the desktop (stop-and-send-queued, newer continuation). A + // preflight-only check would let this stale abort clear that successor. + if ( + this.aiService.isStreaming(workspaceId) || + this.workspaceService.hasPendingQueuedOrPreparingTurn(workspaceId) || + isActiveWorkspaceTurnTaskStatus(ws.taskExecutionStatus) + ) { + return; + } parentWorkspaceId = ws.parentWorkspaceId; transitionedToInterrupted = this.applyInterruptedTaskStatus(ws) === "interrupted"; }, From 170dffccf1b14f86f01669620dbc73f91e5e741a Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 12:41:48 +0000 Subject: [PATCH 13/25] =?UTF-8?q?=F0=9F=A4=96=20tests:=20remove=20obsolete?= =?UTF-8?q?=20Config=20method=20from=20pin-order=20fixture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixture already uses a real HistoryService and never reads this removed method. Remove the stale stub to restore the local typecheck gate. --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high`_ --- src/node/services/workspaceService.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 50e3376a12..8213f233fa 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -13767,7 +13767,6 @@ describe("WorkspaceService reorderPinned across projects", () => { const mockConfig: Partial = { srcDir: "/tmp/src", - getSessionDir: mock(() => "/tmp/test/sessions"), findWorkspace: mock((id: string) => { const found = findEntry(id); if (!found) return null; From 931d3ff175fd00ebc724fc3d55a27eb29211deac Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 12:53:14 +0000 Subject: [PATCH 14/25] =?UTF-8?q?=F0=9F=A4=96=20tests:=20create=20recovery?= =?UTF-8?q?=20fault=20rejections=20at=20invocation=20time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avoid eager rejected Promises escaping before asynchronous record scanning reaches the mocked recovery calls. Both per-record recovery tests now exercise the existing production catches and pass without changing recovery behavior. --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high`_ --- src/node/services/workspaceTurnManager.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/node/services/workspaceTurnManager.test.ts b/src/node/services/workspaceTurnManager.test.ts index 327ca44584..b01684423f 100644 --- a/src/node/services/workspaceTurnManager.test.ts +++ b/src/node/services/workspaceTurnManager.test.ts @@ -5733,10 +5733,11 @@ describe("WorkspaceTurnManager", () => { ) => Promise; recoverTerminalWorkspaceTurnAttentionNotifications: () => Promise; }; + // Reject only when recovery invokes the fault, after its asynchronous disk scan. const replay = spyOn( internal, "deliverPersistentChildWorkspaceTurnResult" - ).mockRejectedValueOnce(new Error("read-only session")); + ).mockImplementationOnce(() => Promise.reject(new Error("read-only session"))); try { await internal.recoverTerminalWorkspaceTurnAttentionNotifications(); @@ -5768,8 +5769,8 @@ describe("WorkspaceTurnManager", () => { }; const enqueueTerminalAttention = taskHost.enqueueTerminalAttention.bind(taskHost); const enqueue = spyOn(taskHost, "enqueueTerminalAttention") - .mockRejectedValueOnce(new Error("read-only attention store")) - .mockImplementation(enqueueTerminalAttention); + .mockImplementation(enqueueTerminalAttention) + .mockImplementationOnce(() => Promise.reject(new Error("read-only attention store"))); try { expect(await internal.recoverTerminalWorkspaceTurnAttentionNotifications()).toBe(1); From 4b95931b17f1c837a0494df02e53cee74aae5d0d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 13:06:12 +0000 Subject: [PATCH 15/25] =?UTF-8?q?=F0=9F=A4=96=20fix:=20settle=20archived?= =?UTF-8?q?=20shared=20desktop=20children=20so=20they=20never=20hold=20or?= =?UTF-8?q?=20wedge=20the=20owner=20desktop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../desktop/DesktopInputCoordinator.test.ts | 64 +++++++++++++++++- .../desktop/DesktopInputCoordinator.ts | 30 ++++++++- src/node/services/workspaceService.test.ts | 67 ++++++++++++++++++- src/node/services/workspaceService.ts | 19 +++++- 4 files changed, 173 insertions(+), 7 deletions(-) diff --git a/src/node/services/desktop/DesktopInputCoordinator.test.ts b/src/node/services/desktop/DesktopInputCoordinator.test.ts index 02271a87d5..b4750b5084 100644 --- a/src/node/services/desktop/DesktopInputCoordinator.test.ts +++ b/src/node/services/desktop/DesktopInputCoordinator.test.ts @@ -4,7 +4,10 @@ import * as os from "node:os"; import * as path from "node:path"; import type { Workspace } from "@/common/types/project"; import { Config } from "@/node/config"; -import { DesktopInputCoordinator } from "./DesktopInputCoordinator"; +import { + DesktopInputCoordinator, + settleArchivedSharedDesktopTask, +} from "./DesktopInputCoordinator"; function deferred() { let resolve!: () => void; @@ -166,6 +169,65 @@ describe("DesktopInputCoordinator", () => { }); }); + test("an archived borrower with a stale active status neither holds nor blocks the desktop", async () => { + await withCoordinator(async (coordinator, write) => { + const archivedAt = "2026-09-01T00:00:00Z"; + for (const stale of [ + { taskStatus: "queued" as const }, + { taskExecutionStatus: "running" as const }, + ]) { + await write([owner, borrower("child", { ...stale, archivedAt }), borrower("other")]); + // Codex P1: the stale row was counted as a borrower and then failed the archived check, + // wedging every owner input and admission until the child was unarchived. + expect(await coordinator.withInput("owner", () => Promise.resolve("input"))).toBe("input"); + expect(await coordinator.withAdmission("other", () => Promise.resolve("admit"))).toBe( + "admit" + ); + expect( + await coordinator.withReservation("owner", "next", () => Promise.resolve("reserved")) + ).toBe("reserved"); + // The archived requester itself stays denied. + expect(coordinator.withInput("child", () => Promise.resolve())).rejects.toThrow("archived"); + expect(coordinator.withAdmission("child", () => Promise.resolve())).rejects.toThrow( + "archived" + ); + } + // Control is only released by archival: the same stale row unarchived blocks again. + await write([ + owner, + borrower("child", { + taskStatus: "queued", + archivedAt, + unarchivedAt: "2026-09-02T00:00:00Z", + }), + borrower("other"), + ]); + expect(coordinator.withInput("owner", () => Promise.resolve())).rejects.toThrow( + "controlled by" + ); + }); + }); + + test("settleArchivedSharedDesktopTask interrupts only bound active children and keeps queued briefs", () => { + for (const taskStatus of ["queued", "starting", "running", "awaiting_report"] as const) { + const child = borrower("child", { taskStatus, taskPrompt: "brief" }); + expect(settleArchivedSharedDesktopTask(child)).toBe(true); + expect(child.taskStatus).toBe("interrupted"); + expect(child.taskPrompt).toBe("brief"); + } + const untouched: Workspace[] = [ + borrower("reported"), + borrower("interrupted", { taskStatus: "interrupted" }), + workspace("isolated", { parentWorkspaceId: "owner", taskStatus: "queued" }), + workspace("owner-running", { taskStatus: "running" }), + ]; + for (const entry of untouched) { + const before = { ...entry }; + expect(settleArchivedSharedDesktopTask(entry)).toBe(false); + expect(entry).toEqual(before); + } + }); + test("an open input holds admission, then persisted admission blocks later owner input", async () => { await withCoordinator(async (coordinator, write) => { const entered = deferred(); diff --git a/src/node/services/desktop/DesktopInputCoordinator.ts b/src/node/services/desktop/DesktopInputCoordinator.ts index b006621bb1..5d96ea85d7 100644 --- a/src/node/services/desktop/DesktopInputCoordinator.ts +++ b/src/node/services/desktop/DesktopInputCoordinator.ts @@ -12,6 +12,29 @@ export interface DesktopTarget { export class UnsupportedDesktopRuntimeError extends Error {} +/** + * Archive/unarchive settle a bound child's stale active task status to `interrupted` (task_stop + * semantics) inside the same config edit that flips its archived state: an archived row must not + * stay an active borrower, and a record archived before this settlement existed must not resurface + * as a second active controller the moment it becomes visible again. Queued briefs stay in + * taskPrompt for the ordinary interrupted-task reawaken path. Returns whether the entry changed. + */ +export function settleArchivedSharedDesktopTask(workspace: Workspace): boolean { + if (workspace.taskDesktopOwnerWorkspaceId === undefined || workspace.parentWorkspaceId == null) { + return false; + } + if ( + workspace.taskStatus !== "queued" && + workspace.taskStatus !== "starting" && + workspace.taskStatus !== "running" && + workspace.taskStatus !== "awaiting_report" + ) { + return false; + } + workspace.taskStatus = "interrupted"; + return true; +} + /** * Delegation changes the operator, not the computer; checkout isolation is separate. * The gate covers input and durable admission together. Config task statuses are the @@ -114,7 +137,12 @@ export class DesktopInputCoordinator { for (const workspace of project.workspaces) { if ( workspace.taskDesktopOwnerWorkspaceId !== ownerWorkspaceId || - !this.isActive(workspace) + !this.isActive(workspace) || + // An archived row can never be admitted (resolve refuses archived requesters), so it + // must not count as a controller either: a stale active status on a manually archived + // child (legacy records, or an execution mirror the archive could not settle) would + // otherwise throw here and wedge every owner input and admission until unarchive. + isWorkspaceArchived(workspace.archivedAt, workspace.unarchivedAt) ) { continue; } diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 8213f233fa..95fd0b0abb 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -13047,16 +13047,27 @@ describe("WorkspaceService remove desktop session cleanup", () => { }); test("remove() closes desktop sessions on success", async () => { - const close = mock(() => Promise.resolve(undefined)); + let guard: ((workspaceId: string) => boolean) | undefined; + const guardDuringClose: { value?: boolean } = {}; + const close = mock(() => { + // Desktop startups consult this guard synchronously; a borrower bridge connecting between + // the close and the awaited config deletion must be refused just like during archive. + guardDuringClose.value = guard?.(workspaceId); + return Promise.resolve(undefined); + }); const desktopSessionManager = { close, - setWorkspaceArchiveGuard: () => undefined, + setWorkspaceArchiveGuard: (next: (workspaceId: string) => boolean) => { + guard = next; + }, } as unknown as DesktopSessionManager; workspaceService.setDesktopSessionManager(desktopSessionManager); + expect(guard?.(workspaceId)).toBe(false); const result = await workspaceService.remove(workspaceId); expect(result.success).toBe(true); + expect(guardDuringClose.value).toBe(true); expect(close).toHaveBeenCalledTimes(1); expect(close).toHaveBeenCalledWith(workspaceId); }); @@ -13958,6 +13969,33 @@ describe("WorkspaceService archive lifecycle hooks", () => { expect(editConfigSpy).not.toHaveBeenCalled(); }); + test.each([ + ["shared", "interrupted", "owner"], + ["isolated", "queued", undefined], + ] as const)( + "archiving a %s queued child leaves its task status %s", + async (_kind, expectedStatus, taskDesktopOwnerWorkspaceId) => { + const project = configState.projects.get(projectPath); + if (!project) throw new Error("project fixture must exist"); + project.workspaces.unshift({ path: "/tmp/project/owner", id: "owner" }); + Object.assign(project.workspaces[1]!, { + parentWorkspaceId: "owner", + taskStatus: "queued", + taskPrompt: "brief", + ...(taskDesktopOwnerWorkspaceId !== undefined ? { taskDesktopOwnerWorkspaceId } : {}), + }); + + expect(await workspaceService.archive(workspaceId)).toEqual(Ok({ kind: "archived" })); + + // A shared child must not stay an active borrower of the owner's desktop while archived; + // the queued brief survives for the reawaken path. + const entry = project.workspaces.find((w) => w.id === workspaceId); + expect(entry?.archivedAt).toBeTruthy(); + expect(entry?.taskStatus).toBe(expectedStatus); + expect(entry?.taskPrompt).toBe("brief"); + } + ); + test("returns Err and does not persist archivedAt when beforeArchive hook fails", async () => { const hooks = new WorkspaceLifecycleHooks(); hooks.registerBeforeArchive(() => Promise.resolve(Err("hook failed"))); @@ -14959,6 +14997,31 @@ describe("WorkspaceService unarchive lifecycle hooks", () => { await cleanupHistory(); }); + test.each([ + ["shared", "interrupted", "owner"], + ["isolated", "queued", undefined], + ] as const)( + "unarchiving a legacy archived %s queued child leaves its task status %s", + async (_kind, expectedStatus, taskDesktopOwnerWorkspaceId) => { + const project = configState.projects.get(projectPath); + if (!project) throw new Error("project fixture must exist"); + project.workspaces.unshift({ path: "/tmp/project/owner", id: "owner" }); + Object.assign(project.workspaces[1]!, { + parentWorkspaceId: "owner", + taskStatus: "queued", + ...(taskDesktopOwnerWorkspaceId !== undefined ? { taskDesktopOwnerWorkspaceId } : {}), + }); + + expect(await workspaceService.unarchive(workspaceId)).toEqual(Ok(undefined)); + + // Records archived before archive-time settlement must not resurface as a second active + // controller in the same edit that makes them visible again. + const entry = project.workspaces.find((w) => w.id === workspaceId); + expect(entry?.unarchivedAt).toBeTruthy(); + expect(entry?.taskStatus).toBe(expectedStatus); + } + ); + test("persists unarchivedAt and runs afterUnarchive hooks (best-effort)", async () => { const hooks = new WorkspaceLifecycleHooks(); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index fcb8c337aa..774a0524f3 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -1,4 +1,7 @@ -import { DesktopInputCoordinator } from "@/node/services/desktop/DesktopInputCoordinator"; +import { + DesktopInputCoordinator, + settleArchivedSharedDesktopTask, +} from "@/node/services/desktop/DesktopInputCoordinator"; import * as path from "path"; import { TASK_TERMINATION_STOP_STREAM_TIMEOUT_MS } from "@/constants/terminationTimeouts"; import { raceWithAbortAndTimeout } from "@/node/utils/concurrency/withTimeout"; @@ -2928,8 +2931,13 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { // Archive admission pairing for desktop startups (mirrors setTerminalService above): // ensureStarted checks this guard in the same synchronous block that registers its startup // promise, so whichever of {archive gate, desktop startup entry} runs first is observed by - // the other. - manager.setWorkspaceArchiveGuard((workspaceId) => this.archivingWorkspaces.has(workspaceId)); + // the other. Removal latches removingWorkspaces before closing the desktop and only then + // awaits config deletion, so a borrower bridge connecting in that window must be refused + // by the same guard rather than start a session the removal never closes. + manager.setWorkspaceArchiveGuard( + (workspaceId) => + this.archivingWorkspaces.has(workspaceId) || this.removingWorkspaces.has(workspaceId) + ); } private async closeDesktopSessionBestEffort( @@ -8715,6 +8723,8 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { if (workspaceEntry) { // Just set archivedAt - archived state is derived from archivedAt > unarchivedAt. workspaceEntry.archivedAt = new Date().toISOString(); + // A shared-desktop child releases the owner's desktop in the same edit. + settleArchivedSharedDesktopTask(workspaceEntry); // Archiving clears the pin; unarchive does not restore it. delete workspaceEntry.pinnedAt; if (capturedWorktreeSnapshot) { @@ -8884,6 +8894,9 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { previousUnarchivedAt = workspaceEntry.unarchivedAt; persistedUnarchivedAt = new Date().toISOString(); workspaceEntry.unarchivedAt = persistedUnarchivedAt; + // Records archived before archive-time settlement must reappear as interrupted, + // never as a competing active desktop controller. + settleArchivedSharedDesktopTask(workspaceEntry); didUnarchive = true; } } From a08545b8bae62e7122cfeb402f18fd4556f4cd1f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 13:06:55 +0000 Subject: [PATCH 16/25] =?UTF-8?q?=F0=9F=A4=96=20tests:=20drop=20unnecessar?= =?UTF-8?q?y=20assertions=20in=20archive=20settlement=20fixtures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/services/workspaceService.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 95fd0b0abb..02ee6cfb1d 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -13978,7 +13978,7 @@ describe("WorkspaceService archive lifecycle hooks", () => { const project = configState.projects.get(projectPath); if (!project) throw new Error("project fixture must exist"); project.workspaces.unshift({ path: "/tmp/project/owner", id: "owner" }); - Object.assign(project.workspaces[1]!, { + Object.assign(project.workspaces[1], { parentWorkspaceId: "owner", taskStatus: "queued", taskPrompt: "brief", @@ -15006,7 +15006,7 @@ describe("WorkspaceService unarchive lifecycle hooks", () => { const project = configState.projects.get(projectPath); if (!project) throw new Error("project fixture must exist"); project.workspaces.unshift({ path: "/tmp/project/owner", id: "owner" }); - Object.assign(project.workspaces[1]!, { + Object.assign(project.workspaces[1], { parentWorkspaceId: "owner", taskStatus: "queued", ...(taskDesktopOwnerWorkspaceId !== undefined ? { taskDesktopOwnerWorkspaceId } : {}), From 340fd90f2fd41b7f33919147f1d1d2b430022286 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 13:14:09 +0000 Subject: [PATCH 17/25] =?UTF-8?q?=F0=9F=A4=96=20fix:=20retire=20stale=20de?= =?UTF-8?q?sktop=20execution=20mirrors=20during=20archive=20restoration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both taskStatus and taskExecutionStatus reserve desktop input. Settle active execution mirrors alongside active task status so legacy archived continuations cannot reclaim a sibling desktop on unarchive. --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high`_ --- .../desktop/DesktopInputCoordinator.test.ts | 21 +++++++++++++++++ .../desktop/DesktopInputCoordinator.ts | 23 +++++++++++-------- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/node/services/desktop/DesktopInputCoordinator.test.ts b/src/node/services/desktop/DesktopInputCoordinator.test.ts index b4750b5084..1d10e0e52c 100644 --- a/src/node/services/desktop/DesktopInputCoordinator.test.ts +++ b/src/node/services/desktop/DesktopInputCoordinator.test.ts @@ -208,6 +208,27 @@ describe("DesktopInputCoordinator", () => { }); }); + test("unarchiving a stale execution mirror cannot steal a sibling's desktop", async () => { + await withCoordinator(async (coordinator, write) => { + for (const taskExecutionStatus of ["queued", "starting", "running"] as const) { + const restored = borrower("restored", { + taskExecutionId: "old-execution", + taskExecutionStatus, + archivedAt: "2026-09-01T00:00:00Z", + }); + settleArchivedSharedDesktopTask(restored); + restored.unarchivedAt = "2026-09-02T00:00:00Z"; + await write([owner, restored, borrower("active", { taskStatus: "running" })]); + expect(await coordinator.withInput("active", () => Promise.resolve("input"))).toBe("input"); + expect(restored.taskExecutionStatus).toBe("interrupted"); + expect(restored.taskExecutionId).toBe("old-execution"); + expect(coordinator.withAdmission("restored", () => Promise.resolve())).rejects.toThrow( + "controlled by active borrower active" + ); + } + }); + }); + test("settleArchivedSharedDesktopTask interrupts only bound active children and keeps queued briefs", () => { for (const taskStatus of ["queued", "starting", "running", "awaiting_report"] as const) { const child = borrower("child", { taskStatus, taskPrompt: "brief" }); diff --git a/src/node/services/desktop/DesktopInputCoordinator.ts b/src/node/services/desktop/DesktopInputCoordinator.ts index 5d96ea85d7..56d4852aa0 100644 --- a/src/node/services/desktop/DesktopInputCoordinator.ts +++ b/src/node/services/desktop/DesktopInputCoordinator.ts @@ -23,16 +23,19 @@ export function settleArchivedSharedDesktopTask(workspace: Workspace): boolean { if (workspace.taskDesktopOwnerWorkspaceId === undefined || workspace.parentWorkspaceId == null) { return false; } - if ( - workspace.taskStatus !== "queued" && - workspace.taskStatus !== "starting" && - workspace.taskStatus !== "running" && - workspace.taskStatus !== "awaiting_report" - ) { - return false; - } - workspace.taskStatus = "interrupted"; - return true; + const activeTask = + workspace.taskStatus === "queued" || + workspace.taskStatus === "starting" || + workspace.taskStatus === "running" || + workspace.taskStatus === "awaiting_report"; + const activeExecution = + workspace.taskExecutionStatus === "queued" || + workspace.taskExecutionStatus === "starting" || + workspace.taskExecutionStatus === "running"; + if (activeTask) workspace.taskStatus = "interrupted"; + // Both status sources reserve input; an old execution must not reclaim it on unarchive. + if (activeExecution) workspace.taskExecutionStatus = "interrupted"; + return activeTask || activeExecution; } /** From 6a9fe20f7682d89540ae9b276b72ac772171f29d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 13:22:12 +0000 Subject: [PATCH 18/25] =?UTF-8?q?=F0=9F=A4=96=20tests:=20align=20CI=20fixt?= =?UTF-8?q?ures=20with=20desktop=20targeting=20and=20event=20realms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep fake noVNC events and targets in the same happy-dom realm under monolithic CI. Assert the scratch task desktop-owner result and restore missing archive-state fields in flat sidebar context fixtures. All eight failing CI cases reproduce locally and now pass. --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high`_ --- .../components/ProjectSidebar/ProjectSidebar.test.tsx | 5 +++++ src/browser/features/desktop/DesktopPanel.test.tsx | 3 ++- src/node/services/taskService.test.ts | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx index e5bf32afd5..ee96b62512 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx @@ -957,6 +957,7 @@ describe("ProjectSidebar flat chat list", () => { spyOn(WorkspaceContextModule, "useWorkspaceActions").mockImplementation( () => ({ + archivingWorkspaceIds: new Set(), selectedWorkspace: null, setSelectedWorkspace: () => undefined, preflightArchiveWorkspace: () => @@ -1008,6 +1009,7 @@ describe("ProjectSidebar flat chat list", () => { spyOn(WorkspaceContextModule, "useWorkspaceActions").mockImplementation( () => ({ + archivingWorkspaceIds: new Set(), selectedWorkspace: null, setSelectedWorkspace: () => undefined, preflightArchiveWorkspace: () => @@ -1098,6 +1100,7 @@ describe("ProjectSidebar flat chat list", () => { spyOn(WorkspaceContextModule, "useWorkspaceActions").mockImplementation( () => ({ + archivingWorkspaceIds: new Set(), selectedWorkspace: null, setSelectedWorkspace: () => undefined, preflightArchiveWorkspace: () => @@ -1278,6 +1281,7 @@ describe("ProjectSidebar flat chat list", () => { spyOn(WorkspaceContextModule, "useWorkspaceActions").mockImplementation( () => ({ + archivingWorkspaceIds: new Set(), selectedWorkspace: null, setSelectedWorkspace: () => undefined, preflightArchiveWorkspace: () => @@ -1333,6 +1337,7 @@ describe("ProjectSidebar flat chat list", () => { spyOn(WorkspaceContextModule, "useWorkspaceActions").mockImplementation( () => ({ + archivingWorkspaceIds: new Set(), selectedWorkspace: null, setSelectedWorkspace: () => undefined, preflightArchiveWorkspace: () => diff --git a/src/browser/features/desktop/DesktopPanel.test.tsx b/src/browser/features/desktop/DesktopPanel.test.tsx index eac4703f3c..a131c7d80a 100644 --- a/src/browser/features/desktop/DesktopPanel.test.tsx +++ b/src/browser/features/desktop/DesktopPanel.test.tsx @@ -1,6 +1,7 @@ import { act, cleanup, render, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; -import { GlobalWindow } from "happy-dom"; +// Keep the fake transport and its events in one realm even after other UI tests install a DOM. +import { GlobalWindow, EventTarget, Event, CustomEvent } from "happy-dom"; import type { APIClient } from "@/browser/contexts/API"; const getBootstrap = mock(); diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index e90fa8383d..4c2022236e 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -477,6 +477,7 @@ describe("TaskService", () => { expect(result).toEqual( Ok({ taskId: childId, + desktopOwnerWorkspaceId: childId, kind: "agent", status: "running", modelString: "anthropic:claude-opus-4-6", From 067febcbb359cac64dcbb81d6e89d8175c6019ed Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 13:11:35 +0000 Subject: [PATCH 19/25] =?UTF-8?q?=F0=9F=A4=96=20fix:=20revoke=20shared=20d?= =?UTF-8?q?esktop=20bridges=20when=20workspaces=20close?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bind each pending or active bridge to requester and owner cleanup events. Close affected VNC sockets synchronously without closing an unrelated owner session, abort pending connections, and unsubscribe when each bridge closes. Red-first real WebSocket/TCP tests reproduced input continuing after child cleanup. All 58 desktop tests, scoped ESLint/Prettier, and both TypeScript configurations pass, including delayed connection and owner-close coverage. --- .../desktop/DesktopBridgeServer.test.ts | 204 +++++++++++++++++- .../services/desktop/DesktopBridgeServer.ts | 107 ++++++--- .../desktop/DesktopSessionManager.test.ts | 1 + .../services/desktop/DesktopSessionManager.ts | 21 +- 4 files changed, 297 insertions(+), 36 deletions(-) diff --git a/src/node/services/desktop/DesktopBridgeServer.test.ts b/src/node/services/desktop/DesktopBridgeServer.test.ts index c4ec0976a1..4ac5478bb1 100644 --- a/src/node/services/desktop/DesktopBridgeServer.test.ts +++ b/src/node/services/desktop/DesktopBridgeServer.test.ts @@ -1,6 +1,13 @@ import * as http from "node:http"; import * as net from "node:net"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; import { describe, expect, mock, spyOn, test } from "bun:test"; +import { Config } from "@/node/config"; +import type { ExperimentsService } from "@/node/services/experimentsService"; +import type { WorkspaceService } from "@/node/services/workspaceService"; +import { DesktopSessionManager } from "./DesktopSessionManager"; import { WebSocket, type RawData } from "ws"; import { DesktopBridgeServer } from "./DesktopBridgeServer"; import { DesktopTokenManager } from "./DesktopTokenManager"; @@ -24,15 +31,18 @@ interface UpgradeHarness { interface Deferred { promise: Promise; reject: (reason?: unknown) => void; + resolve: (value: T) => void; } function createDeferred(): Deferred { let reject!: (reason?: unknown) => void; - const promise = new Promise((_innerResolve, innerReject) => { + let resolve!: (value: T) => void; + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve; reject = innerReject; }); void promise.catch(() => undefined); - return { promise, reject }; + return { promise, reject, resolve }; } function createBridgeServer(options: { @@ -52,11 +62,15 @@ function createBridgeServer(options: { ), }, desktopSessionManager: { - getLiveSessionConnection: - options.getLiveSessionConnection ?? - mock((workspaceId: string) => - workspaceId === VALID_WORKSPACE_ID ? { sessionId: VALID_SESSION_ID, vncPort: 5900 } : null - ), + getLiveSessionConnection: (workspaceId) => { + const live = options.getLiveSessionConnection + ? options.getLiveSessionConnection(workspaceId) + : workspaceId === VALID_WORKSPACE_ID + ? { sessionId: VALID_SESSION_ID, vncPort: 5900 } + : null; + return live ? { ...live, ownerWorkspaceId: workspaceId } : null; + }, + onWorkspaceClose: () => () => undefined, }, }); } @@ -304,17 +318,189 @@ async function waitForTcpData(socket: net.Socket, timeoutMs = 2_000): Promise Promise; + }) => Promise +): Promise { + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "desktop-bridge-revocation-")); + const config = new Config(rootDir); + await config.editConfig((current) => { + current.projects.set(rootDir, { + workspaces: ["owner", "child", "sibling", "unrelated"].map((id) => ({ + id, + name: id, + path: path.join(rootDir, id), + ...(["child", "sibling"].includes(id) + ? { parentWorkspaceId: "owner", taskDesktopOwnerWorkspaceId: "owner" } + : {}), + })), + }); + return current; + }); + const experimentsService: Partial = { isExperimentEnabled: () => true }; + const workspaceService: Partial = { getInfo: () => Promise.resolve(null) }; + const manager = new DesktopSessionManager({ + config, + experimentsService: experimentsService as ExperimentsService, + workspaceService: workspaceService as WorkspaceService, + }); + const tcp = await listenTcpServer(); + tcp.server.on("connection", (socket) => { + socket.on("data", (data) => { + socket.write(data, (error) => { + if (error) socket.destroy(error); + }); + }); + socket.write(Buffer.from([0])); + }); + const connection = spyOn(manager, "getLiveSessionConnection").mockImplementation( + (workspaceId) => { + try { + const { ownerWorkspaceId } = manager.resolveTarget(workspaceId); + return { ownerWorkspaceId, sessionId: `session:${ownerWorkspaceId}`, vncPort: tcp.port }; + } catch { + return null; + } + } + ); + const tokens = new DesktopTokenManager(); + const bridge = new DesktopBridgeServer({ + desktopSessionManager: manager, + desktopTokenManager: tokens, + }); + const upgrade = await listenUpgradeServer(bridge); + const clients: WebSocket[] = []; + try { + await run({ + manager, + bridge, + connect: async (workspaceId, waitForVnc = true) => { + const live = manager.getLiveSessionConnection(workspaceId); + if (!live) throw new Error("Expected live test connection"); + const token = tokens.mint(workspaceId, live.sessionId); + const ws = new WebSocket(`ws://127.0.0.1:${upgrade.port}/?token=${token}`); + clients.push(ws); + ws.on("message", (_data, isBinary) => expect(isBinary).toBe(true)); + const greeting = waitForVnc ? waitForWebSocketMessage(ws) : null; + await waitForWebSocketOpen(ws); + if (greeting) expect(await greeting).toEqual(Buffer.from([0])); + return ws; + }, + }); + } finally { + await bridge.stop(); + await Promise.all(clients.map(closeWebSocket)); + await upgrade.close(); + await tcp.close(); + tokens.dispose(); + connection.mockRestore(); + await manager.closeAll(); + await fs.rm(rootDir, { recursive: true, force: true }); + } +} + +async function expectEcho(ws: WebSocket): Promise { + const echoed = waitForWebSocketMessage(ws); + ws.send(Buffer.from([1, 2, 3])); + expect(await echoed).toEqual(Buffer.from([1, 2, 3])); +} + describe("DesktopBridgeServer", () => { + for (const closingWorkspaceId of ["child", "owner"]) { + test(`closing ${closingWorkspaceId} revokes established affected bridges only`, async () => { + await withSharedBridge(async ({ manager, connect }) => { + const clients = new Map(); + for (const workspaceId of ["owner", "child", "sibling", "unrelated"]) { + const ws = await connect(workspaceId); + await expectEcho(ws); + clients.set(workspaceId, ws); + } + const revoked = closingWorkspaceId === "owner" ? ["owner", "child", "sibling"] : ["child"]; + const results = [...clients].map(([workspaceId, ws]) => ({ + workspaceId, + ws, + response: waitForWebSocketMessage(ws).then( + () => "message", + () => "closed" + ), + closed: revoked.includes(workspaceId) ? waitForWebSocketClose(ws) : null, + })); + await manager.close(closingWorkspaceId); + // A post-cleanup input frame exposes the old bug deterministically: an unrevoked + // connection echoes it instead of closing, without a polling or timeout assertion. + for (const { ws } of results) ws.send(Buffer.from([4, 5, 6])); + for (const result of results) { + expect(await result.response).toBe(result.closed ? "closed" : "message"); + if (result.closed) expect((await result.closed).code).toBe(4002); + } + const unrelated = clients.get("unrelated"); + if (!unrelated) throw new Error("Missing unrelated test viewer"); + await expectEcho(unrelated); + }); + }); + } + + for (const cleanup of ["child", "owner", "all", "guard"] as const) { + test(`${cleanup} cleanup refuses a late TCP connection without leaking a subscription`, async () => { + await withSharedBridge(async ({ manager, bridge, connect }) => { + interface ConnectingBridge { + connectToVnc: (port: number, signal: AbortSignal) => Promise; + } + const internal = bridge as unknown as ConnectingBridge; + const connectToVnc = internal.connectToVnc.bind(bridge); + const connected = createDeferred(); + const release = createDeferred(); + const pending = spyOn(internal, "connectToVnc").mockImplementation(async (port, signal) => { + const tcp = await connectToVnc(port, signal); + connected.resolve(tcp); + await release.promise; + return tcp; + }); + try { + const ws = await connect("child", false); + const tcp = await connected.promise; + const tcpClosed = new Promise((resolve) => tcp.once("close", () => resolve())); + const closed = waitForWebSocketClose(ws); + if (cleanup === "guard") { + manager.setWorkspaceArchiveGuard((workspaceId) => workspaceId === "child"); + } else if (cleanup === "all") { + await manager.closeAll(); + } else { + await manager.close(cleanup); + } + release.resolve(); + expect((await closed).code).toBe(4002); + await tcpClosed; + expect(tcp.destroyed).toBe(true); + const listeners: unknown = Reflect.get(manager, "closeListeners"); + expect(listeners).toBeInstanceOf(Set); + if (!(listeners instanceof Set)) throw new Error("Expected close subscriptions"); + expect(listeners.size).toBe(0); + pending.mockRestore(); + await expectEcho(await connect("unrelated")); + } finally { + release.resolve(); + pending.mockRestore(); + } + }); + }); + } + test("shared tokens authorize the requester and bind its owner's session", async () => { const tcpHarness = await listenTcpServer(); const tokens = new DesktopTokenManager(); const token = tokens.mint("child", "owner-session"); const getLiveSessionConnection = mock((workspaceId: string) => - workspaceId === "child" ? { sessionId: "owner-session", vncPort: tcpHarness.port } : null + workspaceId === "child" + ? { ownerWorkspaceId: "owner", sessionId: "owner-session", vncPort: tcpHarness.port } + : null ); const bridgeServer = new DesktopBridgeServer({ desktopTokenManager: tokens, - desktopSessionManager: { getLiveSessionConnection }, + desktopSessionManager: { getLiveSessionConnection, onWorkspaceClose: () => () => undefined }, }); const upgradeHarness = await listenUpgradeServer(bridgeServer); let ws: WebSocket | null = null; diff --git a/src/node/services/desktop/DesktopBridgeServer.ts b/src/node/services/desktop/DesktopBridgeServer.ts index ec13e2ce1d..d40fe7de4e 100644 --- a/src/node/services/desktop/DesktopBridgeServer.ts +++ b/src/node/services/desktop/DesktopBridgeServer.ts @@ -15,12 +15,19 @@ const VNC_HOST = "127.0.0.1"; interface BridgePair { ws: WebSocket; - tcp: net.Socket; + tcp: net.Socket | null; + requesterWorkspaceId: string; + ownerWorkspaceId: string; + connectAbort: AbortController; + unsubscribeClose?: () => void; closed: boolean; } export interface DesktopBridgeServerOptions { - desktopSessionManager: Pick; + desktopSessionManager: Pick< + DesktopSessionManager, + "getLiveSessionConnection" | "onWorkspaceClose" + >; desktopTokenManager: Pick; } @@ -92,7 +99,10 @@ async function waitForWebSocketClose(ws: WebSocket, timeoutMs = 250): Promise; + private readonly desktopSessionManager: Pick< + DesktopSessionManager, + "getLiveSessionConnection" | "onWorkspaceClose" + >; private readonly desktopTokenManager: Pick; private readonly wss: WebSocketServer; private readonly activePairs = new Set(); @@ -212,8 +222,45 @@ export class DesktopBridgeServer { return; } + // Subscribe before connecting: cleanup must revoke both established viewers and connections + // still awaiting TCP, even when a borrower has no owned desktop session to close. + const pair: BridgePair = { + ws, + tcp: null, + requesterWorkspaceId: payload.workspaceId, + ownerWorkspaceId: liveSession.ownerWorkspaceId, + connectAbort: new AbortController(), + closed: false, + }; + pair.unsubscribeClose = this.desktopSessionManager.onWorkspaceClose((workspaceId) => { + if ( + workspaceId === null || + workspaceId === pair.requesterWorkspaceId || + workspaceId === pair.ownerWorkspaceId + ) { + this.cleanupPair(pair, { + closeCode: MISSING_SESSION_CLOSE_CODE, + closeReason: "session unavailable", + }); + } + }); + this.activePairs.add(pair); + ws.once("close", () => this.cleanupPair(pair, { closeReason: "websocket closed" })); + ws.on("error", (error) => { + log.error("DesktopBridgeServer: WebSocket bridge failed", { + workspaceId: payload.workspaceId, + error, + }); + this.cleanupPair(pair, { closeReason: "websocket error" }); + }); + try { - const tcp = await this.connectToVnc(liveSession.vncPort); + const tcp = await this.connectToVnc(liveSession.vncPort, pair.connectAbort.signal); + if (pair.closed) { + tcp.destroy(); + return; + } + pair.tcp = tcp; // Tokens name the requester, not the owner: revalidate the current relationship after // connecting too, so an archive/removal during TCP setup cannot attach a stale borrower. const currentSession = this.desktopSessionManager.getLiveSessionConnection( @@ -221,15 +268,16 @@ export class DesktopBridgeServer { ); if ( currentSession?.sessionId !== payload.sessionId || + currentSession.ownerWorkspaceId !== pair.ownerWorkspaceId || currentSession.vncPort !== liveSession.vncPort ) { - tcp.destroy(); - closeWebSocket(ws, MISSING_SESSION_CLOSE_CODE, "session unavailable"); + this.cleanupPair(pair, { + closeCode: MISSING_SESSION_CLOSE_CODE, + closeReason: "session unavailable", + }); return; } - const pair: BridgePair = { ws, tcp, closed: false }; this.attachBridgeListeners(pair, payload.workspaceId, liveSession.sessionId); - this.activePairs.add(pair); log.debug("DesktopBridgeServer: bridged desktop session", { workspaceId: payload.workspaceId, sessionId: liveSession.sessionId, @@ -240,17 +288,21 @@ export class DesktopBridgeServer { this.cleanupPair(pair, { closeReason: "websocket closed before bridge finished" }); } } catch (error) { + if (pair.closed) return; log.warn("DesktopBridgeServer: failed to connect to VNC endpoint", { workspaceId: payload.workspaceId, sessionId: payload.sessionId, vncPort: liveSession.vncPort, error, }); - closeWebSocket(ws, VNC_CONNECT_FAILURE_CLOSE_CODE, "vnc connect failed"); + this.cleanupPair(pair, { + closeCode: VNC_CONNECT_FAILURE_CLOSE_CODE, + closeReason: "vnc connect failed", + }); } } - private async connectToVnc(port: number): Promise { + private async connectToVnc(port: number, signal: AbortSignal): Promise { assert(Number.isInteger(port), "DesktopBridgeServer VNC port must be an integer"); assert(port > 0, "DesktopBridgeServer VNC port must be positive"); @@ -262,6 +314,14 @@ export class DesktopBridgeServer { tcp.off("connect", onConnect); tcp.off("error", onError); tcp.off("close", onCloseBeforeConnect); + signal.removeEventListener("abort", onAbort); + }; + const onAbort = () => { + if (settled) return; + settled = true; + cleanup(); + tcp.destroy(); + reject(new Error("VNC connection cancelled")); }; const onConnect = () => { @@ -295,10 +355,14 @@ export class DesktopBridgeServer { tcp.once("connect", onConnect); tcp.once("error", onError); tcp.once("close", onCloseBeforeConnect); + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) onAbort(); }); } private attachBridgeListeners(pair: BridgePair, workspaceId: string, sessionId: string): void { + const tcp = pair.tcp; + assert(tcp, "Desktop bridge listeners require a connected TCP socket"); pair.ws.on("message", (data, isBinary) => { if (pair.closed) { return; @@ -313,7 +377,7 @@ export class DesktopBridgeServer { } try { - pair.tcp.write(normalizeBinaryMessage(data)); + tcp.write(normalizeBinaryMessage(data)); } catch (error) { log.error("DesktopBridgeServer: failed to forward client frame to VNC", { workspaceId, @@ -324,16 +388,7 @@ export class DesktopBridgeServer { } }); - pair.ws.on("close", () => { - this.cleanupPair(pair, { closeReason: "websocket closed" }); - }); - - pair.ws.on("error", (error) => { - log.error("DesktopBridgeServer: WebSocket bridge failed", { workspaceId, sessionId, error }); - this.cleanupPair(pair, { closeReason: "websocket error" }); - }); - - pair.tcp.on("data", (chunk) => { + tcp.on("data", (chunk) => { if (pair.closed) { return; } @@ -355,15 +410,15 @@ export class DesktopBridgeServer { } }); - pair.tcp.on("end", () => { + tcp.on("end", () => { this.cleanupPair(pair, { closeReason: "tcp ended" }); }); - pair.tcp.on("close", () => { + tcp.on("close", () => { this.cleanupPair(pair, { closeReason: "tcp closed" }); }); - pair.tcp.on("error", (error) => { + tcp.on("error", (error) => { log.error("DesktopBridgeServer: TCP bridge failed", { workspaceId, sessionId, error }); this.cleanupPair(pair, { closeReason: "tcp error" }); }); @@ -379,8 +434,10 @@ export class DesktopBridgeServer { pair.closed = true; this.activePairs.delete(pair); + pair.unsubscribeClose?.(); + pair.connectAbort.abort(); - if (!pair.tcp.destroyed) { + if (pair.tcp && !pair.tcp.destroyed) { try { pair.tcp.destroy(); } catch (error) { diff --git a/src/node/services/desktop/DesktopSessionManager.test.ts b/src/node/services/desktop/DesktopSessionManager.test.ts index 9cfb2cd04f..35fedb2a11 100644 --- a/src/node/services/desktop/DesktopSessionManager.test.ts +++ b/src/node/services/desktop/DesktopSessionManager.test.ts @@ -445,6 +445,7 @@ describe("DesktopSessionManager", () => { expect(manager.getLiveSessionConnection("child")).toEqual( manager.getLiveSessionConnection("owner") ); + expect(manager.getLiveSessionConnection("child")?.ownerWorkspaceId).toBe("owner"); await config.editConfig((current) => { const child = current.projects diff --git a/src/node/services/desktop/DesktopSessionManager.ts b/src/node/services/desktop/DesktopSessionManager.ts index 1286b58881..90a305a460 100644 --- a/src/node/services/desktop/DesktopSessionManager.ts +++ b/src/node/services/desktop/DesktopSessionManager.ts @@ -22,6 +22,7 @@ export class DesktopSessionManager { private readonly sessions = new Map(); private readonly startupPromises = new Map>(); private readonly inputCoordinator: DesktopInputCoordinator; + private readonly closeListeners = new Set<(workspaceId: string | null) => void>(); private workspaceArchiveGuard: ((workspaceId: string) => boolean) | undefined; /** @@ -50,7 +51,9 @@ export class DesktopSessionManager { const target = this.inputCoordinator.resolveTarget(workspaceId); for (const id of new Set([workspaceId, target.ownerWorkspaceId])) { if (this.workspaceArchiveGuard?.(id) === true) { - throw new Error(`Workspace is being archived: ${id}. Unarchive it before using a desktop.`); + throw new Error( + `Workspace is being archived or removed: ${id}. Wait for cleanup to finish.` + ); } } return target; @@ -231,7 +234,15 @@ export class DesktopSessionManager { ); } + /** A null workspace ID revokes all viewers, including pending bridge connections. */ + onWorkspaceClose(listener: (workspaceId: string | null) => void): () => void { + this.closeListeners.add(listener); + return () => this.closeListeners.delete(listener); + } + async close(workspaceId: string): Promise { + // A shared borrower has no owned session, but cleanup must still revoke its viewers. + for (const listener of this.closeListeners) listener(workspaceId); const session = this.sessions.get(workspaceId); const startupPromise = this.startupPromises.get(workspaceId); @@ -256,6 +267,7 @@ export class DesktopSessionManager { } async closeAll(): Promise { + for (const listener of this.closeListeners) listener(null); const sessions = Array.from(this.sessions.values()); const startupPromises = Array.from(this.startupPromises.values()); @@ -275,7 +287,11 @@ export class DesktopSessionManager { * Returns null if no live session exists for the workspace. * Used by DesktopBridgeServer to resolve token→VNC-port mappings. */ - getLiveSessionConnection(workspaceId: string): { sessionId: string; vncPort: number } | null { + getLiveSessionConnection(workspaceId: string): { + ownerWorkspaceId: string; + sessionId: string; + vncPort: number; + } | null { let ownerWorkspaceId: string; try { ownerWorkspaceId = this.resolveTarget(workspaceId).ownerWorkspaceId; @@ -298,6 +314,7 @@ export class DesktopSessionManager { } return { + ownerWorkspaceId, sessionId: sessionInfo.sessionId ?? `desktop:${ownerWorkspaceId}`, vncPort: sessionInfo.vncPort, }; From 2f9121f1db1eca0f002a46d8265dd56e2d146305 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 13:42:17 +0000 Subject: [PATCH 20/25] =?UTF-8?q?=F0=9F=A4=96=20tests:=20target=20the=20ph?= =?UTF-8?q?one=20frame=20explicitly=20in=20Pixel=20assertions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pixel can prepend non-layout nodes to its story canvas, so firstElementChild is not necessarily the 390px wrapper. Keep the responsive width and overflow assertions but select the actual frame. Both desktop story plays pass locally; static-check is green. --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high`_ --- src/browser/stories/App.desktop.stories.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/browser/stories/App.desktop.stories.tsx b/src/browser/stories/App.desktop.stories.tsx index ea39bbe512..a9807c8a92 100644 --- a/src/browser/stories/App.desktop.stories.tsx +++ b/src/browser/stories/App.desktop.stories.tsx @@ -153,7 +153,7 @@ export const SharedBindingPhone: AppStory = { }, // The test-runner ignores viewport globals; the wrapper exercises the same narrow container. render: () => ( -
+
setupDesktopStory(true)} />
), @@ -169,8 +169,7 @@ export const SharedBindingPhone: AppStory = { if (!sidebar) throw new Error("Missing workspace insights sidebar"); await expect(getComputedStyle(sidebar).display).toBe("none"); }); - const frame = canvasElement.firstElementChild; - if (!(frame instanceof HTMLElement)) throw new Error("Missing phone frame"); + const frame = within(canvasElement).getByTestId("desktop-phone-frame"); await expect(frame.getBoundingClientRect().width).toBe(390); await expect(frame.scrollWidth).toBeLessThanOrEqual(390); await waitForChatInputAutofocusDone(canvasElement); From 06b19524b7a260db9901ed6b5614b52f310b903e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 15:05:38 +0000 Subject: [PATCH 21/25] =?UTF-8?q?=F0=9F=A4=96=20fix:=20clear=20orphan=20ta?= =?UTF-8?q?sk=20execution=20mirrors=20during=20startup=20reconciliation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/workspaceTurnManager.test.ts | 50 +++++++++++++++++++ src/node/services/workspaceTurnManager.ts | 38 +++++++++++--- 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/src/node/services/workspaceTurnManager.test.ts b/src/node/services/workspaceTurnManager.test.ts index b01684423f..0f325a04b8 100644 --- a/src/node/services/workspaceTurnManager.test.ts +++ b/src/node/services/workspaceTurnManager.test.ts @@ -12,6 +12,7 @@ import { } from "@/node/services/taskHandleStore"; import { ForegroundWaitBackgroundedError } from "@/node/services/taskService"; import { WorkspaceTurnManager } from "@/node/services/workspaceTurnManager"; +import { DesktopInputCoordinator } from "@/node/services/desktop/DesktopInputCoordinator"; import { MutexMap } from "@/node/utils/concurrency/mutexMap"; import { WorkflowRunStore } from "@/node/services/workflows/WorkflowRunStore"; import { Ok, Err, type Result } from "@/common/types/result"; @@ -347,6 +348,55 @@ describe("WorkspaceTurnManager", () => { expect(findWorkspaceInConfig(config, "child")?.taskExecutionStatus).toBe("completed"); }); + test("startup clears an orphan execution mirror that has no handle ID and no handle record", async () => { + const config = await createTestConfig(rootDir); + const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); + await config.editConfig((cfg) => { + cfg.projects.get(projectPath)!.workspaces.push( + // Codex P2: a bound child whose persisted mirror lost its ID (and whose handle record is + // gone) reads as live desktop control forever — the ID-guarded clear never matches it. + projectWorkspace(projectPath, "shared-orphan", "shared-orphan", { + parentWorkspaceId: parentId, + agentId: "explore", + taskStatus: "reported", + runtimeConfig: { type: "local" }, + taskDesktopOwnerWorkspaceId: parentId, + taskExecutionStatus: "running", + }), + // The stable task status is a separate activity source and must survive the repair. + projectWorkspace(projectPath, "running-orphan", "running-orphan", { + parentWorkspaceId: parentId, + agentId: "explore", + taskStatus: "running", + runtimeConfig: { type: "local" }, + taskExecutionStatus: "starting", + }) + ); + return cfg; + }); + const { taskService } = createWorkspaceTurnManagerHarness(config); + const desktop = new DesktopInputCoordinator(config); + const ownerInput = () => + desktop + .withInput(parentId, () => Promise.resolve("clicked")) + .then( + (value) => value, + (error: unknown) => (error instanceof Error ? error.message : String(error)) + ); + expect(await ownerInput()).toContain("active borrower shared-orphan"); + + await taskService.reconcileAgentTaskExecutionIds(); + + for (const id of ["shared-orphan", "running-orphan"]) { + expect(findWorkspaceInConfig(config, id)?.taskExecutionStatus).toBeUndefined(); + expect(findWorkspaceInConfig(config, id)?.taskExecutionId).toBeUndefined(); + expect(taskService.getLiveWorkspaceTurnRegistration(id)).toBeUndefined(); + } + expect(findWorkspaceInConfig(config, "shared-orphan")?.taskStatus).toBe("reported"); + expect(findWorkspaceInConfig(config, "running-orphan")?.taskStatus).toBe("running"); + expect(await ownerInput()).toBe("clicked"); + }); + test("shared desktop active mirror refuses a missing target or competing child", async () => { const config = await createTestConfig(rootDir); const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); diff --git a/src/node/services/workspaceTurnManager.ts b/src/node/services/workspaceTurnManager.ts index 0df5024a79..16b73f81b5 100644 --- a/src/node/services/workspaceTurnManager.ts +++ b/src/node/services/workspaceTurnManager.ts @@ -4840,9 +4840,7 @@ export class WorkspaceTurnManager { : referenced; const record = selected?.record; if (record == null) { - if (task.taskExecutionId != null) { - await this.updateAgentTaskExecutionState(task.id, task.taskExecutionId, null); - } + await this.clearUnbackedAgentTaskExecutionMirror(task.id, task.taskExecutionId); continue; } @@ -4858,9 +4856,7 @@ export class WorkspaceTurnManager { continue; } if (normalized?.workspaceId !== task.id) { - if (task.taskExecutionId != null) { - await this.updateAgentTaskExecutionState(task.id, task.taskExecutionId, null); - } + await this.clearUnbackedAgentTaskExecutionMirror(task.id, task.taskExecutionId); continue; } @@ -4922,6 +4918,36 @@ export class WorkspaceTurnManager { } } + /** + * Startup repair for an execution mirror with no backing handle record. With an ID the normal + * generation-guarded clear applies. Without one, the mirror is an orphan: every settlement path + * matches on taskExecutionId, so a stray active taskExecutionStatus could never settle — yet the + * desktop ledger reads it as live control of the owner's desktop. Only the mirror is dropped; + * the stable taskStatus is a separate activity source owned by TaskService recovery. + */ + private async clearUnbackedAgentTaskExecutionMirror( + workspaceId: string, + taskExecutionId: string | undefined + ): Promise { + if (taskExecutionId != null) { + await this.updateAgentTaskExecutionState(workspaceId, taskExecutionId, null); + return; + } + let clearedOrphan = false; + await this.taskHost.editWorkspaceEntry( + workspaceId, + (workspace) => { + if (workspace.taskExecutionId != null || workspace.taskExecutionStatus == null) return; + delete workspace.taskExecutionStatus; + clearedOrphan = true; + }, + { allowMissing: true } + ); + if (clearedOrphan) { + await this.taskHost.emitWorkspaceMetadata(workspaceId); + } + } + async updateAgentTaskExecutionState( workspaceId: string, handleId: string, From ffb8450040d846846fc8efa7378f6af7be90d39c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 15:27:43 +0000 Subject: [PATCH 22/25] =?UTF-8?q?=F0=9F=A4=96=20fix:=20re-validate=20deskt?= =?UTF-8?q?op=20admission=20inside=20the=20active=20execution=20mirror=20t?= =?UTF-8?q?ransaction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/workspaceTurnManager.test.ts | 67 ++++++++++++++++++- src/node/services/workspaceTurnManager.ts | 19 ++++-- 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/src/node/services/workspaceTurnManager.test.ts b/src/node/services/workspaceTurnManager.test.ts index 0f325a04b8..312c84ba69 100644 --- a/src/node/services/workspaceTurnManager.test.ts +++ b/src/node/services/workspaceTurnManager.test.ts @@ -13,6 +13,7 @@ import { import { ForegroundWaitBackgroundedError } from "@/node/services/taskService"; import { WorkspaceTurnManager } from "@/node/services/workspaceTurnManager"; import { DesktopInputCoordinator } from "@/node/services/desktop/DesktopInputCoordinator"; +import { findWorkspaceEntry } from "@/node/services/taskUtils"; import { MutexMap } from "@/node/utils/concurrency/mutexMap"; import { WorkflowRunStore } from "@/node/services/workflows/WorkflowRunStore"; import { Ok, Err, type Result } from "@/common/types/result"; @@ -165,7 +166,7 @@ function createWorkspaceTurnManagerHost( for (const project of cfg.projects.values()) { const workspace = project.workspaces.find((candidate) => candidate.id === workspaceId); if (workspace != null) { - updater(workspace); + updater(workspace, cfg); found = true; break; } @@ -348,6 +349,70 @@ describe("WorkspaceTurnManager", () => { expect(findWorkspaceInConfig(config, "child")?.taskExecutionStatus).toBe("completed"); }); + test("an active mirror commit rejects a competing controller written while admission was suspended", async () => { + const config = await createTestConfig(rootDir); + const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); + await config.editConfig((cfg) => { + cfg.projects.get(projectPath)!.workspaces.push( + ...["child", "competitor"].map((id) => + projectWorkspace(projectPath, id, id, { + parentWorkspaceId: parentId, + agentId: "explore", + taskStatus: "reported", + runtimeConfig: { type: "local" }, + taskDesktopOwnerWorkspaceId: parentId, + }) + ) + ); + return cfg; + }); + const { taskService } = createWorkspaceTurnManagerHarness(config); + // A reservation registered by createWorkspaceTurn: the acceptance below claims this handle. + ( + taskService as unknown as { + activeWorkspaceTurnHandleByWorkspaceId: Map< + string, + { handleId: string; ownerWorkspaceId: string; accepted: boolean } + >; + } + ).activeWorkspaceTurnHandleByWorkspaceId.set("child", { + handleId: "wst_child", + ownerWorkspaceId: parentId, + accepted: false, + }); + // The gate admitted the child against a config where nothing else was active. Publish an + // independent (gate-bypassing) competing controller after the mirror edit was scheduled but + // before its transform runs, as a concurrent process or unrelated writer could. + const editConfig = config.editConfig.bind(config); + let intercepted = false; + const editSpy = spyOn(config, "editConfig").mockImplementation(async (transform) => { + if (intercepted) return editConfig(transform); + intercepted = true; + await editConfig((cfg) => { + const competitor = findWorkspaceEntry(cfg, "competitor")?.workspace; + assert(competitor, "competitor fixture must exist"); + competitor.taskStatus = "running"; + return cfg; + }); + return editConfig(transform); + }); + try { + const failure = await taskService + .updateAgentTaskExecutionState("child", "wst_child", "running") + .then( + () => null, + (error: unknown) => (error instanceof Error ? error.message : String(error)) + ); + expect(failure).not.toBeNull(); + } finally { + editSpy.mockRestore(); + } + // Nothing from the rejected transaction reached disk; the competitor keeps control. + expect(findWorkspaceInConfig(config, "child")?.taskExecutionId).toBeUndefined(); + expect(findWorkspaceInConfig(config, "child")?.taskExecutionStatus).toBeUndefined(); + expect(findWorkspaceInConfig(config, "competitor")?.taskStatus).toBe("running"); + }); + test("startup clears an orphan execution mirror that has no handle ID and no handle record", async () => { const config = await createTestConfig(rootDir); const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); diff --git a/src/node/services/workspaceTurnManager.ts b/src/node/services/workspaceTurnManager.ts index 16b73f81b5..11926413c8 100644 --- a/src/node/services/workspaceTurnManager.ts +++ b/src/node/services/workspaceTurnManager.ts @@ -4840,7 +4840,7 @@ export class WorkspaceTurnManager { : referenced; const record = selected?.record; if (record == null) { - await this.clearUnbackedAgentTaskExecutionMirror(task.id, task.taskExecutionId); + await this.clearUnbackedAgentTaskExecutionMirror(task.id, task.taskExecutionId, config); continue; } @@ -4856,7 +4856,7 @@ export class WorkspaceTurnManager { continue; } if (normalized?.workspaceId !== task.id) { - await this.clearUnbackedAgentTaskExecutionMirror(task.id, task.taskExecutionId); + await this.clearUnbackedAgentTaskExecutionMirror(task.id, task.taskExecutionId, config); continue; } @@ -4927,12 +4927,18 @@ export class WorkspaceTurnManager { */ private async clearUnbackedAgentTaskExecutionMirror( workspaceId: string, - taskExecutionId: string | undefined + taskExecutionId: string | undefined, + snapshot: ReturnType ): Promise { if (taskExecutionId != null) { await this.updateAgentTaskExecutionState(workspaceId, taskExecutionId, null); return; } + // Decide from the startup snapshot so the common no-mirror case costs no config reload + // (initialize must not re-read config.json per completed-report task). + if (findWorkspaceEntry(snapshot, workspaceId)?.workspace.taskExecutionStatus == null) { + return; + } let clearedOrphan = false; await this.taskHost.editWorkspaceEntry( workspaceId, @@ -4976,7 +4982,7 @@ export class WorkspaceTurnManager { let claimedActiveMirror = false; const updated = await this.taskHost.editWorkspaceEntry( workspaceId, - (workspace) => { + (workspace, config) => { if (status == null) { if (workspace.taskExecutionId === handleId) { delete workspace.taskExecutionId; @@ -5009,6 +5015,11 @@ export class WorkspaceTurnManager { claimedActiveMirror = true; workspace.taskExecutionId = handleId; workspace.taskExecutionStatus = status; + // Cross-process/independent writes can land between the desktop gate's admission check + // and this transform. Re-validate against the config this transaction actually + // commits so a competing controller published meanwhile rejects the active mirror + // (terminal/clear branches stay ungated: releasing must always be allowed). + this.desktopInputCoordinator.assertAdmission(config, workspaceId); return; } if (workspace.taskExecutionId === handleId) { From 00042db755444bc1e2479659d62505046a8ee5ca Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 15:41:03 +0000 Subject: [PATCH 23/25] =?UTF-8?q?=F0=9F=A4=96=20fix:=20validate=20desktop?= =?UTF-8?q?=20admissions=20inside=20config=20transactions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revalidate task creation, reactivation, and plan handoff against the locked config snapshot so independent backends cannot persist competing desktop borrowers. Keep process-local input gates and use the existing cross-process config lock. Validation: deterministic two-backend task creation reproduced two successful reservations before the fix and one afterward; 600 task, execution, and coordinator tests passed; make static-check passed. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$308.58`_ --- .../desktop/DesktopInputCoordinator.ts | 8 +++ src/node/services/taskService.test.ts | 57 +++++++++++++++++++ src/node/services/taskService.ts | 29 +++++++--- src/node/services/taskWorkspaceSeam.ts | 5 +- 4 files changed, 91 insertions(+), 8 deletions(-) diff --git a/src/node/services/desktop/DesktopInputCoordinator.ts b/src/node/services/desktop/DesktopInputCoordinator.ts index 56d4852aa0..f26ae68f02 100644 --- a/src/node/services/desktop/DesktopInputCoordinator.ts +++ b/src/node/services/desktop/DesktopInputCoordinator.ts @@ -113,6 +113,14 @@ export class DesktopInputCoordinator { }); } + /** Recheck inside the config transaction: another backend has its own input gates. */ + assertAdmission(config: ProjectsConfig, workspaceId: string): void { + const workspace = findWorkspaceEntry(config, workspaceId)?.workspace; + if (workspace?.taskDesktopOwnerWorkspaceId === undefined) return; + const target = this.resolveFromConfig(config, workspaceId); + this.assertController(config, target.ownerWorkspaceId, workspaceId, false); + } + async withInput(workspaceId: string, run: () => Promise): Promise { const target = this.resolveTarget(workspaceId); return this.gates.withLock(target.ownerWorkspaceId, async () => { diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 4c2022236e..d6f8ec6b21 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -4061,6 +4061,63 @@ describe("TaskService", () => { } }); + test("shared desktop reservations from separate backends commit only one borrower", async () => { + const config = await createTestConfig(rootDir); + const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); + await config.editConfig((cfg) => { + cfg.taskSettings = testTaskSettings(1, 3); + cfg.projects.get(projectPath)!.workspaces.push( + projectWorkspace(projectPath, "busy", "busy", { + parentWorkspaceId: parentId, + taskStatus: "running", + agentId: "explore", + }) + ); + return cfg; + }); + const otherConfig = new Config(config.rootDir); + const services = [config, otherConfig].map((cfg) => createTaskServiceHarness(cfg).taskService); + let release!: () => void; + const bothReserved = new Promise((resolve) => { + release = resolve; + }); + let reservations = 0; + const results = await Promise.all( + services.map((service) => + service.createMany( + [ + { + parentWorkspaceId: parentId, + kind: "agent", + agentId: "explore", + prompt: "Inspect", + title: "Inspector", + desktop: "shared", + }, + ], + { + onTaskReserved: async () => { + reservations += 1; + if (reservations === services.length) release(); + // Both process-local gates have read an unreserved desktop before either writes. + await bothReserved; + }, + } + ) + ) + ); + expect(results.filter((result) => result.success)).toHaveLength(1); + const failure = results.find((result) => !result.success); + assert(failure != null && !failure.success); + expect(failure.error).toContain("active borrowers"); + expect( + config + .loadConfigOrDefault() + .projects.get(projectPath)! + .workspaces.filter((workspace) => workspace.taskDesktopOwnerWorkspaceId === parentId) + ).toHaveLength(1); + }); + test("shared desktop batch preserves distinct owners through queued reservation", async () => { const config = await createTestConfig(rootDir); const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 9fa2ba5dbc..516a72962e 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -2239,7 +2239,7 @@ export class TaskService implements AgentTaskIntegration { async editWorkspaceEntry( workspaceId: string, - updater: (workspace: WorkspaceConfigEntry) => void, + updater: (workspace: WorkspaceConfigEntry, config: ProjectsConfig) => void, options?: { allowMissing?: boolean } ): Promise { assert(workspaceId.length > 0, "editWorkspaceEntry: workspaceId must be non-empty"); @@ -2249,7 +2249,7 @@ export class TaskService implements AgentTaskIntegration { for (const [_projectPath, project] of config.projects) { const ws = project.workspaces.find((w) => w.id === workspaceId); if (!ws) continue; - updater(ws); + updater(ws, config); found = true; return config; } @@ -2794,7 +2794,14 @@ export class TaskService implements AgentTaskIntegration { ): Promise { // Admission protects only persistence. Never hold the desktop gate across nested sends. return await this.desktopInputCoordinator.withAdmission(workspaceId, () => - this.editWorkspaceEntry(workspaceId, updater, options) + this.editWorkspaceEntry( + workspaceId, + (workspace, config) => { + updater(workspace); + this.desktopInputCoordinator.assertAdmission(config, workspaceId); + }, + options + ) ); } @@ -3187,6 +3194,7 @@ export class TaskService implements AgentTaskIntegration { taskDesktopOwnerWorkspaceId: plan.taskDesktopOwnerWorkspaceId, projects: plan.parentMeta.projects, }); + this.desktopInputCoordinator.assertAdmission(config, plan.taskId); } return config; }); @@ -4049,6 +4057,7 @@ export class TaskService implements AgentTaskIntegration { taskDesktopOwnerWorkspaceId, projects: parentMeta.projects, }); + this.desktopInputCoordinator.assertAdmission(config, taskId); return config; }); }); @@ -4231,6 +4240,7 @@ export class TaskService implements AgentTaskIntegration { taskDesktopOwnerWorkspaceId, projects: inheritedProjects, }); + this.desktopInputCoordinator.assertAdmission(config, taskId); return config; }); @@ -10352,12 +10362,17 @@ export class TaskService implements AgentTaskIntegration { private async setTaskStatus(workspaceId: string, status: AgentTaskStatus): Promise { assert(workspaceId.length > 0, "setTaskStatus: workspaceId must be non-empty"); - await this.editWorkspaceEntry(workspaceId, (ws) => { - ws.taskStatus = status; + const update = (workspace: WorkspaceConfigEntry) => { + workspace.taskStatus = status; if (status === "running") { - ws.taskPrompt = undefined; + workspace.taskPrompt = undefined; } - }); + }; + if (ACTIVE_AGENT_TASK_STATUSES.has(status)) { + await this.editActiveWorkspaceEntry(workspaceId, update); + } else { + await this.editWorkspaceEntry(workspaceId, update); + } await this.emitWorkspaceMetadata(workspaceId); diff --git a/src/node/services/taskWorkspaceSeam.ts b/src/node/services/taskWorkspaceSeam.ts index 9c687545bb..15b51bc562 100644 --- a/src/node/services/taskWorkspaceSeam.ts +++ b/src/node/services/taskWorkspaceSeam.ts @@ -552,7 +552,10 @@ export interface WorkspaceTurnTaskHost { countActiveAgentTasks(config: ReturnType): number; editWorkspaceEntry( workspaceId: string, - updater: (workspace: WorkspaceConfigEntry) => void, + updater: ( + workspace: WorkspaceConfigEntry, + config: ReturnType + ) => void, options?: { allowMissing?: boolean } ): Promise; emitWorkspaceMetadata(workspaceId: string): Promise; From cb334a24011328e33e94b709c045d96f229a4970 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 15:53:35 +0000 Subject: [PATCH 24/25] =?UTF-8?q?=F0=9F=A4=96=20fix:=20revoke=20desktop=20?= =?UTF-8?q?viewers=20after=20external=20config=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Watch the config parent directory while viewers are pending or connected, revalidate their persisted requester/owner and session bindings on changes, and fail closed if watching fails. Retain immediate local revocation and close the validation-to-watch-install race before starting TCP connection. Real cross-backend filesystem tests run the backend under Node, matching production, to avoid Bun idle fs.watch event-loss artifacts. All 72 desktop tests, scoped lint/format checks and both TypeScript configurations pass. --- .../DesktopBridgeServer.nodeFixture.ts | 89 ++++++ .../desktop/DesktopBridgeServer.test.ts | 294 +++++++++++++++++- .../services/desktop/DesktopBridgeServer.ts | 69 +++- .../desktop/DesktopSessionManager.test.ts | 45 +++ .../services/desktop/DesktopSessionManager.ts | 42 +++ 5 files changed, 520 insertions(+), 19 deletions(-) create mode 100644 src/node/services/desktop/DesktopBridgeServer.nodeFixture.ts diff --git a/src/node/services/desktop/DesktopBridgeServer.nodeFixture.ts b/src/node/services/desktop/DesktopBridgeServer.nodeFixture.ts new file mode 100644 index 0000000000..655e4aefe6 --- /dev/null +++ b/src/node/services/desktop/DesktopBridgeServer.nodeFixture.ts @@ -0,0 +1,89 @@ +// Run the real watcher under Node, like the desktop/server backend. Bun's fs.watch can drop +// atomic-replace notifications while its test loop is idle; Bun still drives the external writer. +import * as http from "node:http"; +import * as net from "node:net"; +import { once } from "node:events"; +import assert from "node:assert/strict"; +import { Config } from "@/node/config"; +import type { ExperimentsService } from "@/node/services/experimentsService"; +import type { WorkspaceService } from "@/node/services/workspaceService"; +import { DesktopBridgeServer } from "./DesktopBridgeServer"; +import { DesktopSessionManager } from "./DesktopSessionManager"; +import { DesktopTokenManager } from "./DesktopTokenManager"; + +async function run(): Promise { + const rootDir = process.argv[2]; + assert(rootDir, "Bridge fixture requires a config root"); + const tcp = net.createServer((socket) => { + socket.on("error", () => socket.destroy()); + socket.on("data", (data) => socket.write(data)); + socket.write(Buffer.from([0])); + }); + tcp.listen(0, "127.0.0.1"); + await once(tcp, "listening"); + const tcpAddress = tcp.address(); + assert(tcpAddress && typeof tcpAddress !== "string"); + const vncPort = tcpAddress.port; + + // Only the PortableDesktop transport is replaced: target resolution, config watching, + // WebSocket authentication/revocation and TCP forwarding use the production services. + class FixtureSessionManager extends DesktopSessionManager { + override getLiveSessionConnection(workspaceId: string) { + try { + const { ownerWorkspaceId } = this.resolveTarget(workspaceId); + return { + ownerWorkspaceId, + sessionId: `session:${ownerWorkspaceId}`, + vncPort, + }; + } catch { + return null; + } + } + } + const experimentsService: Partial = { isExperimentEnabled: () => true }; + const workspaceService: Partial = { getInfo: () => Promise.resolve(null) }; + const manager = new FixtureSessionManager({ + config: new Config(rootDir), + experimentsService: experimentsService as ExperimentsService, + workspaceService: workspaceService as WorkspaceService, + }); + const tokens = new DesktopTokenManager(); + const bridge = new DesktopBridgeServer({ + desktopSessionManager: manager, + desktopTokenManager: tokens, + }); + const server = http.createServer((request, response) => { + const workspaceId = new URL(request.url ?? "/", "http://127.0.0.1").searchParams.get( + "workspaceId" + ); + const session = workspaceId ? manager.getLiveSessionConnection(workspaceId) : null; + if (!workspaceId || !session) { + response.writeHead(404).end(); + return; + } + response.end(tokens.mint(workspaceId, session.sessionId)); + }); + server.on("upgrade", (request, socket, head) => bridge.handleUpgrade(request, socket, head)); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + assert(address && typeof address !== "string"); + const stopped = once(process, "message"); + process.send?.({ port: address.port }); + await stopped; + await bridge.stop(); + await manager.closeAll(); + tokens.dispose(); + await Promise.all([ + new Promise((resolve) => server.close(() => resolve())), + new Promise((resolve) => tcp.close(() => resolve())), + ]); + process.disconnect?.(); +} + +run().catch((error) => { + console.error(error); + process.exitCode = 1; + process.disconnect?.(); +}); diff --git a/src/node/services/desktop/DesktopBridgeServer.test.ts b/src/node/services/desktop/DesktopBridgeServer.test.ts index 4ac5478bb1..d8130dd556 100644 --- a/src/node/services/desktop/DesktopBridgeServer.test.ts +++ b/src/node/services/desktop/DesktopBridgeServer.test.ts @@ -5,6 +5,10 @@ import * as os from "node:os"; import * as path from "node:path"; import { describe, expect, mock, spyOn, test } from "bun:test"; import { Config } from "@/node/config"; +import { DisposableProcess } from "@/node/utils/disposableExec"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { build } from "esbuild"; import type { ExperimentsService } from "@/node/services/experimentsService"; import type { WorkspaceService } from "@/node/services/workspaceService"; import { DesktopSessionManager } from "./DesktopSessionManager"; @@ -71,6 +75,7 @@ function createBridgeServer(options: { return live ? { ...live, ownerWorkspaceId: workspaceId } : null; }, onWorkspaceClose: () => () => undefined, + watchWorkspaceConfig: () => () => undefined, }, }); } @@ -318,14 +323,7 @@ async function waitForTcpData(socket: net.Socket, timeoutMs = 2_000): Promise Promise; - }) => Promise -): Promise { - const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "desktop-bridge-revocation-")); +async function createBridgeConfig(rootDir: string): Promise { const config = new Config(rootDir); await config.editConfig((current) => { current.projects.set(rootDir, { @@ -340,6 +338,20 @@ async function withSharedBridge( }); return current; }); + return config; +} + +async function withSharedBridge( + run: (harness: { + manager: DesktopSessionManager; + bridge: DesktopBridgeServer; + config: Config; + closed: (ws: WebSocket) => Promise<{ code: number; reason: string }>; + connect: (workspaceId: string, waitForVnc?: boolean) => Promise; + }) => Promise +): Promise { + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "desktop-bridge-revocation-")); + const config = await createBridgeConfig(rootDir); const experimentsService: Partial = { isExperimentEnabled: () => true }; const workspaceService: Partial = { getInfo: () => Promise.resolve(null) }; const manager = new DesktopSessionManager({ @@ -372,17 +384,23 @@ async function withSharedBridge( desktopTokenManager: tokens, }); const upgrade = await listenUpgradeServer(bridge); - const clients: WebSocket[] = []; + const clients = new Map>(); try { await run({ manager, bridge, + config, + closed: (ws) => { + const closed = clients.get(ws); + if (!closed) throw new Error("Unknown test viewer"); + return closed; + }, connect: async (workspaceId, waitForVnc = true) => { const live = manager.getLiveSessionConnection(workspaceId); if (!live) throw new Error("Expected live test connection"); const token = tokens.mint(workspaceId, live.sessionId); const ws = new WebSocket(`ws://127.0.0.1:${upgrade.port}/?token=${token}`); - clients.push(ws); + clients.set(ws, waitForWebSocketClose(ws)); ws.on("message", (_data, isBinary) => expect(isBinary).toBe(true)); const greeting = waitForVnc ? waitForWebSocketMessage(ws) : null; await waitForWebSocketOpen(ws); @@ -392,7 +410,7 @@ async function withSharedBridge( }); } finally { await bridge.stop(); - await Promise.all(clients.map(closeWebSocket)); + await Promise.all([...clients.keys()].map(closeWebSocket)); await upgrade.close(); await tcp.close(); tokens.dispose(); @@ -402,6 +420,74 @@ async function withSharedBridge( } } +async function withNodeBridge( + run: (config: Config, connect: (workspaceId: string) => Promise) => Promise +): Promise { + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "desktop-node-bridge-")); + const config = await createBridgeConfig(rootDir); + const fixturePath = path.join(rootDir, "bridge.mjs"); + await fs.symlink(path.resolve("node_modules"), path.join(rootDir, "node_modules"), "junction"); + await build({ + entryPoints: [path.resolve("src/node/services/desktop/DesktopBridgeServer.nodeFixture.ts")], + bundle: true, + platform: "node", + target: "node20", + format: "esm", + packages: "external", + outfile: fixturePath, + banner: { + js: 'import { createRequire as fixtureCreateRequire } from "node:module"; const require = fixtureCreateRequire(import.meta.url);', + }, + }); + using child = new DisposableProcess( + spawn("node", [fixturePath, rootDir], { + stdio: ["ignore", "pipe", "pipe", "ipc"], + windowsHide: true, + }) + ); + const exited = once(child.underlying, "exit"); + let stderr = ""; + child.underlying.stderr?.on("data", (data) => { + stderr += String(data); + }); + const clients: WebSocket[] = []; + try { + const ready = await Promise.race([ + once(child.underlying, "message").then((args: unknown[]) => args[0]), + exited.then(() => { + throw new Error(`Node bridge exited before ready: ${stderr}`); + }), + ]); + if ( + typeof ready !== "object" || + ready === null || + !("port" in ready) || + typeof ready.port !== "number" + ) { + throw new Error("Expected Node bridge port"); + } + const baseUrl = `http://127.0.0.1:${ready.port}`; + await run(config, async (workspaceId) => { + const response = await fetch(`${baseUrl}/?workspaceId=${encodeURIComponent(workspaceId)}`); + expect(response.status).toBe(200); + const ws = new WebSocket( + `${baseUrl.replace("http:", "ws:")}/?token=${await response.text()}` + ); + clients.push(ws); + ws.on("message", (_data, isBinary) => expect(isBinary).toBe(true)); + const greeting = waitForWebSocketMessage(ws); + await waitForWebSocketOpen(ws); + expect(await greeting).toEqual(Buffer.from([0])); + return ws; + }); + } finally { + if (child.underlying.connected) child.underlying.send("stop"); + await exited; + await Promise.all(clients.map(closeWebSocket)); + await fs.rm(rootDir, { recursive: true, force: true }); + } +} + async function expectEcho(ws: WebSocket): Promise { const echoed = waitForWebSocketMessage(ws); ws.send(Buffer.from([1, 2, 3])); @@ -443,9 +529,163 @@ describe("DesktopBridgeServer", () => { }); } - for (const cleanup of ["child", "owner", "all", "guard"] as const) { + for (const changedWorkspaceId of ["child", "owner"]) { + for (const change of ["archive", "remove"] as const) { + test(`another backend's ${change} of ${changedWorkspaceId} revokes idle affected viewers`, async () => { + await withNodeBridge(async (config, connect) => { + const owner = await connect("owner"); + const child = await connect("child"); + const unrelated = await connect("unrelated"); + const childClosed = waitForWebSocketClose(child); + const ownerClosed = changedWorkspaceId === "owner" ? waitForWebSocketClose(owner) : null; + // This Config instance lives outside the Node backend: no in-process close hook fires. + await config.editConfig((current) => { + const project = current.projects.get(config.rootDir); + if (!project) throw new Error("Missing test project"); + if (change === "remove") { + project.workspaces = project.workspaces.filter( + (workspace) => workspace.id !== changedWorkspaceId + ); + } else { + const workspace = project.workspaces.find( + (workspace) => workspace.id === changedWorkspaceId + ); + if (!workspace) throw new Error("Missing test workspace"); + workspace.archivedAt = "2026-09-04T12:00:00Z"; + } + return current; + }); + // No client frame is sent: even an idle viewer must be revoked by persisted state. + expect((await childClosed).code).toBe(4002); + if (ownerClosed) expect((await ownerClosed).code).toBe(4002); + else await expectEcho(owner); + await expectEcho(unrelated); + }); + }); + } + } + + test("watch setup failures reject viewers and watcher errors revoke every active viewer", async () => { + await withSharedBridge(async ({ manager, bridge, connect, closed: closedEvent }) => { + const stop = mock(() => undefined); + let failWatch!: (error: unknown) => void; + const watch = spyOn(manager, "watchWorkspaceConfig").mockImplementation( + (_change, onError) => { + failWatch = onError; + return stop; + } + ); + try { + const clients = await Promise.all([ + connect("owner"), + connect("child"), + connect("unrelated"), + ]); + expect(watch).toHaveBeenCalledTimes(1); + const closed = clients.map(waitForWebSocketClose); + failWatch(new Error("watch lost")); + for (const result of await Promise.all(closed)) expect(result.code).toBe(4002); + expect(stop).toHaveBeenCalledTimes(1); + + watch.mockImplementation(() => { + throw new Error("watch unavailable"); + }); + const rejected = await connect("child", false); + expect((await closedEvent(rejected)).code).toBe(4002); + await bridge.stop(); + expect(stop).toHaveBeenCalledTimes(1); + } finally { + watch.mockRestore(); + } + }); + }); + + test("rechecks a target changed before watcher installation without waiting for TCP", async () => { + await withSharedBridge(async ({ manager, bridge, connect, closed }) => { + const stop = mock(() => undefined); + const watch = spyOn(manager, "watchWorkspaceConfig").mockImplementation(() => { + // The change predates the watch, so no filesystem notification will arrive for it. + manager.setWorkspaceArchiveGuard((workspaceId) => workspaceId === "child"); + return stop; + }); + const internal = bridge as unknown as { connectToVnc: () => Promise }; + const connecting = spyOn(internal, "connectToVnc"); + try { + const ws = await connect("child", false); + expect((await closed(ws)).code).toBe(4002); + expect(connecting).not.toHaveBeenCalled(); + expect(stop).toHaveBeenCalledTimes(1); + } finally { + watch.mockRestore(); + connecting.mockRestore(); + } + }); + }); + + test("config revalidation deduplicates requesters and checks owner, session and port bindings", async () => { + await withSharedBridge(async ({ manager, bridge, connect }) => { + let changed!: () => void; + const stop = mock(() => undefined); + const watch = spyOn(manager, "watchWorkspaceConfig").mockImplementation((onChange) => { + changed = onChange; + return stop; + }); + const initialConnections = new Map( + ["owner", "child", "unrelated"].map((workspaceId) => [ + workspaceId, + manager.getLiveSessionConnection(workspaceId), + ]) + ); + let childOverride: ReturnType | undefined; + const lookup = spyOn(manager, "getLiveSessionConnection").mockImplementation((workspaceId) => + workspaceId === "child" && childOverride !== undefined + ? childOverride + : (initialConnections.get(workspaceId) ?? null) + ); + try { + const owner = await connect("owner"); + const unrelated = await connect("unrelated"); + for (const field of ["ownerWorkspaceId", "sessionId", "vncPort"] as const) { + childOverride = undefined; + const child = await connect("child"); + const duplicate = await connect("child"); + const current = initialConnections.get("child"); + if (!current) throw new Error("Expected child connection"); + const childClosed = [child, duplicate].map(waitForWebSocketClose); + childOverride = + field === "vncPort" + ? { ...current, vncPort: current.vncPort + 1 } + : { ...current, [field]: "changed" }; + lookup.mockClear(); + changed(); + expect(lookup.mock.calls.filter(([id]) => id === "child")).toHaveLength(1); + for (const result of await Promise.all(childClosed)) expect(result.code).toBe(4002); + await expectEcho(owner); + await expectEcho(unrelated); + } + const closed = [owner, unrelated].map(waitForWebSocketClose); + await bridge.stop(); + await Promise.all(closed); + expect(stop).toHaveBeenCalledTimes(1); + } finally { + lookup.mockRestore(); + watch.mockRestore(); + } + }); + }); + + for (const cleanup of ["child", "owner", "all", "guard", "config", "watch-error"] as const) { test(`${cleanup} cleanup refuses a late TCP connection without leaking a subscription`, async () => { - await withSharedBridge(async ({ manager, bridge, connect }) => { + await withSharedBridge(async ({ manager, bridge, config, connect }) => { + let changed!: () => void; + let failed!: (error: unknown) => void; + const watch = spyOn(manager, "watchWorkspaceConfig").mockImplementation( + (onChange, onError) => { + changed = onChange; + failed = onError; + return () => undefined; + } + ); interface ConnectingBridge { connectToVnc: (port: number, signal: AbortSignal) => Promise; } @@ -464,7 +704,21 @@ describe("DesktopBridgeServer", () => { const tcp = await connected.promise; const tcpClosed = new Promise((resolve) => tcp.once("close", () => resolve())); const closed = waitForWebSocketClose(ws); - if (cleanup === "guard") { + if (cleanup === "config") { + await config.editConfig((current) => { + const child = current.projects + .get(config.rootDir) + ?.workspaces.find((workspace) => workspace.id === "child"); + if (!child) throw new Error("Missing child"); + child.archivedAt = "2026-09-04T12:00:00Z"; + return current; + }); + changed(); + expect((await closed).code).toBe(4002); + } else if (cleanup === "watch-error") { + failed(new Error("watch lost")); + expect((await closed).code).toBe(4002); + } else if (cleanup === "guard") { manager.setWorkspaceArchiveGuard((workspaceId) => workspaceId === "child"); } else if (cleanup === "all") { await manager.closeAll(); @@ -484,6 +738,7 @@ describe("DesktopBridgeServer", () => { } finally { release.resolve(); pending.mockRestore(); + watch.mockRestore(); } }); }); @@ -500,7 +755,11 @@ describe("DesktopBridgeServer", () => { ); const bridgeServer = new DesktopBridgeServer({ desktopTokenManager: tokens, - desktopSessionManager: { getLiveSessionConnection, onWorkspaceClose: () => () => undefined }, + desktopSessionManager: { + getLiveSessionConnection, + onWorkspaceClose: () => () => undefined, + watchWorkspaceConfig: () => () => undefined, + }, }); const upgradeHarness = await listenUpgradeServer(bridgeServer); let ws: WebSocket | null = null; @@ -513,6 +772,7 @@ describe("DesktopBridgeServer", () => { expect(getLiveSessionConnection.mock.calls.map((call) => call[0])).toEqual([ "child", "child", + "child", ]); const replay = new WebSocket(`ws://127.0.0.1:${upgradeHarness.port}/?token=${token}`); expect((await waitForWebSocketClose(replay)).code).toBe(4001); @@ -531,14 +791,14 @@ describe("DesktopBridgeServer", () => { const bridgeServer = createBridgeServer({ getLiveSessionConnection: () => { checks += 1; - return checks === 1 ? { sessionId: VALID_SESSION_ID, vncPort: tcpHarness.port } : null; + return checks <= 2 ? { sessionId: VALID_SESSION_ID, vncPort: tcpHarness.port } : null; }, }); const upgradeHarness = await listenUpgradeServer(bridgeServer); try { const ws = new WebSocket(`ws://127.0.0.1:${upgradeHarness.port}/?token=${VALID_TOKEN}`); expect((await waitForWebSocketClose(ws)).code).toBe(4002); - expect(checks).toBe(2); + expect(checks).toBe(3); } finally { await upgradeHarness.close(); await bridgeServer.stop(); diff --git a/src/node/services/desktop/DesktopBridgeServer.ts b/src/node/services/desktop/DesktopBridgeServer.ts index d40fe7de4e..3b8530cc13 100644 --- a/src/node/services/desktop/DesktopBridgeServer.ts +++ b/src/node/services/desktop/DesktopBridgeServer.ts @@ -18,6 +18,8 @@ interface BridgePair { tcp: net.Socket | null; requesterWorkspaceId: string; ownerWorkspaceId: string; + sessionId: string; + vncPort: number; connectAbort: AbortController; unsubscribeClose?: () => void; closed: boolean; @@ -26,7 +28,7 @@ interface BridgePair { export interface DesktopBridgeServerOptions { desktopSessionManager: Pick< DesktopSessionManager, - "getLiveSessionConnection" | "onWorkspaceClose" + "getLiveSessionConnection" | "onWorkspaceClose" | "watchWorkspaceConfig" >; desktopTokenManager: Pick; } @@ -101,11 +103,12 @@ async function waitForWebSocketClose(ws: WebSocket, timeoutMs = 250): Promise; private readonly desktopTokenManager: Pick; private readonly wss: WebSocketServer; private readonly activePairs = new Set(); + private stopConfigWatch: (() => void) | undefined; // Keep upgrade rejection aligned with stop() so httpServer.close() cannot hang on sockets // that reconnect after shutdown snapshots the current bridge clients. private isStopping = false; @@ -229,6 +232,8 @@ export class DesktopBridgeServer { tcp: null, requesterWorkspaceId: payload.workspaceId, ownerWorkspaceId: liveSession.ownerWorkspaceId, + sessionId: liveSession.sessionId, + vncPort: liveSession.vncPort, connectAbort: new AbortController(), closed: false, }; @@ -255,6 +260,21 @@ export class DesktopBridgeServer { }); try { + if (!this.stopConfigWatch) { + try { + this.stopConfigWatch = this.desktopSessionManager.watchWorkspaceConfig( + () => this.revalidateConnections(), + (error) => this.revokeForConfigWatchFailure(error) + ); + // Catch a persisted change between the initial lookup and watcher installation, + // even if the TCP connection would never finish to perform its later recheck. + this.revalidateConnections(); + if (pair.closed) return; + } catch (error) { + this.revokeForConfigWatchFailure(error); + return; + } + } const tcp = await this.connectToVnc(liveSession.vncPort, pair.connectAbort.signal); if (pair.closed) { tcp.destroy(); @@ -302,6 +322,46 @@ export class DesktopBridgeServer { } } + private revalidateConnections(): void { + try { + const connections = new Map< + string, + ReturnType + >(); + for (const pair of this.activePairs) { + if (!connections.has(pair.requesterWorkspaceId)) { + connections.set( + pair.requesterWorkspaceId, + this.desktopSessionManager.getLiveSessionConnection(pair.requesterWorkspaceId) + ); + } + const current = connections.get(pair.requesterWorkspaceId); + if ( + current?.ownerWorkspaceId !== pair.ownerWorkspaceId || + current.sessionId !== pair.sessionId || + current.vncPort !== pair.vncPort + ) { + this.cleanupPair(pair, { + closeCode: MISSING_SESSION_CLOSE_CODE, + closeReason: "session unavailable", + }); + } + } + } catch (error) { + this.revokeForConfigWatchFailure(error); + } + } + + private revokeForConfigWatchFailure(error: unknown): void { + log.warn("DesktopBridgeServer: config watching failed; revoking viewers", { error }); + for (const pair of this.activePairs) { + this.cleanupPair(pair, { + closeCode: MISSING_SESSION_CLOSE_CODE, + closeReason: "session unavailable", + }); + } + } + private async connectToVnc(port: number, signal: AbortSignal): Promise { assert(Number.isInteger(port), "DesktopBridgeServer VNC port must be an integer"); assert(port > 0, "DesktopBridgeServer VNC port must be positive"); @@ -434,6 +494,11 @@ export class DesktopBridgeServer { pair.closed = true; this.activePairs.delete(pair); + if (this.activePairs.size === 0) { + const stopConfigWatch = this.stopConfigWatch; + this.stopConfigWatch = undefined; + stopConfigWatch?.(); + } pair.unsubscribeClose?.(); pair.connectAbort.abort(); diff --git a/src/node/services/desktop/DesktopSessionManager.test.ts b/src/node/services/desktop/DesktopSessionManager.test.ts index 35fedb2a11..a9184ee478 100644 --- a/src/node/services/desktop/DesktopSessionManager.test.ts +++ b/src/node/services/desktop/DesktopSessionManager.test.ts @@ -1,4 +1,5 @@ import * as fs from "fs/promises"; +import * as nodeFs from "node:fs"; import * as os from "os"; import * as path from "path"; import { describe, expect, spyOn, test } from "bun:test"; @@ -547,6 +548,50 @@ describe("DesktopSessionManager", () => { }); }); + for (const event of ["error", "close"] as const) { + test(`config watcher ${event} fails closed once and explicit disposal does not`, async () => { + await withDesktopManagerHarness(({ config, tempDir }) => { + const manager = new DesktopSessionManager({ + config, + experimentsService: createExperimentsService(true), + workspaceService: createWorkspaceService(() => Promise.resolve(null)), + }); + const watcher = nodeFs.watch(tempDir, { persistent: false }); + const watch = spyOn(nodeFs, "watch").mockReturnValue(watcher); + const failures: unknown[] = []; + const stop = manager.watchWorkspaceConfig( + () => undefined, + (error) => failures.push(error) + ); + try { + watcher.emit(event, new Error("watch lost")); + expect(failures).toHaveLength(1); + stop(); + expect(failures).toHaveLength(1); + } finally { + stop(); + watch.mockRestore(); + } + + const cleanWatch = nodeFs.watch(tempDir, { persistent: false }); + const cleanSpy = spyOn(nodeFs, "watch").mockReturnValue(cleanWatch); + try { + const dispose = manager.watchWorkspaceConfig( + () => undefined, + (error) => failures.push(error) + ); + dispose(); + cleanWatch.emit("close"); + expect(failures).toHaveLength(1); + } finally { + cleanWatch.close(); + cleanSpy.mockRestore(); + } + return Promise.resolve(); + }); + }); + } + test("reports machine-level prereqs without consulting workspace metadata when the binary is missing", async () => { await withDesktopManagerHarness(async ({ config }) => { process.env.PATH = ""; diff --git a/src/node/services/desktop/DesktopSessionManager.ts b/src/node/services/desktop/DesktopSessionManager.ts index 90a305a460..9454761987 100644 --- a/src/node/services/desktop/DesktopSessionManager.ts +++ b/src/node/services/desktop/DesktopSessionManager.ts @@ -1,3 +1,5 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; import { DESKTOP_DEFAULTS } from "@/common/constants/desktop"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import type { @@ -234,6 +236,46 @@ export class DesktopSessionManager { ); } + watchWorkspaceConfig(onChange: () => void, onError: (error: unknown) => void): () => void { + // Watch the directory: Config replaces config.json atomically, so watching the file's + // inode would silently miss subsequent writes from another backend. + let closed = false; + let queued = false; + const watcher = fs.watch( + this.deps.config.rootDir, + { persistent: false }, + (_event, filename) => { + if (closed) return; + if (filename === path.basename(this.deps.config.rootDir)) { + fail(new Error("Desktop config directory was moved or removed")); + } else if ((filename == null || filename === "config.json") && !queued) { + queued = true; + queueMicrotask(() => { + queued = false; + if (!closed) onChange(); + }); + } + } + ); + const stop = () => { + if (closed) return; + closed = true; + try { + watcher.close(); + } catch (error) { + log.debug("Desktop config watcher cleanup failed", { error }); + } + }; + const fail = (error: unknown) => { + if (closed) return; + stop(); + onError(error); + }; + watcher.on("error", fail); + watcher.on("close", () => fail(new Error("Desktop config watcher closed unexpectedly"))); + return stop; + } + /** A null workspace ID revokes all viewers, including pending bridge connections. */ onWorkspaceClose(listener: (workspaceId: string | null) => void): () => void { this.closeListeners.add(listener); From 633c64a78cdc7e2e4775a839b342ef4ad1736e32 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 16:16:21 +0000 Subject: [PATCH 25/25] =?UTF-8?q?=F0=9F=A4=96=20fix:=20serialize=20desktop?= =?UTF-8?q?=20input=20and=20admission=20across=20backends?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the owner gate with the existing transient process-file-lock primitive so another backend cannot admit a new controller before an in-flight input finishes. Keep config as the ownership ledger and retain sorted multi-owner acquisition, automatic release, and unlocked screenshots/viewers. Validation: both cross-backend input/admission regressions failed before the change and pass afterward; 678 combined regressions, 420 coordinator stress cases, make static-check, and independent review passed. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$336.29`_ --- src/common/constants/desktop.ts | 2 + .../desktop/DesktopInputCoordinator.test.ts | 44 ++++++++++++++++++- .../desktop/DesktopInputCoordinator.ts | 25 +++++++++-- src/node/services/taskService.test.ts | 12 ++--- 4 files changed, 69 insertions(+), 14 deletions(-) diff --git a/src/common/constants/desktop.ts b/src/common/constants/desktop.ts index a4c0f510e0..1120e6ce35 100644 --- a/src/common/constants/desktop.ts +++ b/src/common/constants/desktop.ts @@ -20,6 +20,8 @@ export const DESKTOP_DEFAULTS = { RECONNECT_BASE_DELAY_MS: 1_000, /** Maximum delay (ms) between reconnect attempts (caps exponential backoff). */ RECONNECT_MAX_DELAY_MS: 30_000, + /** Maximum time (ms) to wait for another backend's desktop input/admission. */ + INPUT_LOCK_TIMEOUT_MS: 60_000, /** Maximum time (ms) to wait for an action command. */ ACTION_TIMEOUT_MS: 10_000, } as const; diff --git a/src/node/services/desktop/DesktopInputCoordinator.test.ts b/src/node/services/desktop/DesktopInputCoordinator.test.ts index 1d10e0e52c..add3e2ac5e 100644 --- a/src/node/services/desktop/DesktopInputCoordinator.test.ts +++ b/src/node/services/desktop/DesktopInputCoordinator.test.ts @@ -33,7 +33,8 @@ const borrower = (id: string, fields: Partial = {}) => async function withCoordinator( run: ( coordinator: DesktopInputCoordinator, - write: (workspaces: Workspace[]) => Promise + write: (workspaces: Workspace[]) => Promise, + config: Config ) => Promise ) { const root = await fs.mkdtemp(path.join(os.tmpdir(), "desktop-coordinator-")); @@ -46,7 +47,7 @@ async function withCoordinator( }; try { await write([owner, borrower("child")]); - await run(new DesktopInputCoordinator(config), write); + await run(new DesktopInputCoordinator(config), write, config); } finally { await fs.rm(root, { recursive: true, force: true }); } @@ -273,6 +274,45 @@ describe("DesktopInputCoordinator", () => { }); }); + test.each(["admission", "reservation"] as const)( + "another backend's %s waits for the owner's in-flight input", + async (mode) => { + await withCoordinator(async (coordinator, write, config) => { + const other = new DesktopInputCoordinator(new Config(config.rootDir)); + const entered = deferred(); + const release = deferred(); + const events: string[] = []; + const input = coordinator.withInput("owner", async () => { + entered.resolve(); + await release.promise; + events.push("input finished"); + }); + await entered.promise; + const admit = async () => { + events.push("admitted"); + await write([owner, borrower("child", { taskStatus: "running" })]); + }; + const admission = + mode === "admission" + ? other.withAdmission("child", admit) + : other.withReservation("owner", "child", admit); + try { + // Flush the independent coordinator's local gate; it must still wait for owner input. + await Promise.resolve(); + expect(events).toEqual([]); + } finally { + release.resolve(); + await Promise.all([input, admission]); + } + expect(events).toEqual(["input finished", "admitted"]); + expect(await other.withInput("child", () => Promise.resolve("clicked"))).toBe("clicked"); + expect(coordinator.withInput("owner", () => Promise.resolve())).rejects.toThrow( + "controlled by" + ); + }); + } + ); + test("overlapping reservations observe persisted winners and permit the same borrower", async () => { await withCoordinator(async (coordinator, write) => { const entered = deferred(); diff --git a/src/node/services/desktop/DesktopInputCoordinator.ts b/src/node/services/desktop/DesktopInputCoordinator.ts index f26ae68f02..7875120e81 100644 --- a/src/node/services/desktop/DesktopInputCoordinator.ts +++ b/src/node/services/desktop/DesktopInputCoordinator.ts @@ -1,4 +1,8 @@ import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import * as path from "node:path"; +import { DESKTOP_DEFAULTS } from "@/common/constants/desktop"; +import { acquireProcessFileLock } from "@/node/utils/concurrency/fileLock"; import type { ProjectsConfig, Workspace } from "@/common/types/project"; import { isWorkspaceArchived } from "@/common/utils/archive"; import type { Config } from "@/node/config"; @@ -82,7 +86,7 @@ export class DesktopInputCoordinator { const lockNext = (index: number): Promise => { const ownerId = ownerIds[index]; if (ownerId !== undefined) { - return this.gates.withLock(ownerId, () => lockNext(index + 1)); + return this.withOwnerGate(ownerId, () => lockNext(index + 1)); } const config = this.config.loadConfigOrDefault(); for (const [ownerWorkspaceId, borrowerWorkspaceId] of borrowers) { @@ -105,7 +109,7 @@ export class DesktopInputCoordinator { // Non-desktop/legacy tasks retain their existing admission behavior, including remote runtimes. if (entry?.workspace.taskDesktopOwnerWorkspaceId === undefined) return admit(); const target = this.resolveTarget(workspaceId); - return this.gates.withLock(target.ownerWorkspaceId, async () => { + return this.withOwnerGate(target.ownerWorkspaceId, async () => { const config = this.config.loadConfigOrDefault(); this.assertSameTarget(config, workspaceId, target.ownerWorkspaceId); this.assertController(config, target.ownerWorkspaceId, workspaceId, false); @@ -123,7 +127,7 @@ export class DesktopInputCoordinator { async withInput(workspaceId: string, run: () => Promise): Promise { const target = this.resolveTarget(workspaceId); - return this.gates.withLock(target.ownerWorkspaceId, async () => { + return this.withOwnerGate(target.ownerWorkspaceId, async () => { const config = this.config.loadConfigOrDefault(); this.assertSameTarget(config, workspaceId, target.ownerWorkspaceId); this.assertController(config, target.ownerWorkspaceId, workspaceId, true); @@ -131,6 +135,21 @@ export class DesktopInputCoordinator { }); } + private withOwnerGate(ownerWorkspaceId: string, run: () => Promise): Promise { + return this.gates.withLock(ownerWorkspaceId, async () => { + // The transient mutex spans input completion as well as admission in other backends. + // Keep it outside the config transaction; unrelated config writes must remain unblocked. + const ownerKey = createHash("sha256").update(ownerWorkspaceId).digest("hex"); + await using lock = await acquireProcessFileLock({ + lockPath: path.join(this.config.rootDir, "locks", `desktop-input-${ownerKey}.lock`), + timeoutMs: DESKTOP_DEFAULTS.INPUT_LOCK_TIMEOUT_MS, + label: "desktop input lock", + }); + await lock.assertStillOwned(); + return await run(); + }); + } + private assertSameTarget(config: ProjectsConfig, workspaceId: string, ownerWorkspaceId: string) { if (this.resolveFromConfig(config, workspaceId).ownerWorkspaceId !== ownerWorkspaceId) { throw new Error(`Desktop target changed for workspace ${workspaceId}`); diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index d6f8ec6b21..54673e6c66 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -4077,10 +4077,6 @@ describe("TaskService", () => { }); const otherConfig = new Config(config.rootDir); const services = [config, otherConfig].map((cfg) => createTaskServiceHarness(cfg).taskService); - let release!: () => void; - const bothReserved = new Promise((resolve) => { - release = resolve; - }); let reservations = 0; const results = await Promise.all( services.map((service) => @@ -4096,20 +4092,18 @@ describe("TaskService", () => { }, ], { - onTaskReserved: async () => { + onTaskReserved: () => { reservations += 1; - if (reservations === services.length) release(); - // Both process-local gates have read an unreserved desktop before either writes. - await bothReserved; }, } ) ) ); expect(results.filter((result) => result.success)).toHaveLength(1); + expect(reservations).toBe(1); const failure = results.find((result) => !result.success); assert(failure != null && !failure.success); - expect(failure.error).toContain("active borrowers"); + expect(failure.error).toContain("controlled by active borrower"); expect( config .loadConfigOrDefault()