forked from farhoodlabs/paperclip
Stabilize runtime probes and Codex env tests (#5445)
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies
> - Adapters expose a Test action that probes the configured runtime —
install, resolvability, hello — to give operators a fast yes/no on
whether an environment is healthy
> - The Codex test path was running its hello probe directly without
going through the managed-runtime preparation that production runs use,
so a healthy production setup could still report a probe failure
> - The plugin worker manager wasn't surfacing terminated workers
cleanly, leaving the runtime probe waiting on a dead worker until the
request timed out
> - This pull request routes the Codex test probe through
`prepareAdapterExecutionTargetRuntime` (so it sees the same managed
Codex home production sees), exposes `commandCwd` on
`createCommandManagedRuntimeClient` so callers can target a per-probe
directory without leaking the workspace `remoteCwd`, and propagates
plugin-worker termination as a usable error instead of a hang
> - The benefit is the Codex Test action mirrors production behavior
end-to-end, and probes against a terminated plugin worker fail fast
instead of timing out
## What Changed
- `packages/adapter-utils/src/command-managed-runtime.ts`: rename the
`remoteCwd` knob to `commandCwd` so callers can target a per-probe
directory without inheriting the workspace cwd; matching test coverage
in `command-managed-runtime.test.ts`
- `packages/adapter-utils/src/sandbox-callback-bridge.{ts,test.ts}`:
small fixes to keep callback bridge stop semantics deterministic
- `packages/adapters/codex-local/src/server/test.ts`: thread the Codex
hello probe through `prepareAdapterExecutionTargetRuntime` +
`prepareManagedCodexHome` so the probe sees the same managed home
production sees; new `test.remote.test.ts` covers the remote probe path
- `packages/adapters/cursor-local/src/server/execute.ts`: small
probe-side cleanup that aligns with the new commandCwd contract
- `server/src/services/plugin-worker-manager.ts`: surface plugin-worker
termination as a structured error so callers fail fast; new
`plugin-worker-terminated.cjs` fixture and
`plugin-worker-manager.test.ts` cases pin the behavior
## Verification
- `pnpm vitest run --no-coverage --project @paperclipai/adapter-utils
--project @paperclipai/adapter-codex-local --project
@paperclipai/adapter-cursor-local --project @paperclipai/server` —
1749/1750 passing (1 unrelated skip)
- `pnpm typecheck` clean
## Risks
Low–medium. The `remoteCwd → commandCwd` rename is a parameter renaming
on an internal helper used only by adapter test/execute paths in this
repo. The plugin-worker-terminated path was previously a hang; failing
fast may surface latent timeouts as explicit termination errors in
callers that already expected them.
## Model Used
Claude Opus 4.7 (1M context)
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable — new tests cover
commandCwd, plugin-worker termination, and Codex remote test path
- [x] If this change affects the UI, I have included before/after
screenshots — N/A (no UI)
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
---
> **Stacked PR.** Sits on top of #5444 which adds the per-run runtime
API surface this PR builds on. Cumulative diff against `master` includes
that PR's content; the files touched by *this* PR's commit are listed
under "What Changed" above. Will rebase onto `master` and force-push
once #5444 merges.
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
const readline = require("node:readline");
|
||||
|
||||
function send(message) {
|
||||
process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
rl.on("line", (line) => {
|
||||
if (!line.trim()) return;
|
||||
const message = JSON.parse(line);
|
||||
const method = message && typeof message.method === "string" ? message.method : null;
|
||||
|
||||
if (method === "initialize") {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
result: {
|
||||
ok: true,
|
||||
supportedMethods: ["environmentExecute"],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "environmentExecute") {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
error: {
|
||||
code: -32002,
|
||||
message: "[unknown] terminated",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "shutdown") {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
result: {},
|
||||
});
|
||||
setImmediate(() => process.exit(0));
|
||||
return;
|
||||
}
|
||||
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
error: {
|
||||
code: -32601,
|
||||
message: `Unhandled method: ${method}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { appendStderrExcerpt, formatWorkerFailureMessage } from "../services/plugin-worker-manager.js";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
import {
|
||||
JsonRpcCallError,
|
||||
type HostToWorkerMethods,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
import {
|
||||
appendStderrExcerpt,
|
||||
createPluginWorkerHandle,
|
||||
formatWorkerFailureMessage,
|
||||
} from "../services/plugin-worker-manager.js";
|
||||
|
||||
const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
|
||||
const TERMINATED_WORKER_ENTRYPOINT = path.join(FIXTURES_DIR, "plugin-worker-terminated.cjs");
|
||||
|
||||
const TEST_MANIFEST: PaperclipPluginManifestV1 = {
|
||||
id: "test.plugin",
|
||||
apiVersion: 1,
|
||||
version: "1.0.0",
|
||||
displayName: "Test plugin",
|
||||
description: "Test plugin",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: [],
|
||||
entrypoints: { worker: "dist/worker.js" },
|
||||
};
|
||||
|
||||
describe("plugin-worker-manager stderr failure context", () => {
|
||||
it("appends worker stderr context to failure messages", () => {
|
||||
@@ -40,4 +66,48 @@ describe("plugin-worker-manager stderr failure context", () => {
|
||||
expect(excerpt).not.toContain("second line");
|
||||
expect(excerpt.length).toBeLessThanOrEqual(8_000);
|
||||
});
|
||||
|
||||
it("does not emit an unhandled rejection when a plugin responds with terminated before callers attach handlers", async () => {
|
||||
const unhandledRejection = vi.fn();
|
||||
process.on("unhandledRejection", unhandledRejection);
|
||||
|
||||
const handle = createPluginWorkerHandle("test.plugin", {
|
||||
entrypointPath: TERMINATED_WORKER_ENTRYPOINT,
|
||||
manifest: TEST_MANIFEST,
|
||||
config: {},
|
||||
instanceInfo: {
|
||||
instanceId: "instance-1",
|
||||
hostVersion: "1.0.0",
|
||||
},
|
||||
apiVersion: 1,
|
||||
hostHandlers: {},
|
||||
});
|
||||
|
||||
try {
|
||||
await handle.start();
|
||||
|
||||
const pendingCall = handle.call(
|
||||
"environmentExecute" as keyof HostToWorkerMethods,
|
||||
{
|
||||
driverKey: "e2b",
|
||||
companyId: "company-1",
|
||||
environmentId: "environment-1",
|
||||
config: {},
|
||||
lease: { providerLeaseId: "lease-1" },
|
||||
command: "echo",
|
||||
} as HostToWorkerMethods[keyof HostToWorkerMethods][0],
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
await expect(pendingCall).rejects.toBeInstanceOf(JsonRpcCallError);
|
||||
await expect(pendingCall).rejects.toMatchObject({
|
||||
message: expect.stringContaining("terminated"),
|
||||
});
|
||||
expect(unhandledRejection).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
process.off("unhandledRejection", unhandledRejection);
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1006,7 +1006,7 @@ export function createPluginWorkerHandle(
|
||||
params: HostToWorkerMethods[M][0],
|
||||
timeoutMs?: number,
|
||||
): Promise<HostToWorkerMethods[M][1]> {
|
||||
return new Promise<HostToWorkerMethods[M][1]>((resolve, reject) => {
|
||||
const rpcPromise = new Promise<HostToWorkerMethods[M][1]>((resolve, reject) => {
|
||||
if (!childProcess?.stdin?.writable) {
|
||||
reject(
|
||||
new Error(
|
||||
@@ -1076,6 +1076,14 @@ export function createPluginWorkerHandle(
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Some call sites hand these promises across async boundaries before
|
||||
// attaching their own handlers. Mark the promise as handled here so a
|
||||
// worker-side JSON-RPC error can fail the caller without killing the host
|
||||
// process via an unhandled rejection.
|
||||
void rpcPromise.catch(() => undefined);
|
||||
|
||||
return rpcPromise;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user