diff --git a/apps/host-daemon/src/plugin-host-manager.test.ts b/apps/host-daemon/src/plugin-host-manager.test.ts index 7f539a9227..f1899dcac3 100644 --- a/apps/host-daemon/src/plugin-host-manager.test.ts +++ b/apps/host-daemon/src/plugin-host-manager.test.ts @@ -58,9 +58,20 @@ export default { await context.experimental_emitSignal("changed", payload); return payload; }, - async environment(input) { + async environment(input, context) { const before = process.env.GATE_VALUE; - await new Promise((resolve) => setTimeout(resolve, input.delay ?? 0)); + if (input.id) await context.experimental_emitSignal("changed", { id: input.id, pid: process.pid }); + await new Promise((resolve) => { + if (input.hang) return; + const finish = () => { + clearTimeout(timer); + context.signal.removeEventListener("abort", finish); + resolve(); + }; + const timer = setTimeout(finish, input.delay ?? 0); + context.signal.addEventListener("abort", finish, { once: true }); + if (context.signal.aborted) finish(); + }); return { before: before ?? null, after: process.env.GATE_VALUE ?? null, token: process.env.GH_TOKEN ?? null }; }, echo(input) { return { input, pid: process.pid }; }, @@ -189,7 +200,7 @@ describe("PluginHostManager", () => { expect(fetchArtifact).toHaveBeenCalledOnce(); }); - it("scopes setup env, waits for rotation, and returns worker output as-is", async () => { + it("scopes setup env, rotates while idle, and returns worker output as-is", async () => { const manager = await createManager({ shellEnv: () => ({ npm_config_user_agent: "test" }), }); @@ -207,22 +218,22 @@ describe("PluginHostManager", () => { source: { core: "machine-git" as const }, }, ]; - const [first, rotated] = await Promise.all([ - manager.call( + const [first, rotated] = [ + await manager.call( callCommand({ method: "environment", input: { delay: 100 }, contributedEnv: contribution("first"), }), ), - manager.call( + await manager.call( callCommand({ method: "environment", input: {}, contributedEnv: contribution("rotated"), }), ), - ]); + ]; expect(first.output).toEqual({ before: "first", after: "first", @@ -239,6 +250,183 @@ describe("PluginHostManager", () => { ).toEqual({ before: null, after: null, token: null }); }); + describe("environment reuse across active calls", () => { + async function fixture() { + const onSignal = vi.fn(); + const onWorkerExit = vi.fn(); + const manager = await createManager({ + shellEnv: () => ({ GATE_VALUE: "base" }), + onSignal, + onWorkerExit, + }); + const command = (id: string, value: string, delay = 0) => + callCommand({ + callId: id, + method: "environment", + input: { id, delay }, + timeoutMs: 15_000, + contributedEnv: [ + { + name: "GATE_VALUE", + value, + reason: "test", + source: { core: "machine-environment" }, + }, + ], + }); + const cancel = (callId: string) => + manager.cancel({ + type: "plugin.host.cancel", + pluginId: "fixture", + generation: "generation-1", + callId, + }); + const started = (id: string) => + expect.objectContaining({ + payload: expect.objectContaining({ id }), + }); + const waitForStart = (id: string) => + vi.waitFor(() => expect(onSignal).toHaveBeenCalledWith(started(id))); + return { + manager, + command, + cancel, + started, + waitForStart, + onSignal, + onWorkerExit, + }; + } + + it.each(["cancel", "deadline", "same-value"])( + "preserves a long running call and its PID after %s", + async (mode) => { + const { + manager, + command, + cancel, + started, + waitForStart, + onSignal, + onWorkerExit, + } = await fixture(); + const initial = await manager.call(callCommand()); + const running = Promise.allSettled([ + manager.call(command("a", "first", 6500)), + ]); + await waitForStart("a"); + const b = command( + "b", + mode === "same-value" ? "first" : "second", + 10_000, + ); + const cancelled = manager + .call({ + ...b, + timeoutMs: mode === "deadline" ? 200 : b.timeoutMs, + }) + .catch((error: unknown) => error); + await waitForStart("b"); + if (mode !== "deadline") { + expect(cancel("b")).toEqual({ cancelled: true }); + cancel("b"); + } + const error = await cancelled; + if (mode === "deadline") + expect(error).toMatchObject({ + message: expect.stringMatching(/deadline/u), + }); + else expect(error).toMatchObject({ name: "AbortError" }); + expect(await running).toMatchObject([ + { + status: "fulfilled", + value: { output: { before: "first", after: "first" } }, + }, + ]); + expect(onSignal).toHaveBeenCalledWith(started("b")); + expect( + (await manager.call(command("c", "third"))).output, + ).toMatchObject({ before: "third", after: "third" }); + expect( + ( + await manager.call( + callCommand({ method: "environment", input: {} }), + ) + ).output, + ).toMatchObject({ before: "base", after: "base" }); + expect((await manager.call(callCommand())).output).toEqual( + initial.output, + ); + expect(onWorkerExit).not.toHaveBeenCalled(); + }, + ); + + it("keeps the first values until every overlapping call finishes", async () => { + const { manager, command, cancel, waitForStart } = await fixture(); + const a = manager + .call(command("a", "first", 10_000)) + .catch((error: unknown) => error); + await waitForStart("a"); + const b = manager + .call(command("b", "second", 10_000)) + .catch((error: unknown) => error); + await waitForStart("b"); + cancel("a"); + await expect(a).resolves.toMatchObject({ name: "AbortError" }); + await expect(manager.call(command("c", "third"))).resolves.toMatchObject({ + output: { before: "first", after: "first" }, + }); + cancel("b"); + await expect(b).resolves.toMatchObject({ name: "AbortError" }); + await expect(manager.call(command("d", "fourth"))).resolves.toMatchObject( + { + output: { before: "fourth", after: "fourth" }, + }, + ); + }); + + it("disposes with overlapping calls and starts a fresh generation", async () => { + const { manager, command, waitForStart } = await fixture(); + const a = manager + .call(command("a", "first", 10_000)) + .catch((error: unknown) => error); + await waitForStart("a"); + const b = manager + .call(command("b", "second", 10_000)) + .catch((error: unknown) => error); + await waitForStart("b"); + await manager.dispose({ + type: "plugin.host.dispose", + pluginId: "fixture", + generation: "generation-1", + }); + expect(await a).toBeInstanceOf(Error); + expect(await b).toBeInstanceOf(Error); + await expect( + manager.call({ ...command("c", "third"), generation: "generation-2" }), + ).resolves.toMatchObject({ output: { before: "third", after: "third" } }); + }); + + it("still force-kills a started handler that ignores cancellation", async () => { + const { manager, command, cancel, waitForStart, onWorkerExit } = + await fixture(); + const initial = await manager.call(callCommand()); + const hung = manager + .call({ + ...command("hung", "first"), + input: { id: "hung", hang: true }, + }) + .catch((error: unknown) => error); + await waitForStart("hung"); + cancel("hung"); + await expect(hung).resolves.toMatchObject({ name: "AbortError" }); + expect(onWorkerExit).toHaveBeenCalledOnce(); + expect((await manager.call(callCommand())).output).not.toEqual( + initial.output, + ); + }); + }); + it.each(["type", "true", "changed"])( "preserves worker RPC structure and identifiers when the secret is %s", async (secret) => { diff --git a/apps/host-daemon/src/plugin-host-worker.ts b/apps/host-daemon/src/plugin-host-worker.ts index 07d4e23111..7521933131 100644 --- a/apps/host-daemon/src/plugin-host-worker.ts +++ b/apps/host-daemon/src/plugin-host-worker.ts @@ -7,13 +7,7 @@ function createOperationEnvironmentScope(target: NodeJS.ProcessEnv) { let active = 0; let current: Record = {}; let previous: NodeJS.ProcessEnv = {}; - let waiters: Array<() => void> = []; - return async (env: Record): Promise<() => void> => { - const same = () => - Object.keys(current).length === Object.keys(env).length && - Object.entries(env).every(([key, value]) => current[key] === value); - while (active > 0 && !same()) - await new Promise((resolve) => waiters.push(resolve)); + return (env: Record): (() => void) => { if (active === 0) { current = env; previous = {}; @@ -32,9 +26,6 @@ function createOperationEnvironmentScope(target: NodeJS.ProcessEnv) { } current = {}; previous = {}; - const ready = waiters; - waiters = []; - for (const resolve of ready) resolve(); }; }; } @@ -536,8 +527,9 @@ async function handleCall( const controller = new AbortController(); activeCalls.set(message.callId, controller); let contextOpen = true; - const releaseEnvironment = await acquireEnvironment(message.envVars); + let releaseEnvironment: (() => void) | undefined; try { + releaseEnvironment = acquireEnvironment(message.envVars); controller.signal.throwIfAborted(); const input = await validate(method.input, message.input); const result = await handler(input, { @@ -594,7 +586,7 @@ async function handleCall( } finally { contextOpen = false; activeCalls.delete(message.callId); - releaseEnvironment(); + releaseEnvironment?.(); } } diff --git a/docs/configuration.md b/docs/configuration.md index c509ac2cc8..de52e366ed 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1336,8 +1336,14 @@ the environment they started with: open a new terminal after a change. Agent turns receive refreshed values on their next turn and after resume. Codex rebuilds its loaded session from the existing conversation when the environment changes. -Ordinary setup variable delivery requires host-daemon protocol 205. Older -daemons must update before the server accepts their session. +Plugin host calls start immediately using the current environment while any calls +are active in that plugin worker. Changed or removed machine variables take +effect on the next call after all active calls finish. Continuous overlapping +calls can keep the previous values until the worker becomes idle. + +Ordinary setup variable delivery requires host-daemon protocol 205; immediate +plugin-call reuse across environment changes requires protocol 206. Older daemons +must update before the server accepts their session. The built-in GitHub row uses `gh auth token --hostname github.com` and `gh api --hostname github.com user` on the server host. It supplies `GH_TOKEN`, Git's diff --git a/packages/host-daemon-contract/src/protocol.ts b/packages/host-daemon-contract/src/protocol.ts index d4620598a1..88112ab0cd 100644 --- a/packages/host-daemon-contract/src/protocol.ts +++ b/packages/host-daemon-contract/src/protocol.ts @@ -1,3 +1,3 @@ -export const HOST_DAEMON_PROTOCOL_VERSION = 205 as const; +export const HOST_DAEMON_PROTOCOL_VERSION = 206 as const; export const HOST_ARTIFACT_MAX_BYTES = 256 * 1024 * 1024; diff --git a/packages/host-daemon-contract/test/contract.test.ts b/packages/host-daemon-contract/test/contract.test.ts index 98df9e768f..036f98dfd4 100644 --- a/packages/host-daemon-contract/test/contract.test.ts +++ b/packages/host-daemon-contract/test/contract.test.ts @@ -999,7 +999,7 @@ const CONTRIBUTED_ENV = [ describe("host-daemon command schemas", () => { it("uses the current host-daemon protocol version", () => { - expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(205); + expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(206); expect(HOST_ARTIFACT_MAX_BYTES).toBe(256 * 1024 * 1024); }); diff --git a/packages/templates/src/templates/bb-guide-machines.md b/packages/templates/src/templates/bb-guide-machines.md index 53661f72b5..40b3634626 100644 --- a/packages/templates/src/templates/bb-guide-machines.md +++ b/packages/templates/src/templates/bb-guide-machines.md @@ -274,6 +274,11 @@ apply to the next turn, setup operation, or newly opened BB terminal; existing terminals retain their launch environment. Runtime output is forwarded as-is, so commands and providers can print contributed values. +Plugin host calls start immediately using the current environment while any calls +are active in that plugin worker. Changed or removed machine variables take +effect on the next call after all active calls finish. Continuous overlapping +calls can keep the previous values until the worker becomes idle. + The server's gh login provides GitHub credentials, a Git environment-only HTTPS helper and SSH rewrites, and commit identity. The built-in row reports logged in, not logged in, or overridden. No credentials are installed in images or global diff --git a/plugins/bb-guide/skills/bb-cli/references/configuration.md b/plugins/bb-guide/skills/bb-cli/references/configuration.md index 76b6895cca..e560e6eeb9 100644 --- a/plugins/bb-guide/skills/bb-cli/references/configuration.md +++ b/plugins/bb-guide/skills/bb-cli/references/configuration.md @@ -136,3 +136,8 @@ agent-provider entries override host values. Reopen existing terminals after a change. The server gh login provides GitHub Git/gh authentication and commit identity by default; a user GH_TOKEN replaces it. See Settings → Machines → Machine environment, and `bb machine env list` for builtInGit readiness. + +Plugin host calls start immediately using the current environment while any calls +are active in that plugin worker. Changed or removed machine variables take +effect on the next call after all active calls finish. Continuous overlapping +calls can keep the previous values until the worker becomes idle.