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
203 changes: 202 additions & 1 deletion packages/runtime-host/src/__tests__/execution-composition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,13 @@ import type {
AgentGraphIntentClaim,
AgentGraphIntentClaimRequest,
} from '@maka/core/agent-graph-control';
import type { HostedUserQuestionSettlement } from '@maka/core/backend-types';
import type { ShellRunRecord } from '@maka/core/shell-run';
import { waitFor as pollFor } from '@maka/core/test-only/async-primitives';
import {
AgentGraphCoordinator,
agentGraphIdForRootSession,
} from '@maka/runtime/stream-graph-coordinator';
import {
FAKE_ASK_USER_QUESTION_PROMPT,
FAKE_HOLD_OPEN_PROMPT,
Expand Down Expand Up @@ -74,7 +80,10 @@ import {
stopOwnedWorkHubRoot,
stopReplacedWorkHubRoot,
} from '../server/execution-composition.js';
import { waitFor as pollFor } from '@maka/core/test-only/async-primitives';
import { RuntimeHostKernel, type RuntimeHostCompositionContext } from '../server/host-kernel.js';
import { defineInteractiveRuntimeHostComposition } from '../server/host-composition.js';
import { connectRuntimeHost, RuntimeHostOperationError } from '../client/index.js';
import { RUNTIME_HOST_PROTOCOL_VERSION } from '../protocol/index.js';
import { readLedgerMessages } from './fixtures/ledger-transcript.js';

const require = createRequire(import.meta.url);
Expand Down Expand Up @@ -2102,6 +2111,193 @@ test('production composition validates graph stop before aborting a claimed chil
});
});

