From ab8682d2aa4ed7a4d600c99c42151f02d48e907b Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 13 Apr 2026 11:21:58 -0500 Subject: [PATCH] Handle slow and stale workspace startup Co-Authored-By: Paperclip --- scripts/provision-worktree.sh | 47 ++++++- .../src/__tests__/workspace-runtime.test.ts | 121 ++++++++++++++++++ server/src/services/workspace-runtime.ts | 17 ++- 3 files changed, 178 insertions(+), 7 deletions(-) diff --git a/scripts/provision-worktree.sh b/scripts/provision-worktree.sh index 81c4aff423d..ba90e9ff9fd 100644 --- a/scripts/provision-worktree.sh +++ b/scripts/provision-worktree.sh @@ -404,14 +404,49 @@ if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; t done } - ( - cd "$worktree_cwd" - pnpm install --frozen-lockfile - ) || { - restore_moved_symlinks - exit 1 + run_pnpm_install() { + local stdout_path stderr_path + stdout_path="$(mktemp)" + stderr_path="$(mktemp)" + + if ( + cd "$worktree_cwd" + pnpm install "$@" + ) >"$stdout_path" 2>"$stderr_path"; then + cat "$stdout_path" + cat "$stderr_path" >&2 + rm -f "$stdout_path" "$stderr_path" + return 0 + fi + + local exit_code=$? + cat "$stdout_path" + cat "$stderr_path" >&2 + if grep -q "ERR_PNPM_OUTDATED_LOCKFILE" "$stdout_path" "$stderr_path"; then + rm -f "$stdout_path" "$stderr_path" + return 90 + fi + + rm -f "$stdout_path" "$stderr_path" + return "$exit_code" } + if run_pnpm_install --frozen-lockfile; then + : + else + install_exit_code=$? + if [[ "$install_exit_code" -eq 90 ]]; then + echo "pnpm-lock.yaml is out of date in this execution workspace; retrying install without --frozen-lockfile." >&2 + run_pnpm_install --no-frozen-lockfile || { + restore_moved_symlinks + exit 1 + } + else + restore_moved_symlinks + exit "$install_exit_code" + fi + fi + cleanup_moved_symlinks fi diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 1ff4e738c29..e44f40da957 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -30,6 +30,7 @@ import { realizeExecutionWorkspace, releaseRuntimeServicesForRun, resetRuntimeServicesForTests, + resolveWorkspaceRuntimeReadinessTimeoutSec, resolveShell, sanitizeRuntimeServiceBaseEnv, startRuntimeServicesForWorkspaceControl, @@ -1191,6 +1192,81 @@ describe("realizeExecutionWorkspace", () => { } }); + it("retries worktree-local pnpm install without a frozen lockfile when the lockfile is outdated", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-outdated-lockfile-")); + const baseRoot = path.join(tempRoot, "base"); + const worktreeRoot = path.join(tempRoot, "worktree"); + const fakeBin = path.join(tempRoot, "bin"); + const fakePnpmPath = path.join(fakeBin, "pnpm"); + const scriptPath = path.join(worktreeRoot, "provision-worktree.sh"); + + try { + await fs.mkdir(path.join(baseRoot, "node_modules"), { recursive: true }); + await fs.mkdir(worktreeRoot, { recursive: true }); + await fs.mkdir(fakeBin, { recursive: true }); + await fs.copyFile(provisionWorktreeScriptPath, scriptPath); + await fs.chmod(scriptPath, 0o755); + await fs.writeFile( + path.join(worktreeRoot, "package.json"), + JSON.stringify( + { + name: "workspace-root", + private: true, + packageManager: "pnpm@9.15.4", + }, + null, + 2, + ), + "utf8", + ); + await fs.writeFile( + path.join(worktreeRoot, "pnpm-lock.yaml"), + ["lockfileVersion: '9.0'", "", "importers:", " .: {}", ""].join("\n"), + "utf8", + ); + await fs.writeFile( + fakePnpmPath, + [ + "#!/bin/sh", + "if [ \"$1\" = \"paperclipai\" ] && [ \"$2\" = \"--help\" ]; then", + " exit 1", + "fi", + "if [ \"$1\" = \"install\" ] && [ \"$2\" = \"--frozen-lockfile\" ]; then", + " echo \"ERR_PNPM_OUTDATED_LOCKFILE\" >&2", + " exit 1", + "fi", + "if [ \"$1\" = \"install\" ] && [ \"$2\" = \"--no-frozen-lockfile\" ]; then", + " mkdir -p \"$PWD/node_modules\"", + " : > \"$PWD/node_modules/.retry-success\"", + " exit 0", + "fi", + "exit 0", + "", + ].join("\n"), + "utf8", + ); + await fs.chmod(fakePnpmPath, 0o755); + + const result = await execFileAsync(scriptPath, [], { + cwd: worktreeRoot, + env: { + ...process.env, + PATH: `${fakeBin}:${process.env.PATH ?? ""}`, + PAPERCLIP_WORKSPACE_BASE_CWD: baseRoot, + PAPERCLIP_WORKSPACE_CWD: worktreeRoot, + }, + }); + + expect(result.stderr).toContain("retrying install without --frozen-lockfile"); + await expect(fs.readFile(path.join(worktreeRoot, "node_modules", ".retry-success"), "utf8")).resolves.toBe(""); + await expect(fs.readFile(path.join(worktreeRoot, ".paperclip", "config.json"), "utf8")).resolves.toContain( + "\"database\"", + ); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } + }); + it( "provisions worktree-local pnpm node_modules instead of reusing base-repo links", async () => { @@ -2501,6 +2577,51 @@ describe("buildWorkspaceRuntimeDesiredStatePatch", () => { }); }); +describe("resolveWorkspaceRuntimeReadinessTimeoutSec", () => { + it("extends the default readiness timeout for dev-server commands", () => { + expect( + resolveWorkspaceRuntimeReadinessTimeoutSec({ + command: "pnpm dev", + readiness: { + type: "http", + urlTemplate: "http://127.0.0.1:{{port}}", + }, + }), + ).toBe(90); + expect( + resolveWorkspaceRuntimeReadinessTimeoutSec({ + command: "npm run dev -- --host 127.0.0.1", + readiness: { + type: "http", + urlTemplate: "http://127.0.0.1:{{port}}", + }, + }), + ).toBe(90); + }); + + it("keeps explicit readiness timeouts and non-dev defaults unchanged", () => { + expect( + resolveWorkspaceRuntimeReadinessTimeoutSec({ + command: "pnpm dev", + readiness: { + type: "http", + timeoutSec: 12, + urlTemplate: "http://127.0.0.1:{{port}}", + }, + }), + ).toBe(12); + expect( + resolveWorkspaceRuntimeReadinessTimeoutSec({ + command: "node server.js", + readiness: { + type: "http", + urlTemplate: "http://127.0.0.1:{{port}}", + }, + }), + ).toBe(30); + }); +}); + describe("resolveShell (shell fallback)", () => { const originalShell = process.env.SHELL; const originalPlatform = process.platform; diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index fc9aa64e51b..87a093a50a4 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -1697,6 +1697,21 @@ function resolveServiceScopeId(input: { return { scopeType: "run" as const, scopeId: input.runId }; } +function looksLikeWorkspaceDevServerCommand(command: string) { + const normalized = command.trim().toLowerCase(); + if (!normalized) return false; + return /(?:^|\s)(?:pnpm|npm|yarn|bun)\s+(?:run\s+)?dev(?:\s|$)/.test(normalized); +} + +export function resolveWorkspaceRuntimeReadinessTimeoutSec(service: Record) { + const readiness = parseObject(service.readiness); + const explicitTimeoutSec = asNumber(readiness.timeoutSec, 0); + if (explicitTimeoutSec > 0) { + return Math.max(1, explicitTimeoutSec); + } + return looksLikeWorkspaceDevServerCommand(asString(service.command, "")) ? 90 : 30; +} + async function waitForReadiness(input: { service: Record; url: string | null; @@ -1704,7 +1719,7 @@ async function waitForReadiness(input: { const readiness = parseObject(input.service.readiness); const readinessType = asString(readiness.type, ""); if (readinessType !== "http" || !input.url) return; - const timeoutSec = Math.max(1, asNumber(readiness.timeoutSec, 30)); + const timeoutSec = resolveWorkspaceRuntimeReadinessTimeoutSec(input.service); const intervalMs = Math.max(100, asNumber(readiness.intervalMs, 500)); const deadline = Date.now() + timeoutSec * 1000; let lastError = "service did not become ready";