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
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,18 @@ impl DialogScheduler {
let session = &request.session_id;
openbitfun_core_types::validate_session_id(session).map_err(error)?;
let _admission = self.host_queue_locks.lock(session).await;
if self.session_manager.get_session(session).is_none() {
// Passive observers (queue panels, timeline projection) poll a session
// that may not be restored on this execution host yet, e.g. right after
// a local <-> remote workspace switch. Reads may degrade: the scheduler
// keeps its own receipts, so report that state without failing every
// poll. Mutations keep failing — accepting user work against a session
// this runtime cannot vouch for would silently lose it.
let session_unloaded = self.session_manager.get_session(session).is_none();
if !matches!(
request.action,
DialogQueueAction::List | DialogQueueAction::Get { .. }
) && session_unloaded
{
return Err(PortError::new(
PortErrorKind::NotFound,
"Session is not loaded on the execution host",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,77 @@ async fn fixture() -> (
(scheduler, sessions, root, snapshot.queue_epoch)
}

/// A session switched away from (local <-> remote) is no longer loaded on the
/// execution host. Passive queue reads must keep answering from the
/// scheduler's own receipts instead of failing every poll.
#[tokio::test]
async fn host_queue_reads_degrade_for_a_session_not_loaded_on_the_host() {
let (scheduler, sessions, _root, epoch) = fixture().await;
scheduler
.manage_host_queue(request(
Some(&epoch),
Action::Submit {
message: message("queued-a"),
},
))
.await
.unwrap();
sessions.evict_loaded_session_for_test("host-queue-session");

let list = scheduler
.manage_host_queue(request(None, Action::List))
.await
.unwrap();
assert_eq!(list.queue_epoch, epoch, "receipts stay readable");
assert_eq!(list.items.len(), 1);
assert_eq!(list.items[0].turn_id, "queued-a");
assert_eq!(list.used, 1);

let receipt = scheduler
.manage_host_queue(request(
Some(&epoch),
Action::Get {
turn_id: "queued-a".into(),
},
))
.await
.unwrap();
assert_eq!(
receipt.receipt.as_ref().map(|item| item.turn_id.as_str()),
Some("queued-a")
);

// A mutation still refuses to accept user work for an unloaded session.
let submit = scheduler
.manage_host_queue(request(
Some(&epoch),
Action::Submit {
message: message("queued-b"),
},
))
.await
.unwrap_err();
assert_eq!(
submit.message,
"Session is not loaded on the execution host"
);
let promote = scheduler
.manage_host_queue(request(
Some(&epoch),
Action::Promote {
turn_id: "queued-a".into(),
operation_id: "promote-a".into(),
expected_active_turn_id: None,
},
))
.await
.unwrap_err();
assert_eq!(
promote.message,
"Session is not loaded on the execution host"
);
}

#[tokio::test]
async fn host_queue_subagent_conversation_admission_precedes_receipts_and_queue_mutation() {
use openbitfun_core_types::{SessionContinuationPolicy, SessionKind};
Expand Down
25 changes: 25 additions & 0 deletions src/web-ui/src/infrastructure/api/adapters/tauri-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,31 @@ describe('Tauri adapter expected errors', () => {
)).toBe(false);
});

it('classifies a structured host_unavailable external-source answer as expected', () => {
expect(isExpectedTauriRequestError(
'get_external_source_control_snapshot',
{ request: { workspaceId: 'ws-1', forceRefresh: false } },
new Error(JSON.stringify({
code: 'host_unavailable',
detail: 'The remote workspace is not running the external compatibility service',
retryable: true,
})),
)).toBe(true);
});

it('keeps unexpected external-source failures visible', () => {
expect(isExpectedTauriRequestError(
'get_external_source_control_snapshot',
{ request: { workspaceId: 'ws-1', forceRefresh: false } },
new Error('Internal runtime failure'),
)).toBe(false);
expect(isExpectedTauriRequestError(
'update_external_integration_policy_command',
{ request: {} },
new Error(JSON.stringify({ code: 'host_unavailable', detail: 'unavailable host', retryable: true })),
)).toBe(false);
});

it('records adapter init and invoke timings for each request', async () => {
invokeMock.mockResolvedValueOnce({ ok: true });
const adapter = new TauriTransportAdapter();
Expand Down
47 changes: 47 additions & 0 deletions src/web-ui/src/infrastructure/api/adapters/tauri-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,54 @@ function registerSharedTauriEventListener(
return registration;
}

/** Structured external-source operation errors travel as the serialized
* `ExternalSourceOperationError` contract ({code, detail, retryable}). */
interface ExternalSourceOperationErrorShape {
code?: unknown;
detail?: unknown;
retryable?: unknown;
}

function parseExternalSourceOperationError(error: unknown): ExternalSourceOperationErrorShape | null {
const candidates = [error, (error as { details?: unknown } | null)?.details];
for (const candidate of candidates) {
if (!candidate || typeof candidate !== 'object') continue;
const record = candidate as Record<string, unknown>;
if (typeof record.code === 'string' && typeof record.detail === 'string') {
return candidate as ExternalSourceOperationErrorShape;
}
// A serialized payload inside `message` (Tauri rejects as `Error`) or
// `error`/`details` wrappers from an older host also counts.
for (const key of ['message', 'error'] as const) {
const value = record[key];
if (typeof value !== 'string') continue;
try {
const nested: unknown = JSON.parse(value);
if (nested && typeof nested === 'object'
&& typeof (nested as Record<string, unknown>).code === 'string'
&& typeof (nested as Record<string, unknown>).detail === 'string') {
return nested as ExternalSourceOperationErrorShape;
}
} catch {
// Plain prose stays classified by the caller below.
}
}
}
return null;
}

/**
* A host that by design cannot serve a request is not a product failure.
* Remote workspaces without the external compatibility service answer
* `get_external_source_*` with structured `host_unavailable`; callers already
* degrade quietly, so the transport must not log the poll as an ERROR.
*/
export function isExpectedTauriRequestError(action: string, params: unknown, error: unknown): boolean {
if (action.startsWith('get_external_source_')) {
const parsed = parseExternalSourceOperationError(error);
if (parsed?.code === 'host_unavailable') return true;
}

if (action !== 'get_config') {
return false;
}
Expand Down
14 changes: 10 additions & 4 deletions src/web-ui/src/infrastructure/api/service-api/ApiClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,16 @@ const loggerMocks = vi.hoisted(() => ({
error: vi.fn(),
}));

vi.mock('../adapters', () => ({
getTransportAdapter: () => adapterMocks,
isPeerLocalOnlyCommand: (command: string) => command === 'account_status',
}));
vi.mock('../adapters', async () => {
const actual = await vi.importActual<typeof import('../adapters')>('../adapters');
return {
getTransportAdapter: () => adapterMocks,
isPeerLocalOnlyCommand: (command: string) => command === 'account_status',
// The command-level log path consumes the transport classifier, so keep
// the real one instead of a stub that would break every rejection.
isExpectedTauriRequestError: actual.isExpectedTauriRequestError,
};
});

vi.mock('@/shared/utils/logger', () => ({
createLogger: () => loggerMocks,
Expand Down
14 changes: 10 additions & 4 deletions src/web-ui/src/infrastructure/api/service-api/ApiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import {
getTransportAdapter,
isPeerLocalOnlyCommand,
isExpectedTauriRequestError,
ITransportAdapter,
type TransportRequestTiming,
} from '../adapters';
Expand Down Expand Up @@ -543,11 +544,16 @@ export class ApiClient implements IApiClient {
throw this.createApiError('REQUEST_TIMEOUT', 'Request timeout', error);
}
const errorMessage = transportErrorMessage(error);
const isExpectedError = errorMessage.includes('not found') ||


const isExpectedError = errorMessage.includes('not found') ||
errorMessage.includes('Config path') ||
errorMessage.includes('Configuration error');
errorMessage.includes('Configuration error') ||
// A host that by design cannot serve a poll (a
// remote workspace without the external
// compatibility service) is quiet control flow,
// not a product failure.
isExpectedTauriRequestError(config.command, config.args, error);
const optionalConfigNotFound = isOptionalConfigNotFoundCommand(config, error);


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const globalStateMocks = vi.hoisted(() => ({
getCurrentWorkspace: vi.fn(),
getPrimaryAssistantWorkspace: vi.fn(),
updateWorkspaceInfo: vi.fn(),
scanWorkspaceInfo: vi.fn(),
}));

const listenMock = vi.hoisted(() => vi.fn());
Expand Down Expand Up @@ -70,6 +71,7 @@ function configureGlobalState(): void {
globalStateMocks.getCurrentWorkspace.mockResolvedValue(null);
globalStateMocks.getPrimaryAssistantWorkspace.mockResolvedValue(null);
globalStateMocks.updateWorkspaceInfo.mockReset();
globalStateMocks.scanWorkspaceInfo.mockReset();
}

async function getFreshWorkspaceManager() {
Expand Down Expand Up @@ -1063,3 +1065,62 @@ describe('WorkspaceManager host catalog hints', () => {
expect(manager.getState().openedWorkspaces.size).toBe(1);
});
});

describe('WorkspaceManager state-quiet loading and error events', () => {
const workspace = {
id: 'workspace-quiet',
name: 'Quiet repo',
rootPath: '/repo/quiet',
workspaceKind: 'normal',
identity: null,
};

beforeEach(() => {
vi.clearAllMocks();
configureGlobalState();
globalStateMocks.initializeWorkspaceStartupState.mockResolvedValue({
cleanupRemovedCount: 0,
recentWorkspaces: [workspace],
openedWorkspaces: [workspace],
currentWorkspace: workspace,
legacyRemoteWorkspace: null,
});
globalStateMocks.getCurrentWorkspace.mockResolvedValue(workspace);
globalStateMocks.getRecentWorkspaces.mockResolvedValue([workspace]);
globalStateMocks.getOpenedWorkspaces.mockResolvedValue([workspace]);
});

function collectEvents(manager: {
addEventListener: (listener: (event: { type: string }) => void) => () => void;
}): { types: string[]; unsubscribe: () => void } {
const types: string[] = [];
const unsubscribe = manager.addEventListener(event => {
types.push(event.type);
});
return { types, unsubscribe };
}

it('does not emit an instant-failure loading/error pair when a load starts cleanly', async () => {
const manager = await getFreshWorkspaceManager();
await manager.initialize();

const { types } = collectEvents(manager);
globalStateMocks.scanWorkspaceInfo.mockResolvedValue(workspace);
await manager.scanWorkspaceInfo();

expect(types).not.toContain('workspace:error');
expect(types.filter(type => type === 'workspace:loading')).toEqual(['workspace:loading']);
});

it('still reports real failures through workspace:error', async () => {
const manager = await getFreshWorkspaceManager();
await manager.initialize();

const { types } = collectEvents(manager);
globalStateMocks.scanWorkspaceInfo.mockRejectedValue(new Error('scan failed'));
await expect(manager.scanWorkspaceInfo()).rejects.toThrow('scan failed');

expect(types).toContain('workspace:error');
expect(manager.getState().error).toBe('scan failed');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -347,10 +347,16 @@ class WorkspaceManager {
}

private setLoading(loading: boolean): void {
// State-quiet events: repeating an unchanged value would have listeners
// observe `workspace:loading` and `workspace:error` in the same tick —
// e.g. loading:true then error:null right after — and treat the pair as a
// load that failed instantly.
if (this.state.loading === loading) return;
this.updateState({ loading }, { type: 'workspace:loading', loading });
}

private setError(error: string | null): void {
if (this.state.error === error) return;
this.updateState({ error }, { type: 'workspace:error', error });
}

Expand Down
Loading