Skip to content

Commit ccbd0e9

Browse files
committed
fix(server): preserve provider admission lineage
1 parent 8d3fd19 commit ccbd0e9

4 files changed

Lines changed: 306 additions & 31 deletions

File tree

apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
DEFAULT_PROVIDER_INTERACTION_MODE,
55
MessageId,
66
ProjectId,
7+
RuntimeSessionId,
78
ThreadId,
89
TurnId,
910
type OrchestrationEvent,
@@ -243,6 +244,95 @@ describe("OrchestrationEngine", () => {
243244
await runtime.dispose();
244245
});
245246

247+
it("accepts a stale session lifecycle CAS without producing an event", async () => {
248+
const system = await createOrchestrationSystem();
249+
const { engine } = system;
250+
const createdAt = now();
251+
const projectId = asProjectId("project-stale-session-lifecycle");
252+
const threadId = ThreadId.make("thread-stale-session-lifecycle");
253+
const providerInstanceId = ProviderInstanceId.make("codex");
254+
const sessionIncarnationId = RuntimeSessionId.make("session-stale-lifecycle-current");
255+
256+
await system.run(
257+
engine.dispatch({
258+
type: "project.create",
259+
commandId: CommandId.make("cmd-project-stale-session-lifecycle-create"),
260+
projectId,
261+
title: "Stale session lifecycle",
262+
workspaceRoot: "/tmp/project-stale-session-lifecycle",
263+
defaultModelSelection: {
264+
instanceId: providerInstanceId,
265+
model: "gpt-5-codex",
266+
},
267+
createdAt,
268+
}),
269+
);
270+
await system.run(
271+
engine.dispatch({
272+
type: "thread.create",
273+
commandId: CommandId.make("cmd-thread-stale-session-lifecycle-create"),
274+
threadId,
275+
projectId,
276+
title: "Stale lifecycle",
277+
modelSelection: {
278+
instanceId: providerInstanceId,
279+
model: "gpt-5-codex",
280+
},
281+
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
282+
runtimeMode: "full-access",
283+
branch: null,
284+
worktreePath: null,
285+
createdAt,
286+
}),
287+
);
288+
const currentSession = {
289+
threadId,
290+
status: "ready" as const,
291+
providerName: "codex",
292+
providerInstanceId,
293+
runtimeMode: "full-access" as const,
294+
sessionIncarnationId,
295+
activeTurnId: null,
296+
lastError: null,
297+
updatedAt: createdAt,
298+
};
299+
await system.run(
300+
engine.dispatch({
301+
type: "thread.session.set",
302+
commandId: CommandId.make("cmd-session-stale-lifecycle-set"),
303+
threadId,
304+
session: currentSession,
305+
createdAt,
306+
}),
307+
);
308+
const sequenceBeforeStaleLifecycle = await system.run(engine.latestSequence);
309+
310+
const result = await system.run(
311+
engine.dispatch({
312+
type: "thread.session.apply-lifecycle",
313+
commandId: CommandId.make("cmd-session-stale-lifecycle-apply"),
314+
threadId,
315+
expectedStatus: "running",
316+
expectedProviderInstanceId: providerInstanceId,
317+
expectedSessionIncarnationId: sessionIncarnationId,
318+
expectedPendingTurnRequestId: null,
319+
expectedPendingTurnSessionId: null,
320+
expectedActiveTurnRequestId: null,
321+
expectedActiveTurnId: null,
322+
expectedFailedTurnRequestId: null,
323+
session: currentSession,
324+
createdAt,
325+
}),
326+
);
327+
328+
expect(result).toEqual({ sequence: sequenceBeforeStaleLifecycle, eventCount: 0 });
329+
expect(await system.run(engine.latestSequence)).toBe(sequenceBeforeStaleLifecycle);
330+
expect(
331+
(await system.readModel()).threads.find((thread) => thread.id === threadId)?.session,
332+
).toEqual(currentSession);
333+
await system.dispose();
334+
});
335+
246336
it("persists deterministic read models for repeated snapshot reads", async () => {
247337
const createdAt = now();
248338
const system = await createOrchestrationSystem();

apps/server/src/orchestration/Layers/OrchestrationEngine.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,8 @@ const makeOrchestrationEngine = Effect.gen(function* () {
211211
const acceptsStaleNoEvent =
212212
envelope.command.type === "thread.turn.admission.accept" ||
213213
envelope.command.type === "thread.turn.admission.fail" ||
214-
envelope.command.type === "thread.session.bind-pending";
214+
envelope.command.type === "thread.session.bind-pending" ||
215+
envelope.command.type === "thread.session.apply-lifecycle";
215216
if (lastSavedEvent === null && !acceptsStaleNoEvent) {
216217
return yield* new OrchestrationCommandInvariantError({
217218
commandType: envelope.command.type,

apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts

Lines changed: 183 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1169,6 +1169,82 @@ describe("ProviderCommandReactor", () => {
11691169
}),
11701170
);
11711171

1172+
effectIt.effect(
1173+
"preserves a pending admission when runtime mode changes during provider start",
1174+
() =>
1175+
Effect.gen(function* () {
1176+
const startEntered = yield* Deferred.make<void>();
1177+
const releaseStart = yield* Deferred.make<void>();
1178+
const harness = yield* Effect.promise(() =>
1179+
createHarness({
1180+
startSessionEffect: (session) =>
1181+
Deferred.succeed(startEntered, undefined).pipe(
1182+
Effect.andThen(Deferred.await(releaseStart)),
1183+
Effect.as(session),
1184+
),
1185+
}),
1186+
);
1187+
const requestId = CommandId.make("cmd-turn-start-runtime-mode-race");
1188+
const messageId = asMessageId("user-message-runtime-mode-race");
1189+
1190+
yield* harness.engine.dispatch({
1191+
type: "thread.turn.start",
1192+
commandId: requestId,
1193+
threadId: ThreadId.make("thread-1"),
1194+
message: {
1195+
messageId,
1196+
role: "user",
1197+
text: "keep this admission",
1198+
attachments: [],
1199+
},
1200+
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
1201+
runtimeMode: "approval-required",
1202+
createdAt: "2026-01-01T00:00:00.000Z",
1203+
});
1204+
yield* Deferred.await(startEntered);
1205+
1206+
yield* harness.engine.dispatch({
1207+
type: "thread.runtime-mode.set",
1208+
commandId: CommandId.make("cmd-runtime-mode-set-during-admission"),
1209+
threadId: ThreadId.make("thread-1"),
1210+
runtimeMode: "full-access",
1211+
createdAt: "2026-01-01T00:00:01.000Z",
1212+
});
1213+
yield* Effect.promise(() => harness.drain());
1214+
1215+
let readModel = yield* Effect.promise(() => harness.readModel());
1216+
let thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
1217+
expect(harness.startSession).toHaveBeenCalledTimes(1);
1218+
expect(thread?.session?.status).toBe("starting");
1219+
expect(thread?.session?.pendingTurnRequestId).toBe(requestId);
1220+
expect(thread?.session?.pendingTurnMessageId).toBe(messageId);
1221+
1222+
const subscriptionReady = yield* Deferred.make<void>();
1223+
const runningSession = yield* Stream.runHead(
1224+
harness.engine.streamDomainEvents.pipe(
1225+
Stream.onStart(Deferred.succeed(subscriptionReady, undefined)),
1226+
Stream.filter(
1227+
(event) =>
1228+
event.type === "thread.session-set" &&
1229+
event.payload.threadId === ThreadId.make("thread-1") &&
1230+
event.payload.session.status === "running" &&
1231+
event.payload.session.activeTurnRequestId === requestId,
1232+
),
1233+
),
1234+
).pipe(Effect.forkChild);
1235+
yield* Deferred.await(subscriptionReady);
1236+
yield* Deferred.succeed(releaseStart, undefined);
1237+
yield* Fiber.join(runningSession);
1238+
1239+
readModel = yield* Effect.promise(() => harness.readModel());
1240+
thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
1241+
expect(thread?.runtimeMode).toBe("full-access");
1242+
expect(thread?.session?.status).toBe("running");
1243+
expect(thread?.session?.activeTurnRequestId).toBe(requestId);
1244+
expect(harness.startSession).toHaveBeenCalledTimes(1);
1245+
}),
1246+
);
1247+
11721248
effectIt.effect("interrupts a detached admission when the reactor layer closes", () =>
11731249
Effect.gen(function* () {
11741250
const providerStartInterrupted = yield* Deferred.make<void>();
@@ -1331,11 +1407,12 @@ describe("ProviderCommandReactor", () => {
13311407
effectIt.effect("retries unknown per-instance inventory then records an inventory error", () =>
13321408
Effect.gen(function* () {
13331409
const testClock = yield* TestClock.make();
1334-
yield* testClock.setTime(PROVIDER_TURN_ADMISSION_TIMEOUT_MS + 1);
1410+
yield* testClock.setTime(0);
13351411
const requestId = CommandId.make("cmd-boot-inventory-unknown");
13361412
const harness = yield* Effect.promise(() =>
13371413
createHarness({
13381414
clock: testClock,
1415+
beforeReactorStart: testClock.adjust(PROVIDER_TURN_ADMISSION_TIMEOUT_MS + 1),
13391416
overdueTurnStartBeforeReactor: {
13401417
commandId: requestId,
13411418
messageId: asMessageId("message-boot-inventory-unknown"),
@@ -1363,15 +1440,119 @@ describe("ProviderCommandReactor", () => {
13631440
}),
13641441
);
13651442

1443+
effectIt.effect("preserves a non-overdue admission when boot inventory is unknown", () =>
1444+
Effect.gen(function* () {
1445+
const testClock = yield* TestClock.make();
1446+
yield* testClock.setTime(0);
1447+
const requestId = CommandId.make("cmd-boot-inventory-unknown-not-overdue");
1448+
const harness = yield* Effect.promise(() =>
1449+
createHarness({
1450+
clock: testClock,
1451+
overdueTurnStartBeforeReactor: {
1452+
commandId: requestId,
1453+
messageId: asMessageId("message-boot-inventory-unknown-not-overdue"),
1454+
createdAt: isoAt(0),
1455+
sessionIncarnationId: RuntimeSessionId.make(
1456+
"session-boot-inventory-unknown-not-overdue",
1457+
),
1458+
},
1459+
inventoryEffect: () =>
1460+
Effect.fail(
1461+
new ProviderAdapterRequestError({
1462+
provider: "codex",
1463+
method: "listSessions",
1464+
detail: "inventory temporarily unavailable",
1465+
}),
1466+
),
1467+
}),
1468+
);
1469+
1470+
let readModel = yield* Effect.promise(() => harness.readModel());
1471+
let thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
1472+
expect(thread?.session?.status).toBe("starting");
1473+
expect(thread?.session?.pendingTurnRequestId).toBe(requestId);
1474+
expect(harness.listSessionsForInstance).toHaveBeenCalledTimes(3);
1475+
1476+
yield* testClock.adjust(PROVIDER_TURN_ADMISSION_TIMEOUT_MS - 1);
1477+
readModel = yield* Effect.promise(() => harness.readModel());
1478+
thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
1479+
expect(thread?.session?.status).toBe("starting");
1480+
1481+
yield* testClock.adjust(1);
1482+
readModel = yield* Effect.promise(() => harness.readModel());
1483+
thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
1484+
expect(thread?.session?.status).toBe("error");
1485+
expect(thread?.session?.failedTurnRequestId).toBe(requestId);
1486+
expect(thread?.session?.lastError).toContain("could not inventory");
1487+
expect(thread?.session?.lastError).toContain("inventory temporarily unavailable");
1488+
}),
1489+
);
1490+
1491+
effectIt.effect("keeps an exact late start after boot inventory was unknown", () =>
1492+
Effect.gen(function* () {
1493+
const testClock = yield* TestClock.make();
1494+
yield* testClock.setTime(0);
1495+
const requestId = CommandId.make("cmd-boot-inventory-unknown-late-start");
1496+
const messageId = asMessageId("message-boot-inventory-unknown-late-start");
1497+
const sessionIncarnationId = RuntimeSessionId.make(
1498+
"session-boot-inventory-unknown-late-start",
1499+
);
1500+
const harness = yield* Effect.promise(() =>
1501+
createHarness({
1502+
clock: testClock,
1503+
overdueTurnStartBeforeReactor: {
1504+
commandId: requestId,
1505+
messageId,
1506+
createdAt: isoAt(0),
1507+
sessionIncarnationId,
1508+
},
1509+
inventoryEffect: () =>
1510+
Effect.fail(
1511+
new ProviderAdapterRequestError({
1512+
provider: "codex",
1513+
method: "listSessions",
1514+
detail: "inventory temporarily unavailable",
1515+
}),
1516+
),
1517+
}),
1518+
);
1519+
1520+
yield* harness.engine.dispatch({
1521+
type: "thread.turn.admission.accept",
1522+
commandId: CommandId.make("cmd-boot-inventory-unknown-late-start-accept"),
1523+
threadId: ThreadId.make("thread-1"),
1524+
requestId,
1525+
messageId,
1526+
providerInstanceId: ProviderInstanceId.make("codex"),
1527+
sessionIncarnationId,
1528+
turnId: asTurnId("turn-boot-inventory-unknown-late-start"),
1529+
createdAt: isoAt(1),
1530+
});
1531+
yield* testClock.adjust(PROVIDER_TURN_ADMISSION_TIMEOUT_MS);
1532+
1533+
const readModel = yield* Effect.promise(() => harness.readModel());
1534+
const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
1535+
expect(thread?.session?.status).toBe("running");
1536+
expect(thread?.session?.activeTurnRequestId).toBe(requestId);
1537+
expect(thread?.session?.activeTurnId).toBe(
1538+
asTurnId("turn-boot-inventory-unknown-late-start"),
1539+
);
1540+
expect(
1541+
thread?.activities.filter((activity) => activity.kind === "provider.turn.start.failed"),
1542+
).toHaveLength(0);
1543+
}),
1544+
);
1545+
13661546
effectIt.effect("bounds a hanging per-instance inventory retry chain", () =>
13671547
Effect.gen(function* () {
13681548
const testClock = yield* TestClock.make();
1369-
yield* testClock.setTime(PROVIDER_TURN_ADMISSION_TIMEOUT_MS + 1);
1549+
yield* testClock.setTime(0);
13701550
const requestId = CommandId.make("cmd-boot-inventory-hangs");
13711551
const inventoryEntered = yield* Deferred.make<void>();
13721552
const harnessFiber = yield* Effect.promise(() =>
13731553
createHarness({
13741554
clock: testClock,
1555+
beforeReactorStart: testClock.adjust(PROVIDER_TURN_ADMISSION_TIMEOUT_MS + 1),
13751556
overdueTurnStartBeforeReactor: {
13761557
commandId: requestId,
13771558
messageId: asMessageId("message-boot-inventory-hangs"),

0 commit comments

Comments
 (0)