test('interaction fail-stop stops graph operators through the kernel and releases ownership', {
timeout: 10_000,
}, async (t) => {
await withCompositionRoot(async ({ root, owner }) => {
const failure = new Error('backend continuation apply failed');
let graph!: AgentGraphCoordinator;
const recoverGraph = AgentGraphCoordinator.prototype.recover;
t.mock.method(
AgentGraphCoordinator.prototype,
'recover',
async function (this: AgentGraphCoordinator) {
graph = this;
return recoverGraph.call(this);
},
);
const published = deferred<string>();
const stopped = deferred<void>();
const stopObservations: Array<{ error?: unknown }> = [];
let settlement: HostedUserQuestionSettlement | undefined;
let retained = false;
let retainedAtShutdownRequest = false;
let captured!: Awaited<ReturnType<typeof createCapturedExecutionComposition>>;
const host = await RuntimeHostKernel.start({
owner,
idleGraceMs: 60_000,
shutdownGraceMs: 5_000,
composition: defineInteractiveRuntimeHostComposition(async (kernelContext) => {
captured = await createCapturedExecutionComposition(owner, {
context: {
...kernelContext,
retainUntilProcessExit: () => {
retained = true;
kernelContext.retainUntilProcessExit();
},
requestDrain: () => {
retainedAtShutdownRequest = retained;
kernelContext.requestDrain();
},
},
primaryBackendFactory: (backendContext) => {
const backend = new FakeBackend(backendContext);
const send = backend.send.bind(backend);
backend.send = async function* (input) {
const bridge = input.hostedInteraction;
assert.ok(bridge);
yield* send({
...input,
hostedInteraction: {
...bridge,
admitUserQuestionRequest: async (request) => {
settlement = request.settlement;
await bridge.admitUserQuestionRequest({
...request,
settlement: {
...request.settlement,
applyAnswer: async () => {
throw failure;
},
},
});
published.resolve(request.request.requestId);
},
},
});
};
return backend;
},
});
return captured.composition;
}),
});
const closed = host.closed.then(
() => undefined,
(error: unknown) => error,
);
const connected = await connectRuntimeHost({
rootPath: root,
protocol: { min: RUNTIME_HOST_PROTOCOL_VERSION, max: RUNTIME_HOST_PROTOCOL_VERSION },
});
assert.equal(connected.kind, 'connected');
if (connected.kind !== 'connected') throw new Error('kernel connection unavailable');
const { manager } = captured;
const stores = await openInteractiveExecutionStoresForWrite(owner.lease);
try {
const session = await manager.createSession({
cwd: root,
llmConnectionId: FAKE_CONNECTION_ID,
llmConnectionSlug: 'fake',
model: 'fake-model',
permissionMode: 'ask',
});
await graph.toolsForSession(session.id);
const turnId = 'interaction-drain-turn';
// Prepare the held-open backend with a fixture residency. The answer below exercises
// kernel drain over UDS; poisoned root-execution settlement is a separate close path.
const started = await captured.composition.handlers['turn.start'](
{
sessionId: session.id,
turnId,
content: { text: FAKE_ASK_USER_QUESTION_PROMPT },
},
{
hostEpoch: host.hostEpoch,
connectionId: 'interaction-drain-fixture',
principal: 'local_os_user',
acquireResidency: () => ({ release() {} }),
},
);
assert.equal(started.ok, true);
const interactionId = await published.promise;
const run = (await stores.runtimeEventStore.listSessionInvocations(session.id)).find(
(run) => run.turnId === turnId,
);
assert.ok(run);
const operator = await manager.provisionAgentGraphOperator({
graphId: agentGraphIdForRootSession(session.id),
workId: `graph_work_${'a'.repeat(32)}`,
operatorId: `graph_operator_${'b'.repeat(32)}`,
agentId: LOCAL_READ_AGENT_DEFINITION.id,
source: {
sessionId: session.id,
turnId,
runId: run.runId,
toolCallId: 'provision-for-drain',
},
edges: [],
expectedScheduleRevision: 0,
});
const stopSession = manager.stopSession.bind(manager);
t.mock.method(
manager,
'stopSession',
async (sessionId: string, input: Parameters<SessionManager['stopSession']>[1]) => {
if (sessionId !== operator.header.id) return stopSession(sessionId, input);
const observation: (typeof stopObservations)[number] = {};
stopObservations.push(observation);
try {
await stopSession(sessionId, input);
} catch (error) {
observation.error = error;
throw error;
} finally {
stopped.resolve();
}
},
);
await assert.rejects(
connected.connection.request('interaction.answer', {
sessionId: session.id,
interactionId,
answer: { kind: 'question', answers: ['邀请制', '本周', '是'] },
}),
(error: unknown) =>
error instanceof RuntimeHostOperationError && error.code === 'internal_failure',
);
await stopped.promise;
assert.equal(retained, true);
assert.equal(retainedAtShutdownRequest, true);
assert.deepEqual(stopObservations, [{}]);
} finally {
// Release the injected backend waiter; fail-stop intentionally cannot apply its continuation.
await settlement?.applyClosure('turn_stopped');
await connected.connection.close();
void host.close().catch(() => undefined);
const closeError = await closed;
assert.ok(
closeError instanceof AggregateError,
`Unexpected shutdown result: ${String(closeError)}`,
);
const errorTree = (error: unknown): string =>
error instanceof AggregateError
? [error.message, ...error.errors.map(errorTree)].join('\n')
: String(error);
// Poisoned compositions can aggregate other close errors; operator stop must not reenter admission.
const details = errorTree(closeError);
assert.match(details, /Interaction coordinator entered fail-stop/);
assert.doesNotMatch(
details,
/Cannot enter Session admission|termination required|shutdown deadline/i,
);
const replacementOwner = await tryAcquireInteractiveRootOwner(owner.capability);
assert.ok(replacementOwner, 'kernel released exclusive root ownership');
await replacementOwner.close();
}
});
});

