Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/durable-pi.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 36 additions & 22 deletions src/durable/worker.ts
Original file line number Diff line number Diff line change
@@ -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<number>((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;
Expand Down
30 changes: 30 additions & 0 deletions tests/durable-worker-installation.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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"] });
Expand Down
Loading