diff --git a/docs/durable-pi.md b/docs/durable-pi.md index 3b7a69bfa..5f7e521dd 100644 --- a/docs/durable-pi.md +++ b/docs/durable-pi.md @@ -11,7 +11,10 @@ alias), because Pi-managed installs deliberately omit host-provided peers. Only the durable worker maps Pi imports to that SDK and its dependencies; the extension continues to use Main's host modules. If a partial/older installation reports a missing worker SDK, reinstall or update Fabric to restore its runtime -dependencies. Installing Pi peers manually is not required. +dependencies. Installing Pi peers manually is not required. When Bun launches +the parent worker, the durable SDK host runs through `node` on PATH and requires +Node.js 24+, matching Fabric's declared engine. This keeps module resolution +isolated without passing `NODE_PATH` to tools or borrowing Main's SDK. Set `agents.runner: "pi"` (or `runner: "pi"` per request) for the previous Pi CLI worker. Existing explicit configurations and stored actor runners are not diff --git a/src/durable/worker.ts b/src/durable/worker.ts index 8f293fbf6..ad7fafe25 100644 --- a/src/durable/worker.ts +++ b/src/durable/worker.ts @@ -1,31 +1,45 @@ #!/usr/bin/env node -import { registerHooks } from "node:module"; +import { spawn } from "node:child_process"; +import module from "node:module"; +import { fileURLToPath } from "node:url"; import { parseDurableWorkerOptions } from "./worker-options.js"; try { // This process-only entry is never imported by extension registration. const options = parseDurableWorkerOptions(process.argv.slice(2)); - // Managed Pi installs omit host peers. A subprocess cannot use Main's virtual - // modules, so it owns a pinned SDK under a non-peer name. Resolve all Pi peers - // through that SDK's dependency tree (also with non-hoisting installers), not - // an ambient checkout or another extension's SDK. This hook is process-local - // and must be installed before importing any durable host/engine modules. - const sdkEntry = import.meta.resolve("pi-fabric-worker-sdk"); - const sdkName = "@earendil-works/pi-coding-agent"; - const peers = ["@earendil-works/pi-agent-core", "@earendil-works/pi-ai", "@earendil-works/pi-tui", "typebox"]; - registerHooks({ - resolve(specifier, context, nextResolve) { - if (specifier === sdkName || specifier.startsWith(`${sdkName}/`)) { - return nextResolve(`pi-fabric-worker-sdk${specifier.slice(sdkName.length)}`, { ...context, parentURL: import.meta.url }); - } - if (peers.some(name => specifier === name || specifier.startsWith(`${name}/`))) { - return nextResolve(specifier, { ...context, parentURL: sdkEntry }); - } - return nextResolve(specifier, context); - }, - }); - const { runDurableWorker } = await import("./worker-host.js"); - await runDurableWorker(options); + if (process.versions.bun) { + // Bun cannot install Node's peer-resolution hooks. Keep the pinned SDK in + // the package's required Node 24+ runtime; never inject ambient NODE_PATH. + const child = spawn("node", [fileURLToPath(import.meta.url), ...process.argv.slice(2)], { stdio: "inherit" }); + const code = await new Promise((resolve, reject) => { + child.once("error", error => reject(new Error(`Bun-launched durable workers require Node.js 24+ on PATH: ${error.message}`, { cause: error }))); + child.once("exit", (code, signal) => resolve(code ?? (signal ? 1 : 0))); + }); + process.exitCode = code; + } else { + if (Number.parseInt(process.versions.node, 10) < 24) throw new Error("Durable Pi workers require Node.js 24+"); + // Managed Pi installs omit host peers. A subprocess cannot use Main's virtual + // modules, so it owns a pinned SDK under a non-peer name. Resolve all Pi peers + // through that SDK's dependency tree (also with non-hoisting installers), not + // an ambient checkout or another extension's SDK. This hook is process-local + // and must be installed before importing any durable host/engine modules. + const sdkEntry = import.meta.resolve("pi-fabric-worker-sdk"); + const sdkName = "@earendil-works/pi-coding-agent"; + const peers = ["@earendil-works/pi-agent-core", "@earendil-works/pi-ai", "@earendil-works/pi-tui", "typebox"]; + module.registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === sdkName || specifier.startsWith(`${sdkName}/`)) { + return nextResolve(`pi-fabric-worker-sdk${specifier.slice(sdkName.length)}`, { ...context, parentURL: import.meta.url }); + } + if (peers.some(name => specifier === name || specifier.startsWith(`${name}/`))) { + return nextResolve(specifier, { ...context, parentURL: sdkEntry }); + } + return nextResolve(specifier, context); + }, + }); + const { runDurableWorker } = await import("./worker-host.js"); + await runDurableWorker(options); + } } catch (error) { process.stderr.write(`Durable Pi worker failed: ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`); process.exitCode = 1; diff --git a/tests/durable-worker-installation.test.ts b/tests/durable-worker-installation.test.ts index e3e830738..3dd2b5f52 100644 --- a/tests/durable-worker-installation.test.ts +++ b/tests/durable-worker-installation.test.ts @@ -1,3 +1,6 @@ +import { execFile, spawnSync } from "node:child_process"; +import { promisify } from "node:util"; +import type { AgentRunRecord } from "../src/agents/types.js"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -56,7 +59,34 @@ function installation() { return { root, manager }; } +const bunAvailable = spawnSync("bun", ["--version"], { timeout: 5000 }).status === 0; + describe("installed durable worker without host peers", () => { + it.skipIf(!bunAvailable)("runs the parent and durable SDK worker under Bun without host peers", async () => { + const { root } = installation(); + const taskFile = path.join(root, "task.txt"); + const statusFile = path.join(root, "bun-status.json"); + fs.writeFileSync(taskFile, "read fixture"); + const flags = { + id: "bun-probe", name: "bun-probe", runner: "pi-durable", cwd: root, + "task-file": taskFile, "status-file": statusFile, + "lifecycle-file": path.join(root, "bun-lifecycle.jsonl"), "log-file": path.join(root, "bun-events.jsonl"), + "pi-binary": "forbidden-legacy-fallback", "claude-binary": "unused", "veda-binary": "unused", + "veda-backend": "agy", "veda-persona": "unused", "timeout-ms": "20000", depth: "1", + "full-code-mode": "false", extensions: "true", tools: '["read"]', "granted-risks": "[]", transport: "process", + }; + const child = await promisify(execFile)("bun", [path.join(root, "dist/worker.js"), + ...Object.entries(flags).flatMap(([key, value]) => [`--${key}`, value])], + { cwd: root, timeout: 25000, maxBuffer: 100000 }).then( + output => ({ ...output, exitCode: 0 }), error => ({ stderr: String(error.stderr), exitCode: error.code }), + ); + expect(child, child.stderr).toMatchObject({ exitCode: 0 }); + const record = JSON.parse(fs.readFileSync(statusFile, "utf8")) as AgentRunRecord; + expect(record.status, record.error).toBe("completed"); + expect(record.text).toContain("installed durable worker works"); + expect(record.toolCalls).toBe(1); + }, 30000); + it("runs SDK RPC, a discovered extension, and a real tool outside the checkout", async () => { const { manager } = installation(); const result = await manager.run({ task: "read fixture", tools: ["read"] });