Move connection orchestration into connection actions

This commit is contained in:
murashit 2026-07-10 12:33:33 +09:00
parent 03a026512e
commit dccd63b75b
10 changed files with 138 additions and 156 deletions

View file

@ -1,7 +1,6 @@
import type { ServerInitialization } from "../../../../domain/server/initialization";
import type { ChatConnectionPhase } from "../state/root-reducer";
import type { ChatStateStore } from "../state/store";
import type { ActiveConnectionWork, ConnectionWorkTracker } from "./connection-work";
const STATUS_CONNECTION_STOPPED = "Codex app-server stopped.";
const STATUS_CONNECTION_STARTING = "Starting Codex app-server...";
@ -24,7 +23,6 @@ export interface ChatConnectionDiagnosticsActions {
export interface ChatConnectionActionsHost {
stateStore: ChatStateStore;
connection: ChatConnectionAdapter;
connectionWork: ConnectionWorkTracker;
metadata: ChatConnectionMetadataActions;
diagnostics: ChatConnectionDiagnosticsActions;
invalidateThreadWork: () => void;
@ -44,11 +42,10 @@ export interface ChatConnectionActionsHost {
type ChatConnectionExitHost = Pick<
ChatConnectionActionsHost,
"connectionWork" | "invalidateThreadWork" | "setStatus" | "stateStore" | "resetThreadTurnPresence" | "refreshLiveState"
"invalidateThreadWork" | "setStatus" | "stateStore" | "resetThreadTurnPresence" | "refreshLiveState"
>;
export function handleChatConnectionExit(host: ChatConnectionExitHost): void {
host.connectionWork.invalidate();
function handleChatConnectionExit(host: ChatConnectionExitHost): void {
host.invalidateThreadWork();
host.setStatus(STATUS_CONNECTION_STOPPED, { kind: "disconnected", message: STATUS_CONNECTION_STOPPED });
host.stateStore.dispatch({ type: "connection/scoped-cleared" });
@ -66,12 +63,33 @@ export interface ChatConnectionActions {
}
export function createChatConnectionActions(host: ChatConnectionActionsHost): ChatConnectionActions {
let generation = 0;
let activeConnection: { generation: number; promise: Promise<void> } | null = null;
const invalidate = (): void => {
generation += 1;
activeConnection = null;
};
const isStale = (candidateGeneration: number): boolean => candidateGeneration !== generation;
const actions: ChatConnectionActions = {
ensureConnected: () => ensureConnected(host),
invalidate: () => {
host.connectionWork.invalidate();
ensureConnected: async () => {
if (activeConnection) return activeConnection.promise;
if (host.connection.isConnected()) return;
const connectionGeneration = generation;
const promise = initializeConnection(host, () => isStale(connectionGeneration));
const active = { generation: connectionGeneration, promise };
activeConnection = active;
try {
await promise;
} finally {
if (activeConnection === active) {
activeConnection = null;
}
}
},
invalidate,
handleExit: () => {
invalidate();
handleChatConnectionExit(host);
},
refreshActiveThreads: () => refreshActiveThreads(host),
@ -81,24 +99,6 @@ export function createChatConnectionActions(host: ChatConnectionActionsHost): Ch
return actions;
}
async function ensureConnected(host: ChatConnectionActionsHost): Promise<void> {
const connecting = host.connectionWork.active();
if (connecting?.promise) return connecting.promise;
if (host.connection.isConnected()) {
return;
}
const connection = host.connectionWork.begin();
const promise = initializeConnection(host, connection);
connection.promise = promise;
try {
await promise;
} finally {
host.connectionWork.finish(connection, promise);
}
}
async function refreshActiveThreads(host: ChatConnectionActionsHost): Promise<void> {
if (!host.connection.isConnected()) return;
try {
@ -131,17 +131,17 @@ async function refreshStatusPanel(
await actions.refreshActiveThreads();
}
async function initializeConnection(host: ChatConnectionActionsHost, connection: ActiveConnectionWork): Promise<void> {
async function initializeConnection(host: ChatConnectionActionsHost, isStale: () => boolean): Promise<void> {
host.setStatus(STATUS_CONNECTION_STARTING, { kind: "connecting" });
try {
const initialization = await host.connection.connect();
if (host.connectionWork.isStale(connection)) return;
if (isStale()) return;
host.stateStore.dispatch({ type: "connection/initialized", initializeResponse: initialization });
if (!host.connection.isConnected()) throw new Error("Codex app-server connection did not initialize.");
host.refreshTabHeader();
host.setStatus(STATUS_CONNECTED, { kind: "connected" });
} catch (error) {
if (host.connectionWork.isStale(connection)) return;
if (isStale()) return;
if (host.isStaleConnectionError(error)) return;
if (host.isStaleSharedQueryError(error)) return;
const message = connectionErrorMessage(error, host.configuredCommand());
@ -151,25 +151,25 @@ async function initializeConnection(host: ChatConnectionActionsHost, connection:
return;
}
await hydrateConnectedResources(host, connection);
await hydrateConnectedResources(host, isStale);
}
async function hydrateConnectedResources(host: ChatConnectionActionsHost, connection: ActiveConnectionWork): Promise<void> {
async function hydrateConnectedResources(host: ChatConnectionActionsHost, isStale: () => boolean): Promise<void> {
try {
await host.metadata.refreshAppServerMetadata();
} catch (error) {
if (host.connectionWork.isStale(connection) || host.isStaleSharedQueryError(error)) return;
if (isStale() || host.isStaleSharedQueryError(error)) return;
host.addSystemMessage(`Could not refresh Codex metadata: ${errorMessage(error)}`);
}
if (host.connectionWork.isStale(connection)) return;
if (isStale()) return;
try {
await host.refreshSharedThreads();
} catch (error) {
if (host.connectionWork.isStale(connection) || host.isStaleSharedQueryError(error)) return;
if (isStale() || host.isStaleSharedQueryError(error)) return;
host.addSystemMessage(`Could not refresh Codex threads: ${errorMessage(error)}`);
}
if (host.connectionWork.isStale(connection)) return;
if (isStale()) return;
host.scheduleDeferredDiagnostics();
}

View file

@ -1,46 +0,0 @@
export type ConnectionWorkLifecycleState = { kind: "idle" } | { kind: "connecting"; promise: Promise<void> | null };
export type ActiveConnectionWork = Extract<ConnectionWorkLifecycleState, { kind: "connecting" }>;
type ConnectionWorkLifecycleEvent =
| { type: "started"; connection: ActiveConnectionWork }
| { type: "finished"; connection: ActiveConnectionWork; promise: Promise<void> }
| { type: "invalidated" };
function transitionConnectionWorkLifecycle(
state: ConnectionWorkLifecycleState,
event: ConnectionWorkLifecycleEvent,
): ConnectionWorkLifecycleState {
switch (event.type) {
case "started":
return event.connection;
case "finished":
return state === event.connection && state.promise === event.promise ? { kind: "idle" } : state;
case "invalidated":
return state.kind === "idle" ? state : { kind: "idle" };
}
}
export class ConnectionWorkTracker {
private state: ConnectionWorkLifecycleState = { kind: "idle" };
active(): ActiveConnectionWork | null {
return this.state.kind === "connecting" ? this.state : null;
}
begin(): ActiveConnectionWork {
const connection: ActiveConnectionWork = { kind: "connecting", promise: null };
this.state = transitionConnectionWorkLifecycle(this.state, { type: "started", connection });
return connection;
}
finish(connection: ActiveConnectionWork, promise: Promise<void>): void {
this.state = transitionConnectionWorkLifecycle(this.state, { type: "finished", connection, promise });
}
invalidate(): void {
this.state = transitionConnectionWorkLifecycle(this.state, { type: "invalidated" });
}
isStale(connection: ActiveConnectionWork): boolean {
return this.state !== connection;
}
}

View file

@ -6,12 +6,7 @@ import { isStaleAppServerSharedQueryContextError } from "../../../../app-server/
import type { SharedServerMetadata } from "../../../../domain/server/metadata";
import { type ChatInboundHandler, createChatInboundHandler } from "../../app-server/inbound/handler";
import type { ChatAppServerGateway } from "../../app-server/session-gateway";
import {
type ChatConnectionActions,
createChatConnectionActions,
handleChatConnectionExit,
} from "../../application/connection/connection-actions";
import type { ConnectionWorkTracker } from "../../application/connection/connection-work";
import { type ChatConnectionActions, createChatConnectionActions } from "../../application/connection/connection-actions";
import { createServerDiagnosticsActions } from "../../application/connection/server-diagnostics-actions";
import { createServerMetadataActions } from "../../application/connection/server-metadata-actions";
import type { LocalIdSource } from "../../application/local-id-source";
@ -40,7 +35,6 @@ interface ChatPanelConnectionBundleInput {
interface ChatPanelConnectionBundleHost {
environment: ChatPanelEnvironment;
stateStore: ChatStateStore;
connectionWork: ConnectionWorkTracker;
deferredTasks: ChatViewDeferredTasks;
invalidateThreadWork: () => void;
deferLiveStateRefresh: () => void;
@ -180,7 +174,6 @@ export function createConnectionBundle(
);
const connectionExitHost = {
stateStore,
connectionWork: host.connectionWork,
invalidateThreadWork: () => {
host.invalidateThreadWork();
},
@ -212,7 +205,7 @@ export function createConnectionBundle(
onExit: () => {
serverRequestResponders.clear();
serverDiagnostics.invalidate();
handleChatConnectionExit(connectionExitHost);
connectionActions.handleExit();
},
}),
isConnected: () => connection.isConnected(),

View file

@ -2,7 +2,6 @@ import { codexPanelAppServerInitializeParams } from "../../../app-server/connect
import { ConnectionManager } from "../../../app-server/connection/connection-manager";
import { isStaleAppServerSharedQueryContextError } from "../../../app-server/query/shared-queries";
import { createChatAppServerGateway } from "../app-server/session-gateway";
import type { ConnectionWorkTracker } from "../application/connection/connection-work";
import { reconnectPanel } from "../application/connection/reconnect-actions";
import { createLocalIdSource, type LocalIdSource } from "../application/local-id-source";
import { runtimeSnapshotForChatState } from "../application/runtime/snapshot";
@ -24,6 +23,7 @@ import { type ChatPanelShellBundle, createShellBundle } from "./bundles/shell-bu
import { createThreadActionBundle, createThreadFoundation, createThreadLifecycleBundle } from "./bundles/thread-bundle";
import { createTurnBundle } from "./bundles/turn-bundle";
import type { ChatPanelEnvironment } from "./contracts";
import { createConnectedClientResolver } from "./session/connected-client-resolver";
import type { ChatViewDeferredTasks } from "./session/deferred-work";
import { type ChatPanelSharedStateBinding, createChatPanelSharedStateBinding } from "./session/shared-state-binding";
@ -66,7 +66,6 @@ interface ChatPanelSessionGraphHost {
stateStore: ChatStateStore;
deferredTasks: ChatViewDeferredTasks;
resumeWork: ChatResumeWorkTracker;
connectionWork: ConnectionWorkTracker;
threadStreamScrollBinding: ChatThreadStreamScrollBinding;
getClosing: () => boolean;
viewWindow: () => Window;
@ -77,18 +76,12 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch
const localItemIds = createLocalIdSource();
const connection = createConnectionManager(environment);
const currentClient = () => connection.currentClient();
let ensureConnected: () => Promise<void> = async () => {
throw new Error("Codex app-server connection actions are not initialized.");
};
const connectedClient = async () => {
await ensureConnected();
return currentClient();
};
const connectedClient = createConnectedClientResolver(currentClient);
const appServer = createChatAppServerGateway({
codexPath: () => environment.plugin.settingsRef.settings.codexPath(),
vaultPath: environment.plugin.settingsRef.vaultPath,
currentClient,
connectedClient,
connectedClient: () => connectedClient.resolve(),
});
const status = createSessionStatus(stateStore, localItemIds);
const refreshTabHeader = () => {
@ -116,7 +109,6 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch
{
environment,
stateStore,
connectionWork: host.connectionWork,
deferredTasks: host.deferredTasks,
invalidateThreadWork: () => {
threadFoundation.invalidateThreadWork();
@ -137,7 +129,8 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch
connection: { actions: connectionActions },
inboundHandler,
} = connectionBundle;
ensureConnected = () => connectionActions.ensureConnected();
const ensureConnected = () => connectionActions.ensureConnected();
connectedClient.bindEnsureConnected(ensureConnected);
const refreshActiveThreads = () => connectionActions.refreshActiveThreads();
const runtime = createRuntimeBundle(host, {
connection,
@ -182,7 +175,7 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch
reconnectPanel({
stateStore,
invalidateConnectionWork: () => {
host.connectionWork.invalidate();
connectionActions.invalidate();
},
invalidateThreadWork: () => {
threadFoundation.invalidateThreadWork();

View file

@ -2,7 +2,6 @@ import type { AppServerClient } from "../../../app-server/connection/client";
import { type AppServerQueryContext, appServerQueryContextMatches, appServerQueryContextRawEquals } from "../../../app-server/query/keys";
import { pendingRequestCountsFromQueues } from "../../../domain/pending-requests/aggregate";
import { threadMeaningfulTitle, threadWindowTitle } from "../../../domain/threads/title";
import { ConnectionWorkTracker } from "../application/connection/connection-work";
import type { ChatState } from "../application/state/root-reducer";
import { type ChatStateStore, createChatStateStore } from "../application/state/store";
import { parseRestoredThreadState, type RestoredThreadPlaceholderState } from "../application/threads/restored-thread-lifecycle";
@ -18,7 +17,6 @@ export class ChatPanelSession implements ChatPanelHandle {
private readonly graph: ChatPanelSessionGraph;
private readonly deferredTasks: ChatViewDeferredTasks;
private readonly connectionWork = new ConnectionWorkTracker();
private readonly resumeWork = new ChatResumeWorkTracker();
private readonly threadStreamScrollBinding: ChatThreadStreamScrollBinding = createChatThreadStreamScrollBinding();
private observedAppServerContext: AppServerQueryContext;
@ -156,7 +154,7 @@ export class ChatPanelSession implements ChatPanelHandle {
close(): void {
this.opened = false;
this.closing = true;
this.connectionWork.invalidate();
this.graph.connection.actions.invalidate();
this.graph.actions.invalidateThreadWork();
this.deferredTasks.clearAll();
this.graph.runtime.sharedState.unsubscribe();
@ -248,7 +246,6 @@ export class ChatPanelSession implements ChatPanelHandle {
stateStore: this.stateStore,
deferredTasks: this.deferredTasks,
resumeWork: this.resumeWork,
connectionWork: this.connectionWork,
threadStreamScrollBinding: this.threadStreamScrollBinding,
getClosing: () => this.closing,
viewWindow: () => this.viewWindow(),

View file

@ -0,0 +1,22 @@
import type { AppServerClient } from "../../../../app-server/connection/client";
export interface ConnectedClientResolver {
resolve(): Promise<AppServerClient | null>;
bindEnsureConnected(ensureConnected: () => Promise<void>): void;
}
export function createConnectedClientResolver(currentClient: () => AppServerClient | null): ConnectedClientResolver {
let ensureConnected: (() => Promise<void>) | null = null;
return {
resolve: async () => {
if (!ensureConnected) throw new Error("Codex app-server connection actions are not initialized.");
await ensureConnected();
return currentClient();
},
bindEnsureConnected: (nextEnsureConnected) => {
if (ensureConnected) throw new Error("Codex app-server connection actions are already initialized.");
ensureConnected = nextEnsureConnected;
},
};
}

View file

@ -8,9 +8,9 @@ import {
type ChatConnectionMetadataActions,
createChatConnectionActions,
} from "../../../../../src/features/chat/application/connection/connection-actions";
import { ConnectionWorkTracker } from "../../../../../src/features/chat/application/connection/connection-work";
import { createChatState } from "../../../../../src/features/chat/application/state/root-reducer";
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
import { deferred } from "../../../../support/async";
function createActionsHarness({ connected = false } = {}) {
const stateStore = createChatStateStore(createChatState());
@ -34,7 +34,6 @@ function createActionsHarness({ connected = false } = {}) {
const host: ChatConnectionActionsHost = {
stateStore,
connection,
connectionWork: new ConnectionWorkTracker(),
metadata,
diagnostics,
invalidateThreadWork: vi.fn(),
@ -57,11 +56,47 @@ function createActionsHarness({ connected = false } = {}) {
host,
refreshAppServerMetadata,
refreshServerDiagnostics,
setConnected: (value: boolean) => {
isConnected = value;
},
stateStore,
};
}
describe("ChatConnectionActions", () => {
it("coalesces concurrent connection attempts inside the actions boundary", async () => {
const { actions, connect, setConnected } = createActionsHarness();
const pending = deferred<Awaited<ReturnType<ChatConnectionAdapter["connect"]>>>();
connect.mockImplementationOnce(() => pending.promise);
const first = actions.ensureConnected();
const second = actions.ensureConnected();
expect(connect).toHaveBeenCalledOnce();
setConnected(true);
pending.resolve({ codexHome: "/codex", platformFamily: "unix", platformOs: "macos", userAgent: "test" });
await Promise.all([first, second]);
});
it("starts fresh work after invalidation and ignores the obsolete result", async () => {
const { actions, connect, setConnected, stateStore } = createActionsHarness();
const obsolete = deferred<Awaited<ReturnType<ChatConnectionAdapter["connect"]>>>();
const current = deferred<Awaited<ReturnType<ChatConnectionAdapter["connect"]>>>();
connect.mockImplementationOnce(() => obsolete.promise).mockImplementationOnce(() => current.promise);
const first = actions.ensureConnected();
actions.invalidate();
const second = actions.ensureConnected();
setConnected(true);
current.resolve({ codexHome: "/current", platformFamily: "unix", platformOs: "macos", userAgent: "test" });
await second;
obsolete.resolve({ codexHome: "/obsolete", platformFamily: "unix", platformOs: "macos", userAgent: "test" });
await first;
expect(connect).toHaveBeenCalledTimes(2);
expect(stateStore.getState().connection.initializeResponse?.codexHome).toBe("/current");
});
it("connects once and publishes startup metadata", async () => {
const { connect, actions, host, refreshAppServerMetadata, stateStore } = createActionsHarness();

View file

@ -1,39 +0,0 @@
import { describe, expect, it } from "vitest";
import { ConnectionWorkTracker } from "../../../../../src/features/chat/application/connection/connection-work";
describe("ConnectionWorkTracker", () => {
it("tracks active connection work by identity", () => {
const tracker = new ConnectionWorkTracker();
const connection = tracker.begin();
const stale = { kind: "connecting" as const, promise: Promise.resolve() };
expect(tracker.active()).toBe(connection);
expect(tracker.isStale(connection)).toBe(false);
expect(tracker.isStale(stale)).toBe(true);
const promise = Promise.resolve();
connection.promise = promise;
tracker.finish(connection, Promise.resolve());
expect(tracker.active()).toBe(connection);
tracker.finish(connection, promise);
expect(tracker.active()).toBeNull();
});
it("keeps stale connection completions from clearing the active connection", () => {
const firstPromise = Promise.resolve();
const secondPromise = Promise.resolve();
const tracker = new ConnectionWorkTracker();
const first = tracker.begin();
first.promise = firstPromise;
const second = tracker.begin();
second.promise = secondPromise;
tracker.finish(first, firstPromise);
expect(tracker.active()).toBe(second);
tracker.finish(second, firstPromise);
expect(tracker.active()).toBe(second);
tracker.finish(second, secondPromise);
expect(tracker.active()).toBeNull();
});
});

View file

@ -0,0 +1,31 @@
import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../../../src/app-server/connection/client";
import { createConnectedClientResolver } from "../../../../src/features/chat/host/session/connected-client-resolver";
describe("createConnectedClientResolver", () => {
it("connects before resolving the current client", async () => {
const client = {} as AppServerClient;
const currentClient = vi.fn(() => client);
const ensureConnected = vi.fn().mockResolvedValue(undefined);
const resolver = createConnectedClientResolver(currentClient);
resolver.bindEnsureConnected(ensureConnected);
await expect(resolver.resolve()).resolves.toBe(client);
expect(ensureConnected).toHaveBeenCalledOnce();
expect(currentClient).toHaveBeenCalledOnce();
});
it("rejects use before the connection actions are bound", async () => {
const resolver = createConnectedClientResolver(() => null);
await expect(resolver.resolve()).rejects.toThrow("connection actions are not initialized");
});
it("rejects rebinding the connection actions", () => {
const resolver = createConnectedClientResolver(() => null);
resolver.bindEnsureConnected(vi.fn().mockResolvedValue(undefined));
expect(() => resolver.bindEnsureConnected(vi.fn().mockResolvedValue(undefined))).toThrow("connection actions are already initialized");
});
});

View file

@ -5,7 +5,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StaleAppServerSharedQueryContextError } from "../../../../src/app-server/query/shared-queries";
import type { ModelMetadata } from "../../../../src/domain/catalog/metadata";
import type { Thread } from "../../../../src/domain/threads/model";
import { ConnectionWorkTracker } from "../../../../src/features/chat/application/connection/connection-work";
import { type ChatStateStore, createChatStateStore } from "../../../../src/features/chat/application/state/store";
import { HistoryController } from "../../../../src/features/chat/application/threads/history-controller";
import { ChatResumeWorkTracker } from "../../../../src/features/chat/application/threads/resume-work";
@ -201,7 +200,7 @@ describe("createChatPanelSessionGraph actions", () => {
});
it("wires reconnect cleanup through the graph toolbar action", async () => {
const { graph, stateStore, connectionWork, deferredTasks } = sessionGraphFixture();
const { graph, stateStore, deferredTasks } = sessionGraphFixture();
stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicyKnown: true,
@ -217,7 +216,7 @@ describe("createChatPanelSessionGraph actions", () => {
serviceTier: null,
approvalsReviewer: null,
});
const activeConnectionWork = connectionWork.begin();
const invalidateConnection = vi.spyOn(graph.connection.actions, "invalidate");
const clearDiagnostics = vi.spyOn(deferredTasks, "clearDiagnostics");
const resetConnection = vi.spyOn(graph.connection.manager, "resetConnection");
const ensureConnected = vi.spyOn(graph.connection.actions, "ensureConnected").mockResolvedValue(undefined);
@ -228,7 +227,7 @@ describe("createChatPanelSessionGraph actions", () => {
await waitForAsyncWork(() => {
expect(resumeThread).toHaveBeenCalledWith("thread-1");
});
expect(connectionWork.isStale(activeConnectionWork)).toBe(true);
expect(invalidateConnection).toHaveBeenCalledOnce();
expect(clearDiagnostics).toHaveBeenCalledOnce();
expect(resetConnection).toHaveBeenCalledOnce();
expect(stateStore.getState().connection.statusText).toBe("Reconnecting...");
@ -250,12 +249,10 @@ describe("createChatPanelSessionGraph actions", () => {
graph: ReturnType<typeof createChatPanelSessionGraph>;
stateStore: ChatStateStore;
resumeWork: ChatResumeWorkTracker;
connectionWork: ConnectionWorkTracker;
deferredTasks: ReturnType<typeof createChatViewDeferredTasks>;
} {
const stateStore = createChatStateStore();
const resumeWork = new ChatResumeWorkTracker();
const connectionWork = new ConnectionWorkTracker();
const deferredTasks = createChatViewDeferredTasks(() => window);
const environment = chatPanelEnvironmentFixture(options.environment);
const graph = createChatPanelSessionGraph({
@ -263,12 +260,11 @@ describe("createChatPanelSessionGraph actions", () => {
stateStore,
deferredTasks,
resumeWork,
connectionWork,
threadStreamScrollBinding: createChatThreadStreamScrollBinding(),
getClosing: () => false,
viewWindow: () => window,
});
return { graph, stateStore, resumeWork, connectionWork, deferredTasks };
return { graph, stateStore, resumeWork, deferredTasks };
}
interface PartialChatPanelEnvironment {