Skip to content

Commit f16dbca

Browse files
authored
fix(agent): restore previews after sandbox idle (#118)
## Summary - Key managed previews by the canonical project slug instead of nested command cwd. - Skip recursive workspace enumeration before opening Files/code-server. - Fast-relaunch tracked preview and code-server sessions after Daytona idle stops. - Return an explicit no-preview state so the web client does not retry a useless wake loop. - Collapse duplicate project lookups on preview, IDE, and terminal routes. This fixes the production chat where Vite ran from a nested `studio-site` directory, while reopen looked for the project-level preview slot and could not restore it after idle shutdown. ## Architecture `workspaceSlug` is now an explicit runtime contract. `cwd` remains the execution directory and no longer controls durable process identity. No legacy fallback or path-derived process key is retained. ## Verification notes - [x] `pnpm lint` - [x] `pnpm typecheck` - [x] `pnpm turbo build --force` - [x] `pnpm deadcode` - [x] `pnpm architecture:check` - [x] `pnpm turbo skills:build` Full forced production builds completed for all 19 packages and every Worker dry-run. Architecture analysis reported 847 modules and 1,752 dependencies with no violations. Knip exited cleanly with only four existing configuration hints. Production browser QA follows the main-branch Cloudflare deployment because the acceptance case requires stopping and reopening the real persistent Daytona sandbox. The planned check covers the exact affected chat, preview recovery, Files startup, console, network, app logs, and a forced idle-stop cycle. ## Review focus 1. Follow `workspaceSlug` from agent-run workspace binding into `prepareStartDevServer`. 2. Review no-record behavior in `wakePreview` and `projectPreviewStatus`. 3. Review tracked code-server relaunch and the removed recursive file pre-scan. No Linear issue or plan document exists; this is a directly reproduced production incident.
1 parent adf08e4 commit f16dbca

14 files changed

Lines changed: 108 additions & 163 deletions

File tree

apps/agent-worker/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,9 @@ Managed processes use required stable IDs and a maximum of 32 live metadata slot
148148
sandbox. Reusing an ID atomically replaces that slot. At capacity, ProjectSandbox reconciles the
149149
bounded record set against Daytona, removes missing or completed sessions and their port state,
150150
and rejects a new distinct slot only when all 32 remain live.
151+
App-preview identity and port allocation derive from the canonical project workspace root, while
152+
the launch command may run in any descendant folder. Nested app layouts therefore retain the same
153+
project-scoped wake, console, cleanup, and restart identity after Daytona idle-stops the sandbox.
151154

152155
Each user has one durable Daytona sandbox. Projects are lexically confined to their
153156
folders under `/workspace`, and run leases keep the sandbox active while the agent is
@@ -174,6 +177,13 @@ proxy injects only bounded code-server workbench HTML and pins parent messaging
174177
to the environment's exact app origin; generated-app preview HTML remains
175178
streamed.
176179

180+
Opening Files starts code-server directly against the requested workspace. It does not recursively
181+
enumerate the project first, so dependency trees and large generated projects are outside the cold
182+
start critical path. After an idle stop, a current tracked code-server session is relaunched from its
183+
durable command instead of repeating installation and cleanup probes. A project with no tracked
184+
app-preview record returns the terminal `none` state without starting Daytona or entering the
185+
preview wake polling loop.
186+
177187
AgentRun does not count, persist, bill, or emit model-token or model-cost data,
178188
and it does not apply per-run or daily dollar caps. Provider usage remains an
179189
opaque SDK concern.

apps/agent-worker/src/agent-routing.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import {
1010
findAgentEntitlementByUserId,
1111
findAgentRunForUser,
1212
getProject,
13-
getProjectWriteState,
1413
getThread,
1514
type RunPersonalization,
1615
withUserDb,
@@ -73,10 +72,10 @@ export async function requireWritableThreadProject(
7372
env: AgentEnv,
7473
userId: string,
7574
threadId: string,
76-
): Promise<void> {
75+
): Promise<{ id: string; name: string; workspaceSlug: string } | null> {
7776
const parsedUserId = toUserId(userId);
7877
return withUserDb(env, parsedUserId, async ({ transaction }) => {
79-
await transaction(async (tx) => {
78+
return transaction(async (tx) => {
8079
const thread = await getThread(tx, { threadId: toThreadId(threadId), userId: parsedUserId });
8180
if (!thread) {
8281
throw new APIError(404, "resource_thread_not_found", "Thread not found", {
@@ -85,32 +84,33 @@ export async function requireWritableThreadProject(
8584
}
8685
if (!thread.projectId) {
8786
// Project-less chats stay writable until a workspace-backed tool materializes a project.
88-
return;
87+
return null;
8988
}
90-
const state = await getProjectWriteState(tx, {
89+
const project = await getProject(tx, {
9190
projectId: thread.projectId,
9291
userId: parsedUserId,
9392
});
94-
if (!state) {
93+
if (!project) {
9594
throw new APIError(404, "resource_project_not_found", "Project not found", {
9695
retriable: false,
9796
});
9897
}
99-
if (state.readOnly) {
98+
if (project.readOnly) {
10099
throw new APIError(
101100
403,
102101
"permission_plan_required",
103102
"Project is read-only after downgrade",
104103
{
105104
details: {
106-
archiveAfter: state.archiveAfter?.toISOString() ?? null,
107-
overQuota: state.overQuota,
105+
archiveAfter: project.archiveAfter?.toISOString() ?? null,
106+
overQuota: project.overQuota,
108107
},
109108
hint: "Delete or archive over-limit projects, or upgrade your plan to continue editing this project.",
110109
retriable: false,
111110
},
112111
);
113112
}
113+
return { id: project.id, name: project.name, workspaceSlug: project.workspaceSlug };
114114
});
115115
});
116116
}

apps/agent-worker/src/durable-objects/agent-run-app-builder.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ interface AppBuilderWorkspace {
6464
mobile: boolean;
6565
port: number;
6666
slot: string;
67+
slug: string;
6768
}
6869

6970
function isMobileBuild(input: AgentRunAppBuilderInput): boolean {
@@ -120,7 +121,7 @@ async function resolveAppWorkspace(
120121
const slug = input.workspaceSlug;
121122
const slot = `app-preview:${slug}`;
122123
const port = await allocateAppPort(sandbox, slug, mobile, logger);
123-
return { dir, mobile, port, slot };
124+
return { dir, mobile, port, slot, slug };
124125
}
125126

126127
interface RunAppBuilderOptions {
@@ -538,7 +539,7 @@ async function startExpoDevServer(
538539
port: workspace.port,
539540
timeoutMs: 180_000,
540541
},
541-
{ sandbox },
542+
{ sandbox, workspaceDir: workspace.dir, workspaceSlug: workspace.slug },
542543
);
543544
}
544545

@@ -591,7 +592,7 @@ async function startAppBuilderDevServer(
591592
port: workspace.port,
592593
timeoutMs: 180_000,
593594
},
594-
{ sandbox },
595+
{ sandbox, workspaceDir: workspace.dir, workspaceSlug: workspace.slug },
595596
);
596597
}
597598

apps/agent-worker/src/durable-objects/agent-run-mastra-stream.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,13 +193,17 @@ function agentRequestContext(
193193
ensureWorkspace: async () => {
194194
const workspace = await options.workspaceResolver();
195195
codeRuntime.workspaceDir = workspace.workspaceDir;
196+
codeRuntime.workspaceSlug = workspace.workspaceSlug;
196197
return workspace;
197198
},
198199
sandbox: options.sandbox,
199200
...(isSkillCreator
200201
? { workspaceDir: "/workspace" }
201202
: input.workspaceSlug
202-
? { workspaceDir: workspacePathForSlug(input.workspaceSlug) }
203+
? {
204+
workspaceDir: workspacePathForSlug(input.workspaceSlug),
205+
workspaceSlug: input.workspaceSlug,
206+
}
203207
: {}),
204208
};
205209
return createCodeRequestContext(codeRuntime, {

apps/agent-worker/src/durable-objects/project-sandbox-content.ts

Lines changed: 50 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -441,26 +441,29 @@ async function wakePreview(
441441
input: ProjectWakePreviewInput,
442442
): Promise<ProjectWakePreviewResult> {
443443
const parsed = ProjectWakePreviewInputSchema.parse(input);
444-
const id = await context.runtime.ensureSandbox();
445444
const slot = parsed.workspaceSlug
446445
? `${APP_PREVIEW_SLOT_PREFIX}${parsed.workspaceSlug}`
447446
: "app-preview";
448447
const record = await context.dependencies.process.processRecord(slot);
449-
if (!record?.port) return { running: false, state: "started" };
448+
if (!record?.port) return { running: false, state: "none" };
449+
const id = await context.runtime.ensureSandbox();
450450
const mobile = await mobileExpoProxy(context.runtime, id, record);
451451
const repaired = record.isMobile
452452
? await ensureMobileMetroForwardedHostConfig(context.runtime, id, record.cwd)
453453
: false;
454454
let running = await context.dependencies.process.isPortAlive(id, record.port);
455455
if (!running || repaired) {
456-
await context.dependencies.process.relaunchDevServer(
456+
const relaunched = await context.dependencies.process.relaunchDevServer(
457457
id,
458458
slot,
459459
record,
460460
mobile?.restartEnv ?? restartEnvironment(slot, record),
461461
);
462462
await context.dependencies.process
463-
.waitForPort(id, record.port, "/", PREVIEW_WAKE_TIMEOUT_MS)
463+
.waitForPort(id, record.port, "/", PREVIEW_WAKE_TIMEOUT_MS, {
464+
cmdId: relaunched.cmdId,
465+
sessionId: relaunched.sessionId,
466+
})
464467
.catch(() => undefined);
465468
running = await context.dependencies.process.isPortAlive(id, record.port);
466469
}
@@ -501,14 +504,14 @@ async function projectPreviewStatus(
501504
input: ProjectPreviewStatusInput,
502505
): Promise<{ running: boolean; state: string }> {
503506
const parsed = ProjectPreviewStatusInputSchema.parse(input);
507+
const record = await context.dependencies.process.processRecord(
508+
`${APP_PREVIEW_SLOT_PREFIX}${parsed.workspaceSlug}`,
509+
);
510+
if (!record?.port) return { running: false, state: "none" };
504511
const runtimeState = await context.dependencies.sandboxRuntimeState();
505512
if (runtimeState.state !== "started" || !runtimeState.sandboxId) {
506513
return { running: false, state: runtimeState.state };
507514
}
508-
const record = await context.dependencies.process.processRecord(
509-
`${APP_PREVIEW_SLOT_PREFIX}${parsed.workspaceSlug}`,
510-
);
511-
if (!record?.port) return { running: false, state: runtimeState.state };
512515
const running = await context.dependencies.process.httpPortReady(
513516
runtimeState.sandboxId,
514517
record.port,
@@ -597,10 +600,16 @@ async function ensureMobileMetroForwardedHostConfig(
597600
}
598601

599602
async function ensureCodeServer(context: ContentContext, id: string): Promise<void> {
600-
if (
601-
(await context.dependencies.process.httpPortReady(id, CODE_SERVER_PORT, "/", 5_000)) &&
602-
(await hasCodeServerSettingsMarker(context.runtime, id))
603-
) {
603+
const [isPortReady, hasCurrentSettings] = await Promise.all([
604+
context.dependencies.process.httpPortReady(id, CODE_SERVER_PORT, "/", 5_000),
605+
hasCodeServerSettingsMarker(context.runtime, id),
606+
]);
607+
if (isPortReady && hasCurrentSettings) {
608+
return;
609+
}
610+
const tracked = await context.dependencies.process.processRecord(CODE_SERVER_PROCESS_ID);
611+
if (hasCurrentSettings && tracked?.port === CODE_SERVER_PORT) {
612+
await relaunchTrackedCodeServer(context, id, tracked);
604613
return;
605614
}
606615
if (!(await hasCodeServerRuntime(context.runtime, id))) {
@@ -628,15 +637,31 @@ async function ensureCodeServer(context: ContentContext, id: string): Promise<vo
628637
}
629638
}
630639

640+
async function relaunchTrackedCodeServer(
641+
context: ContentContext,
642+
id: string,
643+
record: ProcessRecord,
644+
): Promise<void> {
645+
const relaunched = await context.dependencies.process.relaunchDevServer(
646+
id,
647+
CODE_SERVER_PROCESS_ID,
648+
record,
649+
codeServerEnvironment(context.runtime.previewHostname()),
650+
);
651+
await context.dependencies.process.waitForPort(
652+
id,
653+
CODE_SERVER_PORT,
654+
"/",
655+
CODE_SERVER_START_TIMEOUT_MS,
656+
{ cmdId: relaunched.cmdId, sessionId: relaunched.sessionId },
657+
);
658+
}
659+
631660
async function startCodeServer(context: ContentContext): Promise<void> {
632661
await context.dependencies.coordinatedProcess.startProcess({
633662
command: ["bash", "-lc", codeServerStartCommand()],
634663
cwd: WORKSPACE_DIR,
635-
env: {
636-
CODE_SERVER_PORT: String(CODE_SERVER_PORT),
637-
CODE_SERVER_TRUSTED_ORIGINS: codeServerTrustedOrigins(context.runtime.previewHostname()),
638-
CODE_SERVER_WORKSPACE: WORKSPACE_DIR,
639-
},
664+
env: codeServerEnvironment(context.runtime.previewHostname()),
640665
keepAliveTimeoutMs: 0,
641666
maxRestarts: 3,
642667
processId: CODE_SERVER_PROCESS_ID,
@@ -650,6 +675,14 @@ async function startCodeServer(context: ContentContext): Promise<void> {
650675
});
651676
}
652677

678+
function codeServerEnvironment(previewHostname: string): Record<string, string> {
679+
return {
680+
CODE_SERVER_PORT: String(CODE_SERVER_PORT),
681+
CODE_SERVER_TRUSTED_ORIGINS: codeServerTrustedOrigins(previewHostname),
682+
CODE_SERVER_WORKSPACE: WORKSPACE_DIR,
683+
};
684+
}
685+
653686
async function hasCodeServerRuntime(runtime: ContentRuntime, id: string): Promise<boolean> {
654687
const probe = await runtime
655688
.client()

apps/agent-worker/src/durable-objects/project-sandbox-process-control.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export interface ProcessControl {
5353
name: string,
5454
record: ProcessRecord,
5555
restartEnv?: Record<string, string>,
56-
) => Promise<void>;
56+
) => Promise<ProcessRecord>;
5757
releaseProcessPort: (processId: string) => Promise<void>;
5858
terminateUntrackedSandboxProcesses: (id: string) => Promise<void>;
5959
waitForPort: (
@@ -116,7 +116,7 @@ async function relaunchDevServer(
116116
name: string,
117117
record: ProcessRecord,
118118
restartEnv?: Record<string, string>,
119-
): Promise<void> {
119+
): Promise<ProcessRecord> {
120120
const sessionId = record.sessionId || `cc-${name}`;
121121
await runtime.client().deleteSession(id, sessionId);
122122
const exec = await control.launchSessionProcess(
@@ -127,16 +127,18 @@ async function relaunchDevServer(
127127
supervisedProcessCommand(record.command, record),
128128
restartEnv ?? restartEnvironment(name, record),
129129
);
130+
const relaunched = {
131+
...record,
132+
cmdId: exec.cmdId ?? sessionId,
133+
startedAtMs: Date.now(),
134+
} satisfies ProcessRecord;
130135
try {
131-
await runtime.storage.put(`${PROC_PREFIX}${name}`, {
132-
...record,
133-
cmdId: exec.cmdId ?? sessionId,
134-
startedAtMs: Date.now(),
135-
} satisfies ProcessRecord);
136+
await runtime.storage.put(`${PROC_PREFIX}${name}`, relaunched);
136137
} catch (error) {
137138
await control.cleanupLaunchedProcess(id, sessionId, name);
138139
throw error;
139140
}
141+
return relaunched;
140142
}
141143

142144
async function waitForPort(

apps/agent-worker/src/sandbox-preview-http-routes.ts

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,7 @@ import { requireWritableThreadProject, sandboxForUser } from "./agent-routing";
1111
import {
1212
readSandboxStateCache,
1313
SANDBOX_WORKSPACE_ROOT,
14-
selectInitialCodeServerFile,
1514
terminalDisplayCwd,
16-
terminalProjectForThread,
1715
} from "./sandbox-route-support";
1816
import { parseThreadRouteParam, readGatewayUserId } from "./tenancy";
1917

@@ -38,31 +36,19 @@ async function openComputerIde(c: AgentContext): Promise<Response> {
3836
async function openThreadIde(c: AgentContext): Promise<Response> {
3937
const userId = readGatewayUserId(c.req.raw.headers);
4038
const threadId = parseThreadRouteParam(c.req.param("threadId") ?? "");
41-
await requireWritableThreadProject(c.env, userId, threadId);
42-
const project = await terminalProjectForThread(c.env, userId, threadId);
39+
const project = await requireWritableThreadProject(c.env, userId, threadId);
4340
const sandbox = await sandboxForUser(c.env, userId);
4441
const workspacePath = project
4542
? workspacePathForSlug(project.workspaceSlug)
4643
: SANDBOX_WORKSPACE_ROOT;
47-
const initialFilePath = project
48-
? selectInitialCodeServerFile(
49-
(await sandbox.listFiles({ includeHidden: false, path: workspacePath, recursive: true }))
50-
.files,
51-
workspacePath,
52-
)
53-
: undefined;
54-
const session = await sandbox.exposeCodeServer({
55-
...(initialFilePath ? { initialFilePath } : {}),
56-
workspacePath,
57-
});
44+
const session = await sandbox.exposeCodeServer({ workspacePath });
5845
return ideSessionResponse(c, session);
5946
}
6047

6148
async function wakeThreadPreview(c: AgentContext): Promise<Response> {
6249
const userId = readGatewayUserId(c.req.raw.headers);
6350
const threadId = parseThreadRouteParam(c.req.param("threadId") ?? "");
64-
await requireWritableThreadProject(c.env, userId, threadId);
65-
const project = await terminalProjectForThread(c.env, userId, threadId);
51+
const project = await requireWritableThreadProject(c.env, userId, threadId);
6652
const sandbox = await sandboxForUser(c.env, userId);
6753
const result = await sandbox.wakePreview({
6854
...(project ? { workspaceSlug: project.workspaceSlug } : {}),
@@ -74,8 +60,7 @@ async function wakeThreadPreview(c: AgentContext): Promise<Response> {
7460
async function threadPreviewStatus(c: AgentContext): Promise<Response> {
7561
const userId = readGatewayUserId(c.req.raw.headers);
7662
const threadId = parseThreadRouteParam(c.req.param("threadId") ?? "");
77-
await requireWritableThreadProject(c.env, userId, threadId);
78-
const project = await terminalProjectForThread(c.env, userId, threadId);
63+
const project = await requireWritableThreadProject(c.env, userId, threadId);
7964
const sandbox = await sandboxForUser(c.env, userId);
8065
if (project) {
8166
const status = await sandbox.projectPreviewStatus({ workspaceSlug: project.workspaceSlug });

0 commit comments

Comments
 (0)