Handle slow and stale workspace startup

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-14 11:52:02 -05:00
1 parent 1dce216cb3
commit ab8682d2aa
3 files changed
+178 -7

No files matched your search

+41 -6
View File
@@ -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
@@ -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;
+16 -1
View File
@@ -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<string, unknown>) {
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<string, unknown>;
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";