Skip to content

Commit 4e3c1d7

Browse files
authored
fix(cloud): prewarm selected sandbox image
Include cloud environment and custom image selections in warm requests and lease matching. Reuse only matching configured warm runs, preserve cold fallback, and ignore stale warm responses after the selection changes.
1 parent 6f243a0 commit 4e3c1d7

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)