| author | |
| committer | |
| log | a5da32750125c3bedb959bf331e5e49e95b95e5d |
| tree | 6a62362126c817bbcde2ab63aea000b13563ff53 |
| parent | 3efdcc5296f1754e0f3bf7fee5fc2ada510e0438 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius0216@outlook.com>3 files changed, 177 insertions(+), 23 deletions(-)
packages/client-runtime/src/state/threads-pagination.test.ts+59| ... | @@ -398,6 +398,65 @@ describe("thread pagination state", () => { | ... | @@ -398,6 +398,65 @@ describe("thread pagination state", () => { |
| 398 | }), | 398 | }), |
| 399 | ); | 399 | ); |
| 400 | 400 | ||
| 401 | it.effect("keeps a new page loading when a snapshot replaced a parked older page", () => | ||
| 402 | Effect.gen(function* () { | ||
| 403 | const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); | ||
| 404 | yield* harness.awaitState((value) => Option.isSome(value.page)); | ||
| 405 | requestOlderThreadTurns(TARGET.environmentId, THREAD_ID); | ||
| 406 | yield* harness.resolveNextPage( | ||
| 407 | Option.some({ | ||
| 408 | ...OLDER_PAGE, | ||
| 409 | snapshotSequence: 30, | ||
| 410 | page: { beforeCursor: null, hasMore: false, snapshotSequence: 30, threadSequence: 30 }, | ||
| 411 | }), | ||
| 412 | ); | ||
| 413 | yield* Queue.offer(harness.inputs, titleEvent("Waiting for old watermark", 11)); | ||
| 414 | yield* harness.awaitState((value) => | ||
| 415 | Option.exists(value.data, (thread) => thread.title === "Waiting for old watermark"), | ||
| 416 | ); | ||
| 417 | expect( | ||
| 418 | Option.getOrThrow((yield* SubscriptionRef.get(harness.threadState)).page).loadingOlder, | ||
| 419 | ).toBe(true); | ||
| 420 | |||
| 421 | yield* Queue.offer(harness.inputs, { | ||
| 422 | kind: "snapshot", | ||
| 423 | snapshot: { | ||
| 424 | snapshotSequence: 20, | ||
| 425 | thread: { ...BASE_THREAD, title: "Replacement snapshot" }, | ||
| 426 | page: { beforeCursor: "cursor-2", hasMore: true, snapshotSequence: 20 }, | ||
| 427 | }, | ||
| 428 | }); | ||
| 429 | yield* harness.awaitState((value) => | ||
| 430 | Option.exists(value.data, (thread) => thread.title === "Replacement snapshot"), | ||
| 431 | ); | ||
| 432 | requestOlderThreadTurns(TARGET.environmentId, THREAD_ID); | ||
| 433 | yield* harness.awaitState((value) => | ||
| 434 | Option.exists(value.page, (page) => page.loadingOlder && page.beforeCursor === "cursor-2"), | ||
| 435 | ); | ||
| 436 | yield* Queue.offer(harness.inputs, titleEvent("New request still loading", 21)); | ||
| 437 | yield* harness.awaitState((value) => | ||
| 438 | Option.exists(value.data, (thread) => thread.title === "New request still loading"), | ||
| 439 | ); | ||
| 440 | const loading = yield* SubscriptionRef.get(harness.threadState); | ||
| 441 | expect(Option.getOrThrow(loading.page).loadingOlder).toBe(true); | ||
| 442 | expect(hasMessage(loading, "message-old")).toBe(false); | ||
| 443 | expect((yield* Ref.get(harness.loaderWindows)).map((window) => window?.beforeCursor)).toEqual( | ||
| 444 | [undefined, "cursor-1", "cursor-2"], | ||
| 445 | ); | ||
| 446 | |||
| 447 | yield* harness.resolveNextPage( | ||
| 448 | Option.some({ | ||
| 449 | ...OLDER_PAGE, | ||
| 450 | snapshotSequence: 21, | ||
| 451 | page: { beforeCursor: null, hasMore: false, snapshotSequence: 21, threadSequence: 21 }, | ||
| 452 | }), | ||
| 453 | ); | ||
| 454 | const completed = yield* harness.awaitState((value) => hasMessage(value, "message-old")); | ||
| 455 | expect(Option.getOrThrow(completed.page).loadingOlder).toBe(false); | ||
| 456 | expect(Option.getOrThrow(completed.page).beforeCursor).toBeNull(); | ||
| 457 | }), | ||
| 458 | ); | ||
| 459 | |||
| 401 | it.effect("discards an older page read from a projection behind the loaded state", () => | 460 | it.effect("discards an older page read from a projection behind the loaded state", () => |
| 402 | Effect.gen(function* () { | 461 | Effect.gen(function* () { |
| 403 | const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); | 462 | const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); |
packages/client-runtime/src/state/threads-sync.test.ts+81-4| ... | @@ -146,6 +146,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o | ... | @@ -146,6 +146,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o |
| 146 | const inputs = yield* Queue.unbounded<TestThreadInput>(); | 146 | const inputs = yield* Queue.unbounded<TestThreadInput>(); |
| 147 | const observed = yield* Queue.unbounded<EnvironmentThreadState>(); | 147 | const observed = yield* Queue.unbounded<EnvironmentThreadState>(); |
| 148 | const latest = yield* Ref.make<EnvironmentThreadState>(EMPTY_ENVIRONMENT_THREAD_STATE); | 148 | const latest = yield* Ref.make<EnvironmentThreadState>(EMPTY_ENVIRONMENT_THREAD_STATE); |
| 149 | const stateChangeCount = yield* Ref.make(0); | ||
| 149 | const retryCount = yield* Ref.make(0); | 150 | const retryCount = yield* Ref.make(0); |
| 150 | const subscriptionCount = yield* Ref.make(0); | 151 | const subscriptionCount = yield* Ref.make(0); |
| 151 | const loaderCalls = yield* Ref.make(0); | 152 | const loaderCalls = yield* Ref.make(0); |
| ... | @@ -157,11 +158,19 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o | ... | @@ -157,11 +158,19 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o |
| 157 | const supervisorState = yield* SubscriptionRef.make<SupervisorConnectionState>( | 158 | const supervisorState = yield* SubscriptionRef.make<SupervisorConnectionState>( |
| 158 | AVAILABLE_CONNECTION_STATE, | 159 | AVAILABLE_CONNECTION_STATE, |
| 159 | ); | 160 | ); |
| 161 | // Preserve queued event batches while failing at the first error. | ||
| 160 | const streamFrom = (queue: Queue.Queue<TestThreadInput>) => | 162 | const streamFrom = (queue: Queue.Queue<TestThreadInput>) => |
| 161 | Stream.fromQueue(queue).pipe( | 163 | Stream.fromQueue(queue).pipe( |
| 162 | Stream.mapEffect((input) => | 164 | Stream.chunks, |
| 163 | input instanceof Error ? Effect.fail(input) : Effect.succeed(input), | 165 | Stream.flatMap((chunk) => { |
| 164 | ), | 166 | const errorIndex = chunk.findIndex((input) => input instanceof Error); |
| 167 | if (errorIndex === -1) { | ||
| 168 | return Stream.fromArray(chunk as ReadonlyArray<OrchestrationThreadStreamItem>); | ||
| 169 | } | ||
| 170 | const prefix = chunk.slice(0, errorIndex) as ReadonlyArray<OrchestrationThreadStreamItem>; | ||
| 171 | const failure = Stream.fail(chunk[errorIndex] as Error); | ||
| 172 | return prefix.length === 0 ? failure : Stream.concat(Stream.fromArray(prefix), failure); | ||
| 173 | }), | ||
| 165 | ); | 174 | ); |
| 166 | const client = { | 175 | const client = { |
| 167 | [ORCHESTRATION_WS_METHODS.subscribeThread]: (input: { | 176 | [ORCHESTRATION_WS_METHODS.subscribeThread]: (input: { |
| ... | @@ -244,7 +253,10 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o | ... | @@ -244,7 +253,10 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o |
| 244 | ); | 253 | ); |
| 245 | yield* SubscriptionRef.changes(threadState).pipe( | 254 | yield* SubscriptionRef.changes(threadState).pipe( |
| 246 | Stream.runForEach((state) => | 255 | Stream.runForEach((state) => |
| 247 | Ref.set(latest, state).pipe(Effect.andThen(Queue.offer(observed, state))), | 256 | Ref.update(stateChangeCount, (count) => count + 1).pipe( |
| 257 | Effect.andThen(Ref.set(latest, state)), | ||
| 258 | Effect.andThen(Queue.offer(observed, state)), | ||
| 259 | ), | ||
| 248 | ), | 260 | ), |
| 249 | Effect.forkScoped, | 261 | Effect.forkScoped, |
| 250 | ); | 262 | ); |
| ... | @@ -254,6 +266,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o | ... | @@ -254,6 +266,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o |
| 254 | inputs, | 266 | inputs, |
| 255 | observed, | 267 | observed, |
| 256 | latest, | 268 | latest, |
| 269 | stateChangeCount, | ||
| 257 | retryCount, | 270 | retryCount, |
| 258 | subscriptionCount, | 271 | subscriptionCount, |
| 259 | loaderCalls, | 272 | loaderCalls, |
| ... | @@ -307,6 +320,38 @@ const titleUpdated = (title: string, sequence = 2): OrchestrationThreadStreamIte | ... | @@ -307,6 +320,38 @@ const titleUpdated = (title: string, sequence = 2): OrchestrationThreadStreamIte |
| 307 | }, | 320 | }, |
| 308 | }); | 321 | }); |
| 309 | 322 | ||
| 323 | const sessionSet = ( | ||
| 324 | status: "ready" | "running", | ||
| 325 | turnId: string, | ||
| 326 | sequence: number, | ||
| 327 | ): OrchestrationThreadStreamItem => ({ | ||
| 328 | kind: "event", | ||
| 329 | event: { | ||
| 330 | eventId: EventId.make(`event-session-${status}-${sequence}`), | ||
| 331 | sequence, | ||
| 332 | occurredAt: "2026-04-01T03:00:00.000Z", | ||
| 333 | commandId: null, | ||
| 334 | causationEventId: null, | ||
| 335 | correlationId: null, | ||
| 336 | metadata: {}, | ||
| 337 | aggregateKind: "thread", | ||
| 338 | aggregateId: THREAD_ID, | ||
| 339 | type: "thread.session-set", | ||
| 340 | payload: { | ||
| 341 | threadId: THREAD_ID, | ||
| 342 | session: { | ||
| 343 | threadId: THREAD_ID, | ||
| 344 | status, | ||
| 345 | providerName: "codex", | ||
| 346 | runtimeMode: "full-access", | ||
| 347 | activeTurnId: status === "running" ? TurnId.make(turnId) : null, | ||
| 348 | lastError: null, | ||
| 349 | updatedAt: "2026-04-01T03:00:00.000Z", | ||
| 350 | }, | ||
| 351 | }, | ||
| 352 | }, | ||
| 353 | }); | ||
| 354 | |||
| 310 | const deleted = (): OrchestrationThreadStreamItem => ({ | 355 | const deleted = (): OrchestrationThreadStreamItem => ({ |
| 311 | kind: "event", | 356 | kind: "event", |
| 312 | event: { | 357 | event: { |
| ... | @@ -989,4 +1034,36 @@ describe("EnvironmentThreads", () => { | ... | @@ -989,4 +1034,36 @@ describe("EnvironmentThreads", () => { |
| 989 | expect(yield* Ref.get(harness.subscriptionCount)).toBe(3); | 1034 | expect(yield* Ref.get(harness.subscriptionCount)).toBe(3); |
| 990 | }), | 1035 | }), |
| 991 | ); | 1036 | ); |
| 1037 | |||
| 1038 | it.effect( | ||
| 1039 | "persists a turn that settles mid-batch when the next turn starts in the same batch", | ||
| 1040 | () => | ||
| 1041 | Effect.gen(function* () { | ||
| 1042 | const harness = yield* makeHarness({ cached: ACTIVE_THREAD }); | ||
| 1043 | yield* awaitThreadState(harness.observed, (value) => value.status === "live"); | ||
| 1044 | const before = yield* Ref.get(harness.stateChangeCount); | ||
| 1045 | |||
| 1046 | // Both events arrive in one transport batch: the session settles and the | ||
| 1047 | // next turn starts before the fold publishes. | ||
| 1048 | yield* Queue.offerAll(harness.inputs, [ | ||
| 1049 | sessionSet("ready", "turn-1", CACHED_SNAPSHOT_SEQUENCE + 1), | ||
| 1050 | sessionSet("running", "turn-2", CACHED_SNAPSHOT_SEQUENCE + 2), | ||
| 1051 | ]); | ||
| 1052 | yield* awaitThreadState( | ||
| 1053 | harness.observed, | ||
| 1054 | (value) => | ||
| 1055 | Option.isSome(value.data) && | ||
| 1056 | value.data.value.session?.activeTurnId === TurnId.make("turn-2"), | ||
| 1057 | ); | ||
| 1058 | expect((yield* Ref.get(harness.stateChangeCount)) - before).toBe(1); | ||
| 1059 | yield* TestClock.adjust("500 millis"); | ||
| 1060 | yield* Effect.yieldNow; | ||
| 1061 | |||
| 1062 | // The settled state reached the cache under its own sequence even | ||
| 1063 | // though the batch ended on a running session. | ||
| 1064 | const saved = (yield* Ref.get(harness.savedThreads)).at(-1); | ||
| 1065 | expect(saved?.thread.session?.status).toBe("ready"); | ||
| 1066 | expect(saved?.snapshotSequence).toBe(CACHED_SNAPSHOT_SEQUENCE + 1); | ||
| 1067 | }), | ||
| 1068 | ); | ||
| 992 | }); | 1069 | }); |
packages/client-runtime/src/state/threads.ts+37-19| ... | @@ -353,6 +353,29 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make | ... | @@ -353,6 +353,29 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make |
| 353 | ), | 353 | ), |
| 354 | ); | 354 | ); |
| 355 | 355 | ||
| 356 | const offerThreadPersistence = Effect.fn("EnvironmentThreadState.offerThreadPersistence")( | ||
| 357 | function* (thread: OrchestrationThread, snapshotSequence: number) { | ||
| 358 | const currentPage = yield* SubscriptionRef.get(state).pipe(Effect.map((value) => value.page)); | ||
| 359 | yield* Queue.offer(persistence, { | ||
| 360 | snapshotSequence, | ||
| 361 | thread, | ||
| 362 | // Persist the window boundary with the window's content so a cache | ||
| 363 | // restore can keep paging from where the loaded history ends. | ||
| 364 | ...Option.match(currentPage, { | ||
| 365 | onNone: () => ({}), | ||
| 366 | onSome: (value) => | ||
| 367 | ({ | ||
| 368 | page: { | ||
| 369 | beforeCursor: value.beforeCursor, | ||
| 370 | hasMore: value.hasMore, | ||
| 371 | snapshotSequence, | ||
| 372 | }, | ||
| 373 | }) as const, | ||
| 374 | }), | ||
| 375 | }); | ||
| 376 | }, | ||
| 377 | ); | ||
| 378 | |||
| 356 | const setThread = Effect.fn("EnvironmentThreadState.setThread")(function* ( | 379 | const setThread = Effect.fn("EnvironmentThreadState.setThread")(function* ( |
| 357 | thread: OrchestrationThread, | 380 | thread: OrchestrationThread, |
| 358 | // "keep" preserves the current page state (live events touch only loaded | 381 | // "keep" preserves the current page state (live events touch only loaded |
| ... | @@ -376,24 +399,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make | ... | @@ -376,24 +399,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make |
| 376 | // persist once it settles so cache encoding stays off the streaming path. | 399 | // persist once it settles so cache encoding stays off the streaming path. |
| 377 | if (shouldPersistThread(thread)) { | 400 | if (shouldPersistThread(thread)) { |
| 378 | const snapshotSequence = yield* SubscriptionRef.get(lastSequence); | 401 | const snapshotSequence = yield* SubscriptionRef.get(lastSequence); |
| 379 | const currentPage = yield* SubscriptionRef.get(state).pipe(Effect.map((value) => value.page)); | 402 | yield* offerThreadPersistence(thread, snapshotSequence); |
| 380 | yield* Queue.offer(persistence, { | ||
| 381 | snapshotSequence, | ||
| 382 | thread, | ||
| 383 | // Persist the window boundary with the window's content so a cache | ||
| 384 | // restore can keep paging from where the loaded history ends. | ||
| 385 | ...Option.match(currentPage, { | ||
| 386 | onNone: () => ({}), | ||
| 387 | onSome: (value) => | ||
| 388 | ({ | ||
| 389 | page: { | ||
| 390 | beforeCursor: value.beforeCursor, | ||
| 391 | hasMore: value.hasMore, | ||
| 392 | snapshotSequence, | ||
| 393 | }, | ||
| 394 | }) as const, | ||
| 395 | }), | ||
| 396 | }); | ||
| 397 | } | 403 | } |
| 398 | }); | 404 | }); |
| 399 | 405 | ||
| ... | @@ -441,6 +447,9 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make | ... | @@ -441,6 +447,9 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make |
| 441 | // in the preserved history with no event left to remove it. The | 447 | // in the preserved history with no event left to remove it. The |
| 442 | // epoch bump discards any older-page fetch racing this snapshot. | 448 | // epoch bump discards any older-page fetch racing this snapshot. |
| 443 | yield* Ref.update(historyEpoch, (epoch) => epoch + 1); | 449 | yield* Ref.update(historyEpoch, (epoch) => epoch + 1); |
| 450 | // A parked response must not clear loadingOlder on a request started | ||
| 451 | // from the replacement snapshot's cursor. | ||
| 452 | yield* Ref.set(pendingOlderPage, null); | ||
| 444 | yield* SubscriptionRef.set(lastSequence, item.snapshot.snapshotSequence); | 453 | yield* SubscriptionRef.set(lastSequence, item.snapshot.snapshotSequence); |
| 445 | yield* setThread(item.snapshot.thread, pageStateFromSnapshot(item.snapshot.page)); | 454 | yield* setThread(item.snapshot.thread, pageStateFromSnapshot(item.snapshot.page)); |
| 446 | return; | 455 | return; |
| ... | @@ -539,17 +548,26 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make | ... | @@ -539,17 +548,26 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make |
| 539 | let thread = current.data.value; | 548 | let thread = current.data.value; |
| 540 | let sequence = yield* SubscriptionRef.get(lastSequence); | 549 | let sequence = yield* SubscriptionRef.get(lastSequence); |
| 541 | let synchronized = false; | 550 | let synchronized = false; |
| 551 | // Retain the last settled state even if the next turn starts before | ||
| 552 | // this batch publishes. Its cursor must describe that settled content. | ||
| 553 | let persistable: { thread: OrchestrationThread; sequence: number } | undefined; | ||
| 542 | for (const item of items) { | 554 | for (const item of items) { |
| 543 | if (item.kind === "synchronized") { | 555 | if (item.kind === "synchronized") { |
| 544 | synchronized = true; | 556 | synchronized = true; |
| 545 | } else if (item.kind === "event" && item.event.sequence > sequence) { | 557 | } else if (item.kind === "event" && item.event.sequence > sequence) { |
| 546 | sequence = item.event.sequence; | 558 | sequence = item.event.sequence; |
| 547 | const result = applyThreadDetailEvent(thread, item.event); | 559 | const result = applyThreadDetailEvent(thread, item.event); |
| 548 | if (result.kind === "updated") thread = result.thread; | 560 | if (result.kind === "updated") { |
| 561 | thread = result.thread; | ||
| 562 | if (shouldPersistThread(thread)) persistable = { thread, sequence }; | ||
| 563 | } | ||
| 549 | } | 564 | } |
| 550 | } | 565 | } |
| 551 | yield* SubscriptionRef.set(lastSequence, sequence); | 566 | yield* SubscriptionRef.set(lastSequence, sequence); |
| 552 | if (thread !== current.data.value) yield* setThread(thread, "keep"); | 567 | if (thread !== current.data.value) yield* setThread(thread, "keep"); |
| 568 | if (persistable !== undefined && !shouldPersistThread(thread)) { | ||
| 569 | yield* offerThreadPersistence(persistable.thread, persistable.sequence); | ||
| 570 | } | ||
| 553 | if (synchronized) yield* applyItemLocked({ kind: "synchronized" }); | 571 | if (synchronized) yield* applyItemLocked({ kind: "synchronized" }); |
| 554 | yield* remember; | 572 | yield* remember; |
| 555 | }), | 573 | }), |