diff --git a/foundry/CLAUDE.md b/foundry/CLAUDE.md index 8af6c92..e83c532 100644 --- a/foundry/CLAUDE.md +++ b/foundry/CLAUDE.md @@ -208,6 +208,8 @@ For all Rivet/RivetKit implementation: - Read paths must not force refresh/sync work inline. Serve the latest cached projection, mark staleness explicitly, and trigger background refresh separately when needed. - If a workflow needs to resume after some external work completes, model that as workflow state plus follow-up messages/events instead of holding the original request open. - No retries: never add retry loops (`withRetries`, `setTimeout` retry, exponential backoff) anywhere in the codebase. If an operation fails, surface the error immediately. If a dependency is not ready yet, model that explicitly with workflow state and resume from a push/event instead of polling or retry loops. +- Never throw errors that expect the caller to retry (e.g. `throw new Error("... retry shortly")`). If a dependency is not ready, write the current state to the DB with an appropriate pending status, enqueue the async work, and return successfully. Let the client observe the pending → ready transition via push events. +- Action return contract: every action that creates a resource must write the resource record to the DB before returning, so the client can immediately query/render it. The record may have a pending status, but it must exist. Never return an ID that doesn't yet have a corresponding DB row. - Actor handle policy: - Prefer explicit `get` or explicit `create` based on workflow intent; do not default to `getOrCreate`. - Use `get`/`getForId` when the actor is expected to already exist; if missing, surface an explicit `Actor not found` error with recovery context. diff --git a/foundry/packages/backend/src/actors/project/actions.ts b/foundry/packages/backend/src/actors/project/actions.ts index bcd8f36..6f9856b 100644 --- a/foundry/packages/backend/src/actors/project/actions.ts +++ b/foundry/packages/backend/src/actors/project/actions.ts @@ -437,7 +437,6 @@ async function hydrateTaskIndexMutation(c: any, _cmd?: HydrateTaskIndexCommand): } async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promise { - const localPath = await ensureProjectReady(c); const onBranch = cmd.onBranch?.trim() || null; const initialBranchName = onBranch; const initialTitle = onBranch ? deriveFallbackTitle(cmd.task, cmd.explicitTitle ?? undefined) : null; @@ -463,7 +462,6 @@ async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promise { if (msg.name === "project.command.createTask") { const result = await loopCtx.step({ name: "project-create-task", - timeout: 12 * 60_000, + timeout: 60_000, run: async () => createTaskMutation(loopCtx, msg.body as CreateTaskCommand), }); await msg.complete(result); @@ -1020,7 +1018,7 @@ export const projectActions = { return expectQueueResponse( await self.send(projectWorkflowQueueName("project.command.createTask"), cmd, { wait: true, - timeout: 12 * 60_000, + timeout: 60_000, }), ); }, diff --git a/foundry/packages/backend/src/actors/task/index.ts b/foundry/packages/backend/src/actors/task/index.ts index 8d9f418..0ea121d 100644 --- a/foundry/packages/backend/src/actors/task/index.ts +++ b/foundry/packages/backend/src/actors/task/index.ts @@ -41,7 +41,7 @@ export interface TaskInput { repoId: string; taskId: string; repoRemote: string; - repoLocalPath: string; + repoLocalPath?: string; branchName: string | null; title: string | null; task: string; diff --git a/foundry/packages/backend/src/actors/task/workbench.ts b/foundry/packages/backend/src/actors/task/workbench.ts index 0d00e77..95fa316 100644 --- a/foundry/packages/backend/src/actors/task/workbench.ts +++ b/foundry/packages/backend/src/actors/task/workbench.ts @@ -7,7 +7,6 @@ import { getOrCreateTaskStatusSync, getOrCreateProject, getOrCreateWorkspace, ge import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js"; import { task as taskTable, taskRuntime, taskWorkbenchSessions } from "./db/schema.js"; import { getCurrentRecord } from "./workflow/common.js"; -import { taskWorkflowQueueName } from "./workflow/queue.js"; const STATUS_SYNC_INTERVAL_MS = 1_000; @@ -599,7 +598,13 @@ export async function ensureWorkbenchSeeded(c: any): Promise { function buildSessionSummary(record: any, meta: any): any { const derivedSandboxSessionId = meta.sandboxSessionId ?? (meta.status === "pending_provision" && record.activeSessionId ? record.activeSessionId : null); const sessionStatus = - meta.status === "ready" && derivedSandboxSessionId ? activeSessionStatus(record, derivedSandboxSessionId) : meta.status === "error" ? "error" : "idle"; + meta.status === "pending_provision" || meta.status === "pending_session_create" + ? meta.status + : meta.status === "ready" && derivedSandboxSessionId + ? activeSessionStatus(record, derivedSandboxSessionId) + : meta.status === "error" + ? "error" + : "ready"; let thinkingSinceMs = meta.thinkingSinceMs ?? null; let unread = Boolean(meta.unread); if (thinkingSinceMs && sessionStatus !== "running") { @@ -617,6 +622,7 @@ function buildSessionSummary(record: any, meta: any): any { thinkingSinceMs: sessionStatus === "running" ? thinkingSinceMs : null, unread, created: Boolean(meta.created || derivedSandboxSessionId), + errorMessage: meta.errorMessage ?? null, }; } @@ -633,6 +639,7 @@ function buildSessionDetailFromMeta(record: any, meta: any): any { thinkingSinceMs: summary.thinkingSinceMs, unread: summary.unread, created: summary.created, + errorMessage: summary.errorMessage, draft: { text: meta.draftText ?? "", attachments: Array.isArray(meta.draftAttachments) ? meta.draftAttachments : [], @@ -655,7 +662,7 @@ export async function buildTaskSummary(c: any): Promise { id: c.state.taskId, repoId: c.state.repoId, title: record.title ?? "New Task", - status: record.status === "archived" ? "archived" : record.status === "running" ? "running" : record.status === "idle" ? "idle" : "new", + status: record.status ?? "new", repoName: repoLabelFromRemote(c.state.repoRemote), updatedAtMs: record.updatedAt, branch: record.branchName, @@ -837,14 +844,6 @@ export async function renameWorkbenchBranch(c: any, value: string): Promise { let record = await ensureWorkbenchSeeded(c); - if (!record.activeSandboxId) { - // Fire-and-forget: enqueue provisioning without waiting to avoid self-deadlock - // (this handler already runs inside the task workflow loop, so wait:true would deadlock). - const providerId = record.providerId ?? c.state.providerId ?? getActorRuntimeContext().providers.defaultProviderId(); - await selfTask(c).send(taskWorkflowQueueName("task.command.provision"), { providerId }, { wait: false }); - throw new Error("sandbox is provisioning — retry shortly"); - } - if (record.activeSessionId) { const existingSessions = await listSessionMetaRows(c); if (existingSessions.length === 0) { @@ -1216,9 +1215,16 @@ export async function publishWorkbenchPr(c: any): Promise { if (!record.branchName) { throw new Error("cannot publish PR without a branch"); } + let repoLocalPath = c.state.repoLocalPath; + if (!repoLocalPath) { + const project = await getOrCreateProject(c, c.state.workspaceId, c.state.repoId, c.state.repoRemote); + const result = await project.ensure({ remoteUrl: c.state.repoRemote }); + repoLocalPath = result.localPath; + c.state.repoLocalPath = repoLocalPath; + } const { driver } = getActorRuntimeContext(); const auth = await resolveWorkspaceGithubAuth(c, c.state.workspaceId); - const created = await driver.github.createPr(c.state.repoLocalPath, record.branchName, record.title ?? c.state.task, undefined, { + const created = await driver.github.createPr(repoLocalPath, record.branchName, record.title ?? c.state.task, undefined, { githubToken: auth?.githubToken ?? null, }); await c.db diff --git a/foundry/packages/backend/src/actors/task/workflow/index.ts b/foundry/packages/backend/src/actors/task/workflow/index.ts index 419d36d..791589f 100644 --- a/foundry/packages/backend/src/actors/task/workflow/index.ts +++ b/foundry/packages/backend/src/actors/task/workflow/index.ts @@ -76,6 +76,7 @@ const commandHandlers: Record = { "task.command.provision": async (loopCtx, msg) => { const body = msg.body; await loopCtx.removed("init-failed", "step"); + await loopCtx.removed("init-failed-v2", "step"); try { await loopCtx.step("init-ensure-name", async () => initEnsureNameActivity(loopCtx)); await loopCtx.step("init-assert-name", async () => initAssertNameActivity(loopCtx)); @@ -107,7 +108,7 @@ const commandHandlers: Record = { await loopCtx.step("init-complete", async () => initCompleteActivity(loopCtx, body, sandbox, session)); await msg.complete({ ok: true }); } catch (error) { - await loopCtx.step("init-failed-v2", async () => initFailedActivity(loopCtx, error)); + await loopCtx.step("init-failed-v3", async () => initFailedActivity(loopCtx, error)); await msg.complete({ ok: false, error: resolveErrorMessage(error), diff --git a/foundry/packages/backend/src/actors/task/workflow/init.ts b/foundry/packages/backend/src/actors/task/workflow/init.ts index 4e6fbb5..722d52c 100644 --- a/foundry/packages/backend/src/actors/task/workflow/init.ts +++ b/foundry/packages/backend/src/actors/task/workflow/init.ts @@ -178,8 +178,16 @@ export async function initEnsureNameActivity(loopCtx: any): Promise { const { driver } = getActorRuntimeContext(); const auth = await resolveWorkspaceGithubAuth(loopCtx, loopCtx.state.workspaceId); + let repoLocalPath = loopCtx.state.repoLocalPath; + if (!repoLocalPath) { + const project = await getOrCreateProject(loopCtx, loopCtx.state.workspaceId, loopCtx.state.repoId, loopCtx.state.repoRemote); + const result = await project.ensure({ remoteUrl: loopCtx.state.repoRemote }); + repoLocalPath = result.localPath; + loopCtx.state.repoLocalPath = repoLocalPath; + } + try { - await driver.git.fetch(loopCtx.state.repoLocalPath, { githubToken: auth?.githubToken ?? null }); + await driver.git.fetch(repoLocalPath, { githubToken: auth?.githubToken ?? null }); } catch (error) { logActorWarning("task.init", "fetch before naming failed", { workspaceId: loopCtx.state.workspaceId, @@ -188,7 +196,7 @@ export async function initEnsureNameActivity(loopCtx: any): Promise { error: resolveErrorMessage(error), }); } - const remoteBranches = (await driver.git.listRemoteBranches(loopCtx.state.repoLocalPath, { githubToken: auth?.githubToken ?? null })).map( + const remoteBranches = (await driver.git.listRemoteBranches(repoLocalPath, { githubToken: auth?.githubToken ?? null })).map( (branch: any) => branch.branchName, ); diff --git a/foundry/packages/backend/src/actors/workspace/actions.ts b/foundry/packages/backend/src/actors/workspace/actions.ts index 0ba55f8..8dc4c87 100644 --- a/foundry/packages/backend/src/actors/workspace/actions.ts +++ b/foundry/packages/backend/src/actors/workspace/actions.ts @@ -370,7 +370,6 @@ async function createTaskMutation(c: any, input: CreateTaskInput): Promise { if (msg.name === "workspace.command.createTask") { const result = await loopCtx.step({ name: "workspace-create-task", - timeout: 12 * 60_000, + timeout: 60_000, run: async () => createTaskMutation(loopCtx, msg.body as CreateTaskInput), }); await msg.complete(result); @@ -547,7 +546,7 @@ export const workspaceActions = { return expectQueueResponse( await self.send(workspaceWorkflowQueueName("workspace.command.createTask"), input, { wait: true, - timeout: 12 * 60_000, + timeout: 60_000, }), ); }, diff --git a/foundry/packages/backend/src/providers/daytona/index.ts b/foundry/packages/backend/src/providers/daytona/index.ts index 8166668..3590ea6 100644 --- a/foundry/packages/backend/src/providers/daytona/index.ts +++ b/foundry/packages/backend/src/providers/daytona/index.ts @@ -17,6 +17,9 @@ import type { } from "../provider-api/index.js"; import type { DaytonaDriver } from "../../driver.js"; import { Image } from "@daytonaio/sdk"; +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { resolve } from "node:path"; export interface DaytonaProviderConfig { endpoint?: string; @@ -176,6 +179,51 @@ export class DaytonaProvider implements SandboxProvider { } } + private shellSingleQuote(value: string): string { + return `'${value.replace(/'/g, `'\"'\"'`)}'`; + } + + private readLocalCodexAuth(): string | null { + const authPath = resolve(homedir(), ".codex", "auth.json"); + try { + return readFileSync(authPath, "utf8"); + } catch { + return null; + } + } + + private buildCloneRepoScript(req: CreateSandboxRequest, repoDir: string): string { + const usesGithubHttpAuth = req.repoRemote.startsWith("https://github.com/"); + const githubPath = usesGithubHttpAuth ? req.repoRemote.slice("https://github.com/".length) : ""; + + const lines = [ + "set -eu", + "export GIT_TERMINAL_PROMPT=0", + "export GIT_ASKPASS=/bin/echo", + `TOKEN=${JSON.stringify(req.githubToken ?? "")}`, + 'if [ -z "$TOKEN" ]; then', + ' if [ -n "${GH_TOKEN:-}" ]; then TOKEN="$GH_TOKEN"; else TOKEN="${GITHUB_TOKEN:-}"; fi', + "fi", + 'AUTH_REMOTE=""', + ...(usesGithubHttpAuth ? ['if [ -n "$TOKEN" ]; then', ` AUTH_REMOTE="https://x-access-token:${"$"}TOKEN@github.com/${githubPath}"`, "fi"] : []), + `rm -rf "${repoDir}"`, + `mkdir -p "${repoDir}"`, + `rmdir "${repoDir}"`, + // Foundry test repos can be private, so clone/fetch must use the sandbox's GitHub token when available. + ...(usesGithubHttpAuth + ? ['if [ -n "$AUTH_REMOTE" ]; then', ` git clone "$AUTH_REMOTE" "${repoDir}"`, "else", ` git clone "${req.repoRemote}" "${repoDir}"`, "fi"] + : [`git clone "${req.repoRemote}" "${repoDir}"`]), + `cd "${repoDir}"`, + ...(usesGithubHttpAuth ? ['if [ -n "$AUTH_REMOTE" ]; then', ` git remote set-url origin "${req.repoRemote}"`, "fi"] : []), + // The task branch may not exist remotely yet (agent push creates it). Base off current branch (default branch). + `if git show-ref --verify --quiet "refs/remotes/origin/${req.branchName}"; then git checkout -B "${req.branchName}" "origin/${req.branchName}"; else git checkout -B "${req.branchName}" "$(git branch --show-current 2>/dev/null || echo main)"; fi`, + `git config user.email "foundry@local" >/dev/null 2>&1 || true`, + `git config user.name "Foundry" >/dev/null 2>&1 || true`, + ]; + + return lines.join("\n"); + } + id() { return "daytona" as const; } @@ -242,37 +290,7 @@ export class DaytonaProvider implements SandboxProvider { }); const cloneStartedAt = Date.now(); - await this.runCheckedCommand( - sandbox.id, - [ - "bash", - "-lc", - `${JSON.stringify( - [ - "set -euo pipefail", - "export GIT_TERMINAL_PROMPT=0", - "export GIT_ASKPASS=/bin/echo", - `TOKEN=${JSON.stringify(req.githubToken ?? "")}`, - 'if [ -z "$TOKEN" ]; then TOKEN="${GH_TOKEN:-${GITHUB_TOKEN:-}}"; fi', - "GIT_AUTH_ARGS=()", - `if [ -n "$TOKEN" ] && [[ "${req.repoRemote}" == https://github.com/* ]]; then AUTH_HEADER="$(printf 'x-access-token:%s' "$TOKEN" | base64 | tr -d '\\n')"; GIT_AUTH_ARGS=(-c "http.https://github.com/.extraheader=AUTHORIZATION: basic $AUTH_HEADER"); fi`, - `rm -rf "${repoDir}"`, - `mkdir -p "${repoDir}"`, - `rmdir "${repoDir}"`, - // Foundry test repos can be private, so clone/fetch must use the sandbox's GitHub token when available. - `git "\${GIT_AUTH_ARGS[@]}" clone "${req.repoRemote}" "${repoDir}"`, - `cd "${repoDir}"`, - `if [ -n "$TOKEN" ] && [[ "${req.repoRemote}" == https://github.com/* ]]; then git config --local credential.helper ""; git config --local http.https://github.com/.extraheader "AUTHORIZATION: basic $AUTH_HEADER"; fi`, - `git "\${GIT_AUTH_ARGS[@]}" fetch origin --prune`, - // The task branch may not exist remotely yet (agent push creates it). Base off current branch (default branch). - `if git show-ref --verify --quiet "refs/remotes/origin/${req.branchName}"; then git checkout -B "${req.branchName}" "origin/${req.branchName}"; else git checkout -B "${req.branchName}" "$(git branch --show-current 2>/dev/null || echo main)"; fi`, - `git config user.email "foundry@local" >/dev/null 2>&1 || true`, - `git config user.name "Foundry" >/dev/null 2>&1 || true`, - ].join("; "), - )}`, - ].join(" "), - "clone repo", - ); + await this.runCheckedCommand(sandbox.id, ["bash", "-lc", this.shellSingleQuote(this.buildCloneRepoScript(req, repoDir))].join(" "), "clone repo"); emitDebug("daytona.createSandbox.clone_repo.done", { sandboxId: sandbox.id, durationMs: Date.now() - cloneStartedAt, @@ -357,6 +375,15 @@ export class DaytonaProvider implements SandboxProvider { const sandboxAgentExports = this.buildShellExports({ SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS: acpRequestTimeoutMs.toString(), }); + const codexAuth = this.readLocalCodexAuth(); + const codexAuthSetup = codexAuth + ? [ + 'mkdir -p "$HOME/.codex" "$HOME/.config/codex"', + `printf %s ${JSON.stringify(Buffer.from(codexAuth, "utf8").toString("base64"))} | base64 -d > "$HOME/.codex/auth.json"`, + 'cp "$HOME/.codex/auth.json" "$HOME/.config/codex/auth.json"', + "unset OPENAI_API_KEY CODEX_API_KEY", + ] + : []; await this.ensureStarted(req.sandboxId); @@ -407,16 +434,16 @@ export class DaytonaProvider implements SandboxProvider { [ "bash", "-lc", - JSON.stringify( + this.shellSingleQuote( [ "set -euo pipefail", 'export PATH="$HOME/.local/bin:$PATH"', ...sandboxAgentExports, + ...codexAuthSetup, "command -v sandbox-agent >/dev/null 2>&1", "if pgrep -x sandbox-agent >/dev/null; then exit 0; fi", - 'rm -f "$HOME/.codex/auth.json" "$HOME/.config/codex/auth.json"', `nohup sandbox-agent server --no-token --host 0.0.0.0 --port ${DaytonaProvider.SANDBOX_AGENT_PORT} >/tmp/sandbox-agent.log 2>&1 &`, - ].join("; "), + ].join("\n"), ), ].join(" "), "start sandbox-agent", diff --git a/foundry/packages/backend/test/daytona-provider.test.ts b/foundry/packages/backend/test/daytona-provider.test.ts index 363b405..d5001c0 100644 --- a/foundry/packages/backend/test/daytona-provider.test.ts +++ b/foundry/packages/backend/test/daytona-provider.test.ts @@ -1,3 +1,6 @@ +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import type { DaytonaClientLike, DaytonaDriver } from "../src/driver.js"; import type { DaytonaCreateSandboxOptions } from "../src/integrations/daytona/client.js"; @@ -91,6 +94,10 @@ describe("daytona provider snapshot image behavior", () => { const commands = client.executedCommands.join("\n"); expect(commands).toContain("GIT_TERMINAL_PROMPT=0"); expect(commands).toContain("GIT_ASKPASS=/bin/echo"); + expect(commands).not.toContain("[["); + expect(commands).not.toContain("GIT_AUTH_ARGS=()"); + expect(commands).not.toContain("${GIT_AUTH_ARGS[@]}"); + expect(commands).not.toContain(".extraheader"); expect(handle.metadata.snapshot).toBe("snapshot-foundry"); expect(handle.metadata.image).toBe("ubuntu:24.04"); @@ -100,6 +107,11 @@ describe("daytona provider snapshot image behavior", () => { it("starts sandbox-agent with ACP timeout env override", async () => { const previous = process.env.HF_SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS; + const previousHome = process.env.HOME; + const tempHome = resolve(tmpdir(), `daytona-provider-test-${Date.now()}`); + mkdirSync(resolve(tempHome, ".codex"), { recursive: true }); + writeFileSync(resolve(tempHome, ".codex", "auth.json"), JSON.stringify({ access_token: "test-token" })); + process.env.HOME = tempHome; process.env.HF_SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS = "240000"; try { @@ -111,15 +123,18 @@ describe("daytona provider snapshot image behavior", () => { sandboxId: "sandbox-1", }); - const startCommand = client.executedCommands.find((command) => - command.includes("nohup env SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS=240000 sandbox-agent server"), + const startCommand = client.executedCommands.find( + (command) => command.includes("export SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS=") && command.includes("sandbox-agent server --no-token"), ); const joined = client.executedCommands.join("\n"); expect(joined).toContain("sandbox-agent/0.3.0/install.sh"); - expect(joined).toContain("SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS=240000"); + expect(joined).toContain("SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS"); expect(joined).toContain("apt-get install -y nodejs npm"); expect(joined).toContain("sandbox-agent server --no-token --host 0.0.0.0 --port 2468"); + expect(joined).toContain('mkdir -p "$HOME/.codex" "$HOME/.config/codex"'); + expect(joined).toContain("unset OPENAI_API_KEY CODEX_API_KEY"); + expect(joined).not.toContain('rm -f "$HOME/.codex/auth.json"'); expect(startCommand).toBeTruthy(); } finally { if (previous === undefined) { @@ -127,6 +142,12 @@ describe("daytona provider snapshot image behavior", () => { } else { process.env.HF_SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS = previous; } + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + rmSync(tempHome, { force: true, recursive: true }); } }); diff --git a/foundry/packages/client/src/backend-client.ts b/foundry/packages/client/src/backend-client.ts index 05047bb..287d29d 100644 --- a/foundry/packages/client/src/backend-client.ts +++ b/foundry/packages/client/src/backend-client.ts @@ -54,7 +54,7 @@ export interface SandboxSessionRecord { lastConnectionId: string; createdAt: number; destroyedAt?: number; - status?: "running" | "idle" | "error"; + status?: "pending_provision" | "pending_session_create" | "ready" | "running" | "idle" | "error"; } export interface SandboxSessionEventRecord { diff --git a/foundry/packages/client/src/interest/manager.ts b/foundry/packages/client/src/interest/manager.ts index b2aab57..4b02230 100644 --- a/foundry/packages/client/src/interest/manager.ts +++ b/foundry/packages/client/src/interest/manager.ts @@ -2,6 +2,14 @@ import type { TopicData, TopicKey, TopicParams } from "./topics.js"; export type TopicStatus = "loading" | "connected" | "error"; +export interface DebugInterestTopic { + topicKey: TopicKey; + cacheKey: string; + listenerCount: number; + status: TopicStatus; + lastRefreshAt: number | null; +} + export interface TopicState { data: TopicData | undefined; status: TopicStatus; @@ -20,5 +28,6 @@ export interface InterestManager { getSnapshot(topicKey: K, params: TopicParams): TopicData | undefined; getStatus(topicKey: K, params: TopicParams): TopicStatus; getError(topicKey: K, params: TopicParams): Error | null; + listDebugTopics(): DebugInterestTopic[]; dispose(): void; } diff --git a/foundry/packages/client/src/interest/remote-manager.ts b/foundry/packages/client/src/interest/remote-manager.ts index 3016ad0..f857975 100644 --- a/foundry/packages/client/src/interest/remote-manager.ts +++ b/foundry/packages/client/src/interest/remote-manager.ts @@ -1,5 +1,5 @@ import type { BackendClient } from "../backend-client.js"; -import type { InterestManager, TopicStatus } from "./manager.js"; +import type { DebugInterestTopic, InterestManager, TopicStatus } from "./manager.js"; import { topicDefinitions, type TopicData, type TopicDefinition, type TopicKey, type TopicParams } from "./topics.js"; const GRACE_PERIOD_MS = 30_000; @@ -19,7 +19,7 @@ export class RemoteInterestManager implements InterestManager { let entry = this.entries.get(cacheKey); if (!entry) { - entry = new TopicEntry(definition, this.backend, params as any); + entry = new TopicEntry(topicKey, cacheKey, definition, this.backend, params as any); this.entries.set(cacheKey, entry); } @@ -53,6 +53,13 @@ export class RemoteInterestManager implements InterestManager { return this.entries.get((topicDefinitions[topicKey] as any).key(params))?.error ?? null; } + listDebugTopics(): DebugInterestTopic[] { + return [...this.entries.values()] + .filter((entry) => entry.listenerCount > 0) + .map((entry) => entry.getDebugTopic()) + .sort((left, right) => left.cacheKey.localeCompare(right.cacheKey)); + } + dispose(): void { for (const entry of this.entries.values()) { entry.dispose(); @@ -66,6 +73,7 @@ class TopicEntry { status: TopicStatus = "loading"; error: Error | null = null; listenerCount = 0; + lastRefreshAt: number | null = null; private readonly listeners = new Set<() => void>(); private conn: Awaited["connect"]>> | null = null; @@ -76,11 +84,23 @@ class TopicEntry { private started = false; constructor( + private readonly topicKey: TopicKey, + private readonly cacheKey: string, private readonly definition: TopicDefinition, private readonly backend: BackendClient, private readonly params: TParams, ) {} + getDebugTopic(): DebugInterestTopic { + return { + topicKey: this.topicKey, + cacheKey: this.cacheKey, + listenerCount: this.listenerCount, + status: this.status, + lastRefreshAt: this.lastRefreshAt, + }; + } + addListener(listener: () => void): void { this.listeners.add(listener); this.listenerCount = this.listeners.size; @@ -125,6 +145,7 @@ class TopicEntry { this.data = undefined; this.status = "loading"; this.error = null; + this.lastRefreshAt = null; this.started = false; } @@ -140,6 +161,7 @@ class TopicEntry { return; } this.data = this.definition.applyEvent(this.data, event); + this.lastRefreshAt = Date.now(); this.notify(); }); this.unsubscribeError = this.conn.onError((error: unknown) => { @@ -149,6 +171,7 @@ class TopicEntry { }); this.data = await this.definition.fetchInitial(this.backend, this.params); this.status = "connected"; + this.lastRefreshAt = Date.now(); this.started = true; this.notify(); } catch (error) { diff --git a/foundry/packages/client/test/interest-manager.test.ts b/foundry/packages/client/test/interest-manager.test.ts index 188195c..db86b0d 100644 --- a/foundry/packages/client/test/interest-manager.test.ts +++ b/foundry/packages/client/test/interest-manager.test.ts @@ -104,6 +104,14 @@ describe("RemoteInterestManager", () => { expect(backend.getWorkspaceSummary).toHaveBeenCalledTimes(1); expect(manager.getStatus("workspace", params)).toBe("connected"); expect(manager.getSnapshot("workspace", params)?.taskSummaries[0]?.title).toBe("Initial task"); + expect(manager.listDebugTopics()).toEqual([ + expect.objectContaining({ + topicKey: "workspace", + cacheKey: "workspace:ws-1", + listenerCount: 2, + status: "connected", + }), + ]); conn.emit("workspaceUpdated", { type: "taskSummaryUpdated", @@ -123,6 +131,7 @@ describe("RemoteInterestManager", () => { expect(manager.getSnapshot("workspace", params)?.taskSummaries[0]?.title).toBe("Updated task"); expect(listenerA).toHaveBeenCalled(); expect(listenerB).toHaveBeenCalled(); + expect(manager.listDebugTopics()[0]?.lastRefreshAt).toEqual(expect.any(Number)); unsubscribeA(); unsubscribeB(); @@ -140,6 +149,7 @@ describe("RemoteInterestManager", () => { unsubscribeA(); vi.advanceTimersByTime(29_000); + expect(manager.listDebugTopics()).toEqual([]); const unsubscribeB = manager.subscribe("workspace", params, () => {}); await flushAsyncWork(); @@ -148,6 +158,7 @@ describe("RemoteInterestManager", () => { expect(conn.disposeCount).toBe(0); unsubscribeB(); + expect(manager.listDebugTopics()).toEqual([]); vi.advanceTimersByTime(30_000); expect(conn.disposeCount).toBe(1); diff --git a/foundry/packages/frontend/src/components/dev-panel.tsx b/foundry/packages/frontend/src/components/dev-panel.tsx index f0a176c..e69a70a 100644 --- a/foundry/packages/frontend/src/components/dev-panel.tsx +++ b/foundry/packages/frontend/src/components/dev-panel.tsx @@ -2,7 +2,9 @@ import { memo, useCallback, useEffect, useMemo, useState } from "react"; import { useStyletron } from "baseui"; import { useFoundryTokens } from "../app/theme"; import { isMockFrontendClient } from "../lib/env"; +import { interestManager } from "../lib/interest"; import type { FoundryOrganization, TaskWorkbenchSnapshot, WorkbenchTask } from "@sandbox-agent/foundry-shared"; +import type { DebugInterestTopic } from "@sandbox-agent/foundry-client"; interface DevPanelProps { workspaceId: string; @@ -15,9 +17,25 @@ interface TopicInfo { key: string; listenerCount: number; hasConnection: boolean; + status: "loading" | "connected" | "error"; lastRefresh: number | null; } +function topicLabel(topic: DebugInterestTopic): string { + switch (topic.topicKey) { + case "app": + return "App"; + case "workspace": + return "Workspace"; + case "task": + return "Task"; + case "session": + return "Session"; + case "sandboxProcesses": + return "Sandbox"; + } +} + function timeAgo(ts: number | null): string { if (!ts) return "never"; const seconds = Math.floor((Date.now() - ts) / 1000); @@ -37,8 +55,11 @@ function taskStatusLabel(task: WorkbenchTask): string { function statusColor(status: string, t: ReturnType): string { switch (status) { + case "connected": case "running": return t.statusSuccess; + case "loading": + return t.statusWarning; case "archived": return t.textMuted; case "error": @@ -88,33 +109,15 @@ export const DevPanel = memo(function DevPanel({ workspaceId, snapshot, organiza }, []); const topics = useMemo((): TopicInfo[] => { - const items: TopicInfo[] = []; - - // Workbench subscription topic - items.push({ - label: "Workbench", - key: `ws:${workspaceId}`, - listenerCount: 1, - hasConnection: true, - lastRefresh: now, - }); - - // Per-task tab subscriptions - for (const task of snapshot.tasks ?? []) { - if (task.status === "archived") continue; - for (const tab of task.tabs ?? []) { - items.push({ - label: `Tab/${task.title?.slice(0, 16) || task.id.slice(0, 8)}/${tab.sessionName.slice(0, 10)}`, - key: `${workspaceId}:${task.id}:${tab.id}`, - listenerCount: 1, - hasConnection: tab.status === "running", - lastRefresh: tab.status === "running" ? now : null, - }); - } - } - - return items; - }, [workspaceId, snapshot, now]); + return interestManager.listDebugTopics().map((topic) => ({ + label: topicLabel(topic), + key: topic.cacheKey, + listenerCount: topic.listenerCount, + hasConnection: topic.status === "connected", + status: topic.status, + lastRefresh: topic.lastRefreshAt, + })); + }, [now]); const tasks = snapshot.tasks ?? []; const repos = snapshot.repos ?? []; @@ -199,6 +202,7 @@ export const DevPanel = memo(function DevPanel({ workspaceId, snapshot, organiza {topic.label} + {topic.status} {topic.key.length > 24 ? `...${topic.key.slice(-20)}` : topic.key} {timeAgo(topic.lastRefresh)} diff --git a/foundry/packages/frontend/src/components/mock-layout.tsx b/foundry/packages/frontend/src/components/mock-layout.tsx index 8bb3d5d..7370243 100644 --- a/foundry/packages/frontend/src/components/mock-layout.tsx +++ b/foundry/packages/frontend/src/components/mock-layout.tsx @@ -22,7 +22,7 @@ import { Sidebar } from "./mock-layout/sidebar"; import { TabStrip } from "./mock-layout/tab-strip"; import { TerminalPane } from "./mock-layout/terminal-pane"; import { TranscriptHeader } from "./mock-layout/transcript-header"; -import { PROMPT_TEXTAREA_MAX_HEIGHT, PROMPT_TEXTAREA_MIN_HEIGHT, SPanel, ScrollBody, Shell } from "./mock-layout/ui"; +import { PROMPT_TEXTAREA_MAX_HEIGHT, PROMPT_TEXTAREA_MIN_HEIGHT, SPanel, ScrollBody, Shell, SpinnerDot } from "./mock-layout/ui"; import { DevPanel, useDevPanel } from "./dev-panel"; import { buildDisplayMessages, @@ -88,6 +88,7 @@ function toLegacyTab( thinkingSinceMs: summary.thinkingSinceMs, unread: summary.unread, created: summary.created, + errorMessage: summary.errorMessage ?? null, draft: sessionDetail?.draft ?? { text: "", attachments: [], @@ -107,7 +108,9 @@ function toLegacyTask( id: summary.id, repoId: summary.repoId, title: detail?.title ?? summary.title, - status: detail?.status ?? summary.status, + status: detail?.runtimeStatus ?? detail?.status ?? summary.status, + runtimeStatus: detail?.runtimeStatus, + statusMessage: detail?.statusMessage ?? null, repoName: detail?.repoName ?? summary.repoName, updatedAtMs: detail?.updatedAtMs ?? summary.updatedAtMs, branch: detail?.branch ?? summary.branch, @@ -117,9 +120,30 @@ function toLegacyTask( diffs: detail?.diffs ?? {}, fileTree: detail?.fileTree ?? [], minutesUsed: detail?.minutesUsed ?? 0, + activeSandboxId: detail?.activeSandboxId ?? null, }; } +function isProvisioningTaskStatus(status: string | null | undefined): boolean { + return status === "new" || String(status ?? "").startsWith("init_"); +} + +function sessionStateMessage(tab: Task["tabs"][number] | null | undefined): string | null { + if (!tab) { + return null; + } + if (tab.status === "pending_provision") { + return "Provisioning sandbox..."; + } + if (tab.status === "pending_session_create") { + return "Creating session..."; + } + if (tab.status === "error") { + return tab.errorMessage ?? "Session failed to start."; + } + return null; +} + function groupProjects(repos: Array<{ id: string; label: string }>, tasks: Task[]) { return repos .map((repo) => ({ @@ -202,6 +226,14 @@ const TranscriptPanel = memo(function TranscriptPanel({ const isTerminal = task.status === "archived"; const historyEvents = useMemo(() => buildHistoryEvents(task.tabs), [task.tabs]); const activeMessages = useMemo(() => buildDisplayMessages(activeAgentTab), [activeAgentTab]); + const taskProvisioning = isProvisioningTaskStatus(task.runtimeStatus ?? task.status); + const taskProvisioningMessage = task.statusMessage ?? "Provisioning sandbox..."; + const activeSessionMessage = sessionStateMessage(activeAgentTab); + const showPendingSessionState = + !activeDiff && + !!activeAgentTab && + (activeAgentTab.status === "pending_provision" || activeAgentTab.status === "pending_session_create" || activeAgentTab.status === "error") && + activeMessages.length === 0; const draft = promptTab?.draft.text ?? ""; const attachments = promptTab?.draft.attachments ?? []; @@ -619,26 +651,88 @@ const TranscriptPanel = memo(function TranscriptPanel({ display: "flex", flexDirection: "column", gap: "12px", + alignItems: "center", }} > -

Create the first session

-

Sessions are where you chat with the agent. Start one now to send the first prompt on this task.

- + {taskProvisioning ? ( + <> + +

Provisioning task

+

{taskProvisioningMessage}

+ + ) : ( + <> +

Create the first session

+

Sessions are where you chat with the agent. Start one now to send the first prompt on this task.

+ + + )} + + + + ) : showPendingSessionState ? ( + +
+
+ {activeAgentTab?.status === "error" ? null : } +

+ {activeAgentTab?.status === "pending_provision" + ? "Provisioning sandbox" + : activeAgentTab?.status === "pending_session_create" + ? "Creating session" + : "Session unavailable"} +

+

{activeSessionMessage}

+ {activeAgentTab?.status === "error" ? ( + + ) : null}
@@ -658,7 +752,7 @@ const TranscriptPanel = memo(function TranscriptPanel({ /> )} - {!isTerminal && promptTab ? ( + {!isTerminal && promptTab && (promptTab.status === "ready" || promptTab.status === "running" || promptTab.status === "idle") ? ( tab.status === "running"); + const isProvisioning = + String(task.status).startsWith("init_") || + task.status === "new" || + task.tabs.some((tab) => tab.status === "pending_provision" || tab.status === "pending_session_create"); const hasUnread = task.tabs.some((tab) => tab.unread); const isDraft = task.pullRequest == null || task.pullRequest.status === "draft"; const totalAdded = task.fileChanges.reduce((sum, file) => sum + file.added, 0); @@ -592,7 +596,7 @@ export const Sidebar = memo(function Sidebar({ flexShrink: 0, })} > - + ; + if (isProvisioning) return ; if (hasUnread) return ; if (isDraft) return ; return ; @@ -174,7 +185,7 @@ export const AgentIcon = memo(function AgentIcon({ agent, size = 14 }: { agent: }); export const TabAvatar = memo(function TabAvatar({ tab }: { tab: AgentTab }) { - if (tab.status === "running") return ; + if (tab.status === "running" || tab.status === "pending_provision" || tab.status === "pending_session_create") return ; if (tab.unread) return ; return ; }); diff --git a/foundry/packages/frontend/src/components/workspace-dashboard.tsx b/foundry/packages/frontend/src/components/workspace-dashboard.tsx index fca4279..ba9b937 100644 --- a/foundry/packages/frontend/src/components/workspace-dashboard.tsx +++ b/foundry/packages/frontend/src/components/workspace-dashboard.tsx @@ -99,7 +99,8 @@ const AGENT_OPTIONS: SelectItem[] = [ function statusKind(status: WorkbenchTaskStatus): StatusTagKind { if (status === "running") return "positive"; - if (status === "new") return "warning"; + if (status === "error") return "negative"; + if (status === "new" || String(status).startsWith("init_")) return "warning"; return "neutral"; } @@ -497,6 +498,10 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId } : null, ); + const selectedSessionSummary = useMemo(() => sessionRows.find((session) => session.id === resolvedSessionId) ?? null, [resolvedSessionId, sessionRows]); + const isPendingProvision = selectedSessionSummary?.status === "pending_provision"; + const isPendingSessionCreate = selectedSessionSummary?.status === "pending_session_create"; + const isSessionError = selectedSessionSummary?.status === "error"; const canStartSession = Boolean(selectedForSession && activeSandbox?.sandboxId); const startSessionFromTask = async (): Promise<{ id: string; status: "running" | "idle" | "error" }> => { @@ -1363,19 +1368,47 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId > {resolvedSessionId && sessionState.status === "loading" ? : null} + {selectedSessionSummary && (isPendingProvision || isPendingSessionCreate) ? ( +
+ + {isPendingProvision ? "Provisioning sandbox..." : "Creating session..."} + + + + {selectedForSession?.statusMessage ?? (isPendingProvision ? "The task is still provisioning." : "The session is being created.")} + +
+ ) : null} + {transcript.length === 0 && !(resolvedSessionId && sessionState.status === "loading") ? ( {selectedForSession.runtimeStatus === "error" && selectedForSession.statusMessage ? `Session failed: ${selectedForSession.statusMessage}` - : !activeSandbox?.sandboxId - ? selectedForSession.statusMessage - ? `Sandbox unavailable: ${selectedForSession.statusMessage}` - : "This task is still provisioning its sandbox." - : staleSessionId - ? `Session ${staleSessionId} is unavailable. Start a new session to continue.` - : resolvedSessionId - ? "No transcript events yet. Send a prompt to start this session." - : "No active session for this task."} + : isPendingProvision + ? (selectedForSession.statusMessage ?? "Provisioning sandbox...") + : isPendingSessionCreate + ? "Creating session..." + : isSessionError + ? (selectedSessionSummary?.errorMessage ?? "Session failed to start.") + : !activeSandbox?.sandboxId + ? selectedForSession.statusMessage + ? `Sandbox unavailable: ${selectedForSession.statusMessage}` + : "This task is still provisioning its sandbox." + : staleSessionId + ? `Session ${staleSessionId} is unavailable. Start a new session to continue.` + : resolvedSessionId + ? "No transcript events yet. Send a prompt to start this session." + : "No active session for this task."} ) : null} @@ -1442,7 +1475,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId onChange={(event) => setDraft(event.target.value)} placeholder="Send a follow-up prompt to this session" rows={5} - disabled={!activeSandbox?.sandboxId} + disabled={!activeSandbox?.sandboxId || isPendingProvision || isPendingSessionCreate || isSessionError} overrides={textareaTestIdOverrides("task-session-prompt")} />
- Create Task + {createTask.isPending ? "Creating..." : "Create Task"} diff --git a/foundry/packages/shared/src/workbench.ts b/foundry/packages/shared/src/workbench.ts index 2aa6a6e..c1b8b46 100644 --- a/foundry/packages/shared/src/workbench.ts +++ b/foundry/packages/shared/src/workbench.ts @@ -1,8 +1,9 @@ import type { AgentType, ProviderId, TaskStatus } from "./contracts.js"; -export type WorkbenchTaskStatus = "running" | "idle" | "new" | "archived"; +export type WorkbenchTaskStatus = TaskStatus | "new"; export type WorkbenchAgentKind = "Claude" | "Codex" | "Cursor"; export type WorkbenchModelId = "claude-sonnet-4" | "claude-opus-4" | "gpt-4o" | "o3"; +export type WorkbenchSessionStatus = "pending_provision" | "pending_session_create" | "ready" | "running" | "idle" | "error"; export interface WorkbenchTranscriptEvent { id: string; @@ -27,10 +28,11 @@ export interface WorkbenchSessionSummary { sessionName: string; agent: WorkbenchAgentKind; model: WorkbenchModelId; - status: "running" | "idle" | "error"; + status: WorkbenchSessionStatus; thinkingSinceMs: number | null; unread: boolean; created: boolean; + errorMessage?: string | null; } /** Full session content — only fetched when viewing a specific session tab. */ @@ -42,10 +44,11 @@ export interface WorkbenchSessionDetail { sessionName: string; agent: WorkbenchAgentKind; model: WorkbenchModelId; - status: "running" | "idle" | "error"; + status: WorkbenchSessionStatus; thinkingSinceMs: number | null; unread: boolean; created: boolean; + errorMessage?: string | null; draft: WorkbenchComposerDraft; transcript: WorkbenchTranscriptEvent[]; } @@ -166,6 +169,8 @@ export interface WorkbenchTask { repoId: string; title: string; status: WorkbenchTaskStatus; + runtimeStatus?: TaskStatus; + statusMessage?: string | null; repoName: string; updatedAtMs: number; branch: string | null; @@ -175,6 +180,7 @@ export interface WorkbenchTask { diffs: Record; fileTree: WorkbenchFileTreeNode[]; minutesUsed: number; + activeSandboxId?: string | null; } export interface WorkbenchRepo {