Skip to content

Commit 496e683

Browse files
authored
fix(cloud): prewarm selected sandbox image
Include cloud environment and custom image selections in warm requests and lease matching. Reuse matching configured warm runs while preserving cold fallback when no configured lease is available. Generated-By: PostHog Code Task-Id: 6b752fb1-4fa1-45e4-920a-38306e4b7cee
1 parent 3c223af commit 496e683

10 files changed

Lines changed: 162 additions & 6 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: 38 additions & 1 deletion
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(
@@ -699,8 +701,9 @@ describe("TaskCreationSaga", () => {
699701
expectedRunOptions: { customImageId: "image-123" },
700702
},
701703
])(
702-
"starts a cold run for a selected $selection",
704+
"falls back to a cold run without a matching warm $selection lease",
703705
async ({ input, expectedRunOptions }) => {
706+
mockHost.takeWarmTaskLease.mockReturnValue(null);
704707
const createdTask = createTask();
705708
const startedTask = createTask({ latest_run: createRun() });
706709
const createTaskMock = vi.fn().mockResolvedValue(createdTask);
@@ -729,6 +732,40 @@ describe("TaskCreationSaga", () => {
729732
},
730733
);
731734

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+
732769
it("uses the selected user GitHub integration for cloud task creation", async () => {
733770
const createdTask = createTask({
734771
github_user_integration: "user-integration-123",

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

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -685,24 +685,29 @@ export class TaskCreationSaga extends Saga<
685685
augmented,
686686
};
687687

688-
if (input.sandboxEnvironmentId || input.customImageId) {
689-
return { ...base, suppressWarmReuse: true };
690-
}
691-
692688
const lease = input.repository
693689
? this.deps.host.takeWarmTaskLease({
694690
repository: input.repository,
695691
branch: input.branch ?? null,
696692
runtimeAdapter: input.adapter ?? null,
697693
model: input.model ?? null,
698694
reasoningEffort: input.reasoningLevel ?? null,
695+
sandboxEnvironmentId: input.sandboxEnvironmentId ?? null,
696+
customImageId: input.customImageId ?? null,
699697
})
700698
: null;
701699

700+
const requiresConfiguredWarm = Boolean(
701+
input.sandboxEnvironmentId || input.customImageId,
702+
);
703+
702704
const needsAttachments =
703705
transport.filePaths.length > 0 || transport.skillBundles.length > 0;
704706
if (!needsAttachments) {
705-
return base;
707+
return {
708+
...base,
709+
suppressWarmReuse: requiresConfiguredWarm && !lease,
710+
};
706711
}
707712
if (!lease) {
708713
return { ...base, suppressWarmReuse: true };
@@ -792,6 +797,14 @@ export class TaskCreationSaga extends Saga<
792797
input.workspaceMode === "cloud"
793798
? (input.reasoningLevel ?? null)
794799
: 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,
795808
signal_report: input.signalReportId ?? undefined,
796809
channel: input.channelId ?? undefined,
797810
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: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ interface Props {
2828
runtimeAdapter?: string | null;
2929
model?: string | null;
3030
reasoningEffort?: string | null;
31+
sandboxEnvironmentId?: string | null;
32+
customImageId?: string | null;
3133
}
3234

3335
const cloudTyping: Props = {
@@ -201,6 +203,41 @@ describe("useWarmTask", () => {
201203
expect(mockClient.warmTask).toHaveBeenCalledTimes(2);
202204
});
203205

206+
it("forwards sandbox configuration and re-warms when the image changes", async () => {
207+
const { rerender } = renderHook((props: Props) => useWarmTask(props), {
208+
initialProps: {
209+
...cloudTyping,
210+
sandboxEnvironmentId: "environment-123",
211+
customImageId: "image-123",
212+
},
213+
});
214+
await flushDebounce();
215+
expect(mockClient.warmTask).toHaveBeenLastCalledWith({
216+
repository: "acme/repo",
217+
github_integration: 42,
218+
branch: "main",
219+
...NULL_RUNTIME,
220+
sandbox_environment_id: "environment-123",
221+
custom_image_id: "image-123",
222+
});
223+
224+
rerender({
225+
...cloudTyping,
226+
sandboxEnvironmentId: "environment-123",
227+
customImageId: "image-456",
228+
});
229+
await flushDebounce();
230+
expect(mockClient.warmTask).toHaveBeenLastCalledWith({
231+
repository: "acme/repo",
232+
github_integration: 42,
233+
branch: "main",
234+
...NULL_RUNTIME,
235+
sandbox_environment_id: "environment-123",
236+
custom_image_id: "image-456",
237+
});
238+
expect(mockClient.warmTask).toHaveBeenCalledTimes(2);
239+
});
240+
204241
it("warms again for a new selection after a failed warm", async () => {
205242
mockClient.warmTask.mockRejectedValueOnce(new Error("boom"));
206243
const { rerender } = renderHook((props: Props) => useWarmTask(props), {

packages/ui/src/features/task-detail/hooks/useWarmTask.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ interface UseWarmTaskOptions {
2121
runtimeAdapter?: string | null;
2222
model?: string | null;
2323
reasoningEffort?: string | null;
24+
sandboxEnvironmentId?: string | null;
25+
customImageId?: string | null;
2426
}
2527

2628
export function useWarmTask({
@@ -32,6 +34,8 @@ export function useWarmTask({
3234
runtimeAdapter,
3335
model,
3436
reasoningEffort,
37+
sandboxEnvironmentId,
38+
customImageId,
3539
}: UseWarmTaskOptions): void {
3640
const enabled = useFeatureFlag(TASKS_PREWARM_SANDBOX_FLAG);
3741
const client = useOptionalAuthenticatedClient();
@@ -44,6 +48,8 @@ export function useWarmTask({
4448
const normalizedRuntimeAdapter = runtimeAdapter ?? null;
4549
const normalizedModel = model ?? null;
4650
const normalizedReasoningEffort = reasoningEffort ?? null;
51+
const normalizedSandboxEnvironmentId = sandboxEnvironmentId ?? null;
52+
const normalizedCustomImageId = customImageId ?? null;
4753
const eligible =
4854
enabled &&
4955
isCloud &&
@@ -59,6 +65,8 @@ export function useWarmTask({
5965
runtimeAdapter: normalizedRuntimeAdapter,
6066
model: normalizedModel,
6167
reasoningEffort: normalizedReasoningEffort,
68+
sandboxEnvironmentId: normalizedSandboxEnvironmentId,
69+
customImageId: normalizedCustomImageId,
6270
})}`
6371
: null;
6472

@@ -84,6 +92,8 @@ export function useWarmTask({
8492
const warmRuntimeAdapter = normalizedRuntimeAdapter;
8593
const warmModel = normalizedModel;
8694
const warmReasoningEffort = normalizedReasoningEffort;
95+
const warmSandboxEnvironmentId = normalizedSandboxEnvironmentId;
96+
const warmCustomImageId = normalizedCustomImageId;
8797
debounceRef.current = setTimeout(() => {
8898
debounceRef.current = null;
8999
lastWarmedKeyRef.current = key;
@@ -95,6 +105,10 @@ export function useWarmTask({
95105
runtime_adapter: warmRuntimeAdapter,
96106
model: warmModel,
97107
reasoning_effort: warmReasoningEffort,
108+
...(warmSandboxEnvironmentId
109+
? { sandbox_environment_id: warmSandboxEnvironmentId }
110+
: {}),
111+
...(warmCustomImageId ? { custom_image_id: warmCustomImageId } : {}),
98112
})
99113
.then((warm) => {
100114
if (warm) {
@@ -105,6 +119,8 @@ export function useWarmTask({
105119
runtimeAdapter: warmRuntimeAdapter,
106120
model: warmModel,
107121
reasoningEffort: warmReasoningEffort,
122+
sandboxEnvironmentId: warmSandboxEnvironmentId,
123+
customImageId: warmCustomImageId,
108124
}),
109125
{ taskId: warm.task_id, runId: warm.run_id },
110126
);
@@ -127,5 +143,7 @@ export function useWarmTask({
127143
normalizedRuntimeAdapter,
128144
normalizedModel,
129145
normalizedReasoningEffort,
146+
normalizedSandboxEnvironmentId,
147+
normalizedCustomImageId,
130148
]);
131149
}

packages/ui/src/features/task-detail/hooks/warmTaskLease.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export interface WarmTaskLeaseKeyParts {
99
runtimeAdapter?: string | null;
1010
model?: string | null;
1111
reasoningEffort?: string | null;
12+
sandboxEnvironmentId?: string | null;
13+
customImageId?: string | null;
1214
}
1315

1416
export function buildWarmTaskLeaseKey(parts: WarmTaskLeaseKeyParts): string {
@@ -18,6 +20,8 @@ export function buildWarmTaskLeaseKey(parts: WarmTaskLeaseKeyParts): string {
1820
parts.runtimeAdapter ?? "",
1921
parts.model ?? "",
2022
parts.reasoningEffort ?? "",
23+
parts.sandboxEnvironmentId ?? "",
24+
parts.customImageId ?? "",
2125
].join(":");
2226
}
2327

packages/ui/src/features/task-detail/taskCreationHostImpl.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,8 @@ export class TrpcTaskCreationHost implements ITaskCreationHost {
162162
runtimeAdapter?: string | null;
163163
model?: string | null;
164164
reasoningEffort?: string | null;
165+
sandboxEnvironmentId?: string | null;
166+
customImageId?: string | null;
165167
}): { taskId: string; runId: string } | null {
166168
return takeWarmTaskLease(args);
167169
}

0 commit comments

Comments
 (0)