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
182 changes: 182 additions & 0 deletions packages/runtime-host/src/__tests__/execution-composition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
12 changes: 12 additions & 0 deletions packages/runtime-host/src/__tests__/skill-catalog-protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
3 changes: 2 additions & 1 deletion packages/runtime-host/src/protocol/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions packages/runtime-host/src/protocol/skill-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down Expand Up @@ -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,
},
Expand Down
50 changes: 42 additions & 8 deletions packages/runtime-host/src/server/execution-composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Deleting these three lines leaves all four new tests green, so nothing pins this pre-check — yet it is not redundant with the catch below: resolveAvailableToolNames returns early for subagent headers (:1097-1116) without ever reaching AgentGraphCoordinator#assertRootSupervisor, so an archived linked child Session is refused only here. Assert that too — a case with an archived subagentRuntime header expecting session_archived, or an assertion in the existing archived test that toolsForSession was not called, which is what its name already claims.
删掉这三行后四个新测试仍全绿,但它并不与下面的 catch 重复:subagent header 在 :1097-1116 提前返回,不会走到 assertRootSupervisor,已归档的子 Session 只靠这里拒绝。建议补一例断言,或在现有归档用例里断言未解析实时工具面(测试名已这么声称)。

throw new SkillCatalogInvocableContextError('session_archived', 'Session is archived');
}
let preview;
try {
preview = await requireClientCapabilities(
clientCapabilities,
).runWithSessionBindingPreview(sessionId, connection.connectionId, () =>
requireToolNameResolver(resolveAvailableToolNames)(sessionId),
);
} catch (error) {
Comment thread
liuxiaocs7 marked this conversation as resolved.
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,
Expand Down
43 changes: 34 additions & 9 deletions packages/runtime-host/src/server/skill-catalog-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<OperationOutcome<'skill.catalog.mutate'>> {
Expand Down Expand Up @@ -175,6 +198,8 @@ export class HostSkillCatalogCoordinator {
#admitProtocolOperation<K extends CatalogOperation>(
operation: K,
run: () => Promise<Extract<OperationOutcome<K>, { ok: true }>['result']>,
onFailure: (error: unknown) => OperationOutcome<K> = (error) =>
repositoryFailure(operation, error),
): Promise<OperationOutcome<K>> {
if (!this.#accepting) {
return Promise.resolve({
Expand All @@ -186,7 +211,7 @@ export class HostSkillCatalogCoordinator {
try {
return { ok: true, result: await run() } as OperationOutcome<K>;
} catch (error) {
return repositoryFailure<K>(operation, error);
return onFailure(error);
}
});
}
Expand Down