From 04ae690bbb4e2a5dc3e702821117e32d80cef308 Mon Sep 17 00:00:00 2001 From: Antigravity Contributor Date: Fri, 30 Oct 2026 23:33:16 +0700 Subject: [PATCH] fix(api,runtime): honor session timestamps and add deterministic tie-break in shared-chat-dispatch (#207) --- apps/api/src/shared-chat-dispatch.ts | 12 +++- apps/api/tests/shared-chat-dispatch.test.ts | 63 +++++++++++++++++++++ packages/contracts/src/schemas.ts | 4 +- packages/runtime/src/store.ts | 6 +- 4 files changed, 81 insertions(+), 4 deletions(-) diff --git a/apps/api/src/shared-chat-dispatch.ts b/apps/api/src/shared-chat-dispatch.ts index 65c441d09..a27f09e69 100644 --- a/apps/api/src/shared-chat-dispatch.ts +++ b/apps/api/src/shared-chat-dispatch.ts @@ -65,6 +65,11 @@ function eligibleTargets(sessions: PaneCliSession[], panesForRoom: Map right.session.updatedAt.localeCompare(left.session.updatedAt)); + targets.sort( + (left, right) => + compareTimestampsDesc(left.session.updatedAt, right.session.updatedAt) || + compareTimestampsDesc(left.session.startedAt, right.session.startedAt) || + right.session.sessionId.localeCompare(left.session.sessionId) + ); return targets[0] ?? null; } diff --git a/apps/api/tests/shared-chat-dispatch.test.ts b/apps/api/tests/shared-chat-dispatch.test.ts index 473315155..976f65708 100644 --- a/apps/api/tests/shared-chat-dispatch.test.ts +++ b/apps/api/tests/shared-chat-dispatch.test.ts @@ -123,6 +123,69 @@ describe("pickSharedChatDispatchTarget", () => { const target = await pickSharedChatDispatchTarget(store, "cli:deepseek"); expect(target).not.toBeNull(); expect(target!.pane.id).toBe(newer.pane.id); + expect(target!.session.updatedAt).toBe("2026-08-15T10:00:00.000Z"); + }); + + it("breaks ties deterministically by startedAt and sessionId when updatedAt timestamps match", async () => { + const store = new InMemorySpaceStore(); + const paneA = createIdleDeepseekPane(store, "Deepseek A"); + const paneB = createIdleDeepseekPane(store, "Deepseek B"); + for (const [pane, sessionId] of [ + [paneA, "cli_session:alpha"], + [paneB, "cli_session:beta"] + ] as const) { + store.createPaneCliSession({ + sessionId, + paneId: pane.pane.id, + roomId: pane.room.id, + actorUserId: "user_owner", + runtimeId: "cli:deepseek", + providerId: "deepseek", + modelId: "deepseek-chat", + thinkingEffort: "medium", + command: "opencode", + args: [], + cwd: "/workspace", + pid: 4200, + status: "running", + statusReason: null, + exitCode: null, + isActive: true, + startedAt: "2026-08-15T08:00:00.000Z", + updatedAt: "2026-08-15T10:00:00.000Z", + endedAt: null + }); + } + + const target = await pickSharedChatDispatchTarget(store, "cli:deepseek"); + expect(target).not.toBeNull(); + expect(target!.pane.id).toBe(paneB.pane.id); + }); + + it("compares ISO timestamps by actual instant across timezone offsets", async () => { + const store = new InMemorySpaceStore(); + const olderWithOffset = createIdleDeepseekPane(store, "Deepseek Offset (+02:00)"); + const newerUtc = createIdleDeepseekPane(store, "Deepseek UTC (Z)"); + // 11:30+02:00 is 09:30Z, which is 30 minutes earlier than 10:00Z despite sorting later lexicographically + createRunningSession( + store, + olderWithOffset.room.id, + olderWithOffset.pane.id, + "cli:deepseek", + "2026-08-15T11:30:00.000+02:00" + ); + createRunningSession( + store, + newerUtc.room.id, + newerUtc.pane.id, + "cli:deepseek", + "2026-08-15T10:00:00.000Z" + ); + + const target = await pickSharedChatDispatchTarget(store, "cli:deepseek"); + expect(target).not.toBeNull(); + expect(target!.pane.id).toBe(newerUtc.pane.id); + expect(target!.session.updatedAt).toBe("2026-08-15T10:00:00.000Z"); }); it("only considers the requested runtime", async () => { diff --git a/packages/contracts/src/schemas.ts b/packages/contracts/src/schemas.ts index 696eb6d84..84fc5327b 100644 --- a/packages/contracts/src/schemas.ts +++ b/packages/contracts/src/schemas.ts @@ -2401,7 +2401,9 @@ export const createPaneCliSessionInputSchema = paneCliSessionSchema codexThreadId: codexThreadIdSchema.nullable().default(null), status: paneCliSessionStatusSchema.optional(), statusReason: z.string().trim().min(1).max(500).nullable().optional(), - isActive: z.boolean().optional() + isActive: z.boolean().optional(), + startedAt: isoDateTimeSchema.optional(), + updatedAt: isoDateTimeSchema.optional() }); export const updatePaneCliSessionInputSchema = z diff --git a/packages/runtime/src/store.ts b/packages/runtime/src/store.ts index cd6b28c2e..4c6e4397c 100644 --- a/packages/runtime/src/store.ts +++ b/packages/runtime/src/store.ts @@ -6422,6 +6422,8 @@ export class InMemorySpaceStore implements SpaceStore { throw new SpaceConflictError("CLI task revision does not belong to the selected logical task."); } } + const startedAt = parsed.startedAt ?? timestamp; + const updatedAt = parsed.updatedAt ?? startedAt; const session = paneCliSessionSchema.parse({ ...parsed, sessionId, @@ -6434,8 +6436,8 @@ export class InMemorySpaceStore implements SpaceStore { statusReason: parsed.statusReason ?? null, exitCode: null, isActive: parsed.isActive ?? true, - startedAt: timestamp, - updatedAt: timestamp, + startedAt, + updatedAt, endedAt: null }); if (session.isActive) {