Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit f57936c

Browse files
committed
feat(sessions): add stop run control for cloud runs
1 parent bda54d8 commit f57936c

23 files changed

Lines changed: 774 additions & 32 deletions

packages/core/src/archive/archiveOrchestration.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ class Harness {
2626
restoreCommandCenter: vi.fn(),
2727
getFocusedWorktreePath: vi.fn().mockReturnValue(null),
2828
disableFocus: vi.fn().mockResolvedValue(undefined),
29+
stopCloudRun: vi.fn().mockResolvedValue(true),
2930
disconnectFromTask: vi.fn().mockResolvedValue(undefined),
3031
archive: vi.fn().mockResolvedValue(undefined),
3132
logError: vi.fn(),
@@ -122,6 +123,40 @@ describe("archiveTask", () => {
122123

123124
expect(harness.deps.clearTerminalStates).not.toHaveBeenCalled();
124125
});
126+
127+
it("stops a running cloud task before archiving it", async () => {
128+
harness.deps.getWorkspace = vi.fn().mockResolvedValue({ mode: "cloud" });
129+
130+
await archiveTask(TASK_ID, harness.deps);
131+
132+
expect(harness.deps.stopCloudRun).toHaveBeenCalledWith(TASK_ID);
133+
expect(
134+
vi.mocked(harness.deps.stopCloudRun).mock.invocationCallOrder[0],
135+
).toBeLessThan(
136+
vi.mocked(harness.deps.archive).mock.invocationCallOrder[0] ?? Infinity,
137+
);
138+
});
139+
140+
it("does not archive when a running cloud task cannot be stopped", async () => {
141+
harness.deps.getWorkspace = vi.fn().mockResolvedValue({ mode: "cloud" });
142+
harness.deps.stopCloudRun = vi.fn().mockResolvedValue(false);
143+
144+
await expect(archiveTask(TASK_ID, harness.deps)).rejects.toThrow(
145+
"Couldn't stop the task",
146+
);
147+
148+
expect(harness.deps.archive).not.toHaveBeenCalled();
149+
expect(harness.ids).not.toContain(TASK_ID);
150+
});
151+
152+
it("archives a local workspace without requiring cloud connectivity", async () => {
153+
harness.deps.getWorkspace = vi.fn().mockResolvedValue({ mode: "local" });
154+
155+
await archiveTask(TASK_ID, harness.deps);
156+
157+
expect(harness.deps.stopCloudRun).not.toHaveBeenCalled();
158+
expect(harness.deps.archive).toHaveBeenCalledWith(TASK_ID);
159+
});
125160
});
126161

