From b6f489d326e16672a310e2659f237c88ab7fc980 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Sun, 1 Mar 2026 02:20:10 +0300 Subject: [PATCH 1/2] Use task pause/resume API instead of workspace stop/start Use the dedicated pauseTask/resumeTask endpoints which set the correct build_reason for log snapshot capture and task lifecycle tracking. Falls back to stopWorkspace/startWorkspace on 404 for older Coder servers. --- src/webviews/tasks/tasksPanelProvider.ts | 53 ++++++-- .../webviews/tasks/tasksPanelProvider.test.ts | 128 +++++++++++++----- 2 files changed, 135 insertions(+), 46 deletions(-) diff --git a/src/webviews/tasks/tasksPanelProvider.ts b/src/webviews/tasks/tasksPanelProvider.ts index 42e37255..6caa0812 100644 --- a/src/webviews/tasks/tasksPanelProvider.ts +++ b/src/webviews/tasks/tasksPanelProvider.ts @@ -88,6 +88,7 @@ export class TasksPanelProvider private view?: vscode.WebviewView; private disposables: vscode.Disposable[] = []; + private useLegacyPauseResume = false; // Workspace log streaming private readonly buildLogStream = new LazyStream(); @@ -285,28 +286,50 @@ export class TasksPanelProvider ); } - private async handlePauseTask(taskId: string): Promise { - const task = await this.client.getTask("me", taskId); - if (!task.workspace_id) { - throw new Error("Task has no workspace"); - } - - await this.client.stopWorkspace(task.workspace_id); + private handlePauseTask(taskId: string): Promise { + return this.pauseOrResumeTask( + taskId, + () => this.client.pauseTask("me", taskId), + (task) => this.client.stopWorkspace(task.workspace_id!), + ); + } - await this.refreshAndNotifyTask(taskId); + private handleResumeTask(taskId: string): Promise { + return this.pauseOrResumeTask( + taskId, + () => this.client.resumeTask("me", taskId), + (task) => + this.client.startWorkspace( + task.workspace_id!, + task.template_version_id, + ), + ); } - private async handleResumeTask(taskId: string): Promise { + private async pauseOrResumeTask( + taskId: string, + taskApiCall: () => Promise, + legacyCall: (task: Task) => Promise, + ): Promise { + if (!this.useLegacyPauseResume) { + try { + await taskApiCall(); + await this.refreshAndNotifyTask(taskId); + return; + } catch (err) { + if (isAxiosError(err) && err.response?.status === 404) { + this.useLegacyPauseResume = true; + } else { + throw err; + } + } + } + const task = await this.client.getTask("me", taskId); if (!task.workspace_id) { throw new Error("Task has no workspace"); } - - await this.client.startWorkspace( - task.workspace_id, - task.template_version_id, - ); - + await legacyCall(task); await this.refreshAndNotifyTask(taskId); } diff --git a/test/unit/webviews/tasks/tasksPanelProvider.test.ts b/test/unit/webviews/tasks/tasksPanelProvider.test.ts index 3fc686b2..a1ab44fe 100644 --- a/test/unit/webviews/tasks/tasksPanelProvider.test.ts +++ b/test/unit/webviews/tasks/tasksPanelProvider.test.ts @@ -73,6 +73,8 @@ type TasksPanelClient = Pick< | "getTemplateVersionPresets" | "startWorkspace" | "stopWorkspace" + | "pauseTask" + | "resumeTask" | "sendTaskInput" | "getHost" | "getWorkspace" @@ -91,6 +93,8 @@ function createClient(baseUrl = "https://coder.example.com"): MockClient { getTemplateVersionPresets: vi.fn().mockResolvedValue([]), startWorkspace: vi.fn().mockResolvedValue(undefined), stopWorkspace: vi.fn().mockResolvedValue(undefined), + pauseTask: vi.fn().mockResolvedValue(undefined), + resumeTask: vi.fn().mockResolvedValue(undefined), sendTaskInput: vi.fn().mockResolvedValue(undefined), getHost: vi.fn().mockReturnValue(baseUrl), getWorkspace: vi.fn().mockResolvedValue(workspace()), @@ -413,40 +417,102 @@ describe("TasksPanelProvider", () => { }); describe("pauseTask / resumeTask", () => { - interface WorkspaceControlTestCase { - method: typeof TasksApi.pauseTask; - clientMethod: keyof MockClient; - taskOverrides: Partial; - } - it.each([ - { - method: TasksApi.pauseTask, - clientMethod: "stopWorkspace", - taskOverrides: { workspace_id: "ws-1" }, - }, - { - method: TasksApi.resumeTask, - clientMethod: "startWorkspace", - taskOverrides: { workspace_id: "ws-1", template_version_id: "tv-1" }, - }, - ])( - "$method.method calls $clientMethod", - async ({ method, clientMethod, taskOverrides }) => { - const h = createHarness(); - h.client.getTask.mockResolvedValue(task(taskOverrides)); + it("pauseTask calls client.pauseTask", async () => { + const h = createHarness(); + h.client.getTask.mockResolvedValue(task({ workspace_id: "ws-1" })); - const res = await h.request(method, { - taskId: "task-1", - taskName: "Test Task", - }); + const res = await h.request(TasksApi.pauseTask, { + taskId: "task-1", + taskName: "Test Task", + }); - expect(res.success).toBe(true); - expect(h.client[clientMethod]).toHaveBeenCalled(); - }, - ); + expect(res.success).toBe(true); + expect(h.client.pauseTask).toHaveBeenCalledWith("me", "task-1"); + expect(h.client.stopWorkspace).not.toHaveBeenCalled(); + }); + + it("resumeTask calls client.resumeTask", async () => { + const h = createHarness(); + h.client.getTask.mockResolvedValue(task({ workspace_id: "ws-1" })); + + const res = await h.request(TasksApi.resumeTask, { + taskId: "task-1", + taskName: "Test Task", + }); + + expect(res.success).toBe(true); + expect(h.client.resumeTask).toHaveBeenCalledWith("me", "task-1"); + expect(h.client.startWorkspace).not.toHaveBeenCalled(); + }); + + it("pauseTask falls back to stopWorkspace on 404", async () => { + const h = createHarness(); + h.client.pauseTask.mockRejectedValue(createAxiosError(404, "Not found")); + h.client.getTask.mockResolvedValue(task({ workspace_id: "ws-1" })); + + const res = await h.request(TasksApi.pauseTask, { + taskId: "task-1", + taskName: "Test Task", + }); + + expect(res.success).toBe(true); + expect(h.client.stopWorkspace).toHaveBeenCalledWith("ws-1"); + }); + + it("resumeTask falls back to startWorkspace on 404", async () => { + const h = createHarness(); + h.client.resumeTask.mockRejectedValue(createAxiosError(404, "Not found")); + h.client.getTask.mockResolvedValue( + task({ workspace_id: "ws-1", template_version_id: "tv-1" }), + ); + + const res = await h.request(TasksApi.resumeTask, { + taskId: "task-1", + taskName: "Test Task", + }); + + expect(res.success).toBe(true); + expect(h.client.startWorkspace).toHaveBeenCalledWith("ws-1", "tv-1"); + }); + + it("caches legacy fallback after first 404", async () => { + const h = createHarness(); + h.client.pauseTask.mockRejectedValue(createAxiosError(404, "Not found")); + h.client.getTask.mockResolvedValue(task({ workspace_id: "ws-1" })); + + await h.request(TasksApi.pauseTask, { + taskId: "task-1", + taskName: "Test Task", + }); + h.client.pauseTask.mockClear(); + + await h.request(TasksApi.pauseTask, { + taskId: "task-1", + taskName: "Test Task", + }); + + expect(h.client.pauseTask).not.toHaveBeenCalled(); + expect(h.client.stopWorkspace).toHaveBeenCalledTimes(2); + }); + + it("propagates non-404 errors without fallback", async () => { + const h = createHarness(); + h.client.pauseTask.mockRejectedValue( + createAxiosError(500, "Internal server error"), + ); + + const res = await h.request(TasksApi.pauseTask, { + taskId: "task-1", + taskName: "Test Task", + }); + + expect(res.success).toBe(false); + expect(h.client.stopWorkspace).not.toHaveBeenCalled(); + }); - it("pauseTask fails when no workspace", async () => { + it("legacy pause fails when task has no workspace", async () => { const h = createHarness(); + h.client.pauseTask.mockRejectedValue(createAxiosError(404, "Not found")); h.client.getTask.mockResolvedValue(task({ workspace_id: null })); const res = await h.request(TasksApi.pauseTask, { @@ -719,7 +785,7 @@ describe("TasksPanelProvider", () => { it("shows error notification for user action failures", async () => { const h = createHarness(); - h.client.getTask.mockRejectedValue(new Error("Workspace unavailable")); + h.client.pauseTask.mockRejectedValue(new Error("Workspace unavailable")); const res = await h.request(TasksApi.pauseTask, { taskId: "task-1", From ef50fb2506f953eb3b015cb5a4416594da57468b Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 2 Mar 2026 16:19:19 +0300 Subject: [PATCH 2/2] Address review comments --- src/webviews/tasks/tasksPanelProvider.ts | 44 +++++++++++++----------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/src/webviews/tasks/tasksPanelProvider.ts b/src/webviews/tasks/tasksPanelProvider.ts index 6caa0812..f4728a4a 100644 --- a/src/webviews/tasks/tasksPanelProvider.ts +++ b/src/webviews/tasks/tasksPanelProvider.ts @@ -290,7 +290,7 @@ export class TasksPanelProvider return this.pauseOrResumeTask( taskId, () => this.client.pauseTask("me", taskId), - (task) => this.client.stopWorkspace(task.workspace_id!), + (workspaceId) => this.client.stopWorkspace(workspaceId), ); } @@ -298,38 +298,42 @@ export class TasksPanelProvider return this.pauseOrResumeTask( taskId, () => this.client.resumeTask("me", taskId), - (task) => - this.client.startWorkspace( - task.workspace_id!, - task.template_version_id, - ), + (workspaceId, task) => + this.client.startWorkspace(workspaceId, task.template_version_id), ); } private async pauseOrResumeTask( taskId: string, taskApiCall: () => Promise, - legacyCall: (task: Task) => Promise, + legacyCall: (workspaceId: string, task: Task) => Promise, ): Promise { - if (!this.useLegacyPauseResume) { - try { - await taskApiCall(); - await this.refreshAndNotifyTask(taskId); - return; - } catch (err) { - if (isAxiosError(err) && err.response?.status === 404) { - this.useLegacyPauseResume = true; - } else { - throw err; - } + if (this.useLegacyPauseResume) { + return this.legacyPauseOrResume(taskId, legacyCall); + } + + try { + await taskApiCall(); + await this.refreshAndNotifyTask(taskId); + } catch (err) { + if (isAxiosError(err) && err.response?.status === 404) { + this.useLegacyPauseResume = true; + return this.legacyPauseOrResume(taskId, legacyCall); } + throw err; } + } + private async legacyPauseOrResume( + taskId: string, + legacyCall: (workspaceId: string, task: Task) => Promise, + ): Promise { const task = await this.client.getTask("me", taskId); - if (!task.workspace_id) { + const { workspace_id } = task; + if (!workspace_id) { throw new Error("Task has no workspace"); } - await legacyCall(task); + await legacyCall(workspace_id, task); await this.refreshAndNotifyTask(taskId); }