Skip to content

Commit 5298cb9

Browse files
authored
fix(cloud): prewarm selected sandbox image (#3511)
1 parent 5229b7f commit 5298cb9

10 files changed

Lines changed: 272 additions & 3 deletions

File tree

packages/api-client/src/posthog-client.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,39 @@ describe("PostHogAPIClient", () => {
544544
);
545545
});
546546

547+
it("forwards the selected sandbox environment and custom image", async () => {
548+
const fetch = vi.fn().mockResolvedValue({
549+
ok: true,
550+
text: async () =>
551+
JSON.stringify({ task_id: "task-1", run_id: "run-1" }),
552+
});
553+
const client = makeClient(fetch);
554+
555+
await client.warmTask({
556+
repository: "PostHog/posthog",
557+
github_integration: 42,
558+
sandbox_environment_id: "environment-123",
559+
custom_image_id: "image-123",
560+
});
561+
562+
expect(fetch).toHaveBeenCalledWith(
563+
expect.objectContaining({
564+
overrides: {
565+
body: JSON.stringify({
566+
repository: "PostHog/posthog",
567+
github_integration: 42,
568+
branch: null,
569+
runtime_adapter: null,
570+
model: null,
571+
reasoning_effort: null,
572+
sandbox_environment_id: "environment-123",
573+
custom_image_id: "image-123",
574+
}),
575+
},
576+
}),
577+
);
578+
});
579+
547580
it("sends a null branch when none is provided", async () => {
548581
const fetch = vi.fn().mockResolvedValue({
549582
ok: true,

packages/api-client/src/posthog-client.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2817,6 +2817,8 @@ export class PostHogAPIClient {
28172817
runtime_adapter?: string | null;
28182818
model?: string | null;
28192819
reasoning_effort?: string | null;
2820+
sandbox_environment_id?: string | null;
2821+
custom_image_id?: string | null;
28202822
}): Promise<{ task_id: string; run_id: string } | null> {
28212823
const teamId = await this.getTeamId();
28222824
const urlPath = `/api/projects/${teamId}/tasks/warm/`;
@@ -2833,6 +2835,12 @@ export class PostHogAPIClient {
28332835
runtime_adapter: options.runtime_adapter ?? null,
28342836
model: options.model ?? null,
28352837
reasoning_effort: options.reasoning_effort ?? null,
2838+
...(options.sandbox_environment_id
2839+
? { sandbox_environment_id: options.sandbox_environment_id }
2840+
: {}),
2841+
...(options.custom_image_id
2842+
? { custom_image_id: options.custom_image_id }
2843+
: {}),
28362844
}),
28372845
},
28382846
});

packages/core/src/task-detail/taskCreationHost.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,8 @@ export interface ITaskCreationHost {
125125
runtimeAdapter?: string | null;
126126
model?: string | null;
127127
reasoningEffort?: string | null;
128+
sandboxEnvironmentId?: string | null;
129+
customImageId?: string | null;
128130
}): { taskId: string; runId: string } | null;
129131
uploadRunAttachments(
130132
client: TaskCreationApiClient,

packages/core/src/task-detail/taskCreationSaga.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,8 @@ describe("TaskCreationSaga", () => {
569569
runtimeAdapter: null,
570570
model: null,
571571
reasoningEffort: null,
572+
sandboxEnvironmentId: null,
573+
customImageId: null,
572574
});
573575
// The bundle must land on the warm run before createTask triggers activation.
574576
expect(mockHost.uploadRunAttachments).toHaveBeenCalledWith(
@@ -687,6 +689,83 @@ describe("TaskCreationSaga", () => {
687689
});
688690
});
689691

