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
202 changes: 195 additions & 7 deletions apps/host-daemon/src/plugin-host-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }; },
Expand Down Expand Up @@ -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" }),
});
Expand All @@ -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",
Expand All @@ -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) => {
Expand Down
16 changes: 4 additions & 12 deletions apps/host-daemon/src/plugin-host-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,7 @@ function createOperationEnvironmentScope(target: NodeJS.ProcessEnv) {
let active = 0;
let current: Record<string, string> = {};
let previous: NodeJS.ProcessEnv = {};
let waiters: Array<() => void> = [];
return async (env: Record<string, string>): 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<void>((resolve) => waiters.push(resolve));
return (env: Record<string, string>): (() => void) => {
if (active === 0) {
current = env;
previous = {};
Expand All @@ -32,9 +26,6 @@ function createOperationEnvironmentScope(target: NodeJS.ProcessEnv) {
}
current = {};
previous = {};
const ready = waiters;
waiters = [];
for (const resolve of ready) resolve();
};
};
}
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -594,7 +586,7 @@ async function handleCall(
} finally {
contextOpen = false;
activeCalls.delete(message.callId);
releaseEnvironment();
releaseEnvironment?.();
}
}

Expand Down
10 changes: 8 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/host-daemon-contract/src/protocol.ts
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 1 addition & 1 deletion packages/host-daemon-contract/test/contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
5 changes: 5 additions & 0 deletions packages/templates/src/templates/bb-guide-machines.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions plugins/bb-guide/skills/bb-cli/references/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.