306 lines
11 KiB
TypeScript
306 lines
11 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
DurableSessionCoordinator,
|
|
InMemoryDurableSessionStore,
|
|
type DurableSessionIdentity,
|
|
} from './durable-session.js';
|
|
|
|
const IDENTITY: DurableSessionIdentity = {
|
|
agentName: 'Nova',
|
|
sessionId: 'tess-session-1',
|
|
tenantId: 'tenant-1',
|
|
ownerId: 'owner-1',
|
|
providerId: 'fleet',
|
|
runtimeSessionId: 'nova',
|
|
};
|
|
|
|
describe('DurableSessionCoordinator', () => {
|
|
it('reconstructs an exact session identity, pending inbox/outbox, checkpoint, and handoff after a simulated process restart', async () => {
|
|
const store = new InMemoryDurableSessionStore();
|
|
const beforeRestart = new DurableSessionCoordinator(store);
|
|
|
|
await beforeRestart.create(IDENTITY);
|
|
await beforeRestart.receive({
|
|
sessionId: IDENTITY.sessionId,
|
|
idempotencyKey: 'ingress-1',
|
|
correlationId: 'correlation-1',
|
|
content: 'continue the session',
|
|
});
|
|
await beforeRestart.enqueueOutbox({
|
|
sessionId: IDENTITY.sessionId,
|
|
idempotencyKey: 'outbox-1',
|
|
correlationId: 'correlation-1',
|
|
channelId: 'cli',
|
|
kind: 'provider.send',
|
|
content: 'resumable response',
|
|
});
|
|
await beforeRestart.checkpoint({
|
|
sessionId: IDENTITY.sessionId,
|
|
checkpointId: 'checkpoint-1',
|
|
cursor: 'cursor-42',
|
|
summary: 'operator asked for recovery proof',
|
|
compactionEpoch: 0,
|
|
});
|
|
await beforeRestart.handoff({
|
|
sessionId: IDENTITY.sessionId,
|
|
handoffId: 'handoff-1',
|
|
destination: 'mos',
|
|
correlationId: 'correlation-1',
|
|
checkpointId: 'checkpoint-1',
|
|
status: 'pending',
|
|
});
|
|
|
|
// Simulate an ungraceful process death: no in-memory coordinator state survives.
|
|
const afterRestart = new DurableSessionCoordinator(store);
|
|
const recovered = await afterRestart.recover(IDENTITY.sessionId);
|
|
|
|
expect(recovered.identity).toEqual(IDENTITY);
|
|
expect(recovered.inbox).toMatchObject([{ idempotencyKey: 'ingress-1', status: 'pending' }]);
|
|
expect(recovered.outbox).toMatchObject([{ idempotencyKey: 'outbox-1', status: 'pending' }]);
|
|
expect(recovered.checkpoint).toMatchObject({
|
|
checkpointId: 'checkpoint-1',
|
|
cursor: 'cursor-42',
|
|
});
|
|
expect(recovered.handoffs).toMatchObject([{ handoffId: 'handoff-1', status: 'pending' }]);
|
|
});
|
|
|
|
it('rebinds a recovered runtime while preserving the immutable conversation owner scope', async () => {
|
|
const coordinator = new DurableSessionCoordinator(new InMemoryDurableSessionStore());
|
|
await coordinator.create(IDENTITY);
|
|
await coordinator.create({
|
|
...IDENTITY,
|
|
providerId: 'fleet-next',
|
|
runtimeSessionId: 'nova-next',
|
|
});
|
|
|
|
await expect(coordinator.snapshot(IDENTITY.sessionId)).resolves.toMatchObject({
|
|
identity: { ...IDENTITY, providerId: 'fleet-next', runtimeSessionId: 'nova-next' },
|
|
});
|
|
await expect(coordinator.create({ ...IDENTITY, ownerId: 'other-owner' })).rejects.toThrow(
|
|
/identity conflict/,
|
|
);
|
|
});
|
|
|
|
it('deduplicates duplicate ingress and never reprocesses an inbox record after restart or compaction', async () => {
|
|
const store = new InMemoryDurableSessionStore();
|
|
const firstProcess = new DurableSessionCoordinator(store);
|
|
const handled: string[] = [];
|
|
|
|
await firstProcess.create(IDENTITY);
|
|
await expect(
|
|
firstProcess.receive({
|
|
sessionId: IDENTITY.sessionId,
|
|
idempotencyKey: 'ingress-duplicate',
|
|
correlationId: 'correlation-2',
|
|
content: 'only process me once',
|
|
}),
|
|
).resolves.toMatchObject({ accepted: true });
|
|
await expect(
|
|
firstProcess.receive({
|
|
sessionId: IDENTITY.sessionId,
|
|
idempotencyKey: 'ingress-duplicate',
|
|
correlationId: 'correlation-2',
|
|
content: 'only process me once',
|
|
}),
|
|
).resolves.toMatchObject({ accepted: false, status: 'pending' });
|
|
await expect(
|
|
firstProcess.receive({
|
|
sessionId: IDENTITY.sessionId,
|
|
idempotencyKey: 'ingress-duplicate',
|
|
correlationId: 'forged-correlation',
|
|
content: 'only process me once',
|
|
}),
|
|
).rejects.toThrow(/idempotency conflict/);
|
|
|
|
await firstProcess.drainInbox(IDENTITY.sessionId, async (entry) => {
|
|
handled.push(entry.idempotencyKey);
|
|
});
|
|
await firstProcess.checkpoint({
|
|
sessionId: IDENTITY.sessionId,
|
|
checkpointId: 'checkpoint-after-inbox',
|
|
cursor: 'cursor-43',
|
|
summary: 'safe to compact',
|
|
compactionEpoch: 1,
|
|
});
|
|
|
|
const afterRestartAndCompaction = new DurableSessionCoordinator(store);
|
|
await afterRestartAndCompaction.recover(IDENTITY.sessionId);
|
|
await afterRestartAndCompaction.drainInbox(IDENTITY.sessionId, async (entry) => {
|
|
handled.push(entry.idempotencyKey);
|
|
});
|
|
|
|
expect(handled).toEqual(['ingress-duplicate']);
|
|
});
|
|
|
|
it('does not redispatch an already applied outbox side effect after replay, restart, or compaction', async () => {
|
|
const store = new InMemoryDurableSessionStore();
|
|
const beforeRestart = new DurableSessionCoordinator(store);
|
|
const appliedEffects: string[] = [];
|
|
|
|
await beforeRestart.create(IDENTITY);
|
|
await beforeRestart.enqueueOutbox({
|
|
sessionId: IDENTITY.sessionId,
|
|
idempotencyKey: 'effect-1',
|
|
correlationId: 'correlation-3',
|
|
channelId: 'cli',
|
|
kind: 'provider.send',
|
|
content: 'send exactly once',
|
|
});
|
|
await expect(
|
|
beforeRestart.enqueueOutbox({
|
|
sessionId: IDENTITY.sessionId,
|
|
idempotencyKey: 'effect-1',
|
|
correlationId: 'correlation-3',
|
|
channelId: 'cli',
|
|
kind: 'provider.send',
|
|
content: 'send exactly once',
|
|
}),
|
|
).resolves.toMatchObject({ accepted: false, status: 'pending' });
|
|
|
|
await beforeRestart.dispatchOutbox(IDENTITY.sessionId, async (entry) => {
|
|
appliedEffects.push(entry.idempotencyKey);
|
|
});
|
|
await beforeRestart.checkpoint({
|
|
sessionId: IDENTITY.sessionId,
|
|
checkpointId: 'checkpoint-after-effect',
|
|
cursor: 'cursor-44',
|
|
summary: 'effect persisted before compaction',
|
|
compactionEpoch: 1,
|
|
});
|
|
|
|
const afterRestartAndCompaction = new DurableSessionCoordinator(store);
|
|
await afterRestartAndCompaction.recover(IDENTITY.sessionId);
|
|
await afterRestartAndCompaction.dispatchOutbox(IDENTITY.sessionId, async (entry) => {
|
|
appliedEffects.push(entry.idempotencyKey);
|
|
});
|
|
|
|
expect(appliedEffects).toEqual(['effect-1']);
|
|
});
|
|
|
|
it('rejects outbox idempotency-key reuse when immutable effect data differs', async () => {
|
|
const store = new InMemoryDurableSessionStore();
|
|
const coordinator = new DurableSessionCoordinator(store);
|
|
|
|
await coordinator.create(IDENTITY);
|
|
await coordinator.enqueueOutbox({
|
|
sessionId: IDENTITY.sessionId,
|
|
idempotencyKey: 'outbox-conflict',
|
|
correlationId: 'correlation-outbox',
|
|
channelId: 'cli',
|
|
kind: 'provider.send',
|
|
content: 'original effect',
|
|
});
|
|
await expect(
|
|
coordinator.enqueueOutbox({
|
|
sessionId: IDENTITY.sessionId,
|
|
idempotencyKey: 'outbox-conflict',
|
|
correlationId: 'correlation-outbox',
|
|
channelId: 'forged-channel',
|
|
kind: 'provider.send',
|
|
content: 'original effect',
|
|
}),
|
|
).rejects.toThrow(/idempotency conflict/);
|
|
});
|
|
|
|
it('retains an ambiguous failed provider effect as processing until explicit recovery', async () => {
|
|
const store = new InMemoryDurableSessionStore();
|
|
const beforeRestart = new DurableSessionCoordinator(store);
|
|
|
|
await beforeRestart.create(IDENTITY);
|
|
await beforeRestart.enqueueOutbox({
|
|
sessionId: IDENTITY.sessionId,
|
|
idempotencyKey: 'ambiguous-effect',
|
|
correlationId: 'correlation-ambiguous',
|
|
channelId: 'cli',
|
|
kind: 'provider.send',
|
|
content: 'preserve this effect claim',
|
|
});
|
|
await expect(
|
|
beforeRestart.dispatchOutbox(IDENTITY.sessionId, async (): Promise<void> => {
|
|
throw new Error('provider connection dropped after submit');
|
|
}),
|
|
).rejects.toThrow(/connection dropped/);
|
|
|
|
const afterRestart = new DurableSessionCoordinator(store);
|
|
await afterRestart.recover(IDENTITY.sessionId);
|
|
const calls: string[] = [];
|
|
await afterRestart.dispatchOutbox(IDENTITY.sessionId, async (entry): Promise<void> => {
|
|
calls.push(entry.idempotencyKey);
|
|
});
|
|
|
|
expect(calls).toEqual([]);
|
|
expect(await afterRestart.snapshot(IDENTITY.sessionId)).toMatchObject({
|
|
outbox: [{ idempotencyKey: 'ambiguous-effect', status: 'processing' }],
|
|
});
|
|
});
|
|
|
|
it('rejects a handoff-id replay with different immutable state', async () => {
|
|
const store = new InMemoryDurableSessionStore();
|
|
const coordinator = new DurableSessionCoordinator(store);
|
|
|
|
await coordinator.create(IDENTITY);
|
|
await coordinator.checkpoint({
|
|
sessionId: IDENTITY.sessionId,
|
|
checkpointId: 'checkpoint-conflict',
|
|
cursor: 'cursor-conflict',
|
|
summary: 'handoff conflict proof',
|
|
compactionEpoch: 0,
|
|
});
|
|
await coordinator.handoff({
|
|
sessionId: IDENTITY.sessionId,
|
|
handoffId: 'handoff-conflict',
|
|
destination: 'mos',
|
|
correlationId: 'correlation-conflict',
|
|
checkpointId: 'checkpoint-conflict',
|
|
status: 'pending',
|
|
});
|
|
|
|
await expect(
|
|
coordinator.handoff({
|
|
sessionId: IDENTITY.sessionId,
|
|
handoffId: 'handoff-conflict',
|
|
destination: 'forged-destination',
|
|
correlationId: 'correlation-conflict',
|
|
checkpointId: 'checkpoint-conflict',
|
|
status: 'pending',
|
|
}),
|
|
).rejects.toThrow(/identity conflict/);
|
|
});
|
|
|
|
it('keeps a handoff portable and resumes it from its durable checkpoint without process-local references', async () => {
|
|
const store = new InMemoryDurableSessionStore();
|
|
const source = new DurableSessionCoordinator(store);
|
|
|
|
await source.create(IDENTITY);
|
|
await source.checkpoint({
|
|
sessionId: IDENTITY.sessionId,
|
|
checkpointId: 'checkpoint-handoff',
|
|
cursor: 'cursor-45',
|
|
summary: 'portable state',
|
|
compactionEpoch: 2,
|
|
});
|
|
await source.handoff({
|
|
sessionId: IDENTITY.sessionId,
|
|
handoffId: 'handoff-portable',
|
|
destination: 'mos',
|
|
correlationId: 'correlation-4',
|
|
checkpointId: 'checkpoint-handoff',
|
|
status: 'pending',
|
|
});
|
|
await source.checkpoint({
|
|
sessionId: IDENTITY.sessionId,
|
|
checkpointId: 'checkpoint-later',
|
|
cursor: 'cursor-46',
|
|
summary: 'newer compacted state must not strand the handoff',
|
|
compactionEpoch: 3,
|
|
});
|
|
|
|
const receivingProcess = new DurableSessionCoordinator(store);
|
|
const handoff = await receivingProcess.resumeHandoff('handoff-portable');
|
|
|
|
expect(handoff.identity).toEqual(IDENTITY);
|
|
expect(handoff.checkpoint).toMatchObject({ checkpointId: 'checkpoint-handoff' });
|
|
expect(handoff.handoff).toMatchObject({ handoffId: 'handoff-portable', destination: 'mos' });
|
|
});
|
|
});
|