blob: a249bb08bd2ffdbf25c3ab466ff467bba1437cbd [file]
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import type { SessionEvent, SessionHeader, StoredMessage } from '@maka/core';
import type { SandboxBoundaryResponse } from '@maka/core';
import type { AgentBackend, BackendSendInput, BackendStopMode } from '@maka/core';
import {
RuntimeInteractionFailStopError,
RuntimeInteractionInvariantError,
type RuntimeInteractionAuthority,
} from '../interaction-authority.js';
import {
RuntimeKernel,
type RuntimeKernelDeps,
RuntimeOwnerCleanupError,
} from '../runtime-kernel.js';
import { BackendRegistry, type SessionStore } from '../session-manager.js';
describe('RuntimeKernel Interaction close cleanup', () => {
test('reserve followed by begin failure settles a concurrent stop claim', async () => {
const store = memoryStore();
const updateHeader = store.updateHeader;
const backends = new BackendRegistry();
const backend = new BlockingBackend(SESSION_ID, {});
backends.register('fake', () => backend);
const startupFailure = new Error('mark running rejected after reservation');
let stoppedFailure: Promise<unknown> | undefined;
let kernel!: RuntimeKernel;
store.updateHeader = async (sessionId, patch) => {
if (patch.status === 'running') {
stoppedFailure = rejectionOf(kernel.stopSession(SESSION_ID, { source: 'stop_button' }));
throw startupFailure;
}
return await updateHeader(sessionId, patch);
};
let id = 0;
kernel = new RuntimeKernel({
store,
backends,
newId: () => `begin-failure-id-${++id}`,
now: () => id,
});
const iterator = kernel
.startTurn(SESSION_ID, { turnId: 'turn-begin-failure', text: 'start' })
[Symbol.asyncIterator]();
const startFailure = rejectionOf(iterator.next());
await waitFor(() => stoppedFailure !== undefined);
const ownershipFailure = await within(stoppedFailure!, 'concurrent stop ownership');
assert.equal(containsFailure(ownershipFailure, startupFailure), true);
assert.equal(backend.sendCalls, 0);
assert.equal(
containsFailure(await within(startFailure, 'failed Run start'), startupFailure),
true,
);
await iterator.return?.(undefined).catch(() => undefined);
});
test('stop during owner bind waits for exact close and reports its failure once', async () => {
const store = memoryStore();
const backends = new BackendRegistry();
const backend = new BlockingBackend(SESSION_ID, {});
backends.register('fake', () => backend);
const closeFailure = new Error('bind-race close rejected');
const closeStarted = deferred<void>();
const releaseClose = deferred<void>();
let closeCalls = 0;
let stopped: Promise<void> | undefined;
let stoppedFailure: Promise<unknown> | undefined;
let kernel!: RuntimeKernel;
const interactionAuthority: RuntimeInteractionAuthority = {
bindRun: (identity) => {
stopped = kernel.stopSession(SESSION_ID, { source: 'stop_button' });
stoppedFailure = rejectionOf(stopped);
return {
...identity,
acceptSandboxBoundaryRequest: async () => {},
acceptUserQuestionRequest: async () => {},
close: async () => {
closeCalls += 1;
closeStarted.resolve();
await releaseClose.promise;
throw closeFailure;
},
release: () => assert.fail('failed close must retain the owner'),
};
},
};
let id = 0;
kernel = new RuntimeKernel({
store,
backends,
interactionAuthority,
newId: () => `bind-race-id-${++id}`,
now: () => id,
});
const iterator = kernel
.startTurn(SESSION_ID, { turnId: 'turn-bind-race', text: 'start' })
[Symbol.asyncIterator]();
const firstFailure = rejectionOf(iterator.next());
await waitFor(() => stopped !== undefined && stoppedFailure !== undefined);
await closeStarted.promise;
releaseClose.resolve();
const failure = await stoppedFailure!;
assert.equal(containsFailure(failure, closeFailure), true);
assert.equal(closeCalls, 1);
assert.equal(backend.stopCalls.length, 1);
await firstFailure;
await iterator.return?.(undefined).catch(() => undefined);
assert.equal(closeCalls, 1);
});
test('legacy question responder fails closed when hosted authority is configured', async () => {
const fixture = runtimeFixture({ closeSucceeds: true });
await assert.rejects(
fixture.kernel.respondToUserQuestion(SESSION_ID, {
requestId: 'hosted-question',
answers: ['Yes'],
}),
RuntimeInteractionInvariantError,
);
});
test('explicit stop starts backend cleanup before deferred close settles and reports both failures', async () => {
const stopFailure = new Error('backend stop rejected');
const fixture = runtimeFixture({ deferredClose: true, stopFailure });
const iterator = fixture.kernel
.startTurn(SESSION_ID, { turnId: 'turn-explicit-stop', text: 'start' })
[Symbol.asyncIterator]();
assert.equal((await iterator.next()).value?.type, 'text_delta');
const stopped = fixture.kernel.stopSession(SESSION_ID, { source: 'stop_button' });
await fixture.closeStarted;
assert.equal(fixture.backend.stopCalls.length, 1);
fixture.releaseClose();
const failure = await rejectionOf(stopped);
assert.ok(failure instanceof RuntimeOwnerCleanupError);
assert.equal(containsFailure(failure, fixture.closeFailure), true);
assert.equal(containsFailure(failure, stopFailure), true);
await iterator.return?.(undefined).catch(() => undefined);
});
test('abandoned consumer starts backend cleanup before deferred close settles', async () => {
const fixture = runtimeFixture({ deferredClose: true });
const iterator = fixture.kernel
.startTurn(SESSION_ID, { turnId: 'turn-abandoned', text: 'start' })
[Symbol.asyncIterator]();
assert.equal((await iterator.next()).value?.type, 'text_delta');
const abandoned = iterator.return!(undefined);
await fixture.closeStarted;
assert.equal(fixture.backend.stopCalls.length, 1);
fixture.releaseClose();
const failure = await rejectionOf(abandoned);
assertCanonicalCloseFailure(failure, fixture.closeFailure);
});
test('multiple owner cleanup failures retain one fail-stop marker', async () => {
const messageReleaseFailure = new Error('message owner release rejected');
const fixture = runtimeFixture({ messageReleaseFailure });
const iterator = fixture.kernel
.startTurn(SESSION_ID, { turnId: 'turn-multiple-owner-failures', text: 'start' })
[Symbol.asyncIterator]();
const draining = drainIterator(iterator);
await waitFor(() => fixture.backend.sendCalls === 1);
fixture.backend.releaseBlockedSend();
const failure = await rejectionOf(draining);
assert.ok(failure instanceof RuntimeOwnerCleanupError);
assert.equal(containsFailure(failure, fixture.closeFailure), true);
assert.equal(containsFailure(failure, messageReleaseFailure), true);
});
test('explicit stop and iterator cleanup share one pending backend stop attempt', async () => {
const stopFailure = new Error('shared backend stop rejected');
const fixture = runtimeFixture({
closeSucceeds: true,
deferredStop: true,
stopFailure,
});
const iterator = fixture.kernel
.startTurn(SESSION_ID, { turnId: 'turn-concurrent-stop', text: 'start' })
[Symbol.asyncIterator]();
assert.equal((await iterator.next()).value?.type, 'text_delta');
const stopped = fixture.kernel.stopSession(SESSION_ID, {
source: 'stop_button',
mode: 'after_step',
});
await fixture.backend.stopStarted;
const abandoned = iterator.return!(undefined);
await Promise.resolve();
assert.deepEqual(fixture.backend.stopCalls, [{ reason: 'user_stop', mode: 'after_step' }]);
fixture.backend.releaseStop();
const [explicitFailure, cleanupFailure] = await Promise.all([
rejectionOf(stopped),
rejectionOf(abandoned),
]);
assert.equal(explicitFailure, stopFailure);
assert.equal(cleanupFailure, stopFailure);
assert.equal(fixture.backend.stopCalls.length, 1);
});
test('public stop rejection with a blocked send actively disposes the backend', async () => {
const stopFailure = new Error('stop rejected without releasing send');
const fixture = runtimeFixture({
closeSucceeds: true,
stopFailure,
releaseSendOnStop: false,
releaseSendOnDispose: false,
});
const iterator = fixture.kernel
.startTurn(SESSION_ID, { turnId: 'turn-blocked-send', text: 'start' })
[Symbol.asyncIterator]();
assert.equal((await iterator.next()).value?.type, 'text_delta');
const failure = await rejectionOf(
fixture.kernel.stopSession(SESSION_ID, { source: 'stop_button' }),
);
assert.equal(containsFailure(failure, stopFailure), true);
assert.equal(fixture.backend.disposeCalls, 1);
const retryFailure = await rejectionOf(
fixture.kernel.stopSession(SESSION_ID, { source: 'stop_button' }),
);
assert.equal(containsFailure(retryFailure, stopFailure), true);
assert.equal(fixture.backend.stopCalls.length, 1);
const messages = await fixture.store.readMessages(SESSION_ID);
assert.equal(
messages.filter(
(message) =>
message.type === 'turn_state' &&
message.turnId === 'turn-blocked-send' &&
message.status === 'aborted',
).length,
1,
);
assert.equal(
messages.filter((message) => message.type === 'system_note' && message.kind === 'abort')
.length,
1,
);
const blockedActivation = fixture.kernel
.startTurn(SESSION_ID, { turnId: 'turn-before-runner-settled', text: 'must not send' })
[Symbol.asyncIterator]();
await assert.rejects(blockedActivation.next(), /quarantined/);
assert.equal(fixture.backend.sendCalls, 1);
fixture.backend.releaseBlockedSend();
await drainIterator(iterator);
});
test('a generation stopped after Run reservation cannot send on the stale backend', async () => {
const store = memoryStore();
const backends = new BackendRegistry();
const backend = new BlockingBackend(SESSION_ID, {
stopFailure: new Error('reserved generation stop failed'),
releaseSendOnStop: false,
releaseSendOnDispose: false,
});
backends.register('fake', () => backend);
const secondReserved = deferred<void>();
const releaseSecond = deferred<void>();
let reservations = 0;
let id = 0;
const kernel = new RuntimeKernel({
store,
backends,
newId: () => `reservation-id-${++id}`,
now: () => id,
runBackendActivation: async (operation) => {
const result = await operation();
reservations += 1;
if (reservations === 2) {
secondReserved.resolve();
await releaseSecond.promise;
}
return result;
},
});
const first = kernel
.startTurn(SESSION_ID, { turnId: 'turn-reservation-1', text: 'first' })
[Symbol.asyncIterator]();
assert.equal((await first.next()).value?.type, 'text_delta');
const second = kernel
.startTurn(SESSION_ID, { turnId: 'turn-reservation-2', text: 'second' })
[Symbol.asyncIterator]();
const secondEvent = second.next();
await secondReserved.promise;
await assert.rejects(
kernel.stopSession(SESSION_ID, { source: 'stop_button' }),
/reserved generation stop failed/,
);
assert.equal(backend.disposeCalls, 1);
assert.equal(backend.sendCalls, 1);
releaseSecond.resolve();
assert.equal((await secondEvent).done, true);
assert.equal(backend.sendCalls, 1);
backend.releaseBlockedSend();
await drainIterator(first).catch(() => undefined);
});
test('a failed stop operation is not reused by the next backend Run generation', async () => {
const store = memoryStore();
const backends = new BackendRegistry();
const built: BlockingBackend[] = [];
backends.register('fake', () => {
const backend = new BlockingBackend(
SESSION_ID,
built.length === 0
? {
stopFailure: new Error('first generation stop failed'),
releaseSendOnStop: false,
}
: {},
);
built.push(backend);
return backend;
});
let id = 0;
const kernel = new RuntimeKernel({
store,
backends,
newId: () => `generation-id-${++id}`,
now: () => id,
});
const first = kernel
.startTurn(SESSION_ID, { turnId: 'turn-generation-1', text: 'first' })
[Symbol.asyncIterator]();
assert.equal((await first.next()).value?.type, 'text_delta');
await assert.rejects(
kernel.stopSession(SESSION_ID, { source: 'stop_button', mode: 'after_step' }),
/first generation stop failed/,
);
await drainIterator(first);
assert.equal(built[0]?.disposeCalls, 1);
assert.deepEqual(built[0]?.stopCalls, [{ reason: 'user_stop', mode: 'after_step' }]);
const firstMessages = await store.readMessages(SESSION_ID);
assert.equal(
firstMessages.filter(
(message) =>
message.type === 'turn_state' &&
message.turnId === 'turn-generation-1' &&
message.status === 'aborted',
).length,
1,
);
assert.equal(
firstMessages.filter((message) => message.type === 'system_note' && message.kind === 'abort')
.length,
1,
);
const second = kernel
.startTurn(SESSION_ID, { turnId: 'turn-generation-2', text: 'second' })
[Symbol.asyncIterator]();
assert.equal((await second.next()).value?.type, 'text_delta');
await kernel.stopSession(SESSION_ID, { source: 'stop_button', mode: 'immediate' });
await second.return!(undefined).catch(() => undefined);
assert.equal(built.length, 2);
assert.deepEqual(built[1]?.stopCalls[0], { reason: 'user_stop', mode: 'immediate' });
assert.equal(
built[1]?.stopCalls.some((call) => call.mode === 'after_step'),
false,
);
});
test('disposal failure permanently quarantines the Session from backend activation', async () => {
const store = memoryStore();
const backends = new BackendRegistry();
const built: BlockingBackend[] = [];
const stopFailure = new Error('backend stop failed');
const disposeFailure = new Error('backend disposal failed');
backends.register('fake', () => {
const backend = new BlockingBackend(SESSION_ID, {
stopFailure,
disposeFailure,
releaseSendOnDispose: false,
releaseSendOnStop: false,
});
built.push(backend);
return backend;
});
let id = 0;
const kernel = new RuntimeKernel({
store,
backends,
newId: () => `quarantine-id-${++id}`,
now: () => id,
});
const first = kernel
.startTurn(SESSION_ID, { turnId: 'turn-quarantine-1', text: 'first' })
[Symbol.asyncIterator]();
assert.equal((await first.next()).value?.type, 'text_delta');
const stopped = await rejectionOf(kernel.stopSession(SESSION_ID, { source: 'stop_button' }));
assert.equal(containsFailure(stopped, stopFailure), true);
assert.equal(containsFailure(stopped, disposeFailure), true);
await assert.rejects(kernel.invalidateCachedBackends(), /backend disposal failed/);
assert.equal(built[0]?.disposeCalls, 1);
built[0]?.releaseBlockedSend();
await drainIterator(first);
assert.equal(built[0]?.disposeCalls, 1);
for (const turnId of ['turn-quarantine-2', 'turn-quarantine-3']) {
const blocked = kernel
.startTurn(SESSION_ID, { turnId, text: 'must not build' })
[Symbol.asyncIterator]();
await assert.rejects(blocked.next(), /permanently quarantined/);
}
assert.equal(built.length, 1);
assert.equal(built[0]?.disposeCalls, 1);
});
});
const SESSION_ID = 'session-interaction-cleanup';
interface RuntimeFixtureOptions {
closeSucceeds?: boolean;
deferredClose?: boolean;
deferredStop?: boolean;
stopFailure?: Error;
disposeFailure?: Error;
releaseSendOnDispose?: boolean;
releaseSendOnStop?: boolean;
messageReleaseFailure?: Error;
runBackendActivation?: RuntimeKernelDeps['runBackendActivation'];
}
function runtimeFixture(options: RuntimeFixtureOptions = {}): {
kernel: RuntimeKernel;
backend: BlockingBackend;
store: SessionStore;
closeFailure: Error;
closeStarted: Promise<void>;
releaseClose: () => void;
} {
const store = memoryStore();
const backends = new BackendRegistry();
const backend = new BlockingBackend(SESSION_ID, options);
backends.register('fake', () => backend);
const closeFailure = new Error('durable close rejected');
let markCloseStarted!: () => void;
const closeStarted = new Promise<void>((resolve) => {
markCloseStarted = resolve;
});
let releaseClose!: () => void;
const closeReleased = new Promise<void>((resolve) => {
releaseClose = resolve;
});
const interactionAuthority: RuntimeInteractionAuthority = {
bindRun: (identity) => ({
...identity,
acceptSandboxBoundaryRequest: async () => {},
acceptUserQuestionRequest: async () => {},
close: async () => {
markCloseStarted();
if (options.deferredClose) await closeReleased;
if (!options.closeSucceeds) throw closeFailure;
},
release: () => {
if (!options.closeSucceeds) {
assert.fail('a Run with failed durable close must not release');
}
},
}),
};
let id = 0;
return {
kernel: new RuntimeKernel({
store,
backends,
interactionAuthority,
...(options.messageReleaseFailure
? {
messageAuthority: {
bindRun: (identity) => ({
...identity,
pull: () => [],
ack: () => {},
nack: () => {},
release: () => {
throw options.messageReleaseFailure;
},
}),
},
}
: {}),
newId: () => `id-${++id}`,
now: () => id,
...(options.runBackendActivation
? { runBackendActivation: options.runBackendActivation }
: {}),
}),
backend,
store,
closeFailure,
closeStarted,
releaseClose,
};
}
class BlockingBackend implements AgentBackend {
readonly kind = 'fake' as const;
readonly stopCalls: Array<{
reason: 'user_stop' | 'redirect';
mode: BackendStopMode | undefined;
}> = [];
readonly stopStarted: Promise<void>;
disposeCalls = 0;
sendCalls = 0;
private markStopStarted!: () => void;
private readonly stopReleased: Promise<void>;
private releaseStopGate!: () => void;
private releaseSend: (() => void) | undefined;
private readonly sendReleased = new Promise<void>((resolve) => {
this.releaseSend = resolve;
});
constructor(
readonly sessionId: string,
private readonly options: {
deferredStop?: boolean;
stopFailure?: Error;
disposeFailure?: Error;
releaseSendOnDispose?: boolean;
releaseSendOnStop?: boolean;
},
) {
this.stopStarted = new Promise<void>((resolve) => {
this.markStopStarted = resolve;
});
this.stopReleased = new Promise<void>((resolve) => {
this.releaseStopGate = resolve;
});
}
async *send(input: BackendSendInput): AsyncIterable<SessionEvent> {
this.sendCalls += 1;
yield {
type: 'text_delta',
id: `${input.turnId}-delta`,
turnId: input.turnId,
ts: 1,
messageId: `${input.turnId}-message`,
text: 'started',
};
await this.sendReleased;
yield {
type: 'complete',
id: `${input.turnId}-complete`,
turnId: input.turnId,
ts: 2,
stopReason: 'user_stop',
};
}
async stop(reason: 'user_stop' | 'redirect', mode?: BackendStopMode): Promise<void> {
this.stopCalls.push({ reason, mode });
this.markStopStarted();
if (this.options.deferredStop) await this.stopReleased;
if (this.options.releaseSendOnStop !== false) this.releaseSend?.();
if (this.options.stopFailure) throw this.options.stopFailure;
}
releaseStop(): void {
this.releaseStopGate();
}
releaseBlockedSend(): void {
this.releaseSend?.();
}
async respondToSandboxBoundary(_decision: SandboxBoundaryResponse): Promise<void> {}
async dispose(): Promise<void> {
this.disposeCalls += 1;
if (this.options.releaseSendOnDispose !== false) this.releaseSend?.();
if (this.options.disposeFailure) throw this.options.disposeFailure;
}
}
async function drainIterator(iterator: AsyncIterator<SessionEvent>): Promise<void> {
while (!(await iterator.next()).done) {
// Drain the active Runtime Run without abandoning its consumer.
}
}
function memoryStore(): SessionStore {
let header: SessionHeader = {
id: SESSION_ID,
workspaceRoot: '/tmp/maka-runtime-kernel-interaction',
cwd: '/tmp/maka-runtime-kernel-interaction',
createdAt: 1,
lastUsedAt: 1,
name: 'Interaction cleanup',
titleIsManual: true,
isFlagged: false,
labels: [],
isArchived: false,
status: 'active',
statusUpdatedAt: 1,
hasUnread: false,
backend: 'fake',
llmConnectionSlug: 'test',
connectionLocked: true,
model: 'test',
permissionMode: 'bypass',
schemaVersion: 1,
};
let messages: StoredMessage[] = [];
return {
create: async () => header,
createSubagent: async () => ({ header, created: false }),
setExecutionBoundaryKind: async () => {
throw new Error('not implemented');
},
readExecutionBoundary: async () => {
throw new Error('not implemented');
},
list: async () => [],
readHeader: async () => header,
readMessages: async () => [...messages],
listTurns: async () => [],
appendMessage: async (_sessionId, message) => {
messages.push(message);
},
appendMessages: async (_sessionId, next) => {
messages.push(...next);
},
updateHeader: async (_sessionId, patch) => {
header = { ...header, ...patch };
return header;
},
markSessionReadThrough: async () => header,
archive: async () => {},
unarchive: async () => {},
setFlagged: async () => {},
rename: async () => {},
remove: async () => {},
};
}
async function rejectionOf(promise: Promise<unknown>): Promise<unknown> {
try {
await promise;
} catch (error) {
return error;
}
assert.fail('expected rejection');
}
function assertCanonicalCloseFailure(failure: unknown, closeFailure: Error): void {
assert.ok(failure instanceof RuntimeInteractionFailStopError);
assert.equal(failure.authorityFailure, closeFailure);
}
function containsFailure(failure: unknown, expected: unknown): boolean {
if (failure === expected) return true;
if (failure instanceof RuntimeOwnerCleanupError && containsFailure(failure.cause, expected)) {
return true;
}
if (
failure instanceof RuntimeInteractionFailStopError &&
containsFailure(failure.authorityFailure, expected)
) {
return true;
}
return (
failure instanceof AggregateError &&
failure.errors.some((nested) => containsFailure(nested, expected))
);
}
function deferred<T>(): {
promise: Promise<T>;
resolve(value: T | PromiseLike<T>): void;
} {
let resolve!: (value: T | PromiseLike<T>) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
async function waitFor(predicate: () => boolean): Promise<void> {
for (let attempt = 0; attempt < 50; attempt += 1) {
if (predicate()) return;
await new Promise<void>((resolve) => setImmediate(resolve));
}
assert.fail('condition was not met');
}
async function within<T>(promise: Promise<T>, operation: string): Promise<T> {
let timeout: NodeJS.Timeout | undefined;
try {
return await Promise.race([
promise,
new Promise<T>((_resolve, reject) => {
timeout = setTimeout(() => reject(new Error(`${operation} timed out`)), 1_000);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
}
}