blob: 96a2929a0ceb7bd4e69c256d48f4ad5b78f36918 [file]
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, test } from 'node:test';
import type { AgentRunHeader, RuntimeEvent } from '@maka/core';
import { createSessionStore, createSqliteRuntimeStore } from '@maka/storage';
import { createSqliteAgentRunStore } from '@maka/storage';
import { type RuntimeContinuationFailpoint } from '../agent-run.js';
import { BackendRegistry, SessionManager } from '../session-manager.js';
import { FakeBackend } from '../fake-backend.js';
import { terminateChildProcessTree } from '../process-tree-terminator.js';
const CRASH_CHILD_ENV = 'MAKA_RUNTIME_CONTINUATION_CRASH_CHILD';
const FAILPOINTS: readonly RuntimeContinuationFailpoint[] = [
'after_continuation_claim_committed',
'after_run_created',
'after_continuation_start_committed',
'after_terminal_event_committed',
'after_terminal_header_committed',
];
if (process.env[CRASH_CHILD_ENV] === '1') {
await runCrashChild();
} else {
describe('runtime resume phase 1 process crash harness', () => {
test('reopens and repairs every committed continuation prefix after SIGKILL', {
timeout: 60_000,
}, async () => {
const root = await mkdtemp(join(tmpdir(), 'maka-runtime-continuation-crash-'));
try {
for (const failpoint of FAILPOINTS) {
const workspaceRoot = join(root, failpoint);
await crashContinuationAt(workspaceRoot, failpoint);
const store = createSessionStore(workspaceRoot);
const runStore = createSqliteAgentRunStore(workspaceRoot);
const runtimeEventStore = createCrashRuntimeStore(workspaceRoot);
const [session] = await store.list();
assert.ok(session, `${failpoint} did not persist a session`);
const [claimState] = await runtimeEventStore.listContinuationClaimsForRecovery(
session.id,
);
assert.ok(claimState, `${failpoint} did not persist the continuation claim`);
const runsBeforeRecovery = await runStore.listSessionRuns(session.id);
const continuation = runsBeforeRecovery.find(
(run) => run.runId === claimState.claim.target.runId,
);
const prefix = await runtimeEventStore.readRuntimeEvents(
session.id,
claimState.claim.target.runId,
);
assertPrefix(failpoint, continuation, prefix);
runtimeEventStore.close();
await store.close?.();
const {
manager,
agentRunStore: recoveryAgentRunStore,
runtimeEventStore: recoveryRuntimeStore,
sessionStore: recoverySessionStore,
} = createManager(workspaceRoot);
const repeatedPlan = await manager.planAuthoritativeSafeBoundaryContinuation(session.id, {
sourceRunId: 'source-run',
});
assert.equal(repeatedPlan.disposition, 'park');
assert.deepEqual(repeatedPlan.rejectionReasons, [
failpoint === 'after_continuation_claim_committed' ||
failpoint === 'after_run_created' ||
failpoint === 'after_terminal_event_committed'
? 'continuation_claim_repair_required'
: failpoint === 'after_continuation_start_committed'
? 'continuation_started_indeterminate'
: 'continuation_already_exists',
]);
await manager.recoverInterruptedSessions();
const repaired = await runStore.readRun(session.id, claimState.claim.target.runId);
const repairedEvents = await recoveryRuntimeStore.readRuntimeEvents(
session.id,
claimState.claim.target.runId,
);
const terminalEvents = repairedEvents.filter(
(event) => event.actions?.endInvocation === true,
);
if (failpoint === 'after_continuation_start_committed') {
assert.equal(terminalEvents.length, 0);
assert.equal(['created', 'running'].includes(repaired.status), true);
const parked = await manager.planAuthoritativeSafeBoundaryContinuation(session.id, {
sourceRunId: 'source-run',
});
assert.deepEqual(parked.rejectionReasons, ['continuation_started_indeterminate']);
} else {
assert.equal(terminalEvents.length, 1, `${failpoint} must recover one terminal fact`);
assert.ok(
repaired.status === 'completed' ||
repaired.status === 'failed' ||
repaired.status === 'cancelled',
`${failpoint} left the continuation non-terminal`,
);
}
recoveryAgentRunStore.close?.();
runStore.close?.();
recoveryRuntimeStore.close();
await recoverySessionStore.close?.();
}
} finally {
await rm(root, {
recursive: true,
force: true,
maxRetries: process.platform === 'win32' ? 20 : 0,
retryDelay: 100,
});
}
});
});
}
async function runCrashChild(): Promise<void> {
const workspaceRoot = requiredEnv('MAKA_RUNTIME_CONTINUATION_WORKSPACE');
const failpoint = requiredEnv(
'MAKA_RUNTIME_CONTINUATION_FAILPOINT',
) as RuntimeContinuationFailpoint;
const store = createSessionStore(workspaceRoot);
const runStore = createSqliteAgentRunStore(workspaceRoot);
const runtimeEventStore = createCrashRuntimeStore(workspaceRoot);
const backends = new BackendRegistry();
backends.register(
'fake',
(ctx) =>
new FakeBackend({
sessionId: ctx.sessionId,
header: ctx.header,
store: ctx.store,
appendMessage: ctx.appendMessage,
}),
);
let id = 0;
let resolveSelectedFailpoint!: () => void;
const selectedFailpointReached = new Promise<void>((resolve) => {
resolveSelectedFailpoint = resolve;
});
// A pending Promise does not keep Node alive. Terminal header finalization is
// deliberately detached from the public stream, so keep the crash child
// alive while that background durability work advances to its failpoint.
setInterval(() => {}, 1_000);
const manager = new SessionManager({
store,
runStore,
runtimeEventStore,
backends,
safeBoundaryResumeEnabled: true,
inspectContinuationSafety: async () => stableSafetyObservation(),
continuationFailpoint: async (point) => {
if (point !== failpoint || point === 'after_terminal_header_committed') return;
await suspendCrashChild(point, resolveSelectedFailpoint);
},
newId: () => `id-${++id}`,
now: (() => {
let ts = 10;
return () => ++ts;
})(),
runtimeSource: 'test',
});
const session = await manager.createSession({
cwd: workspaceRoot,
backend: 'fake',
llmConnectionSlug: 'fake',
model: 'fake-model',
permissionMode: 'execute',
name: 'continuation crash child',
});
await runStore.createRun(sourceHeader(session.id, workspaceRoot));
for (const event of sourceEvents(session.id)) {
await runtimeEventStore.appendRuntimeEvent(session.id, 'source-run', event);
}
const plan = await manager.planAuthoritativeSafeBoundaryContinuation(session.id, {
sourceRunId: 'source-run',
});
if (!plan.continuation)
throw new Error(`expected continuation: ${plan.rejectionReasons.join(',')}`);
for await (const _event of manager.resumeSafeBoundaryContinuation(plan.continuation)) {
// drain until the selected failpoint suspends the child
}
if (failpoint === 'after_terminal_header_committed') {
const continuation = await runStore.readRun(session.id, plan.continuation.runId);
if (continuation.status !== 'completed') {
throw new Error(`continuation terminal header did not settle: ${continuation.status}`);
}
await suspendCrashChild(failpoint, resolveSelectedFailpoint);
}
// Terminal projection finalization may continue after the public event stream
// closes. Wait for the selected durable boundary instead of racing that
// background finalizer and reporting a false negative.
await selectedFailpointReached;
await new Promise<never>(() => {
setInterval(() => {}, 1_000);
});
}
async function suspendCrashChild(
point: RuntimeContinuationFailpoint,
markReached: () => void,
): Promise<never> {
process.stdout.write(`READY:${point}\n`);
markReached();
return await new Promise<never>(() => {
setInterval(() => {}, 1_000);
});
}
function createManager(workspaceRoot: string): {
manager: SessionManager;
agentRunStore: ReturnType<typeof createSqliteAgentRunStore>;
runtimeEventStore: ReturnType<typeof createSqliteRuntimeStore>;
sessionStore: ReturnType<typeof createSessionStore>;
} {
const store = createSessionStore(workspaceRoot);
const runStore = createSqliteAgentRunStore(workspaceRoot);
const runtimeEventStore = createCrashRuntimeStore(workspaceRoot);
const backends = new BackendRegistry();
backends.register(
'fake',
(ctx) =>
new FakeBackend({
sessionId: ctx.sessionId,
header: ctx.header,
store: ctx.store,
appendMessage: ctx.appendMessage,
}),
);
let id = 100;
return {
agentRunStore: runStore,
runtimeEventStore,
sessionStore: store,
manager: new SessionManager({
store,
runStore,
runtimeEventStore,
backends,
safeBoundaryResumeEnabled: true,
inspectContinuationSafety: async () => stableSafetyObservation(),
newId: () => `recovery-id-${++id}`,
now: Date.now,
runtimeSource: 'test',
}),
};
}
function createCrashRuntimeStore(workspaceRoot: string) {
return createSqliteRuntimeStore(join(workspaceRoot, '.maka', 'runtime.sqlite'));
}
async function crashContinuationAt(
workspaceRoot: string,
failpoint: RuntimeContinuationFailpoint,
): Promise<void> {
const child = spawn(process.execPath, [fileURLToPath(import.meta.url)], {
cwd: dirname(fileURLToPath(import.meta.url)),
env: {
...process.env,
[CRASH_CHILD_ENV]: '1',
MAKA_RUNTIME_CONTINUATION_WORKSPACE: workspaceRoot,
MAKA_RUNTIME_CONTINUATION_FAILPOINT: failpoint,
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk: string) => {
stdout += chunk;
});
child.stderr.on('data', (chunk: string) => {
stderr += chunk;
});
// `exit` may fire before Windows releases inherited stdio/process handles.
// Wait for `close` so the following reopen and recursive cleanup cannot race
// a dead child that still owns the SQLite files.
const closed = once(child, 'close') as Promise<[number | null, NodeJS.Signals | null]>;
const deadline = Date.now() + 10_000;
while (
!stdout.includes(`READY:${failpoint}\n`) &&
child.exitCode === null &&
Date.now() < deadline
) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
if (!stdout.includes(`READY:${failpoint}\n`)) {
await killCrashChild(child);
await closed;
throw new Error(`${failpoint} child did not reach boundary: ${stderr || stdout}`);
}
assert.equal(await killCrashChild(child), true);
const [exitCode, signal] = await closed;
assert.ok(exitCode !== 0 || signal !== null);
}
function killCrashChild(child: ReturnType<typeof spawn>): Promise<boolean> {
if (process.platform === 'win32') return terminateChildProcessTree(child, 'SIGKILL');
return Promise.resolve(child.kill('SIGKILL'));
}
function assertPrefix(
failpoint: RuntimeContinuationFailpoint,
header: AgentRunHeader | undefined,
events: readonly RuntimeEvent[],
): void {
if (failpoint === 'after_continuation_claim_committed') {
assert.equal(header, undefined);
assert.deepEqual(events, []);
return;
}
assert.ok(header);
if (failpoint === 'after_run_created') {
assert.equal(header.status, 'created');
assert.deepEqual(events, []);
return;
}
assert.equal(events[0]?.actions?.continuationStart?.protocol, 'continuation_start_v2');
if (failpoint === 'after_continuation_start_committed') {
assert.equal(
events.some((event) => event.actions?.endInvocation === true),
false,
);
return;
}
assert.equal(events.filter((event) => event.actions?.endInvocation === true).length, 1);
if (failpoint === 'after_terminal_event_committed') {
assert.equal(['created', 'running'].includes(header.status), true);
return;
}
assert.equal(header.status, 'completed');
}
function sourceHeader(sessionId: string, cwd: string): AgentRunHeader {
return {
runId: 'source-run',
invocationId: 'source-invocation',
sessionId,
turnId: 'source-turn',
status: 'failed',
backendKind: 'fake',
llmConnectionSlug: 'fake',
modelId: 'fake-model',
cwd,
workspaceIdentity: 'workspace-1',
permissionMode: 'execute',
createdAt: 1,
updatedAt: 2,
completedAt: 2,
failureClass: 'app_restarted',
};
}
function sourceEvents(sessionId: string): RuntimeEvent[] {
const identity = {
sessionId,
invocationId: 'source-invocation',
runId: 'source-run',
turnId: 'source-turn',
};
return [
{
...identity,
id: 'source-user',
ts: 1,
partial: false,
author: 'user',
role: 'user',
content: { kind: 'text', text: 'continue after crash' },
},
{
...identity,
id: 'source-terminal',
ts: 2,
partial: false,
author: 'system',
role: 'system',
status: 'failed',
actions: { endInvocation: true, stateDelta: { failureClass: 'app_restarted' } },
},
];
}
function stableSafetyObservation() {
return {
workspaceIdentity: 'workspace-1',
backgroundOperationsSettled: true,
availableToolNames: [] as string[],
};
}
function requiredEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name}`);
return value;
}