Fix E2B sandbox timeout comment, frontend stability, and create-flow improvements

- Add TEMPORARY comment on E2B timeoutMs with pointer to rivetkit sandbox
  resilience proposal for when autoPause lands
- Fix React useEffect dependency stability in mock-layout and
  organization-dashboard to prevent infinite re-render loops
- Fix terminal-pane ref handling
- Improve create-flow service and tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nathan Flurry 2026-03-16 15:19:49 -07:00
parent 78cd38d826
commit ee25a6c6a5
6 changed files with 106 additions and 42 deletions

View file

@ -205,9 +205,12 @@ const baseTaskSandbox = sandboxActor({
create: () => ({ create: () => ({
template: config.sandboxProviders.e2b.template ?? "sandbox-agent-full-0.3.x", template: config.sandboxProviders.e2b.template ?? "sandbox-agent-full-0.3.x",
envs: sandboxEnvObject(), envs: sandboxEnvObject(),
// Default E2B timeout is 5 minutes which is too short for task work. // TEMPORARY: Default E2B timeout is 5 minutes which is too short.
// Set to 1 hour. TODO: use betaCreate + autoPause instead so sandboxes // Set to 1 hour as a stopgap. Remove this once the E2B provider in
// pause (preserving state) rather than being killed on timeout. // sandbox-agent uses betaCreate + autoPause (see
// .context/proposal-rivetkit-sandbox-resilience.md). At that point
// the provider handles timeout/pause lifecycle and this override is
// unnecessary.
timeoutMs: 60 * 60 * 1000, timeoutMs: 60 * 60 * 1000,
}), }),
installAgents: ["claude", "codex"], installAgents: ["claude", "codex"],

View file

@ -1,3 +1,5 @@
import { BRANCH_NAME_PREFIXES } from "./branch-name-prefixes.js";
export interface ResolveCreateFlowDecisionInput { export interface ResolveCreateFlowDecisionInput {
task: string; task: string;
explicitTitle?: string; explicitTitle?: string;
@ -89,30 +91,42 @@ export function sanitizeBranchName(input: string): string {
return trimmed.slice(0, 50).replace(/-+$/g, ""); return trimmed.slice(0, 50).replace(/-+$/g, "");
} }
function generateRandomSuffix(length: number): string {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
let result = "";
for (let i = 0; i < length; i++) {
result += chars[Math.floor(Math.random() * chars.length)];
}
return result;
}
function generateBranchName(): string {
const prefix = BRANCH_NAME_PREFIXES[Math.floor(Math.random() * BRANCH_NAME_PREFIXES.length)]!;
const suffix = generateRandomSuffix(4);
return `${prefix}-${suffix}`;
}
export function resolveCreateFlowDecision(input: ResolveCreateFlowDecisionInput): ResolveCreateFlowDecisionResult { export function resolveCreateFlowDecision(input: ResolveCreateFlowDecisionInput): ResolveCreateFlowDecisionResult {
const explicitBranch = input.explicitBranchName?.trim(); const explicitBranch = input.explicitBranchName?.trim();
const title = deriveFallbackTitle(input.task, input.explicitTitle); const title = deriveFallbackTitle(input.task, input.explicitTitle);
const generatedBase = sanitizeBranchName(title) || "task";
const branchBase = explicitBranch && explicitBranch.length > 0 ? explicitBranch : generatedBase;
const existingBranches = new Set(input.localBranches.map((value) => value.trim()).filter((value) => value.length > 0)); const existingBranches = new Set(input.localBranches.map((value) => value.trim()).filter((value) => value.length > 0));
const existingTaskBranches = new Set(input.taskBranches.map((value) => value.trim()).filter((value) => value.length > 0)); const existingTaskBranches = new Set(input.taskBranches.map((value) => value.trim()).filter((value) => value.length > 0));
const conflicts = (name: string): boolean => existingBranches.has(name) || existingTaskBranches.has(name); const conflicts = (name: string): boolean => existingBranches.has(name) || existingTaskBranches.has(name);
if (explicitBranch && conflicts(branchBase)) { if (explicitBranch && explicitBranch.length > 0) {
throw new Error(`Branch '${branchBase}' already exists. Choose a different --name/--branch value.`); if (conflicts(explicitBranch)) {
throw new Error(`Branch '${explicitBranch}' already exists. Choose a different --name/--branch value.`);
}
return { title, branchName: explicitBranch };
} }
if (explicitBranch) { // Generate a random McMaster-Carr-style branch name, retrying on conflicts
return { title, branchName: branchBase }; let candidate = generateBranchName();
} let attempts = 0;
while (conflicts(candidate) && attempts < 100) {
let candidate = branchBase; candidate = generateBranchName();
let index = 2; attempts += 1;
while (conflicts(candidate)) {
candidate = `${branchBase}-${index}`;
index += 1;
} }
return { return {

View file

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { deriveFallbackTitle, resolveCreateFlowDecision, sanitizeBranchName } from "../src/services/create-flow.js"; import { deriveFallbackTitle, resolveCreateFlowDecision, sanitizeBranchName } from "../src/services/create-flow.js";
import { BRANCH_NAME_PREFIXES } from "../src/services/branch-name-prefixes.js";
describe("create flow decision", () => { describe("create flow decision", () => {
it("derives a conventional-style fallback title from task text", () => { it("derives a conventional-style fallback title from task text", () => {
@ -17,15 +18,49 @@ describe("create flow decision", () => {
expect(sanitizeBranchName(" spaces everywhere ")).toBe("spaces-everywhere"); expect(sanitizeBranchName(" spaces everywhere ")).toBe("spaces-everywhere");
}); });
it("auto-increments generated branch names for conflicts", () => { it("generates a McMaster-Carr-style branch name with random suffix", () => {
const resolved = resolveCreateFlowDecision({ const resolved = resolveCreateFlowDecision({
task: "Add auth", task: "Add auth",
localBranches: ["feat-add-auth"], localBranches: [],
taskBranches: ["feat-add-auth-2"], taskBranches: [],
}); });
expect(resolved.title).toBe("feat: Add auth"); expect(resolved.title).toBe("feat: Add auth");
expect(resolved.branchName).toBe("feat-add-auth-3"); // Branch name should be "<prefix>-<4-char-suffix>" where prefix is from BRANCH_NAME_PREFIXES
const lastDash = resolved.branchName.lastIndexOf("-");
const prefix = resolved.branchName.slice(0, lastDash);
const suffix = resolved.branchName.slice(lastDash + 1);
expect(BRANCH_NAME_PREFIXES).toContain(prefix);
expect(suffix).toMatch(/^[a-z0-9]{4}$/);
});
it("avoids conflicts by generating a different random name", () => {
// Even with a conflicting branch, it should produce something different
const resolved = resolveCreateFlowDecision({
task: "Add auth",
localBranches: [],
taskBranches: [],
});
// Running again with the first result as a conflict should produce a different name
const resolved2 = resolveCreateFlowDecision({
task: "Add auth",
localBranches: [resolved.branchName],
taskBranches: [],
});
expect(resolved2.branchName).not.toBe(resolved.branchName);
});
it("uses explicit branch name when provided", () => {
const resolved = resolveCreateFlowDecision({
task: "new task",
explicitBranchName: "my-branch",
localBranches: [],
taskBranches: [],
});
expect(resolved.branchName).toBe("my-branch");
}); });
it("fails when explicit branch already exists", () => { it("fails when explicit branch already exists", () => {

View file

@ -1317,11 +1317,14 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
[organizationId], [organizationId],
); );
const organizationState = useSubscription(subscriptionManager, "organization", { organizationId }); const organizationState = useSubscription(subscriptionManager, "organization", { organizationId });
const organizationRepos = organizationState.data?.repos ?? []; const organizationReposData = organizationState.data?.repos;
const taskSummaries = organizationState.data?.taskSummaries ?? []; const taskSummariesData = organizationState.data?.taskSummaries;
const openPullRequestsData = organizationState.data?.openPullRequests;
const organizationRepos = organizationReposData ?? [];
const taskSummaries = taskSummariesData ?? [];
const selectedTaskSummary = useMemo( const selectedTaskSummary = useMemo(
() => taskSummaries.find((task) => task.id === selectedTaskId) ?? taskSummaries[0] ?? null, () => taskSummaries.find((task) => task.id === selectedTaskId) ?? taskSummaries[0] ?? null,
[selectedTaskId, taskSummaries], [selectedTaskId, taskSummariesData],
); );
const taskState = useSubscription( const taskState = useSubscription(
subscriptionManager, subscriptionManager,
@ -1401,9 +1404,9 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
summary.id === selectedTaskSummary?.id ? toTaskModel(summary, taskState.data, sessionCache) : toTaskModel(summary), summary.id === selectedTaskSummary?.id ? toTaskModel(summary, taskState.data, sessionCache) : toTaskModel(summary),
); );
return hydratedTasks.sort((left, right) => right.updatedAtMs - left.updatedAtMs); return hydratedTasks.sort((left, right) => right.updatedAtMs - left.updatedAtMs);
}, [selectedTaskSummary, selectedSessionId, sessionState.data, taskState.data, taskSummaries, organizationId]); }, [selectedTaskSummary, selectedSessionId, sessionState.data, taskState.data, taskSummariesData, organizationId]);
const openPullRequests = organizationState.data?.openPullRequests ?? []; const openPullRequests = openPullRequestsData ?? [];
const rawRepositories = useMemo(() => groupRepositories(organizationRepos, tasks, openPullRequests), [tasks, organizationRepos, openPullRequests]); const rawRepositories = useMemo(() => groupRepositories(organizationRepos, tasks, openPullRequests), [tasks, organizationReposData, openPullRequestsData]);
const appSnapshot = useMockAppSnapshot(); const appSnapshot = useMockAppSnapshot();
const currentUser = activeMockUser(appSnapshot); const currentUser = activeMockUser(appSnapshot);
const activeOrg = activeMockOrganization(appSnapshot); const activeOrg = activeMockOrganization(appSnapshot);
@ -1591,6 +1594,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
}, [activeTask, lastAgentSessionIdByTask, selectedSessionId, syncRouteSession]); }, [activeTask, lastAgentSessionIdByTask, selectedSessionId, syncRouteSession]);
useEffect(() => { useEffect(() => {
const organizationRepos = organizationReposData ?? [];
if (selectedNewTaskRepoId && organizationRepos.some((repo) => repo.id === selectedNewTaskRepoId)) { if (selectedNewTaskRepoId && organizationRepos.some((repo) => repo.id === selectedNewTaskRepoId)) {
return; return;
} }
@ -1600,7 +1604,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
if (fallbackRepoId !== selectedNewTaskRepoId) { if (fallbackRepoId !== selectedNewTaskRepoId) {
setSelectedNewTaskRepoId(fallbackRepoId); setSelectedNewTaskRepoId(fallbackRepoId);
} }
}, [activeTask?.repoId, selectedNewTaskRepoId, organizationRepos]); }, [activeTask?.repoId, selectedNewTaskRepoId, organizationReposData]);
useEffect(() => { useEffect(() => {
if (!activeTask) { if (!activeTask) {

View file

@ -305,7 +305,8 @@ export function TerminalPane({ organizationId, taskId, isExpanded, onExpand, onC
setProcessTabs([]); setProcessTabs([]);
}, [taskId]); }, [taskId]);
const processes = processesState.data ?? []; const processesData = processesState.data;
const processes = processesData ?? [];
const openTerminalTab = useCallback((process: SandboxProcessRecord) => { const openTerminalTab = useCallback((process: SandboxProcessRecord) => {
setProcessTabs((current) => { setProcessTabs((current) => {
@ -361,7 +362,7 @@ export function TerminalPane({ organizationId, taskId, isExpanded, onExpand, onC
const activeProcessTab = activeSessionId ? (processTabsById.get(activeSessionId) ?? null) : null; const activeProcessTab = activeSessionId ? (processTabsById.get(activeSessionId) ?? null) : null;
const activeTerminalProcess = useMemo( const activeTerminalProcess = useMemo(
() => (activeProcessTab ? (processes.find((process) => process.id === activeProcessTab.processId) ?? null) : null), () => (activeProcessTab ? (processes.find((process) => process.id === activeProcessTab.processId) ?? null) : null),
[activeProcessTab, processes], [activeProcessTab, processesData],
); );
const emptyBodyClassName = css({ const emptyBodyClassName = css({

View file

@ -335,9 +335,11 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
const activeOrg = appState.data ? currentFoundryOrganization(appState.data) : null; const activeOrg = appState.data ? currentFoundryOrganization(appState.data) : null;
const organizationState = useSubscription(subscriptionManager, "organization", { organizationId }); const organizationState = useSubscription(subscriptionManager, "organization", { organizationId });
const repos = organizationState.data?.repos ?? []; const reposData = organizationState.data?.repos;
const rows = organizationState.data?.taskSummaries ?? []; const rowsData = organizationState.data?.taskSummaries;
const selectedSummary = useMemo(() => rows.find((row) => row.id === selectedTaskId) ?? rows[0] ?? null, [rows, selectedTaskId]); const repos = reposData ?? [];
const rows = rowsData ?? [];
const selectedSummary = useMemo(() => rows.find((row) => row.id === selectedTaskId) ?? rows[0] ?? null, [rowsData, selectedTaskId]);
const taskState = useSubscription( const taskState = useSubscription(
subscriptionManager, subscriptionManager,
"task", "task",
@ -363,6 +365,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
}); });
useEffect(() => { useEffect(() => {
const repos = reposData ?? [];
if (repoOverviewMode && selectedRepoId) { if (repoOverviewMode && selectedRepoId) {
setCreateRepoId(selectedRepoId); setCreateRepoId(selectedRepoId);
return; return;
@ -370,9 +373,11 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
if (!createRepoId && repos.length > 0) { if (!createRepoId && repos.length > 0) {
setCreateRepoId(repos[0]!.id); setCreateRepoId(repos[0]!.id);
} }
}, [createRepoId, repoOverviewMode, repos, selectedRepoId]); }, [createRepoId, repoOverviewMode, reposData, selectedRepoId]);
const repoGroups = useMemo(() => { const repoGroups = useMemo(() => {
const repos = reposData ?? [];
const rows = rowsData ?? [];
const byRepo = new Map<string, typeof rows>(); const byRepo = new Map<string, typeof rows>();
for (const row of rows) { for (const row of rows) {
const bucket = byRepo.get(row.repoId); const bucket = byRepo.get(row.repoId);
@ -400,7 +405,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
} }
return a.repoLabel.localeCompare(b.repoLabel); return a.repoLabel.localeCompare(b.repoLabel);
}); });
}, [repos, rows]); }, [reposData, rowsData]);
const selectedForSession = repoOverviewMode ? null : (taskState.data ?? null); const selectedForSession = repoOverviewMode ? null : (taskState.data ?? null);
@ -413,6 +418,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
}, [selectedForSession]); }, [selectedForSession]);
useEffect(() => { useEffect(() => {
const rows = rowsData ?? [];
if (!repoOverviewMode && !selectedTaskId && rows.length > 0) { if (!repoOverviewMode && !selectedTaskId && rows.length > 0) {
void navigate({ void navigate({
to: "/organizations/$organizationId/tasks/$taskId", to: "/organizations/$organizationId/tasks/$taskId",
@ -424,14 +430,15 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
replace: true, replace: true,
}); });
} }
}, [navigate, repoOverviewMode, rows, selectedTaskId, organizationId]); }, [navigate, repoOverviewMode, rowsData, selectedTaskId, organizationId]);
useEffect(() => { useEffect(() => {
setActiveSessionId(null); setActiveSessionId(null);
setDraft(""); setDraft("");
}, [selectedForSession?.id]); }, [selectedForSession?.id]);
const sessionRows = selectedForSession?.sessionsSummary ?? []; const sessionRowsData = selectedForSession?.sessionsSummary;
const sessionRows = sessionRowsData ?? [];
const taskStatus = selectedForSession?.status ?? null; const taskStatus = selectedForSession?.status ?? null;
const taskStatusState = describeTaskState(taskStatus); const taskStatusState = describeTaskState(taskStatus);
const taskStateSummary = `${taskStatusState.title}. ${taskStatusState.detail}`; const taskStateSummary = `${taskStatusState.title}. ${taskStatusState.detail}`;
@ -450,7 +457,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
status: session.status, status: session.status,
})), })),
}), }),
[activeSessionId, selectedForSession?.activeSessionId, sessionRows], [activeSessionId, selectedForSession?.activeSessionId, sessionRowsData],
); );
const resolvedSessionId = sessionSelection.sessionId; const resolvedSessionId = sessionSelection.sessionId;
const staleSessionId = sessionSelection.staleSessionId; const staleSessionId = sessionSelection.staleSessionId;
@ -466,7 +473,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
} }
: null, : null,
); );
const selectedSessionSummary = useMemo(() => sessionRows.find((session) => session.id === resolvedSessionId) ?? null, [resolvedSessionId, sessionRows]); const selectedSessionSummary = useMemo(() => sessionRows.find((session) => session.id === resolvedSessionId) ?? null, [resolvedSessionId, sessionRowsData]);
const isPendingProvision = selectedSessionSummary?.status === "pending_provision"; const isPendingProvision = selectedSessionSummary?.status === "pending_provision";
const isPendingSessionCreate = selectedSessionSummary?.status === "pending_session_create"; const isPendingSessionCreate = selectedSessionSummary?.status === "pending_session_create";
const isSessionError = selectedSessionSummary?.status === "error"; const isSessionError = selectedSessionSummary?.status === "error";
@ -523,7 +530,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
activeSandboxId: selectedForSession?.id === task.id ? selectedForSession.activeSandboxId : null, activeSandboxId: selectedForSession?.id === task.id ? selectedForSession.activeSandboxId : null,
})), })),
}), }),
[repos, rows, selectedForSession, organizationId], [reposData, rowsData, selectedForSession, organizationId],
); );
const startSessionFromTask = async (): Promise<{ id: string; status: "running" | "idle" | "error" }> => { const startSessionFromTask = async (): Promise<{ id: string; status: "running" | "idle" | "error" }> => {
@ -631,7 +638,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
setCreateTaskOpen(true); setCreateTaskOpen(true);
}; };
const repoOptions = useMemo(() => repos.map((repo) => createOption({ id: repo.id, label: repo.label })), [repos]); const repoOptions = useMemo(() => repos.map((repo) => createOption({ id: repo.id, label: repo.label })), [reposData]);
const selectedRepoOption = repoOptions.find((option) => option.id === createRepoId) ?? null; const selectedRepoOption = repoOptions.find((option) => option.id === createRepoId) ?? null;
const selectedFilterOption = useMemo( const selectedFilterOption = useMemo(
() => createOption(FILTER_OPTIONS.find((option) => option.id === overviewFilter) ?? FILTER_OPTIONS[0]!), () => createOption(FILTER_OPTIONS.find((option) => option.id === overviewFilter) ?? FILTER_OPTIONS[0]!),
@ -639,7 +646,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
); );
const sessionOptions = useMemo( const sessionOptions = useMemo(
() => sessionRows.map((session) => createOption({ id: session.id, label: `${session.sessionName} (${session.status})` })), () => sessionRows.map((session) => createOption({ id: session.id, label: `${session.sessionName} (${session.status})` })),
[sessionRows], [sessionRowsData],
); );
const selectedSessionOption = sessionOptions.find((option) => option.id === resolvedSessionId) ?? null; const selectedSessionOption = sessionOptions.find((option) => option.id === resolvedSessionId) ?? null;