function compositionContext(owner: InteractiveRootOwner) {
return {
owner,
Expand Down Expand Up @@ -2215,6 +2411,10 @@ async function seedLegacyFakeBackendSession(
async function createCapturedExecutionComposition(
owner: InteractiveRootOwner,
options: {
readonly context?: Pick<
RuntimeHostCompositionContext,
'retainUntilProcessExit' | 'requestDrain'
>;
readonly safeBoundaryResume?: boolean;
readonly primaryBackendFactory?: BackendFactory;
readonly residencies?: HostResidencyRegistry;
Expand Down Expand Up @@ -2248,6 +2448,7 @@ async function createCapturedExecutionComposition(
residencies.acquire(label, kind),
}
: {}),
...options.context,
},
{},
{ primaryBackendFactory },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,8 @@ describe('Host Runtime Resource coordinator', () => {
assert.equal(!revoked.ok && revoked.error.code, 'not_found');
});

test('drains for canonical state failure but keeps projection failure scoped to its query', async () => {
test('drains for canonical state failure but keeps projection failure scoped to its query', async (t) => {
t.mock.method(console, 'error', () => {});
const harness = createHarness();
harness.updates = [
resourceUpdate(0, {
Expand Down Expand Up @@ -257,6 +258,39 @@ describe('Host Runtime Resource coordinator', () => {
assert.equal(harness.terminateCount, 0);
});

test('logs a bounded redacted canonical state failure before draining', async (t) => {
const logs: string[] = [];
let drainCount = 0;
let logCountAtDrain = 0;
t.mock.method(console, 'error', (...args: unknown[]) => {
logs.push(args.map(String).join(' '));
});
const harness = createHarness({
requestDrain: () => {
drainCount += 1;
logCountAtDrain = logs.length;
},
});
harness.stateReadFailure = new Error(
`canonical state unavailable api_key=sk-secretvalue123 ${'x'.repeat(16 * 1024)}`,
);

const result = await harness.coordinator.handlers['runtime.resource.query'](
{ kind: 'list_start', sessionId: SESSION_ID },
connection('connection-1'),
);

assert.equal(result.ok, false);
assert.equal(!result.ok && result.error.code, 'internal_failure');
assert.equal(drainCount, 1);
assert.equal(logCountAtDrain, 1);
assert.equal(logs.length, 1);
assert.match(logs[0] ?? '', /canonical state unavailable/);
assert.match(logs[0] ?? '', /\[redacted\]/i);
assert.doesNotMatch(logs[0] ?? '', /sk-secretvalue123/);
assert.ok(Buffer.byteLength(logs[0] ?? '', 'utf8') < 9 * 1024);
});

test('fences PTY control by connection and retains only exact sequence retries', async () => {
const harness = createHarness();
const firstConnection = connection('connection-1');
Expand Down Expand Up @@ -399,6 +433,7 @@ describe('Host Runtime Resource coordinator', () => {
assert.equal(started.ok, false);
assert.ok(harness.lastBackgroundInput);
assert.equal(harness.stopCount, 1);
assert.equal(harness.drainCount, 1);
harness.finishBackground({ successful: false });
});

Expand Down Expand Up @@ -702,12 +737,55 @@ describe('Host Runtime Resource coordinator', () => {
assert.equal(!missing.ok && missing.error.code, 'not_found');
assert.equal(harness.stopCount, 0);
});

test('drains when the admitted mutable Session read fails', async () => {
const harness = createHarness();
harness.sessionReadFailureAt = 2;

const started = await harness.coordinator.handlers['runtime.resource.start'](
{ sessionId: SESSION_ID, launchId: 'session-read-failure' },
connection('connection-1'),
);

assert.equal(started.ok, false);
assert.equal(!started.ok && started.error.code, 'internal_failure');
assert.equal(harness.drainCount, 1);
assert.equal(harness.lastBackgroundInput, undefined);
});
});

test('rejects a queued resource start when drain detaches from the active Session admission', async () => {
const harness = createHarness();
let release!: () => void;
let entered!: () => void;
const blocker = new Promise<void>((resolve) => {
release = resolve;
});
const started = new Promise<void>((resolve) => {
entered = resolve;
});
const active = harness.sessionAdmission.run(SESSION_ID, async () => {
entered();
await blocker;
// Invoke from inside the active async context after the resource launch is queued.
harness.sessionAdmission.detach(() => harness.coordinator.beginDrain());
});
await started;
const resource = harness.coordinator.runBackgroundBash(backgroundInput());
const observed = assert.rejects(resource, /Runtime resources are draining/);
release();
await Promise.all([active, observed]);
assert.equal(harness.lastBackgroundInput, undefined);
assert.equal(harness.terminateCount, 1);
assert.equal(harness.activeResidencies, 0);
});

function createHarness(
options: Pick<
HostRuntimeResourceCoordinatorInput,
'resolveShell' | 'sessionAccessAuthority'
options: Partial<
Pick<
HostRuntimeResourceCoordinatorInput,
'requestDrain' | 'resolveShell' | 'sessionAccessAuthority'
>
> = {},
) {
let backgroundCompletion: ShellRunBashInput['onCompletion'];
Expand All @@ -716,6 +794,8 @@ function createHarness(
const state = {
updates: [resourceUpdate(0)],
sessionState: 'active' as 'active' | 'archived' | 'missing',
sessionReadCount: 0,
sessionReadFailureAt: undefined as number | undefined,
writeCount: 0,
stopCount: 0,
terminateCount: 0,
Expand Down Expand Up @@ -829,6 +909,10 @@ function createHarness(
},
sessionHeaders: {
readHeader: async (sessionId) => {
state.sessionReadCount += 1;
if (state.sessionReadCount === state.sessionReadFailureAt) {
throw new Error('Session state unavailable');
}
if (state.sessionState === 'missing') throw new SessionNotFoundError(sessionId);
return {
cwd: '/workspace',
Expand Down
Loading