authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-28 12:27:45-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 23:00:41-08:00
log75354bbd962d9fc38a1ba40ee78520822b349016
tree5f47577128b4605a7d7db4f7fbfd301d41e7cf31
parent6a10e97a97f768137f7978375e4cfa13211b063e

Track runtime event sequence and order thread activities by sequence

- add per-session `sessionSequence` on provider runtime events and persist activity `sequence` - migrate `projection_thread_activities` with nullable `sequence` column + index - sort server/web activity projections by sequence fallback to timestamp/id - allow provider sessions/turns to start before a real threadId is emitted

17 files changed, 349 insertions(+), 69 deletions(-)

apps/server/src/orchestration/Layers/ProjectionPipeline.ts+3
......@@ -598,6 +598,9 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
598598 kind: event.payload.activity.kind,
599599 summary: event.payload.activity.summary,
600600 payload: event.payload.activity.payload,
601 ...(event.payload.activity.sequence !== undefined
602 ? { sequence: event.payload.activity.sequence }
603 : {}),
601604 createdAt: event.payload.activity.createdAt,
602605 });
603606 return;
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+10-1
......@@ -2,6 +2,7 @@ import {
22 ChatAttachment,
33 IsoDateTime,
44 MessageId,
5 NonNegativeInt,
56 OrchestrationCheckpointFile,
67 OrchestrationReadModel,
78 ProjectScript,
......@@ -53,6 +54,7 @@ const ProjectionThreadDbRowSchema = ProjectionThread;
5354const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(
5455 Struct.assign({
5556 payload: Schema.fromJsonString(Schema.Unknown),
57 sequence: Schema.NullOr(NonNegativeInt),
5658 }),
5759);
5860const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession;
......@@ -195,9 +197,15 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
195197 kind,
196198 summary,
197199 payload_json AS "payload",
200 sequence,
198201 created_at AS "createdAt"
199202 FROM projection_thread_activities
200 ORDER BY thread_id ASC, created_at ASC, activity_id ASC
203 ORDER BY
204 thread_id ASC,
205 CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC,
206 sequence ASC,
207 created_at ASC,
208 activity_id ASC
201209 `,
202210 });
203211
......@@ -398,6 +406,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
398406 summary: row.summary,
399407 payload: row.payload,
400408 turnId: row.turnId,
409 ...(row.sequence !== null ? { sequence: row.sequence } : {}),
401410 createdAt: row.createdAt,
402411 });
403412 activitiesByThread.set(row.threadId, threadActivities);
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+6
......@@ -79,6 +79,7 @@ function truncateDetail(value: string, limit = 180): string {
7979function runtimeEventToActivities(
8080 event: ProviderRuntimeEvent,
8181): ReadonlyArray<OrchestrationThreadActivity> {
82 const maybeSequence = event.sessionSequence !== undefined ? { sequence: event.sessionSequence } : {};
8283 switch (event.type) {
8384 case "approval.requested": {
8485 return [
......@@ -97,6 +98,7 @@ function runtimeEventToActivities(
9798 ...(event.detail ? { detail: truncateDetail(event.detail) } : {}),
9899 },
99100 turnId: toTurnId(event.turnId) ?? null,
101 ...maybeSequence,
100102 },
101103 ];
102104 }
......@@ -115,6 +117,7 @@ function runtimeEventToActivities(
115117 ...(event.decision ? { decision: event.decision } : {}),
116118 },
117119 turnId: toTurnId(event.turnId) ?? null,
120 ...maybeSequence,
118121 },
119122 ];
120123 }
......@@ -131,6 +134,7 @@ function runtimeEventToActivities(
131134 message: truncateDetail(event.message),
132135 },
133136 turnId: toTurnId(event.turnId) ?? null,
137 ...maybeSequence,
134138 },
135139 ];
136140 }
......@@ -148,6 +152,7 @@ function runtimeEventToActivities(
148152 ...(event.detail ? { detail: truncateDetail(event.detail) } : {}),
149153 },
150154 turnId: toTurnId(event.turnId) ?? null,
155 ...maybeSequence,
151156 },
152157 ];
153158 }
......@@ -165,6 +170,7 @@ function runtimeEventToActivities(
165170 ...(event.detail ? { detail: truncateDetail(event.detail) } : {}),
166171 },
167172 turnId: toTurnId(event.turnId) ?? null,
173 ...maybeSequence,
168174 },
169175 ];
170176 }
apps/server/src/orchestration/projector.ts+18-1
......@@ -124,6 +124,23 @@ function retainThreadActivitiesAfterRevert(
124124 );
125125}
126126
127function compareThreadActivities(
128 left: OrchestrationThread["activities"][number],
129 right: OrchestrationThread["activities"][number],
130): number {
131 if (left.sequence !== undefined && right.sequence !== undefined) {
132 if (left.sequence !== right.sequence) {
133 return left.sequence - right.sequence;
134 }
135 } else if (left.sequence !== undefined) {
136 return 1;
137 } else if (right.sequence !== undefined) {
138 return -1;
139 }
140
141 return left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id);
142}
143
127144export function createEmptyReadModel(nowIso: string): OrchestrationReadModel {
128145 return {
129146 snapshotSequence: 0,
......@@ -503,7 +520,7 @@ export function projectEvent(
503520 ...thread.activities.filter((entry) => entry.id !== payload.activity.id),
504521 payload.activity,
505522 ]
506 .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt))
523 .toSorted(compareThreadActivities)
507524 .slice(-500);
508525
509526 return {
apps/server/src/persistence/Layers/ProjectionThreadActivities.ts+25-5
......@@ -1,5 +1,6 @@
11import * as SqlClient from "effect/unstable/sql/SqlClient";
22import * as SqlSchema from "effect/unstable/sql/SqlSchema";
3import { NonNegativeInt } from "@t3tools/contracts";
34import { Effect, Layer, Schema, Struct } from "effect";
45
56import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts";
......@@ -15,6 +16,7 @@ import {
1516const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(
1617 Struct.assign({
1718 payload: Schema.fromJsonString(Schema.Unknown),
19 sequence: Schema.NullOr(NonNegativeInt),
1820 }),
1921);
2022
......@@ -29,7 +31,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () {
2931 const sql = yield* SqlClient.SqlClient;
3032
3133 const upsertProjectionThreadActivityRow = SqlSchema.void({
32 Request: ProjectionThreadActivityDbRowSchema,
34 Request: ProjectionThreadActivity,
3335 execute: (row) =>
3436 sql`
3537 INSERT INTO projection_thread_activities (
......@@ -40,6 +42,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () {
4042 kind,
4143 summary,
4244 payload_json,
45 sequence,
4346 created_at
4447 )
4548 VALUES (
......@@ -49,7 +52,8 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () {
4952 ${row.tone},
5053 ${row.kind},
5154 ${row.summary},
52 ${row.payload},
55 ${JSON.stringify(row.payload)},
56 ${row.sequence ?? null},
5357 ${row.createdAt}
5458 )
5559 ON CONFLICT (activity_id)
......@@ -60,6 +64,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () {
6064 kind = excluded.kind,
6165 summary = excluded.summary,
6266 payload_json = excluded.payload_json,
67 sequence = excluded.sequence,
6368 created_at = excluded.created_at
6469 `,
6570 });
......@@ -77,10 +82,15 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () {
7782 kind,
7883 summary,
7984 payload_json AS "payload",
85 sequence,
8086 created_at AS "createdAt"
8187 FROM projection_thread_activities
8288 WHERE thread_id = ${threadId}
83 ORDER BY created_at ASC, activity_id ASC
89 ORDER BY
90 CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC,
91 sequence ASC,
92 created_at ASC,
93 activity_id ASC
8494 `,
8595 });
8696
......@@ -111,8 +121,18 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () {
111121 "ProjectionThreadActivityRepository.listByThreadId:decodeRows",
112122 ),
113123 ),
114 Effect.map(
115 (rows) => rows as ReadonlyArray<Schema.Schema.Type<typeof ProjectionThreadActivity>>,
124 Effect.map((rows) =>
125 rows.map((row) => ({
126 activityId: row.activityId,
127 threadId: row.threadId,
128 turnId: row.turnId,
129 tone: row.tone,
130 kind: row.kind,
131 summary: row.summary,
132 payload: row.payload,
133 ...(row.sequence !== null ? { sequence: row.sequence } : {}),
134 createdAt: row.createdAt,
135 })),
116136 ),
117137 );
118138
apps/server/src/persistence/Migrations.ts+2
......@@ -19,6 +19,7 @@ import Migration0004 from "./Migrations/004_ProviderSessionRuntime.ts";
1919import Migration0005 from "./Migrations/005_Projections.ts";
2020import Migration0006 from "./Migrations/006_ProjectionThreadSessionRuntimeModeColumns.ts";
2121import Migration0007 from "./Migrations/007_ProjectionThreadMessageAttachments.ts";
22import Migration0008 from "./Migrations/008_ProjectionThreadActivitySequence.ts";
2223
2324/**
2425 * Migration loader with all migrations defined inline.
......@@ -38,6 +39,7 @@ const loader = Migrator.fromRecord({
3839 "5_Projections": Migration0005,
3940 "6_ProjectionThreadSessionRuntimeModeColumns": Migration0006,
4041 "7_ProjectionThreadMessageAttachments": Migration0007,
42 "8_ProjectionThreadActivitySequence": Migration0008,
4143});
4244
4345/**
apps/server/src/persistence/Migrations/008_ProjectionThreadActivitySequence.ts created+16
......@@ -0,0 +1,16 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6
7 yield* sql`
8 ALTER TABLE projection_thread_activities
9 ADD COLUMN sequence INTEGER
10 `;
11
12 yield* sql`
13 CREATE INDEX IF NOT EXISTS idx_projection_thread_activities_thread_sequence
14 ON projection_thread_activities(thread_id, sequence)
15 `;
16});
apps/server/src/persistence/Services/ProjectionThreadActivities.ts+4-1
......@@ -9,6 +9,7 @@
99import {
1010 EventId,
1111 IsoDateTime,
12 NonNegativeInt,
1213 OrchestrationThreadActivityTone,
1314 ThreadId,
1415 TurnId,
......@@ -26,6 +27,7 @@ export const ProjectionThreadActivity = Schema.Struct({
2627 kind: Schema.String,
2728 summary: Schema.String,
2829 payload: Schema.Unknown,
30 sequence: Schema.optional(NonNegativeInt),
2931 createdAt: IsoDateTime,
3032});
3133export type ProjectionThreadActivity = typeof ProjectionThreadActivity.Type;
......@@ -57,7 +59,8 @@ export interface ProjectionThreadActivityRepositoryShape {
5759 /**
5860 * List projected thread activity rows for a thread.
5961 *
60 * Returned in ascending creation order.
62 * Returned in ascending runtime sequence order (or creation order when
63 * sequence is unavailable).
6164 */
6265 readonly listByThreadId: (
6366 input: ListProjectionThreadActivitiesInput,
apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts+67
......@@ -505,6 +505,73 @@ describe("ClaudeCodeAdapterLive", () => {
505505 );
506506 });
507507
508 it.effect("does not fabricate provider thread ids before first SDK session_id", () => {
509 const harness = makeHarness();
510 return Effect.gen(function* () {
511 const adapter = yield* ClaudeCodeAdapter;
512
513 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 5).pipe(
514 Stream.runCollect,
515 Effect.forkChild,
516 );
517
518 const session = yield* adapter.startSession({
519 provider: "claudeCode",
520 });
521 assert.equal(session.threadId, undefined);
522
523 const turn = yield* adapter.sendTurn({
524 sessionId: session.sessionId,
525 input: "hello",
526 attachments: [],
527 });
528 assert.equal(turn.threadId, undefined);
529
530 harness.query.emit({
531 type: "stream_event",
532 session_id: "sdk-thread-real",
533 uuid: "stream-thread-real",
534 parent_tool_use_id: null,
535 event: {
536 type: "message_start",
537 message: {
538 id: "msg-thread-real",
539 },
540 },
541 } as unknown as SDKMessage);
542
543 harness.query.emit({
544 type: "result",
545 subtype: "success",
546 is_error: false,
547 errors: [],
548 session_id: "sdk-thread-real",
549 uuid: "result-thread-real",
550 } as unknown as SDKMessage);
551
552 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
553 assert.deepEqual(
554 runtimeEvents.map((event) => event.type),
555 ["session.started", "turn.started", "thread.started", "message.completed", "turn.completed"],
556 );
557
558 const sessionStarted = runtimeEvents[0];
559 assert.equal(sessionStarted?.type, "session.started");
560 if (sessionStarted?.type === "session.started") {
561 assert.equal("threadId" in sessionStarted, false);
562 }
563
564 const threadStarted = runtimeEvents[2];
565 assert.equal(threadStarted?.type, "thread.started");
566 if (threadStarted?.type === "thread.started") {
567 assert.equal(threadStarted.threadId, "sdk-thread-real");
568 }
569 }).pipe(
570 Effect.provideService(Random.Random, makeDeterministicRandomService()),
571 Effect.provide(harness.layer),
572 );
573 });
574
508575 it.effect("bridges approval request/response lifecycle through canUseTool", () => {
509576 const harness = makeHarness();
510577 return Effect.gen(function* () {
apps/server/src/provider/Layers/ClaudeCodeAdapter.ts+25-20
......@@ -126,6 +126,10 @@ function isUuid(value: string): boolean {
126126 );
127127}
128128
129function isSyntheticClaudeThreadId(value: string): boolean {
130 return value.startsWith("claude-thread-");
131}
132
129133function toMessage(cause: unknown, fallback: string): string {
130134 if (cause instanceof Error && cause.message.length > 0) {
131135 return cause.message;
......@@ -158,7 +162,11 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef
158162 turnCount?: unknown;
159163 };
160164
161 const threadId = typeof cursor.threadId === "string" ? cursor.threadId : undefined;
165 const threadIdCandidate = typeof cursor.threadId === "string" ? cursor.threadId : undefined;
166 const threadId =
167 threadIdCandidate && !isSyntheticClaudeThreadId(threadIdCandidate)
168 ? threadIdCandidate
169 : undefined;
162170 const resumeCandidate =
163171 typeof cursor.resume === "string"
164172 ? cursor.resume
......@@ -464,9 +472,14 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
464472 }>;
465473 }> =>
466474 Effect.gen(function* () {
467 const threadId =
468 context.session.threadId ??
469 ProviderThreadId.makeUnsafe(`claude-thread-${yield* Random.nextUUIDv4}`);
475 const threadId = context.session.threadId;
476 if (!threadId) {
477 return yield* new ProviderAdapterValidationError({
478 provider: PROVIDER,
479 operation: "readThread",
480 issue: "Session thread id is not initialized yet.",
481 });
482 }
470483 return {
471484 threadId,
472485 turns: context.turns.map((turn) => ({
......@@ -500,6 +513,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
500513 message: SDKMessage,
501514 ): Effect.Effect<void> =>
502515 Effect.gen(function* () {
516 if (typeof message.session_id !== "string" || message.session_id.length === 0) {
517 return;
518 }
503519 const nextThreadId = ProviderThreadId.makeUnsafe(message.session_id);
504520 context.resumeSessionId = message.session_id;
505521 const changed = context.session.threadId !== nextThreadId;
......@@ -922,9 +938,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
922938 `claude-session-${yield* Random.nextUUIDv4}`,
923939 );
924940 const resumeState = readClaudeResumeState(input.resumeCursor);
925 const threadId =
926 resumeState?.threadId ??
927 ProviderThreadId.makeUnsafe(`claude-thread-${yield* Random.nextUUIDv4}`);
941 const threadId = resumeState?.threadId;
928942
929943 const promptQueue = yield* Queue.unbounded<PromptQueueItem>();
930944 const prompt = Stream.fromQueue(promptQueue).pipe(
......@@ -1082,9 +1096,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
10821096 status: "ready",
10831097 ...(input.cwd ? { cwd: input.cwd } : {}),
10841098 ...(input.model ? { model: input.model } : {}),
1085 threadId,
1099 ...(threadId ? { threadId } : {}),
10861100 resumeCursor: {
1087 threadId,
1101 ...(threadId ? { threadId } : {}),
10881102 ...(resumeState?.resume ? { resume: resumeState.resume } : {}),
10891103 ...(resumeState?.resumeSessionAt
10901104 ? { resumeSessionAt: resumeState.resumeSessionAt }
......@@ -1119,7 +1133,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
11191133 provider: PROVIDER,
11201134 sessionId,
11211135 createdAt: sessionStartedStamp.createdAt,
1122 threadId,
1136 ...(threadId ? { threadId } : {}),
11231137 });
11241138
11251139 Effect.runFork(runSdkStream(context));
......@@ -1186,17 +1200,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
11861200 message,
11871201 }).pipe(Effect.mapError((cause) => toRequestError(input.sessionId, "turn/start", cause)));
11881202
1189 const threadId = context.session.threadId;
1190 if (!threadId) {
1191 return yield* new ProviderAdapterValidationError({
1192 provider: PROVIDER,
1193 operation: "sendTurn",
1194 issue: "Session thread id is not initialized.",
1195 });
1196 }
1197
11981203 return {
1199 threadId,
1204 ...(context.session.threadId ? { threadId: context.session.threadId } : {}),
12001205 turnId,
12011206 ...(context.session.resumeCursor !== undefined
12021207 ? { resumeCursor: context.session.resumeCursor }
apps/server/src/provider/Layers/ProviderService.test.ts+77-20
......@@ -28,7 +28,6 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
2828import {
2929 ProviderAdapterSessionNotFoundError,
3030 ProviderSessionNotFoundError,
31 ProviderValidationError,
3231 ProviderUnsupportedError,
3332 type ProviderAdapterError,
3433} from "../Errors.ts";
......@@ -648,6 +647,62 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
648647 }),
649648 );
650649
650 it.effect("assigns monotonic per-session sequence numbers to canonical runtime events", () =>
651 Effect.gen(function* () {
652 const provider = yield* ProviderService;
653 const session = yield* provider.startSession(asThreadId("thread-seq"), {
654 provider: "codex",
655 });
656
657 const receivedRef = yield* Ref.make<Array<ProviderRuntimeEvent>>([]);
658 const consumer = yield* Stream.take(provider.streamEvents, 3).pipe(
659 Stream.runForEach((event) => Ref.update(receivedRef, (current) => [...current, event])),
660 Effect.forkChild,
661 );
662 yield* sleep(20);
663
664 fanout.codex.emit({
665 type: "tool.started",
666 eventId: asEventId("evt-seq-1"),
667 provider: "codex",
668 sessionId: session.sessionId,
669 createdAt: new Date().toISOString(),
670 threadId: asProviderThreadId("thread-1"),
671 turnId: asTurnId("turn-1"),
672 toolKind: "command",
673 title: "Command run",
674 });
675 fanout.codex.emit({
676 type: "tool.completed",
677 eventId: asEventId("evt-seq-2"),
678 provider: "codex",
679 sessionId: session.sessionId,
680 createdAt: new Date().toISOString(),
681 threadId: asProviderThreadId("thread-1"),
682 turnId: asTurnId("turn-1"),
683 toolKind: "command",
684 title: "Command run",
685 });
686 fanout.codex.emit({
687 type: "turn.completed",
688 eventId: asEventId("evt-seq-3"),
689 provider: "codex",
690 sessionId: session.sessionId,
691 createdAt: new Date().toISOString(),
692 threadId: asProviderThreadId("thread-1"),
693 turnId: asTurnId("turn-1"),
694 status: "completed",
695 });
696
697 yield* Fiber.join(consumer);
698 const received = yield* Ref.get(receivedRef);
699 assert.deepEqual(
700 received.map((event) => event.sessionSequence),
701 [1, 2, 3],
702 );
703 }),
704 );
705
651706 it.effect("keeps subscriber delivery ordered and isolates failing subscribers", () =>
652707 Effect.gen(function* () {
653708 const provider = yield* ProviderService;
......@@ -746,9 +801,10 @@ validation.layer("ProviderServiceLive validation", (it) => {
746801 }),
747802 );
748803
749 it.effect("fails startSession when adapter returns no threadId", () =>
804 it.effect("accepts startSession when adapter has not emitted provider thread id yet", () =>
750805 Effect.gen(function* () {
751806 const provider = yield* ProviderService;
807 const runtimeRepository = yield* ProviderSessionRuntimeRepository;
752808
753809 validation.codex.startSession.mockImplementationOnce((input: ProviderSessionStartInput) =>
754810 Effect.sync(() => {
......@@ -757,27 +813,28 @@ validation.layer("ProviderServiceLive validation", (it) => {
757813 sessionId: asSessionId("sess-missing-thread"),
758814 provider: "codex",
759815 status: "ready",
760 cwd: input.cwd ?? process.cwd(),
761 createdAt: now,
762 updatedAt: now,
763 } satisfies ProviderSession;
764 }),
816 cwd: input.cwd ?? process.cwd(),
817 createdAt: now,
818 updatedAt: now,
819 } satisfies ProviderSession;
820 }),
765821 );
766822
767 const failure = yield* Effect.result(
768 provider.startSession(asThreadId("thread-missing"), {
769 provider: "codex",
770 cwd: "/tmp/project",
771 }),
772 );
823 const session = yield* provider.startSession(asThreadId("thread-missing"), {
824 provider: "codex",
825 cwd: "/tmp/project",
826 });
773827
774 assertFailure(
775 failure,
776 new ProviderValidationError({
777 operation: "ProviderService.startSession",
778 issue: "Provider 'codex' returned a session without threadId.",
779 }),
780 );
828 assert.equal(session.sessionId, asSessionId("sess-missing-thread"));
829 assert.equal(session.threadId, undefined);
830
831 const runtime = yield* runtimeRepository.getBySessionId({
832 providerSessionId: session.sessionId,
833 });
834 assert.equal(Option.isSome(runtime), true);
835 if (Option.isSome(runtime)) {
836 assert.equal(runtime.value.providerThreadId, null);
837 }
781838 }),
782839 );
783840});
apps/server/src/provider/Layers/ProviderService.ts+38-14
......@@ -123,6 +123,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
123123 const routedSessionAliasesRef = yield* Ref.make<Map<ProviderSessionId, ProviderSessionId>>(
124124 new Map(),
125125 );
126 const sessionRuntimeSequenceRef = yield* Ref.make<Map<ProviderSessionId, number>>(new Map());
126127
127128 const canonicalizeRuntimeEventSession = (
128129 event: ProviderRuntimeEvent,
......@@ -143,6 +144,21 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
143144
144145 const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> =>
145146 canonicalizeRuntimeEventSession(event).pipe(
147 Effect.flatMap((canonicalEvent) =>
148 Ref.modify(sessionRuntimeSequenceRef, (current) => {
149 const next = new Map(current);
150 const currentSequence = next.get(canonicalEvent.sessionId) ?? 0;
151 const sessionSequence = currentSequence + 1;
152 next.set(canonicalEvent.sessionId, sessionSequence);
153 return [
154 {
155 ...canonicalEvent,
156 sessionSequence,
157 } satisfies ProviderRuntimeEvent,
158 next,
159 ] as const;
160 }),
161 ),
146162 Effect.tap((canonicalEvent) =>
147163 canonicalEventLogger
148164 ? Effect.flatMap(
......@@ -165,23 +181,16 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
165181
166182 const upsertSessionBinding = (
167183 session: ProviderSession,
168 operation: string,
169184 threadId: ThreadId,
170185 ) =>
171186 Effect.gen(function* () {
172187 const providerThreadId = session.threadId;
173 if (!providerThreadId) {
174 return yield* toValidationError(
175 operation,
176 `Provider '${session.provider}' returned a session without threadId.`,
177 );
178 }
179188
180189 yield* directory.upsert({
181190 sessionId: session.sessionId,
182191 provider: session.provider,
183192 threadId,
184 providerThreadId,
193 ...(providerThreadId !== undefined ? { providerThreadId } : {}),
185194 status: toRuntimeStatus(session),
186195 ...(session.resumeCursor !== undefined ? { resumeCursor: session.resumeCursor } : {}),
187196 runtimePayload: toRuntimePayloadFromSession(session),
......@@ -214,6 +223,16 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
214223 return changed ? next : current;
215224 });
216225
226 const clearSessionSequence = (sessionId: ProviderSessionId) =>
227 Ref.update(sessionRuntimeSequenceRef, (current) => {
228 if (!current.has(sessionId)) {
229 return current;
230 }
231 const next = new Map(current);
232 next.delete(sessionId);
233 return next;
234 });
235
217236 const setAlias = (staleSessionId: ProviderSessionId, liveSessionId: ProviderSessionId) =>
218237 Ref.update(routedSessionAliasesRef, (current) => {
219238 const existing = current.get(staleSessionId);
......@@ -262,14 +281,15 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
262281 if (existing) {
263282 const existingProviderThreadId = yield* upsertSessionBinding(
264283 existing,
265 `${input.operation}:upsertExistingSession`,
266284 input.binding.threadId,
267285 );
268286 yield* directory.upsert({
269287 sessionId: input.staleSessionId,
270288 provider: existing.provider,
271289 threadId: input.binding.threadId,
272 providerThreadId: existingProviderThreadId,
290 ...(existingProviderThreadId !== undefined
291 ? { providerThreadId: existingProviderThreadId }
292 : {}),
273293 ...(existing.resumeCursor !== undefined ? { resumeCursor: existing.resumeCursor } : {}),
274294 });
275295 if (existing.sessionId !== input.staleSessionId) {
......@@ -307,7 +327,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
307327
308328 const resumedProviderThreadId = yield* upsertSessionBinding(
309329 resumed,
310 `${input.operation}:upsertRecoveredSession`,
311330 input.binding.threadId,
312331 );
313332
......@@ -315,7 +334,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
315334 sessionId: input.staleSessionId,
316335 provider: resumed.provider,
317336 threadId: input.binding.threadId,
318 providerThreadId: resumedProviderThreadId,
337 ...(resumedProviderThreadId !== undefined
338 ? { providerThreadId: resumedProviderThreadId }
339 : {}),
319340 ...(resumed.resumeCursor !== undefined ? { resumeCursor: resumed.resumeCursor } : {}),
320341 });
321342
......@@ -428,7 +449,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
428449 );
429450 }
430451
431 yield* upsertSessionBinding(session, "ProviderService.startSession", threadId);
452 yield* upsertSessionBinding(session, threadId);
432453
433454 return session;
434455 });
......@@ -473,7 +494,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
473494 sessionId: input.sessionId,
474495 provider: routed.adapter.provider,
475496 threadId,
476 providerThreadId: turn.threadId,
497 ...(turn.threadId !== undefined ? { providerThreadId: turn.threadId } : {}),
477498 status: "running",
478499 ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}),
479500 runtimePayload: {
......@@ -533,9 +554,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
533554 if (routed.sessionId !== input.sessionId) {
534555 yield* directory.remove(routed.sessionId);
535556 yield* clearAliasesReferencing(routed.sessionId);
557 yield* clearSessionSequence(routed.sessionId);
536558 }
537559 yield* directory.remove(input.sessionId);
538560 yield* clearAliasesReferencing(input.sessionId);
561 yield* clearSessionSequence(input.sessionId);
539562 });
540563
541564 const listSessions: ProviderServiceShape["listSessions"] = () =>
......@@ -584,6 +607,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
584607 // Keep persisted session bindings so stale sessions can be resumed after
585608 // process restart via providerThreadId.
586609 yield* Ref.set(routedSessionAliasesRef, new Map());
610 yield* Ref.set(sessionRuntimeSequenceRef, new Map());
587611 });
588612
589613 return {
apps/web/src/session-logic.test.ts+24
......@@ -17,6 +17,7 @@ function makeActivity(overrides: {
1717 tone?: OrchestrationThreadActivity["tone"];
1818 payload?: Record<string, unknown>;
1919 turnId?: string;
20 sequence?: number;
2021}): OrchestrationThreadActivity {
2122 const payload = overrides.payload ?? {};
2223 return {
......@@ -27,6 +28,7 @@ function makeActivity(overrides: {
2728 tone: overrides.tone ?? "tool",
2829 payload,
2930 turnId: overrides.turnId ? TurnId.makeUnsafe(overrides.turnId) : null,
31 ...(overrides.sequence !== undefined ? { sequence: overrides.sequence } : {}),
3032 };
3133}
3234
......@@ -131,6 +133,28 @@ describe("deriveWorkLogEntries", () => {
131133 const entries = deriveWorkLogEntries(activities, undefined);
132134 expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]);
133135 });
136
137 it("orders work log by activity sequence when present", () => {
138 const activities: OrchestrationThreadActivity[] = [
139 makeActivity({
140 id: "second",
141 createdAt: "2026-02-23T00:00:03.000Z",
142 sequence: 2,
143 summary: "Tool call complete",
144 kind: "tool.completed",
145 }),
146 makeActivity({
147 id: "first",
148 createdAt: "2026-02-23T00:00:04.000Z",
149 sequence: 1,
150 summary: "Tool call complete",
151 kind: "tool.completed",
152 }),
153 ];
154
155 const entries = deriveWorkLogEntries(activities, undefined);
156 expect(entries.map((entry) => entry.id)).toEqual(["first", "second"]);
157 });
134158});
135159
136160describe("hasToolActivityForTurn", () => {
apps/web/src/session-logic.ts+19-6
......@@ -91,9 +91,7 @@ export function derivePendingApprovals(
9191 activities: ReadonlyArray<OrchestrationThreadActivity>,
9292): PendingApproval[] {
9393 const openByRequestId = new Map<ApprovalRequestId, PendingApproval>();
94 const ordered = [...activities].toSorted((left, right) =>
95 left.createdAt.localeCompare(right.createdAt),
96 );
94 const ordered = [...activities].toSorted(compareActivitiesByOrder);
9795
9896 for (const activity of ordered) {
9997 const payload =
......@@ -134,9 +132,7 @@ export function deriveWorkLogEntries(
134132 activities: ReadonlyArray<OrchestrationThreadActivity>,
135133 latestTurnId: TurnId | undefined,
136134): WorkLogEntry[] {
137 const ordered = [...activities].toSorted((left, right) =>
138 left.createdAt.localeCompare(right.createdAt),
139 );
135 const ordered = [...activities].toSorted(compareActivitiesByOrder);
140136 return ordered
141137 .filter((activity) => (latestTurnId ? activity.turnId === latestTurnId : true))
142138 .filter((activity) => activity.kind !== "tool.started")
......@@ -159,6 +155,23 @@ export function deriveWorkLogEntries(
159155 });
160156}
161157
158function compareActivitiesByOrder(
159 left: OrchestrationThreadActivity,
160 right: OrchestrationThreadActivity,
161): number {
162 if (left.sequence !== undefined && right.sequence !== undefined) {
163 if (left.sequence !== right.sequence) {
164 return left.sequence - right.sequence;
165 }
166 } else if (left.sequence !== undefined) {
167 return 1;
168 } else if (right.sequence !== undefined) {
169 return -1;
170 }
171
172 return left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id);
173}
174
162175export function hasToolActivityForTurn(
163176 activities: ReadonlyArray<OrchestrationThreadActivity>,
164177 turnId: TurnId | null | undefined,
packages/contracts/src/orchestration.ts+1
......@@ -212,6 +212,7 @@ export const OrchestrationThreadActivity = Schema.Struct({
212212 summary: TrimmedNonEmptyString,
213213 payload: Schema.Unknown,
214214 turnId: Schema.NullOr(TurnId),
215 sequence: Schema.optional(NonNegativeInt),
215216 createdAt: IsoDateTime,
216217});
217218export type OrchestrationThreadActivity = typeof OrchestrationThreadActivity.Type;
packages/contracts/src/provider.ts+1-1
......@@ -93,7 +93,7 @@ export const ProviderSendTurnInput = Schema.Struct({
9393export type ProviderSendTurnInput = typeof ProviderSendTurnInput.Type;
9494
9595export const ProviderTurnStartResult = Schema.Struct({
96 threadId: ProviderThreadId,
96 threadId: Schema.optional(ProviderThreadId),
9797 turnId: ProviderTurnId,
9898 resumeCursor: Schema.optional(Schema.Unknown),
9999});
packages/contracts/src/providerRuntime.ts+13
......@@ -31,6 +31,7 @@ export const ProviderRuntimeSessionStartedEvent = Schema.Struct({
3131 eventId: EventId,
3232 provider: ProviderKind,
3333 sessionId: ProviderSessionId,
34 sessionSequence: Schema.optional(NonNegativeInt),
3435 createdAt: IsoDateTime,
3536 threadId: Schema.optional(ProviderThreadId),
3637 message: Schema.optional(TrimmedNonEmptyStringSchema),
......@@ -42,6 +43,7 @@ export const ProviderRuntimeSessionExitedEvent = Schema.Struct({
4243 eventId: EventId,
4344 provider: ProviderKind,
4445 sessionId: ProviderSessionId,
46 sessionSequence: Schema.optional(NonNegativeInt),
4547 createdAt: IsoDateTime,
4648 threadId: Schema.optional(ProviderThreadId),
4749 message: Schema.optional(TrimmedNonEmptyStringSchema),
......@@ -53,6 +55,7 @@ export const ProviderRuntimeThreadStartedEvent = Schema.Struct({
5355 eventId: EventId,
5456 provider: ProviderKind,
5557 sessionId: ProviderSessionId,
58 sessionSequence: Schema.optional(NonNegativeInt),
5659 createdAt: IsoDateTime,
5760 threadId: ProviderThreadId,
5861});
......@@ -63,6 +66,7 @@ export const ProviderRuntimeTurnStartedEvent = Schema.Struct({
6366 eventId: EventId,
6467 provider: ProviderKind,
6568 sessionId: ProviderSessionId,
69 sessionSequence: Schema.optional(NonNegativeInt),
6670 createdAt: IsoDateTime,
6771 threadId: Schema.optional(ProviderThreadId),
6872 turnId: ProviderTurnId,
......@@ -74,6 +78,7 @@ export const ProviderRuntimeTurnCompletedEvent = Schema.Struct({
7478 eventId: EventId,
7579 provider: ProviderKind,
7680 sessionId: ProviderSessionId,
81 sessionSequence: Schema.optional(NonNegativeInt),
7782 createdAt: IsoDateTime,
7883 threadId: Schema.optional(ProviderThreadId),
7984 turnId: Schema.optional(ProviderTurnId),
......@@ -87,6 +92,7 @@ export const ProviderRuntimeMessageDeltaEvent = Schema.Struct({
8792 eventId: EventId,
8893 provider: ProviderKind,
8994 sessionId: ProviderSessionId,
95 sessionSequence: Schema.optional(NonNegativeInt),
9096 createdAt: IsoDateTime,
9197 threadId: Schema.optional(ProviderThreadId),
9298 turnId: Schema.optional(ProviderTurnId),
......@@ -100,6 +106,7 @@ export const ProviderRuntimeMessageCompletedEvent = Schema.Struct({
100106 eventId: EventId,
101107 provider: ProviderKind,
102108 sessionId: ProviderSessionId,
109 sessionSequence: Schema.optional(NonNegativeInt),
103110 createdAt: IsoDateTime,
104111 itemId: ProviderItemId,
105112 threadId: Schema.optional(ProviderThreadId),
......@@ -112,6 +119,7 @@ export const ProviderRuntimeToolStartedEvent = Schema.Struct({
112119 eventId: EventId,
113120 provider: ProviderKind,
114121 sessionId: ProviderSessionId,
122 sessionSequence: Schema.optional(NonNegativeInt),
115123 createdAt: IsoDateTime,
116124 threadId: Schema.optional(ProviderThreadId),
117125 turnId: Schema.optional(ProviderTurnId),
......@@ -127,6 +135,7 @@ export const ProviderRuntimeToolCompletedEvent = Schema.Struct({
127135 eventId: EventId,
128136 provider: ProviderKind,
129137 sessionId: ProviderSessionId,
138 sessionSequence: Schema.optional(NonNegativeInt),
130139 createdAt: IsoDateTime,
131140 threadId: Schema.optional(ProviderThreadId),
132141 turnId: Schema.optional(ProviderTurnId),
......@@ -142,6 +151,7 @@ export const ProviderRuntimeApprovalRequestedEvent = Schema.Struct({
142151 eventId: EventId,
143152 provider: ProviderKind,
144153 sessionId: ProviderSessionId,
154 sessionSequence: Schema.optional(NonNegativeInt),
145155 createdAt: IsoDateTime,
146156 threadId: Schema.optional(ProviderThreadId),
147157 turnId: Schema.optional(ProviderTurnId),
......@@ -158,6 +168,7 @@ export const ProviderRuntimeApprovalResolvedEvent = Schema.Struct({
158168 eventId: EventId,
159169 provider: ProviderKind,
160170 sessionId: ProviderSessionId,
171 sessionSequence: Schema.optional(NonNegativeInt),
161172 createdAt: IsoDateTime,
162173 threadId: Schema.optional(ProviderThreadId),
163174 turnId: Schema.optional(ProviderTurnId),
......@@ -173,6 +184,7 @@ export const ProviderRuntimeCheckpointCapturedEvent = Schema.Struct({
173184 eventId: EventId,
174185 provider: ProviderKind,
175186 sessionId: ProviderSessionId,
187 sessionSequence: Schema.optional(NonNegativeInt),
176188 createdAt: IsoDateTime,
177189 threadId: ProviderThreadId,
178190 turnId: Schema.optional(ProviderTurnId),
......@@ -187,6 +199,7 @@ export const ProviderRuntimeErrorEvent = Schema.Struct({
187199 eventId: EventId,
188200 provider: ProviderKind,
189201 sessionId: ProviderSessionId,
202 sessionSequence: Schema.optional(NonNegativeInt),
190203 createdAt: IsoDateTime,
191204 threadId: Schema.optional(ProviderThreadId),
192205 turnId: Schema.optional(ProviderTurnId),