diff --git a/foundry/packages/backend/src/actors/sandbox/index.ts b/foundry/packages/backend/src/actors/sandbox/index.ts index c565a2d..a35a149 100644 --- a/foundry/packages/backend/src/actors/sandbox/index.ts +++ b/foundry/packages/backend/src/actors/sandbox/index.ts @@ -205,9 +205,12 @@ const baseTaskSandbox = sandboxActor({ create: () => ({ template: config.sandboxProviders.e2b.template ?? "sandbox-agent-full-0.3.x", envs: sandboxEnvObject(), - // Default E2B timeout is 5 minutes which is too short for task work. - // Set to 1 hour. TODO: use betaCreate + autoPause instead so sandboxes - // pause (preserving state) rather than being killed on timeout. + // TEMPORARY: Default E2B timeout is 5 minutes which is too short. + // Set to 1 hour as a stopgap. Remove this once the E2B provider in + // 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, }), installAgents: ["claude", "codex"], diff --git a/foundry/packages/backend/src/services/create-flow.ts b/foundry/packages/backend/src/services/create-flow.ts index 8341399..eb9e53f 100644 --- a/foundry/packages/backend/src/services/create-flow.ts +++ b/foundry/packages/backend/src/services/create-flow.ts @@ -1,3 +1,5 @@ +import { BRANCH_NAME_PREFIXES } from "./branch-name-prefixes.js"; + export interface ResolveCreateFlowDecisionInput { task: string; explicitTitle?: string; @@ -89,30 +91,42 @@ export function sanitizeBranchName(input: string): string { 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 { const explicitBranch = input.explicitBranchName?.trim(); 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 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); - if (explicitBranch && conflicts(branchBase)) { - throw new Error(`Branch '${branchBase}' already exists. Choose a different --name/--branch value.`); + if (explicitBranch && explicitBranch.length > 0) { + if (conflicts(explicitBranch)) { + throw new Error(`Branch '${explicitBranch}' already exists. Choose a different --name/--branch value.`); + } + return { title, branchName: explicitBranch }; } - if (explicitBranch) { - return { title, branchName: branchBase }; - } - - let candidate = branchBase; - let index = 2; - while (conflicts(candidate)) { - candidate = `${branchBase}-${index}`; - index += 1; + // Generate a random McMaster-Carr-style branch name, retrying on conflicts + let candidate = generateBranchName(); + let attempts = 0; + while (conflicts(candidate) && attempts < 100) { + candidate = generateBranchName(); + attempts += 1; } return { diff --git a/foundry/packages/backend/test/create-flow.test.ts b/foundry/packages/backend/test/create-flow.test.ts index 498c4dc..8c66cb4 100644 --- a/foundry/packages/backend/test/create-flow.test.ts +++ b/foundry/packages/backend/test/create-flow.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; 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", () => { 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"); }); - it("auto-increments generated branch names for conflicts", () => { + it("generates a McMaster-Carr-style branch name with random suffix", () => { const resolved = resolveCreateFlowDecision({ task: "Add auth", - localBranches: ["feat-add-auth"], - taskBranches: ["feat-add-auth-2"], + localBranches: [], + taskBranches: [], }); expect(resolved.title).toBe("feat: Add auth"); - expect(resolved.branchName).toBe("feat-add-auth-3"); + // Branch name should be "-<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", () => { diff --git a/foundry/packages/frontend/src/components/mock-layout.tsx b/foundry/packages/frontend/src/components/mock-layout.tsx index e198ee6..042b5a4 100644 --- a/foundry/packages/frontend/src/components/mock-layout.tsx +++ b/foundry/packages/frontend/src/components/mock-layout.tsx @@ -1317,11 +1317,14 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId } [organizationId], ); const organizationState = useSubscription(subscriptionManager, "organization", { organizationId }); - const organizationRepos = organizationState.data?.repos ?? []; - const taskSummaries = organizationState.data?.taskSummaries ?? []; + const organizationReposData = organizationState.data?.repos; + const taskSummariesData = organizationState.data?.taskSummaries; + const openPullRequestsData = organizationState.data?.openPullRequests; + const organizationRepos = organizationReposData ?? []; + const taskSummaries = taskSummariesData ?? []; const selectedTaskSummary = useMemo( () => taskSummaries.find((task) => task.id === selectedTaskId) ?? taskSummaries[0] ?? null, - [selectedTaskId, taskSummaries], + [selectedTaskId, taskSummariesData], ); const taskState = useSubscription( subscriptionManager, @@ -1401,9 +1404,9 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId } summary.id === selectedTaskSummary?.id ? toTaskModel(summary, taskState.data, sessionCache) : toTaskModel(summary), ); return hydratedTasks.sort((left, right) => right.updatedAtMs - left.updatedAtMs); - }, [selectedTaskSummary, selectedSessionId, sessionState.data, taskState.data, taskSummaries, organizationId]); - const openPullRequests = organizationState.data?.openPullRequests ?? []; - const rawRepositories = useMemo(() => groupRepositories(organizationRepos, tasks, openPullRequests), [tasks, organizationRepos, openPullRequests]); + }, [selectedTaskSummary, selectedSessionId, sessionState.data, taskState.data, taskSummariesData, organizationId]); + const openPullRequests = openPullRequestsData ?? []; + const rawRepositories = useMemo(() => groupRepositories(organizationRepos, tasks, openPullRequests), [tasks, organizationReposData, openPullRequestsData]); const appSnapshot = useMockAppSnapshot(); const currentUser = activeMockUser(appSnapshot); const activeOrg = activeMockOrganization(appSnapshot); @@ -1591,6 +1594,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId } }, [activeTask, lastAgentSessionIdByTask, selectedSessionId, syncRouteSession]); useEffect(() => { + const organizationRepos = organizationReposData ?? []; if (selectedNewTaskRepoId && organizationRepos.some((repo) => repo.id === selectedNewTaskRepoId)) { return; } @@ -1600,7 +1604,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId } if (fallbackRepoId !== selectedNewTaskRepoId) { setSelectedNewTaskRepoId(fallbackRepoId); } - }, [activeTask?.repoId, selectedNewTaskRepoId, organizationRepos]); + }, [activeTask?.repoId, selectedNewTaskRepoId, organizationReposData]); useEffect(() => { if (!activeTask) { diff --git a/foundry/packages/frontend/src/components/mock-layout/terminal-pane.tsx b/foundry/packages/frontend/src/components/mock-layout/terminal-pane.tsx index 95e6876..10d74d7 100644 --- a/foundry/packages/frontend/src/components/mock-layout/terminal-pane.tsx +++ b/foundry/packages/frontend/src/components/mock-layout/terminal-pane.tsx @@ -305,7 +305,8 @@ export function TerminalPane({ organizationId, taskId, isExpanded, onExpand, onC setProcessTabs([]); }, [taskId]); - const processes = processesState.data ?? []; + const processesData = processesState.data; + const processes = processesData ?? []; const openTerminalTab = useCallback((process: SandboxProcessRecord) => { setProcessTabs((current) => { @@ -361,7 +362,7 @@ export function TerminalPane({ organizationId, taskId, isExpanded, onExpand, onC const activeProcessTab = activeSessionId ? (processTabsById.get(activeSessionId) ?? null) : null; const activeTerminalProcess = useMemo( () => (activeProcessTab ? (processes.find((process) => process.id === activeProcessTab.processId) ?? null) : null), - [activeProcessTab, processes], + [activeProcessTab, processesData], ); const emptyBodyClassName = css({ diff --git a/foundry/packages/frontend/src/components/organization-dashboard.tsx b/foundry/packages/frontend/src/components/organization-dashboard.tsx index 672de82..4f54ac3 100644 --- a/foundry/packages/frontend/src/components/organization-dashboard.tsx +++ b/foundry/packages/frontend/src/components/organization-dashboard.tsx @@ -335,9 +335,11 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected const activeOrg = appState.data ? currentFoundryOrganization(appState.data) : null; const organizationState = useSubscription(subscriptionManager, "organization", { organizationId }); - const repos = organizationState.data?.repos ?? []; - const rows = organizationState.data?.taskSummaries ?? []; - const selectedSummary = useMemo(() => rows.find((row) => row.id === selectedTaskId) ?? rows[0] ?? null, [rows, selectedTaskId]); + const reposData = organizationState.data?.repos; + const rowsData = organizationState.data?.taskSummaries; + const repos = reposData ?? []; + const rows = rowsData ?? []; + const selectedSummary = useMemo(() => rows.find((row) => row.id === selectedTaskId) ?? rows[0] ?? null, [rowsData, selectedTaskId]); const taskState = useSubscription( subscriptionManager, "task", @@ -363,6 +365,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected }); useEffect(() => { + const repos = reposData ?? []; if (repoOverviewMode && selectedRepoId) { setCreateRepoId(selectedRepoId); return; @@ -370,9 +373,11 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected if (!createRepoId && repos.length > 0) { setCreateRepoId(repos[0]!.id); } - }, [createRepoId, repoOverviewMode, repos, selectedRepoId]); + }, [createRepoId, repoOverviewMode, reposData, selectedRepoId]); const repoGroups = useMemo(() => { + const repos = reposData ?? []; + const rows = rowsData ?? []; const byRepo = new Map(); for (const row of rows) { const bucket = byRepo.get(row.repoId); @@ -400,7 +405,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected } return a.repoLabel.localeCompare(b.repoLabel); }); - }, [repos, rows]); + }, [reposData, rowsData]); const selectedForSession = repoOverviewMode ? null : (taskState.data ?? null); @@ -413,6 +418,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected }, [selectedForSession]); useEffect(() => { + const rows = rowsData ?? []; if (!repoOverviewMode && !selectedTaskId && rows.length > 0) { void navigate({ to: "/organizations/$organizationId/tasks/$taskId", @@ -424,14 +430,15 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected replace: true, }); } - }, [navigate, repoOverviewMode, rows, selectedTaskId, organizationId]); + }, [navigate, repoOverviewMode, rowsData, selectedTaskId, organizationId]); useEffect(() => { setActiveSessionId(null); setDraft(""); }, [selectedForSession?.id]); - const sessionRows = selectedForSession?.sessionsSummary ?? []; + const sessionRowsData = selectedForSession?.sessionsSummary; + const sessionRows = sessionRowsData ?? []; const taskStatus = selectedForSession?.status ?? null; const taskStatusState = describeTaskState(taskStatus); const taskStateSummary = `${taskStatusState.title}. ${taskStatusState.detail}`; @@ -450,7 +457,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected status: session.status, })), }), - [activeSessionId, selectedForSession?.activeSessionId, sessionRows], + [activeSessionId, selectedForSession?.activeSessionId, sessionRowsData], ); const resolvedSessionId = sessionSelection.sessionId; const staleSessionId = sessionSelection.staleSessionId; @@ -466,7 +473,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected } : 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 isPendingSessionCreate = selectedSessionSummary?.status === "pending_session_create"; const isSessionError = selectedSessionSummary?.status === "error"; @@ -523,7 +530,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected 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" }> => { @@ -631,7 +638,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected 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 selectedFilterOption = useMemo( () => createOption(FILTER_OPTIONS.find((option) => option.id === overviewFilter) ?? FILTER_OPTIONS[0]!), @@ -639,7 +646,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected ); const sessionOptions = useMemo( () => sessionRows.map((session) => createOption({ id: session.id, label: `${session.sessionName} (${session.status})` })), - [sessionRows], + [sessionRowsData], ); const selectedSessionOption = sessionOptions.find((option) => option.id === resolvedSessionId) ?? null;