diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index ecc26a7a67..ed2eeee94f 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -1693,6 +1693,188 @@ test('production Skill catalog resolves a Graph child durable tool surface', asy }); }); +test('production Skill catalog reports an archived Session without resolving its live tool surface', async () => { + await withCompositionRoot(async ({ root, owner }) => { + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + const session = await stores.sessionStore.create({ + cwd: root, + llmConnectionId: FAKE_CONNECTION_ID, + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + }); + const snapshot = await stores.sessionStore.readHeaderRecordSnapshot(session.id); + await stores.sessionStore.setSessionsArchivedVersioned( + [{ sessionId: session.id, expectedVersion: snapshot.revision }], + true, + ); + + const composition = await createExecutionRuntimeHostComposition(compositionContext(owner)); + try { + await composition.recover(); + const outcome = await composition.handlers['skill.catalog.invocable.query']( + { + kind: 'start', + target: { kind: 'session', sessionId: session.id }, + }, + { + hostEpoch: 'execution-composition-test', + connectionId: 'archived-session-skill-client', + principal: 'local_os_user', + acquireResidency: () => ({ release() {} }), + }, + ); + assert.deepEqual(outcome, { + ok: false, + error: { code: 'session_archived', message: 'Session is archived' }, + }); + } finally { + await composition.close(); + } + }); +}); + +test('production Skill catalog reports a removed Session as not found', async () => { + await withCompositionRoot(async ({ root, owner }) => { + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + const session = await stores.sessionStore.create({ + cwd: root, + llmConnectionId: FAKE_CONNECTION_ID, + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + }); + const snapshot = await stores.sessionStore.readHeaderRecordSnapshot(session.id); + await stores.sessionStore.removeSessionsVersioned([ + { sessionId: session.id, expectedVersion: snapshot.revision }, + ]); + + const composition = await createExecutionRuntimeHostComposition(compositionContext(owner)); + try { + await composition.recover(); + const outcome = await composition.handlers['skill.catalog.invocable.query']( + { + kind: 'start', + target: { kind: 'session', sessionId: session.id }, + }, + { + hostEpoch: 'execution-composition-test', + connectionId: 'removed-session-skill-client', + principal: 'local_os_user', + acquireResidency: () => ({ release() {} }), + }, + ); + assert.deepEqual(outcome, { + ok: false, + error: { code: 'not_found', message: 'Session does not exist' }, + }); + } finally { + await composition.close(); + } + }); +}); + +test('production Skill catalog preserves an archive race during live tool resolution', async () => { + await withCompositionRoot(async ({ root, owner }) => { + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + const session = await stores.sessionStore.create({ + cwd: root, + llmConnectionId: FAKE_CONNECTION_ID, + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + }); + const composition = await createExecutionRuntimeHostComposition(compositionContext(owner)); + const originalToolsForSession = AgentGraphCoordinator.prototype.toolsForSession; + let archiveInjected = false; + try { + await composition.recover(); + AgentGraphCoordinator.prototype.toolsForSession = async function (sessionId) { + if (sessionId === session.id && !archiveInjected) { + archiveInjected = true; + const snapshot = await stores.sessionStore.readHeaderRecordSnapshot(session.id); + await stores.sessionStore.setSessionsArchivedVersioned( + [{ sessionId: session.id, expectedVersion: snapshot.revision }], + true, + ); + } + return originalToolsForSession.call(this, sessionId); + }; + + const outcome = await composition.handlers['skill.catalog.invocable.query']( + { + kind: 'start', + target: { kind: 'session', sessionId: session.id }, + }, + { + hostEpoch: 'execution-composition-test', + connectionId: 'archive-race-skill-client', + principal: 'local_os_user', + acquireResidency: () => ({ release() {} }), + }, + ); + assert.equal(archiveInjected, true); + assert.deepEqual(outcome, { + ok: false, + error: { code: 'session_archived', message: 'Session is archived' }, + }); + } finally { + AgentGraphCoordinator.prototype.toolsForSession = originalToolsForSession; + await composition.close(); + } + }); +}); + +test('production Skill catalog preserves a removal race during live tool resolution', async () => { + await withCompositionRoot(async ({ root, owner }) => { + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + const session = await stores.sessionStore.create({ + cwd: root, + llmConnectionId: FAKE_CONNECTION_ID, + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + }); + const composition = await createExecutionRuntimeHostComposition(compositionContext(owner)); + const originalToolsForSession = AgentGraphCoordinator.prototype.toolsForSession; + let removalInjected = false; + try { + await composition.recover(); + AgentGraphCoordinator.prototype.toolsForSession = async function (sessionId) { + if (sessionId === session.id && !removalInjected) { + removalInjected = true; + const snapshot = await stores.sessionStore.readHeaderRecordSnapshot(session.id); + await stores.sessionStore.removeSessionsVersioned([ + { sessionId: session.id, expectedVersion: snapshot.revision }, + ]); + } + return originalToolsForSession.call(this, sessionId); + }; + + const outcome = await composition.handlers['skill.catalog.invocable.query']( + { + kind: 'start', + target: { kind: 'session', sessionId: session.id }, + }, + { + hostEpoch: 'execution-composition-test', + connectionId: 'removal-race-skill-client', + principal: 'local_os_user', + acquireResidency: () => ({ release() {} }), + }, + ); + assert.equal(removalInjected, true); + assert.deepEqual(outcome, { + ok: false, + error: { code: 'not_found', message: 'Session does not exist' }, + }); + } finally { + AgentGraphCoordinator.prototype.toolsForSession = originalToolsForSession; + await composition.close(); + } + }); +}); + test('new Full Access Plan Skill previews use the mutating tool surface', async () => { await withCompositionRoot(async ({ root, owner }) => { const skillDirectory = join(root, '.agents', 'skills', 'write-preview'); diff --git a/packages/runtime-host/src/__tests__/skill-catalog-protocol.test.ts b/packages/runtime-host/src/__tests__/skill-catalog-protocol.test.ts index a0ad74b206..9f28dec59f 100644 --- a/packages/runtime-host/src/__tests__/skill-catalog-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/skill-catalog-protocol.test.ts @@ -172,6 +172,18 @@ describe('Runtime Host Skill catalog protocol', () => { result, }, ); + for (const error of [ + { code: 'not_found', message: 'Session does not exist' }, + { code: 'session_archived', message: 'Session is archived' }, + ] as const) { + const refusal = { + requestId: 'request-1', + operation: 'skill.catalog.invocable.query', + ok: false, + error, + }; + assert.deepEqual(decodeHostFrame(refusal), refusal); + } assertInvalidRequest('skill.catalog.invocable.query', { kind: 'start', target: { kind: 'new_session', context: CONTEXT, collaborationMode: 'plan' }, diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 778720f1f9..07170cf8b1 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -101,7 +101,8 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 141 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 142 as const; +// 142: Invocable Skill queries expose missing and archived Session refusals explicitly. // 141: WorkHub root admissions bind model Intent/Recall decisions before actions. // 140: Plugin Platform queries expose scoped Command contribution projections. // Epoch-139 peers reject the added query view and result shape. diff --git a/packages/runtime-host/src/protocol/skill-catalog.ts b/packages/runtime-host/src/protocol/skill-catalog.ts index ee2353400c..6dc294f0f7 100644 --- a/packages/runtime-host/src/protocol/skill-catalog.ts +++ b/packages/runtime-host/src/protocol/skill-catalog.ts @@ -51,6 +51,7 @@ const QUERY_ERRORS = [ 'persistence_failed', 'internal_failure', ] as const; +const INVOCABLE_QUERY_ERRORS = [...QUERY_ERRORS, 'not_found', 'session_archived'] as const; const MUTATION_ERRORS = [...QUERY_ERRORS, 'commit_outcome_unknown'] as const; export type SkillCatalogRevision = `sha256:${string}`; @@ -365,12 +366,12 @@ export const SKILL_CATALOG_OPERATION_SPECS = { 'skill.catalog.invocable.query': defineHostPathOperation< SkillCatalogInvocableQueryInput, SkillCatalogInvocableQueryResult, - (typeof QUERY_ERRORS)[number] + (typeof INVOCABLE_QUERY_ERRORS)[number] >( { mode: 'query', availability: 'ready', - errors: QUERY_ERRORS, + errors: INVOCABLE_QUERY_ERRORS, decodeInput: decodeInvocableQueryInput, decodeOutput: decodeInvocableQueryResult, }, diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 1233037457..f6c2b0eb88 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -40,7 +40,10 @@ import { WORKHUB_COORDINATION_SESSION_ID, WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION, } from '@maka/core/session'; -import { AgentGraphCoordinator } from '@maka/runtime/stream-graph-coordinator'; +import { + AgentGraphClientOperationError, + AgentGraphCoordinator, +} from '@maka/runtime/stream-graph-coordinator'; import { AgentGraphSupervisorWakeCoordinator } from '@maka/runtime/agent-graph-supervisor-wake'; import { BackendRegistry, @@ -215,7 +218,10 @@ import { createSessionTranscriptReader, type SessionTranscriptReader, } from './session-transcript-reader.js'; -import { HostSkillCatalogCoordinator } from './skill-catalog-coordinator.js'; +import { + HostSkillCatalogCoordinator, + SkillCatalogInvocableContextError, +} from './skill-catalog-coordinator.js'; import { SkillCatalogRepository } from './skill-catalog-repository.js'; import { HostSessionTodoCoordinator } from './session-todo-coordinator.js'; import { HostTurnControlCoordinator } from './turn-control-coordinator.js'; @@ -707,12 +713,40 @@ export async function createExecutionRuntimeHostComposition( async (input, connection) => { if (input.target.kind === 'session') { const sessionId = input.target.sessionId; - const header = await stores.sessionStore.readHeaderSnapshot(sessionId); - const preview = await requireClientCapabilities( - clientCapabilities, - ).runWithSessionBindingPreview(sessionId, connection.connectionId, () => - requireToolNameResolver(resolveAvailableToolNames)(sessionId), - ); + let header; + try { + header = await stores.sessionStore.readHeaderSnapshot(sessionId); + } catch (error) { + if (isSessionNotFoundError(error)) { + throw new SkillCatalogInvocableContextError('not_found', 'Session does not exist'); + } + throw error; + } + if (header.isArchived) { + throw new SkillCatalogInvocableContextError('session_archived', 'Session is archived'); + } + let preview; + try { + preview = await requireClientCapabilities( + clientCapabilities, + ).runWithSessionBindingPreview(sessionId, connection.connectionId, () => + requireToolNameResolver(resolveAvailableToolNames)(sessionId), + ); + } catch (error) { + if (isSessionNotFoundError(error)) { + throw new SkillCatalogInvocableContextError('not_found', 'Session does not exist'); + } + if ( + error instanceof AgentGraphClientOperationError && + error.code === 'session_archived' + ) { + throw new SkillCatalogInvocableContextError( + 'session_archived', + 'Session is archived', + ); + } + throw error; + } if (!preview.ok) throw new Error(preview.message); return { projectRoot: header.cwd, diff --git a/packages/runtime-host/src/server/skill-catalog-coordinator.ts b/packages/runtime-host/src/server/skill-catalog-coordinator.ts index 39e53bbcf0..b827622f2a 100644 --- a/packages/runtime-host/src/server/skill-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/skill-catalog-coordinator.ts @@ -54,6 +54,17 @@ export interface SkillCatalogInvocableContext { readonly host: HostCapabilities; } +export class SkillCatalogInvocableContextError extends Error { + readonly name = 'SkillCatalogInvocableContextError'; + + constructor( + readonly code: 'not_found' | 'session_archived', + message: string, + ) { + super(message); + } +} + export type SkillCatalogInvocableContextResolver = ( input: SkillCatalogInvocableQueryInput, context: ConnectionContext, @@ -115,14 +126,26 @@ export class HostSkillCatalogCoordinator { }, }); } - return this.#admitProtocolOperation('skill.catalog.invocable.query', async () => { - const resolved = await this.#resolveInvocableContext!(input, context); - return this.#repository.queryInvocable( - repositoryInvocableQueryInput(input), - { projectRoot: resolved.projectRoot }, - resolved.host, - ); - }); + return this.#admitProtocolOperation( + 'skill.catalog.invocable.query', + async () => { + const resolved = await this.#resolveInvocableContext!(input, context); + return this.#repository.queryInvocable( + repositoryInvocableQueryInput(input), + { projectRoot: resolved.projectRoot }, + resolved.host, + ); + }, + (error) => { + if (error instanceof SkillCatalogInvocableContextError) { + return { + ok: false, + error: { code: error.code, message: error.message }, + }; + } + return repositoryFailure('skill.catalog.invocable.query', error); + }, + ); } mutate(input: SkillCatalogMutateInput): Promise> { @@ -175,6 +198,8 @@ export class HostSkillCatalogCoordinator { #admitProtocolOperation( operation: K, run: () => Promise, { ok: true }>['result']>, + onFailure: (error: unknown) => OperationOutcome = (error) => + repositoryFailure(operation, error), ): Promise> { if (!this.#accepting) { return Promise.resolve({ @@ -186,7 +211,7 @@ export class HostSkillCatalogCoordinator { try { return { ok: true, result: await run() } as OperationOutcome; } catch (error) { - return repositoryFailure(operation, error); + return onFailure(error); } }); }