692+
it.each([
693+
{
694+
selection: "sandbox environment",
695+
input: { sandboxEnvironmentId: "environment-123" },
696+
expectedRunOptions: { sandboxEnvironmentId: "environment-123" },
697+
},
698+
{
699+
selection: "custom image",
700+
input: { customImageId: "image-123" },
701+
expectedRunOptions: { customImageId: "image-123" },
702+
},
703+
])(
704+
"falls back to a cold run without a matching warm $selection lease",
705+
async ({ input, expectedRunOptions }) => {
706+
mockHost.takeWarmTaskLease.mockReturnValue(null);
707+
const createdTask = createTask();
708+
const startedTask = createTask({ latest_run: createRun() });
709+
const createTaskMock = vi.fn().mockResolvedValue(createdTask);
710+
const createTaskRunMock = vi.fn().mockResolvedValue(createRun());
711+
const startTaskRunMock = vi.fn().mockResolvedValue(startedTask);
712+
const saga = makeSaga({
713+
createTask: createTaskMock,
714+
createTaskRun: createTaskRunMock,
715+
startTaskRun: startTaskRunMock,
716+
});
717+
718+
const result = await saga.run({
719+
content: "Ship the fix",
720+
repository: "posthog/posthog",
721+
workspaceMode: "cloud",
722+
branch: "main",
723+
...input,
724+
});
725+
726+
expect(result.success).toBe(true);
727+
expect(createTaskMock.mock.calls[0][0].branch).toBeUndefined();
728+
expect(createTaskRunMock).toHaveBeenCalledWith(
729+
"task-123",
730+
expect.objectContaining(expectedRunOptions),
731+
);
732+
},
733+
);
734+
735+
it("reuses a warm run built from the selected custom image", async () => {
736+
mockHost.takeWarmTaskLease.mockReturnValue({
737+
taskId: "warm-task",
738+
runId: "warm-run",
739+
});
740+
const warmActivatedTask = createTask({
741+
id: "warm-task",
742+
latest_run: createRun({ id: "warm-run", task: "warm-task" }),
743+
});
744+
const createTaskMock = vi.fn().mockResolvedValue(warmActivatedTask);
745+
const createTaskRunMock = vi.fn();
746+
const saga = makeSaga({
747+
createTask: createTaskMock,
748+
createTaskRun: createTaskRunMock,
749+
});
750+
751+
const result = await saga.run({
752+
content: "Ship the fix",
753+
repository: "posthog/posthog",
754+
workspaceMode: "cloud",
755+
branch: "main",
756+
customImageId: "image-123",
757+
});
758+
759+
expect(result.success).toBe(true);
760+
expect(createTaskMock).toHaveBeenCalledWith(
761+
expect.objectContaining({
762+
branch: "main",
763+
custom_image_id: "image-123",
764+
}),
765+
);
766+
expect(createTaskRunMock).not.toHaveBeenCalled();
767+
});
768+
690769
it("uses the selected user GitHub integration for cloud task creation", async () => {
691770
const createdTask = createTask({
692771
github_user_integration: "user-integration-123",

packages/core/src/task-detail/taskCreationSaga.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -692,13 +692,22 @@ export class TaskCreationSaga extends Saga<
692692
runtimeAdapter: input.adapter ?? null,
693693
model: input.model ?? null,
694694
reasoningEffort: input.reasoningLevel ?? null,
695+
sandboxEnvironmentId: input.sandboxEnvironmentId ?? null,
696+
customImageId: input.customImageId ?? null,
695697
})
696698
: null;
697699

700+
const requiresConfiguredWarm = Boolean(
701+
input.sandboxEnvironmentId || input.customImageId,
702+
);
703+
698704
const needsAttachments =
699705
transport.filePaths.length > 0 || transport.skillBundles.length > 0;
700706
if (!needsAttachments) {
701-
return base;
707+
return {
708+
...base,
709+
suppressWarmReuse: requiresConfiguredWarm && !lease,
710+
};
702711
}
703712
if (!lease) {
704713
return { ...base, suppressWarmReuse: true };
@@ -788,6 +797,14 @@ export class TaskCreationSaga extends Saga<
788797
input.workspaceMode === "cloud"
789798
? (input.reasoningLevel ?? null)
790799
: undefined,
800+
sandbox_environment_id:
801+
input.workspaceMode === "cloud" && !warmPayload?.suppressWarmReuse
802+
? input.sandboxEnvironmentId
803+
: undefined,
804+
custom_image_id:
805+
input.workspaceMode === "cloud" && !warmPayload?.suppressWarmReuse
806+
? input.customImageId
807+
: undefined,
791808
signal_report: input.signalReportId ?? undefined,
792809
channel: input.channelId ?? undefined,
793810
pending_user_message: warmPayload?.pendingUserMessage,

packages/ui/src/features/task-detail/components/TaskInput.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,8 @@ export function TaskInput({
730730
runtimeAdapter: adapter ?? null,
731731
model: effectiveModel,
732732
reasoningEffort: effectiveReasoningLevel,
733+
sandboxEnvironmentId: workspaceMode === "cloud" ? selectedCloudEnvId : null,
734+
customImageId: workspaceMode === "cloud" ? selectedCustomImageId : null,
733735
});
734736

735737
const branchForTaskCreation =

packages/ui/src/features/task-detail/hooks/useWarmTask.test.tsx

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ vi.mock("../../../shell/logger", () => ({
1818
}));
1919

2020
import { useWarmTask } from "./useWarmTask";
21+
import { takeWarmTaskLease } from "./warmTaskLease";
2122

2223
interface Props {
2324
workspaceMode: WorkspaceMode;
@@ -28,6 +29,8 @@ interface Props {
2829
runtimeAdapter?: string | null;
2930
model?: string | null;
3031
reasoningEffort?: string | null;
32+
sandboxEnvironmentId?: string | null;
33+
customImageId?: string | null;
3134
}
3235

3336
const cloudTyping: Props = {
@@ -201,6 +204,103 @@ describe("useWarmTask", () => {
201204
expect(mockClient.warmTask).toHaveBeenCalledTimes(2);
202205
});
203206

207+
it("forwards sandbox configuration and re-warms when the image changes", async () => {
208+
const { rerender } = renderHook((props: Props) => useWarmTask(props), {
209+
initialProps: {
210+
...cloudTyping,
211+
sandboxEnvironmentId: "environment-123",
212+
customImageId: "image-123",
213+
},
214+
});
215+
await flushDebounce();
216+
expect(mockClient.warmTask).toHaveBeenLastCalledWith({
217+
repository: "acme/repo",
218+
github_integration: 42,
219+
branch: "main",
220+
...NULL_RUNTIME,
221+
sandbox_environment_id: "environment-123",
222+
custom_image_id: "image-123",
223+
});
224+
225+
rerender({
226+
...cloudTyping,
227+
sandboxEnvironmentId: "environment-123",
228+
customImageId: "image-456",
229+
});
230+
await flushDebounce();
231+
expect(mockClient.warmTask).toHaveBeenLastCalledWith({
232+
repository: "acme/repo",
233+
github_integration: 42,
234+
branch: "main",
235+
...NULL_RUNTIME,
236+
sandbox_environment_id: "environment-123",
237+
custom_image_id: "image-456",
238+
});
239+
expect(mockClient.warmTask).toHaveBeenCalledTimes(2);
240+
});
241+
242+
it("warms only the latest image when selection changes during the debounce", async () => {
243+
const { rerender } = renderHook((props: Props) => useWarmTask(props), {
244+
initialProps: { ...cloudTyping, customImageId: "image-123" },
245+
});
246+
247+
rerender({ ...cloudTyping, customImageId: "image-456" });
248+
await flushDebounce();
249+
250+
expect(mockClient.warmTask).toHaveBeenCalledOnce();
251+
expect(mockClient.warmTask).toHaveBeenCalledWith({
252+
repository: "acme/repo",
253+
github_integration: 42,
254+
branch: "main",
255+
...NULL_RUNTIME,
256+
custom_image_id: "image-456",
257+
});
258+
});
259+
260+
it("keeps the latest image lease when warm responses complete out of order", async () => {
261+
type WarmResponse = { task_id: string; run_id: string };
262+
let resolveFirstWarm!: (value: WarmResponse) => void;
263+
let resolveSecondWarm!: (value: WarmResponse) => void;
264+
const firstWarm = new Promise<WarmResponse>((resolve) => {
265+
resolveFirstWarm = resolve;
266+
});
267+
const secondWarm = new Promise<WarmResponse>((resolve) => {
268+
resolveSecondWarm = resolve;
269+
});
270+
mockClient.warmTask
271+
.mockReturnValueOnce(firstWarm)
272+
.mockReturnValueOnce(secondWarm);
273+
274+
const { rerender } = renderHook((props: Props) => useWarmTask(props), {
275+
initialProps: { ...cloudTyping, customImageId: "image-123" },
276+
});
277+
await flushDebounce();
278+
279+
rerender({ ...cloudTyping, customImageId: "image-456" });
280+
await flushDebounce();
281+
282+
await act(async () => {
283+
resolveSecondWarm({ task_id: "task-2", run_id: "run-2" });
284+
await secondWarm;
285+
});
286+
await act(async () => {
287+
resolveFirstWarm({ task_id: "task-1", run_id: "run-1" });
288+
await firstWarm;
289+
});
290+
291+
expect(
292+
takeWarmTaskLease({
293+
repository: "acme/repo",
294+
branch: "main",
295+
runtimeAdapter: null,
296+
model: null,
297+
reasoningEffort: null,
298+
sandboxEnvironmentId: null,
299+
customImageId: "image-456",
300+
}),
301+
).toEqual({ taskId: "task-2", runId: "run-2" });
302+
});
303+
204304
it("warms again for a new selection after a failed warm", async () => {
205305
mockClient.warmTask.mockRejectedValueOnce(new Error("boom"));
206306
const { rerender } = renderHook((props: Props) => useWarmTask(props), {

0 commit comments

Comments
 (0)