Skip to content

Commit 2a97bf8

Browse files
committed
fix(agent-worker): restart Metro after mobile build so preview shows the built app
Root cause of the stale mobile web preview ('Welcome to Expo' scaffold showing even after the agent built the app): Metro boots on the scaffold BEFORE the agent edits files, and its file-watcher never sees those edits — inotify is unreliable on the Daytona FS (the Next.js path already force-polls via CHOKIDAR/WATCHPACK, but Metro has no polling watcher). So Metro's module graph stays pinned to the scaffold; even a hard refresh re-bundles from the stale server-side map. Reproduces on prod. Fix (mobile only): after the edit stream finishes, restart the Metro dev server once. A fresh 'expo start -c' re-crawls the workspace and reads the agent's edited files, sidestepping the watcher entirely. Reuses existing process-replacement (startProcess kills the old app-preview + frees port 8081) and the deterministic port-8081 preview URL, so the client URL is unchanged. New restartMobilePreview() + a gated await before snapshotAppBuilderWorkspace; extracted executeAppBuilderRunPath to keep complexity <=15. Also added -c to the Expo argv (defense-in-depth, matches V1). Web/Next.js untouched. Gate green (18/18).
1 parent 1b40eac commit 2a97bf8

2 files changed

Lines changed: 77 additions & 31 deletions

File tree

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,26 @@ async function startTemplatePreview(
147147
return { previewUrl, expoUrl };
148148
}
149149

150+
// Metro (mobile) starts BEFORE the model edits files, and its file-watcher never observes the
151+
// agent's Daytona uploadFile writes in the sandbox container (no watchman + unreliable inotify —
152+
// the same reason the Next.js path force-polls). So its in-memory file-map keeps bundling the
153+
// scaffold even after the counter lands on disk, and a browser hard-refresh just re-bundles from
154+
// that stale map. Restarting the dev server once the edit stream completes makes a fresh Metro
155+
// process re-crawl the workspace and bundle the finished app. startProcess reuses the "app-preview"
156+
// slot (it kills the old process + frees port 8081), and the port-8081 preview URL is deterministic,
157+
// so the restart is transparent to the client. Mobile only — web/Next.js hot-reloads via polling.
158+
export async function restartMobilePreview(
159+
options: Pick<RunAppBuilderOptions, "append" | "env" | "logger" | "sandbox" | "setRunStage">,
160+
): Promise<void> {
161+
const { append, env, logger, sandbox, setRunStage } = options;
162+
setRunStage("Reloading the preview.");
163+
const { expoUrl, previewUrl } = await startExpoDevServer(env, sandbox, logger);
164+
await append({
165+
type: "data-sandbox-status",
166+
data: { v: 1, status: "ready", previewUrl, ...(expoUrl ? { expoUrl } : {}) },
167+
});
168+
}
169+
150170
// First import run only: clone the public GitHub repo over the empty workspace,
151171
// drop the one-shot marker, best-effort install, and hand control to the agent
152172
// without auto-starting a dev server (framework/port are unknowable). Failure
@@ -415,11 +435,15 @@ async function startExpoDevServer(
415435
// build as a real web page at `/` (iframe-renderable in the Computer panel),
416436
// while the SAME server keeps answering exp:// manifests for Expo Go — so we
417437
// get both the in-panel preview and the QR from one process on port 8081.
438+
// `-c` clears Metro's transform/file-map cache on start (boolean flag, order-
439+
// independent among the other flags): harmless on the initial boot, and what
440+
// makes the post-edit restart (restartMobilePreview) re-crawl from a clean slate.
418441
command: [
419442
"pnpm",
420443
"exec",
421444
"expo",
422445
"start",
446+
"-c",
423447
"--web",
424448
"--host",
425449
"lan",

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

Lines changed: 53 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from "../streaming/ui-message-stream";
1313
import { emitRunAbandoned } from "./agent-run-abandonment";
1414
import {
15+
restartMobilePreview,
1516
restoreBestEffortSnapshot,
1617
runAppBuilder,
1718
snapshotAppBuilderWorkspace,
@@ -510,48 +511,69 @@ export class AgentRun extends DurableObject<AgentRunEnv> {
510511
input.projectMode === "app-builder-mobile" ||
511512
isAppBuilderRequest(input.messageText)
512513
) {
513-
await warmSandbox(sandbox, logger);
514-
if (this.isRunCanceled()) {
515-
return "completed";
516-
}
517-
const { agentContextNote } = await runAppBuilder({
514+
return this.executeAppBuilderRunPath(input, sandbox, logger, abortSignal);
515+
}
516+
await streamMastraRunWithFallback(this.streamDriverDeps(), {
517+
abortSignal,
518+
input,
519+
logger,
520+
sandbox,
521+
});
522+
if (this.isRunCanceled()) {
523+
return "completed";
524+
}
525+
return "continue";
526+
}
527+
528+
private async executeAppBuilderRunPath(
529+
input: StartRunInput,
530+
sandbox: ProjectSandboxStub,
531+
logger: ReturnType<typeof createLogger>,
532+
abortSignal: AbortSignal,
533+
): Promise<"completed" | "continue"> {
534+
await warmSandbox(sandbox, logger);
535+
if (this.isRunCanceled()) {
536+
return "completed";
537+
}
538+
const { agentContextNote } = await runAppBuilder({
539+
append: (chunk) => this.append(chunk),
540+
env: this.env,
541+
input,
542+
logger,
543+
sandbox,
544+
setRunStage: (stage) => this.setRunStage(stage),
545+
});
546+
if (this.isRunCanceled()) {
547+
return "completed";
548+
}
549+
await streamMastraRunWithFallback(this.streamDriverDeps(), {
550+
abortSignal,
551+
...(agentContextNote === undefined ? {} : { agentContextNote }),
552+
input,
553+
logger,
554+
sandbox,
555+
});
556+
if (this.isRunCanceled()) {
557+
return "completed";
558+
}
559+
// Mobile Metro can't watch the model's sandbox file edits, so restart it once the edit
560+
// stream is done to re-crawl the finished app onto the (unchanged) preview URL. Web/Next.js
561+
// hot-reloads via polling and needs no restart.
562+
if (input.projectMode === "app-builder-mobile") {
563+
await restartMobilePreview({
518564
append: (chunk) => this.append(chunk),
519565
env: this.env,
520-
input,
521566
logger,
522567
sandbox,
523568
setRunStage: (stage) => this.setRunStage(stage),
524569
});
525-
if (this.isRunCanceled()) {
526-
return "completed";
527-
}
528-
await streamMastraRunWithFallback(this.streamDriverDeps(), {
529-
abortSignal,
530-
...(agentContextNote === undefined ? {} : { agentContextNote }),
531-
input,
532-
logger,
533-
sandbox,
534-
});
535-
if (this.isRunCanceled()) {
536-
return "completed";
537-
}
538-
await snapshotAppBuilderWorkspace({
539-
env: this.env,
540-
input,
541-
logger,
542-
sandbox,
543-
});
544-
return "continue";
545570
}
546-
await streamMastraRunWithFallback(this.streamDriverDeps(), {
547-
abortSignal,
571+
await snapshotAppBuilderWorkspace({
572+
env: this.env,
548573
input,
549574
logger,
550575
sandbox,
551576
});
552-
if (this.isRunCanceled()) {
553-
return "completed";
554-
}
555577
return "continue";
556578
}
557579

0 commit comments

Comments
 (0)