127162
describe("archiveTasks", () => {

packages/core/src/archive/archiveOrchestration.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export interface ArchiveOrchestrationDeps {
3636
): void;
3737
getFocusedWorktreePath(): string | null | undefined;
3838
disableFocus(): Promise<void>;
39+
stopCloudRun(taskId: string, runId?: string): Promise<boolean>;
3940
disconnectFromTask(taskId: string): Promise<void>;
4041
archive(taskId: string): Promise<void>;
4142
logError(message: string, error: unknown): void;
@@ -58,8 +59,15 @@ export async function archiveTask(
5859
deps: ArchiveOrchestrationDeps,
5960
options?: ArchiveTaskOptions,
6061
): Promise<void> {
61-
const optimistic = options?.optimistic ?? true;
6262
const workspace = await deps.getWorkspace(taskId);
63+
if (!workspace || workspace.mode === "cloud") {
64+
const stopped = await deps.stopCloudRun(taskId);
65+
if (!stopped) {
66+
throw new Error("Couldn't stop the task. Try again in a moment.");
67+
}
68+
}
69+
70+
const optimistic = options?.optimistic ?? true;
6371
const pinnedTaskIds = await deps.getPinnedTaskIds();
6472
const wasPinned = pinnedTaskIds.includes(taskId);
6573

packages/core/src/cloud-task/cloud-task.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2335,6 +2335,46 @@ describe("CloudTaskService", () => {
23352335
).toBe(false);
23362336
});
23372337

2338+
it("stops a cloud run through the run cancel endpoint", async () => {
2339+
mockNetFetch.mockResolvedValueOnce(
2340+
createJsonResponse({ id: "run-1", status: "in_progress" }, 202),
2341+
);
2342+
2343+
const result = await service.stop({
2344+
taskId: "task-1",
2345+
runId: "run-1",
2346+
apiHost: "https://us.posthog.com",
2347+
teamId: 2,
2348+
});
2349+
2350+
expect(result).toEqual({ success: true, runStatus: "in_progress" });
2351+
const [url, init] = mockNetFetch.mock.calls[0] as [string, RequestInit];
2352+
expect(url).toBe(
2353+
"https://us.posthog.com/api/projects/2/tasks/task-1/runs/run-1/cancel/",
2354+
);
2355+
expect(init.method).toBe("POST");
2356+
});
2357+
2358+
it("surfaces the backend error and retryability when a stop fails", async () => {
2359+
mockNetFetch.mockResolvedValueOnce(
2360+
createJsonResponse(
2361+
{ error: "Could not reach the run's workflow; try again" },
2362+
503,
2363+
),
2364+
);
2365+
2366+
const result = await service.stop({
2367+
taskId: "task-1",
2368+
runId: "run-1",
2369+
apiHost: "https://us.posthog.com",
2370+
teamId: 2,
2371+
});
2372+
2373+
expect(result.success).toBe(false);
2374+
expect(result.error).toBe("Could not reach the run's workflow; try again");
2375+
expect(result.retryable).toBe(true);
2376+
});
2377+
23382378
it("does not let a stale backend-error count inflate a transport reconnect delay", async () => {
23392379
vi.useFakeTimers();
23402380

packages/core/src/cloud-task/cloud-task.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import {
1919
isTerminalStatus,
2020
type SendCommandInput,
2121
type SendCommandOutput,
22+
type StopInput,
23+
type StopOutput,
2224
type TaskRunStatus,
2325
type WatchInput,
2426
} from "./schemas";
@@ -520,6 +522,69 @@ export class CloudTaskService extends TypedEventEmitter<CloudTaskEvents> {
520522
}
521523
}
522524

525+
/**
526+
* Stop a cloud run for good: the backend interrupts the agent, snapshots the
527+
* session for later resume, tears down the sandbox, and marks the run
528+
* cancelled. Distinct from sendCommand("cancel"), which only interrupts the
529+
* in-flight turn and leaves the sandbox running.
530+
*/
531+
async stop(input: StopInput): Promise<StopOutput> {
532+
const url = `${input.apiHost}/api/projects/${input.teamId}/tasks/${input.taskId}/runs/${input.runId}/cancel/`;
533+
534+
try {
535+
const response = await this.auth.authenticatedFetch(url, {
536+
method: "POST",
537+
headers: {
538+
"Content-Type": "application/json",
539+
},
540+
body: JSON.stringify(input.reason ? { reason: input.reason } : {}),
541+
});
542+
543+
if (!response.ok) {
544+
const errorText = await response.text().catch(() => "");
545+
let errorMessage = `Stop failed with status ${response.status}`;
546+
try {
547+
const errorJson = JSON.parse(errorText) as { error?: unknown };
548+
if (typeof errorJson.error === "string" && errorJson.error) {
549+
errorMessage = errorJson.error;
550+
}
551+
} catch {
552+
if (errorText) errorMessage = errorText;
553+
}
554+
555+
this.log.warn("Cloud run stop failed", {
556+
taskId: input.taskId,
557+
runId: input.runId,
558+
status: response.status,
559+
error: errorMessage,
560+
});
561+
return {
562+
success: false,
563+
error: errorMessage,
564+
// 503 = Temporal briefly unreachable on the backend; 5xx = transient server trouble.
565+
retryable: response.status === 503 || response.status >= 500,
566+
};
567+
}
568+
569+
const data = (await response.json()) as { status?: string };
570+
this.log.info("Cloud run stop accepted", {
571+
taskId: input.taskId,
572+
runId: input.runId,
573+
runStatus: data.status,
574+
});
575+
return { success: true, runStatus: data.status };
576+
} catch (error) {
577+
const errorMessage =
578+
error instanceof Error ? error.message : "Unknown error";
579+
this.log.error("Cloud run stop error", {
580+
taskId: input.taskId,
581+
runId: input.runId,
582+
error: errorMessage,
583+
});
584+
return { success: false, error: errorMessage, retryable: true };
585+
}
586+
}
587+
523588
@preDestroy()
524589
unwatchAll(): void {
525590
for (const key of [...this.watchers.keys()]) {

packages/core/src/cloud-task/schemas.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,22 @@ export const sendCommandOutput = z.object({
7777
});
7878

7979
export type SendCommandOutput = z.infer<typeof sendCommandOutput>;
80+
81+
export const stopInput = z.object({
82+
taskId: z.string(),
83+
runId: z.string(),
84+
apiHost: z.string(),
85+
teamId: z.number(),
86+
reason: z.string().optional(),
87+
});
88+
89+
export type StopInput = z.infer<typeof stopInput>;
90+
91+
export const stopOutput = z.object({
92+
success: z.boolean(),
93+
runStatus: z.string().optional(),
94+
error: z.string().optional(),
95+
retryable: z.boolean().optional(),
96+
});
97+
98+
export type StopOutput = z.infer<typeof stopOutput>;

packages/core/src/context-menu/context-menu.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,24 @@ describe("ContextMenuService.showTaskContextMenu", () => {
103103
expect(labels(menu.lastItems)).not.toContain("Suspend");
104104
});
105105

106+
it("offers Stop task only for a stoppable run and gates it on confirmation", async () => {
107+
const running = new FakeContextMenu();
108+
const result = makeService(running, dialogReturning(1)).showTaskContextMenu(
109+
{
110+
...baseTask,
111+
canStop: true,
112+
},
113+
);
114+
await running.shown;
115+
findItem(running.lastItems, "Stop task").click();
116+
expect(await result).toEqual({ action: { type: "stop" } });
117+
118+
const idle = new FakeContextMenu();
119+
makeService(idle).showTaskContextMenu(baseTask);
120+
await idle.shown;
121+
expect(labels(idle.lastItems)).not.toContain("Stop task");
122+
});
123+
106124
it("hides Add to Command Center when already in it", async () => {
107125
const inCc = new FakeContextMenu();
108126
makeService(inCc).showTaskContextMenu({

packages/core/src/context-menu/context-menu.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ export class ContextMenuService {
114114
folderPath,
115115
isPinned,
116116
isSuspended,
117+
canStop,
117118
isInCommandCenter,
118119
hasEmptyCommandCenterCell,
119120
channels,
@@ -141,6 +142,24 @@ export class ContextMenuService {
141142
return this.showMenu<TaskAction>([
142143
this.item(isPinned ? "Unpin" : "Pin", { type: "pin" }),
143144
this.item("Rename", { type: "rename" }),
145+
...(canStop
146+
? [
147+
this.separator(),
148+
this.item(
149+
"Stop task",
150+
{ type: "stop" as const },
151+
{
152+
confirm: {
153+
title: "Stop task",
154+
message: `Stop "${input.taskTitle}"?`,
155+
detail:
156+
"This ends the cloud session and shuts down its sandbox. To stop only the current response, press Esc instead.",
157+
confirmLabel: "Stop task",
158+
},
159+
},
160+
),
161+
]
162+
: []),
144163
...(worktreePath
145164
? [
146165
this.separator(),

packages/core/src/context-menu/schemas.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export const taskContextMenuInput = z.object({
66
folderPath: z.string().optional(),
77
isPinned: z.boolean().optional(),
88
isSuspended: z.boolean().optional(),
9+
canStop: z.boolean().optional(),
910
isInCommandCenter: z.boolean().optional(),
1011
hasEmptyCommandCenterCell: z.boolean().optional(),
1112
// Top-level desktop_file_system channels available as "File to…" targets.
@@ -45,6 +46,7 @@ const taskAction = z.discriminatedUnion("type", [
4546
z.object({ type: z.literal("rename") }),
4647
z.object({ type: z.literal("pin") }),
4748
z.object({ type: z.literal("suspend") }),
49+
z.object({ type: z.literal("stop") }),
4850
z.object({ type: z.literal("archive") }),
4951
z.object({ type: z.literal("archive-prior") }),
5052
z.object({ type: z.literal("delete") }),

0 commit comments

Comments
 (0)