Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { ProviderCliInstallLogDialogState } from "@/components/dialogs/Prov
import type { ProviderCliActionableIssue } from "@/components/provider-cli/provider-cli-install";
import { appToast } from "@/components/ui/app-toast";
import { invalidateHostProviderCliStatus } from "@/hooks/cache-owners/provider-cli-status-cache-owner";
import { invalidateSystemExecutionOptions } from "@/hooks/cache-owners/system-cache-effects";
import { sdk } from "@/lib/sdk";

type ProviderCliInstallCompletedEvent = Extract<
Expand Down Expand Up @@ -202,6 +203,7 @@ function runInstall(job: ProviderCliInstallJob): void {
if (completedEvent?.success) {
if (queryClient !== null) {
void invalidateHostProviderCliStatus({ queryClient, hostId });
void invalidateSystemExecutionOptions({ queryClient, hostId });
}
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import type {
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { sdk } from "@/lib/sdk";
import { appToast } from "@/components/ui/app-toast";
import { hostProviderCliStatusQueryKey } from "@/hooks/queries/query-keys";
import {
allSystemExecutionOptionsQueryKeyPrefix,
hostProviderCliStatusQueryKey,
} from "@/hooks/queries/query-keys";
import type { ProviderCliActionableIssue } from "./provider-cli-install";
import { useProviderCliInstallRunner } from "./provider-cli-install";
import {
Expand Down Expand Up @@ -203,6 +206,10 @@ describe("useProviderCliInstallRunner", () => {
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: hostProviderCliStatusQueryKey("host_1"),
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: allSystemExecutionOptionsQueryKeyPrefix(),
predicate: expect.any(Function),
});
expect(appToastMock.success).not.toHaveBeenCalled();
});

Expand Down
16 changes: 16 additions & 0 deletions apps/app/src/hooks/cache-owners/system-cache-effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ interface SystemExecutionOptionsCacheArgs extends QueryClientArg {
providerId: string | null;
}

interface SystemExecutionOptionsInvalidationArgs extends QueryClientArg {
hostId: string;
}

export function seedSystemExecutionOptionsCache({
environmentId,
executionOptions,
Expand Down Expand Up @@ -134,6 +138,18 @@ export function invalidateSystemConfig({ queryClient }: QueryClientArg): void {
queryClient.invalidateQueries({ queryKey: systemConfigQueryKey() });
}

/** Refresh provider/model catalogs after a provider CLI install or update. */
export function invalidateSystemExecutionOptions({
hostId,
queryClient,
}: SystemExecutionOptionsInvalidationArgs): Promise<void> {
return queryClient.invalidateQueries({
queryKey: allSystemExecutionOptionsQueryKeyPrefix(),
predicate: (query) =>
query.queryKey[2] === hostId || query.queryKey[2] === null,
});
}

/** Refresh settings and timeline projections after a General settings write. */
export function invalidateGeneralSettingsDependencies({
queryClient,
Expand Down
8 changes: 4 additions & 4 deletions apps/host-daemon/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -747,10 +747,10 @@ export async function createHostDaemonApp(
terminalManager,
listModels: async (args) => {
await refreshRuntimeShellEnv();
const runtime = await runtimeManager.ensureProviderMaintenanceRuntime({
dataDir: options.dataDir,
});
return runtime.listModels(args);
return runtimeManager.withProviderMaintenanceRuntime(
{ dataDir: options.dataDir },
(runtime) => runtime.listModels(args),
);
},
resolveInteractiveRequest: async (request) => {
interactiveRequestRegistry.resolve(request);
Expand Down
4 changes: 4 additions & 0 deletions apps/host-daemon/src/command-dispatch-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
HostDaemonInjectedSkillSource,
HostDaemonOnlineRpcCommand,
HostDaemonConnectTunnelIdentity,
ProviderCliInstallRequest,
ProviderCliStatus,
WorkspaceContext,
} from "@bb/host-daemon-contract";
Expand Down Expand Up @@ -57,6 +58,9 @@ export interface CommandDispatchOptions {
getProviderCliStatusForProvider?: (
providerId: string,
) => Promise<ProviderCliStatus | null>;
streamProviderCliInstall?: (
args: ProviderCliInstallRequest & { env?: NodeJS.ProcessEnv },
) => ReadableStream<Uint8Array>;
resolveInteractiveRequest?: (
request: InteractiveResolveCommandInput,
) => Promise<void>;
Expand Down
163 changes: 162 additions & 1 deletion apps/host-daemon/src/command-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@ import path from "node:path";
import type { AgentRuntime } from "@bb/agent-runtime";
import type {
HostDaemonInjectedSkillSource,
ProviderCliInstallEvent,
ProviderCliStatus,
} from "@bb/host-daemon-contract";
import type { HostWorkspace } from "@bb/host-workspace";
import { afterEach, describe, expect, it, vi, type Mock } from "vitest";
import { dispatchCommand } from "./command-dispatch.js";
import {
dispatchCommand,
dispatchOnlineRpcCommand,
} from "./command-dispatch.js";
import type { CommandOf } from "./command-dispatch-support.js";
import { RuntimeManager } from "./runtime-manager.js";

Expand Down Expand Up @@ -215,6 +219,20 @@ function createRuntime(): FakeDispatchRuntime {
};
}

function createProviderCliInstallEventStream(
events: readonly ProviderCliInstallEvent[],
): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
return new ReadableStream({
start(controller) {
for (const event of events) {
controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
}
controller.close();
},
});
}

describe("dispatchCommand", () => {
it("flushes buffered events before reporting thread.stop success", async () => {
const runtime = createRuntime();
Expand Down Expand Up @@ -595,6 +613,149 @@ describe("dispatchCommand", () => {
expect(runtime.startThread).toHaveBeenCalledOnce();
});

it("invalidates the provider maintenance runtime after a successful Codex CLI update", async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — Test the negative branches

This test covers only a successful Codex event. The predicate also contains failure and non-Codex branches.

Add cases that confirm those events do not shut down the runtime.

const dataDir = await makeTempDir("bb-command-dispatch-provider-cli-");
const staleRuntime = createRuntime();
const freshRuntime = createRuntime();
const createRuntimeSpy = vi.fn(() => staleRuntime);
createRuntimeSpy.mockReturnValueOnce(staleRuntime);
createRuntimeSpy.mockReturnValueOnce(freshRuntime);
const manager = new RuntimeManager({
createRuntime: createRuntimeSpy,
dataDir,
provisionWorkspace: async () => createWorkspace(),
});
await manager.ensureProviderMaintenanceRuntime({ dataDir });

const events: ProviderCliInstallEvent[] = [
{
type: "started",
provider: "codex",
command: "codex update",
},
{
type: "completed",
provider: "codex",
exitCode: 0,
signal: null,
success: true,
},
];
const streamProviderCliInstall = vi.fn(() =>
createProviderCliInstallEventStream(events),
);
const command: CommandOf<"provider_cli.install"> = {
type: "provider_cli.install",
provider: "codex",
actionKind: "update",
};

const result = await dispatchOnlineRpcCommand(command, {
dataDir,
eventSink: {
emit: vi.fn(),
flush: vi.fn(async () => undefined),
},
fetchProjectAttachment: async () => {
throw new Error("Unexpected project attachment fetch");
},
runtimeManager: manager,
streamProviderCliInstall,
threadStorageRootPath: "/tmp/bb-thread-storage",
});

expect(result).toEqual({ events });
expect(streamProviderCliInstall).toHaveBeenCalledWith(
expect.objectContaining({
actionKind: "update",
provider: "codex",
}),
);
expect(staleRuntime.shutdown).toHaveBeenCalledOnce();

await manager.ensureProviderMaintenanceRuntime({ dataDir });
expect(createRuntimeSpy).toHaveBeenCalledTimes(2);
expect(freshRuntime.shutdown).not.toHaveBeenCalled();
});

it("keeps the provider maintenance runtime after failed or non-Codex CLI installs", async () => {
const cases: Array<{
actionKind: CommandOf<"provider_cli.install">["actionKind"];
events: ProviderCliInstallEvent[];
provider: CommandOf<"provider_cli.install">["provider"];
}> = [
{
actionKind: "update",
provider: "codex",
events: [
{
type: "completed",
provider: "codex",
exitCode: 1,
signal: null,
success: false,
},
],
},
{
actionKind: "update",
provider: "claudeCode",
events: [
{
type: "completed",
provider: "claudeCode",
exitCode: 0,
signal: null,
success: true,
},
],
},
];

for (const testCase of cases) {
const dataDir = await makeTempDir("bb-command-dispatch-provider-cli-");
const runtime = createRuntime();
const createRuntimeSpy = vi.fn(() => runtime);
const manager = new RuntimeManager({
createRuntime: createRuntimeSpy,
dataDir,
provisionWorkspace: async () => createWorkspace(),
});
await manager.ensureProviderMaintenanceRuntime({ dataDir });
const streamProviderCliInstall = vi.fn(() =>
createProviderCliInstallEventStream(testCase.events),
);

const result = await dispatchOnlineRpcCommand(
{
type: "provider_cli.install",
provider: testCase.provider,
actionKind: testCase.actionKind,
},
{
dataDir,
eventSink: {
emit: vi.fn(),
flush: vi.fn(async () => undefined),
},
fetchProjectAttachment: async () => {
throw new Error("Unexpected project attachment fetch");
},
runtimeManager: manager,
streamProviderCliInstall,
threadStorageRootPath: "/tmp/bb-thread-storage",
},
);

expect(result).toEqual({ events: testCase.events });
expect(runtime.shutdown).not.toHaveBeenCalled();
await expect(
manager.ensureProviderMaintenanceRuntime({ dataDir }),
).resolves.toBe(runtime);
expect(createRuntimeSpy).toHaveBeenCalledTimes(1);
}
});

// Regression: a thread.start whose freshly staged skill catalog differed
// from the busy runtime's catalog used to fail the command (and brick the
// thread) instead of reusing the runtime. This drives the real plumbing —
Expand Down
62 changes: 45 additions & 17 deletions apps/host-daemon/src/command-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,15 +176,24 @@ async function installProviderCliOnHost(
const env = providerCliEnvFromShellEnv(
options.runtimeManager.getShellEnv(),
);
return {
events: await readProviderCliInstallEvents(
streamProviderCliInstall({
provider: command.provider,
actionKind: command.actionKind,
env,
}),
),
};
const streamInstall =
options.streamProviderCliInstall ?? streamProviderCliInstall;
const events = await readProviderCliInstallEvents(
streamInstall({
provider: command.provider,
actionKind: command.actionKind,
env,
}),
);
if (
shouldInvalidateProviderMaintenanceRuntimeAfterProviderCliInstall({
command,
events,
})
) {
await options.runtimeManager.invalidateProviderMaintenanceRuntime();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — Protect current maintenance calls before shutdown

This runtime also serves provider.list_models and thread.unarchive. Shutdown rejects their pending calls and can break the unarchive barrier.

Add a runtime lease. Alternatively, mark the runtime stale and wait for current calls before shutdown.

}
return { events };
} catch (error) {
if (error instanceof ProviderCliInstallInProgressError) {
return {
Expand All @@ -201,6 +210,23 @@ async function installProviderCliOnHost(
}
}

function shouldInvalidateProviderMaintenanceRuntimeAfterProviderCliInstall(args: {
command: CommandOf<"provider_cli.install">;
events: readonly ProviderCliInstallEvent[];
}): boolean {
return (
// Codex model listing goes through the resident provider-maintenance
// app-server, so a Codex CLI update can leave a stale model catalog alive.
args.command.provider === "codex" &&
args.events.some(
(event) =>
event.type === "completed" &&
event.provider === args.command.provider &&
event.success,
)
);
}

const commandHandlers: CommandHandlerMap = {
"thread.start": async (command, options) => {
const release = options.runtimeManager.retainEnvironmentForThreadCommand(
Expand Down Expand Up @@ -302,15 +328,17 @@ const commandHandlers: CommandHandlerMap = {
return {};
},
"thread.unarchive": async (command, options) => {
const runtime =
await options.runtimeManager.ensureProviderMaintenanceRuntime({
await options.runtimeManager.withProviderMaintenanceRuntime(
{
dataDir: options.dataDir,
});
await runtime.unarchiveThread({
threadId: command.threadId,
providerId: command.providerId,
providerThreadId: command.providerThreadId,
});
},
(runtime) =>
runtime.unarchiveThread({
threadId: command.threadId,
providerId: command.providerId,
providerThreadId: command.providerThreadId,
}),
);
return {};
},
"interactive.resolve": resolveInteractiveRequest,
Expand Down
Loading
Loading