fix(queue): keep remote host identity stable

This commit is contained in:
Bohdan Triapitsyn
2026-08-22 13:22:42 +03:00
parent 171a03d8f1
commit 049ff52427
3 changed files with 27 additions and 3 deletions
+1 -1
View File
@@ -64,7 +64,7 @@ These stores coordinate persistent project/session metadata across multiple view
`useProjectContextStore.ts` caches server-owned project notes, todos, and plan links, keyed by the path-derived project id. It replaced a pair of `window` CustomEvents that made every mounted notes panel re-read the whole project config. Writes are optimistic and roll back on failure; they are serialized per project, because the server's own store does a read-modify-write and two concurrent saves would otherwise race it. A load that resolves while a write is in flight keeps the local value for that field group only, so a slow snapshot cannot undo newer typing while still delivering the plan list it fetched. A failed load sets `error` and preserves the cached snapshot — an unreachable server must never render as "this project has no notes". Note and plan creation are deliberately not optimistic, since ids and timestamps are assigned by the server. Notes, todos, and plans are written through separate routes and tracked by separate in-flight flags, so a todo toggle cannot clobber a note edit in the same window. Pinned notes and plans are assembled into a synthetic context part by `lib/projectContextPinning.ts` at send time; that module tracks per-session what it already sent so an unchanged pinned set is not re-sent every turn. `useProjectContextStore.ts` caches server-owned project notes, todos, and plan links, keyed by the path-derived project id. It replaced a pair of `window` CustomEvents that made every mounted notes panel re-read the whole project config. Writes are optimistic and roll back on failure; they are serialized per project, because the server's own store does a read-modify-write and two concurrent saves would otherwise race it. A load that resolves while a write is in flight keeps the local value for that field group only, so a slow snapshot cannot undo newer typing while still delivering the plan list it fetched. A failed load sets `error` and preserves the cached snapshot — an unreachable server must never render as "this project has no notes". Note and plan creation are deliberately not optimistic, since ids and timestamps are assigned by the server. Notes, todos, and plans are written through separate routes and tracked by separate in-flight flags, so a todo toggle cannot clobber a note edit in the same window. Pinned notes and plans are assembled into a synthetic context part by `lib/projectContextPinning.ts` at send time; that module tracks per-session what it already sent so an unchanged pinned set is not re-sent every turn.
`messageQueueStore.ts` keeps a queued message until its own send resolves, so between dispatch and resolution the entry is still visible to every reader. Dispatchers must therefore mark the send (`markSending`/`clearSending`) and read `getSendableQueue()` — or filter `sendingIds` themselves — instead of dispatching straight from `queuedMessages`; otherwise a composer submit merges a message the auto-send hook is already delivering and it is sent twice (the window is seconds over a relay). `clearQueue()` retains in-flight entries for the same reason. `sendingIds` is deliberately not persisted: a restart has no in-flight sends, and a stale flag would strand a queued message. `messageQueueStore.ts` keeps a queued message until its own send resolves, so between dispatch and resolution the entry is still visible to every reader. Dispatchers must therefore mark the send (`markSending`/`clearSending`) and read `getSendableQueue()` — or filter `sendingIds` themselves — instead of dispatching straight from `queuedMessages`; otherwise a composer submit merges a message the auto-send hook is already delivering and it is sent twice (the window is seconds over a relay). `clearQueue()` retains in-flight entries for the same reason. `sendingIds` is deliberately not persisted: a restart has no in-flight sends, and a stale flag would strand a queued message. Desktop queues use the configured host id as runtime identity, not the current API URL, because an SSH reconnect allocates a new local forwarding port while the remote host remains the same.
`useGlobalSessionsStore.ts` owns cold/global active and archived session coverage, including `sessionsByDirectory`. It is complementary to directory child stores: it is not the source of live busy/retry status or session messages. `useGlobalSessionsStore.ts` owns cold/global active and archived session coverage, including `sessionsByDirectory`. It is complementary to directory child stores: it is not the source of live busy/retry status or session messages.
+22 -1
View File
@@ -108,7 +108,7 @@ describe('createConfiguredWebAPIs', () => {
expect(initializeRuntimeEndpoint).toHaveBeenCalledWith({ expect(initializeRuntimeEndpoint).toHaveBeenCalledWith({
apiBaseUrl: bootstrap.apiBaseUrl, apiBaseUrl: bootstrap.apiBaseUrl,
runtimeKey: null, runtimeKey: 'host:host-1',
}); });
expect(setRuntimeBearerToken).toHaveBeenCalledWith(bootstrap.clientToken); expect(setRuntimeBearerToken).toHaveBeenCalledWith(bootstrap.clientToken);
expect(setRuntimeExtraHeaders).toHaveBeenCalledWith(bootstrap.runtimeHeaders); expect(setRuntimeExtraHeaders).toHaveBeenCalledWith(bootstrap.runtimeHeaders);
@@ -116,6 +116,27 @@ describe('createConfiguredWebAPIs', () => {
expect(opencodeClient.reconnectToRuntimeBaseUrl).toHaveBeenCalled(); expect(opencodeClient.reconnectToRuntimeBaseUrl).toHaveBeenCalled();
}); });
test('uses the configured desktop host id across changing SSH tunnel URLs', () => {
const current = makeWindow();
current.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ = {
target: 'remote',
status: 'ok',
hostId: 'ssh-castle',
url: 'http://127.0.0.1:62545',
localAvailable: true,
};
current.__OPENCHAMBER_API_BASE_URL__ = 'http://127.0.0.1:62545';
current.__OPENCHAMBER_LOCAL_ORIGIN__ = 'http://127.0.0.1:3901';
installWindow(current);
createConfiguredWebAPIs();
expect(initializeRuntimeEndpoint).toHaveBeenCalledWith({
apiBaseUrl: 'http://127.0.0.1:62545',
runtimeKey: 'host:ssh-castle',
});
});
test('activates an embedded relay without relying on Electron preload IPC', () => { test('activates an embedded relay without relying on Electron preload IPC', () => {
const relay = { const relay = {
relayUrl: 'wss://relay.example.com', relayUrl: 'wss://relay.example.com',
+4 -1
View File
@@ -2,6 +2,7 @@ import { getRuntimeExtraHeadersSync, refreshLocalRuntimeUrlAuthToken, refreshRun
import { installRuntimeFetchBridge } from '@openchamber/ui/lib/runtime-fetch'; import { installRuntimeFetchBridge } from '@openchamber/ui/lib/runtime-fetch';
import { initializeRuntimeEndpoint, switchRuntimeEndpoint } from '@openchamber/ui/lib/runtime-switch'; import { initializeRuntimeEndpoint, switchRuntimeEndpoint } from '@openchamber/ui/lib/runtime-switch';
import { restoreDesktopRelayRuntime } from '@openchamber/ui/lib/desktopRelayRestore'; import { restoreDesktopRelayRuntime } from '@openchamber/ui/lib/desktopRelayRestore';
import { getInjectedBootOutcome } from '@openchamber/ui/lib/desktopBoot';
import { configureRuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url'; import { configureRuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url';
import type { EmbeddedSessionRuntimeBootstrap } from '@openchamber/ui/components/layout/contextPanelEmbeddedChat'; import type { EmbeddedSessionRuntimeBootstrap } from '@openchamber/ui/components/layout/contextPanelEmbeddedChat';
import { opencodeClient } from '@openchamber/ui/lib/opencode/client'; import { opencodeClient } from '@openchamber/ui/lib/opencode/client';
@@ -45,6 +46,8 @@ export const getDesktopRelayRestoreReady = (): Promise<void> => desktopRelayRest
export const createConfiguredWebAPIs = (bootstrap?: EmbeddedSessionRuntimeBootstrap | null) => { export const createConfiguredWebAPIs = (bootstrap?: EmbeddedSessionRuntimeBootstrap | null) => {
const { apiBaseUrl, clientToken, localOrigin, runtimeHeaders, relayHostId, relay } = bootstrap ?? readRuntimeBootstrapConfig(); const { apiBaseUrl, clientToken, localOrigin, runtimeHeaders, relayHostId, relay } = bootstrap ?? readRuntimeBootstrapConfig();
const bootOutcome = bootstrap ? null : getInjectedBootOutcome();
const desktopHostId = relayHostId || (bootOutcome?.target === 'remote' ? bootOutcome.hostId : '');
const urls = configureRuntimeUrlResolver({ const urls = configureRuntimeUrlResolver({
apiBaseUrl: apiBaseUrl || undefined, apiBaseUrl: apiBaseUrl || undefined,
@@ -52,7 +55,7 @@ export const createConfiguredWebAPIs = (bootstrap?: EmbeddedSessionRuntimeBootst
}); });
initializeRuntimeEndpoint({ initializeRuntimeEndpoint({
apiBaseUrl, apiBaseUrl,
runtimeKey: sameOrigin(apiBaseUrl, localOrigin) ? 'local' : null, runtimeKey: sameOrigin(apiBaseUrl, localOrigin) ? 'local' : (desktopHostId ? `host:${desktopHostId}` : null),
}); });
setRuntimeBearerToken(clientToken || null); setRuntimeBearerToken(clientToken || null);
setRuntimeExtraHeaders(runtimeHeaders || null); setRuntimeExtraHeaders(runtimeHeaders || null);