Просмотр исходного кода

fix: serialize transcript seq allocation

fanghanjun 6 месяцев назад
Родитель
Сommit
7cf0da6087

+ 44 - 2
packages/core/src/__tests__/book-session-store.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect, it, beforeEach, afterEach } from "vitest";
-import { mkdtemp, rm } from "node:fs/promises";
+import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
 import { join } from "node:path";
 import { tmpdir } from "node:os";
 import {
@@ -14,7 +14,8 @@ import {
   SessionAlreadyMigratedError,
 } from "../interaction/book-session-store.js";
 import { createBookSession, appendBookSessionMessage } from "../interaction/session.js";
-import { mkdir, readFile, writeFile } from "node:fs/promises";
+import { legacyBookSessionPath, readTranscriptEvents, sessionsDir } from "../interaction/session-transcript.js";
+import { restoreAgentMessagesFromTranscript } from "../interaction/session-transcript-restore.js";
 
 describe("book-session-store", () => {
   let tempDir: string;
@@ -109,6 +110,34 @@ describe("book-session-store", () => {
         .toContain("request_committed");
     });
 
+    it("does not duplicate legacy messages when migration runs concurrently", async () => {
+      const legacy = createBookSession("book-a", "legacy-race");
+      const withMessages = {
+        ...legacy,
+        messages: [
+          { role: "user" as const, content: "old user", timestamp: legacy.createdAt },
+          { role: "assistant" as const, content: "old assistant", timestamp: legacy.createdAt + 1 },
+        ],
+      };
+      await mkdir(sessionsDir(tempDir), { recursive: true });
+      await writeFile(
+        legacyBookSessionPath(tempDir, "legacy-race"),
+        JSON.stringify(withMessages, null, 2),
+        "utf-8",
+      );
+
+      const [first, second] = await Promise.all([
+        loadBookSession(tempDir, "legacy-race"),
+        loadBookSession(tempDir, "legacy-race"),
+      ]);
+
+      expect(first?.messages).toHaveLength(2);
+      expect(second?.messages).toHaveLength(2);
+
+      const restored = await restoreAgentMessagesFromTranscript(tempDir, "legacy-race");
+      expect(restored.map((message) => message.role)).toEqual(["user", "assistant"]);
+    });
+
     it("createAndPersistBookSession 为新 session 写 JSONL 而不是 legacy JSON", async () => {
       const session = await createAndPersistBookSession(tempDir, "book-a", "123456-abcdef");
 
@@ -206,6 +235,19 @@ describe("book-session-store", () => {
       const result = await renameBookSession(tempDir, "nonexistent", "title");
       expect(result).toBeNull();
     });
+
+    it("assigns unique seq for concurrent metadata updates", async () => {
+      await createAndPersistBookSession(tempDir, "book-a", "metadata-race");
+
+      await Promise.all([
+        renameBookSession(tempDir, "metadata-race", "标题 A"),
+        renameBookSession(tempDir, "metadata-race", "标题 B"),
+      ]);
+
+      const events = await readTranscriptEvents(tempDir, "metadata-race");
+      expect(events.map((event) => event.seq)).toEqual([1, 2, 3]);
+      expect(new Set(events.map((event) => event.seq)).size).toBe(events.length);
+    });
   });
 
   describe("deleteBookSession", () => {

+ 21 - 0
packages/core/src/__tests__/session-transcript.test.ts

@@ -4,6 +4,7 @@ import { join } from "node:path";
 import { tmpdir } from "node:os";
 import {
   appendManualSessionMessages,
+  appendTranscriptEvents,
   appendTranscriptEvent,
   nextTranscriptSeq,
   readTranscriptEvents,
@@ -132,6 +133,26 @@ describe("session transcript codec", () => {
     await expect(nextTranscriptSeq(projectRoot, "s1")).resolves.toBe(8);
   });
 
+  it("atomically assigns unique seq for concurrent generated events", async () => {
+    await Promise.all(
+      Array.from({ length: 10 }, (_, index) =>
+        appendTranscriptEvents(projectRoot, "s-concurrent", ({ nextSeq }) => [{
+          type: "request_started",
+          version: 1,
+          sessionId: "s-concurrent",
+          requestId: `r-${index}`,
+          seq: nextSeq,
+          timestamp: 100 + index,
+          input: `input-${index}`,
+        }]),
+      ),
+    );
+
+    const events = await readTranscriptEvents(projectRoot, "s-concurrent");
+    expect(events).toHaveLength(10);
+    expect(events.map((event) => event.seq)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
+  });
+
   it("appendManualSessionMessages 写入 committed request 并保留 raw assistant message", async () => {
     await appendManualSessionMessages(projectRoot, "s1", [{
       role: "assistant",

+ 7 - 8
packages/core/src/interaction/book-session-store.ts

@@ -2,9 +2,8 @@ import { readdir, unlink } from "node:fs/promises";
 import { createBookSession } from "./session.js";
 import type { BookSession } from "./session.js";
 import {
-  appendTranscriptEvent,
+  appendTranscriptEvents,
   legacyBookSessionPath,
-  nextTranscriptSeq,
   readTranscriptEvents,
   sessionsDir,
   transcriptPath,
@@ -58,17 +57,17 @@ async function appendSessionCreatedEvent(
   projectRoot: string,
   session: BookSession,
 ): Promise<void> {
-  await appendTranscriptEvent(projectRoot, {
+  await appendTranscriptEvents(projectRoot, session.sessionId, ({ nextSeq }) => [{
     type: "session_created",
     version: 1,
     sessionId: session.sessionId,
-    seq: await nextTranscriptSeq(projectRoot, session.sessionId),
+    seq: nextSeq,
     timestamp: session.createdAt,
     bookId: session.bookId,
     title: session.title,
     createdAt: session.createdAt,
     updatedAt: session.updatedAt,
-  });
+  }]);
 }
 
 async function appendSessionMetadataUpdatedEvent(
@@ -80,16 +79,16 @@ async function appendSessionMetadataUpdatedEvent(
     readonly updatedAt: number;
   },
 ): Promise<void> {
-  await appendTranscriptEvent(projectRoot, {
+  await appendTranscriptEvents(projectRoot, sessionId, ({ nextSeq }) => [{
     type: "session_metadata_updated",
     version: 1,
     sessionId,
-    seq: await nextTranscriptSeq(projectRoot, sessionId),
+    seq: nextSeq,
     timestamp: metadata.updatedAt,
     updatedAt: metadata.updatedAt,
     ...("bookId" in metadata ? { bookId: metadata.bookId } : {}),
     ...("title" in metadata ? { title: metadata.title } : {}),
-  });
+  }]);
 }
 
 export async function persistBookSession(

+ 75 - 72
packages/core/src/interaction/session-transcript-legacy.ts

@@ -2,10 +2,8 @@ import { randomUUID } from "node:crypto";
 import { readFile } from "node:fs/promises";
 import { BookSessionSchema, type BookSession } from "./session.js";
 import {
-  appendTranscriptEvent,
+  appendTranscriptEvents,
   legacyBookSessionPath,
-  nextTranscriptSeq,
-  readTranscriptEvents,
 } from "./session-transcript.js";
 import type { MessageEvent } from "./session-transcript-schema.js";
 
@@ -34,77 +32,82 @@ export async function migrateLegacyBookSessionToTranscript(
   projectRoot: string,
   session: BookSession,
 ): Promise<void> {
-  const existing = await readTranscriptEvents(projectRoot, session.sessionId);
-  if (existing.length > 0) return;
+  await appendTranscriptEvents(projectRoot, session.sessionId, ({ events, nextSeq }) => {
+    if (events.length > 0) return [];
 
-  let seq = await nextTranscriptSeq(projectRoot, session.sessionId);
-  await appendTranscriptEvent(projectRoot, {
-    type: "session_created",
-    version: 1,
-    sessionId: session.sessionId,
-    seq: seq++,
-    timestamp: session.createdAt,
-    bookId: session.bookId,
-    title: session.title,
-    createdAt: session.createdAt,
-    updatedAt: session.updatedAt,
-  });
-
-  const requestId = `legacy-${session.sessionId}`;
-  await appendTranscriptEvent(projectRoot, {
-    type: "request_started",
-    version: 1,
-    sessionId: session.sessionId,
-    requestId,
-    seq: seq++,
-    timestamp: session.createdAt,
-    input: "",
-  });
+    const sessionCreatedSeq = nextSeq;
+    const requestStartedSeq = nextSeq + 1;
+    let messageSeq = nextSeq + 2;
+    const requestId = `legacy-${randomUUID()}`;
+    const transcriptEvents: MessageEvent[] = [];
+    let parentUuid: string | null = null;
 
-  let parentUuid: string | null = null;
-  for (const legacyMessage of session.messages) {
-    const uuid = randomUUID();
-    const message = legacyMessage.role === "assistant"
-      ? {
-          role: "assistant",
-          content: [{ type: "text", text: legacyMessage.content }],
-          api: "anthropic-messages",
-          provider: "legacy",
-          model: "unknown",
-          usage: EMPTY_USAGE,
-          stopReason: "stop",
-          timestamp: legacyMessage.timestamp,
-        }
-      : {
-          role: legacyMessage.role,
-          content: legacyMessage.content,
-          timestamp: legacyMessage.timestamp,
-        };
-    const event: MessageEvent = {
-      type: "message",
-      version: 1,
-      sessionId: session.sessionId,
-      requestId,
-      uuid,
-      parentUuid,
-      seq: seq++,
-      role: legacyMessage.role === "assistant" ? "assistant" : legacyMessage.role,
-      timestamp: legacyMessage.timestamp,
-      ...(legacyMessage.role === "assistant" && legacyMessage.thinking
-        ? { legacyDisplay: { thinking: legacyMessage.thinking } }
-        : {}),
-      message,
-    };
-    await appendTranscriptEvent(projectRoot, event);
-    parentUuid = uuid;
-  }
+    for (const legacyMessage of session.messages) {
+      const uuid = randomUUID();
+      const message = legacyMessage.role === "assistant"
+        ? {
+            role: "assistant",
+            content: [{ type: "text", text: legacyMessage.content }],
+            api: "anthropic-messages",
+            provider: "legacy",
+            model: "unknown",
+            usage: EMPTY_USAGE,
+            stopReason: "stop",
+            timestamp: legacyMessage.timestamp,
+          }
+        : {
+            role: legacyMessage.role,
+            content: legacyMessage.content,
+            timestamp: legacyMessage.timestamp,
+          };
+      transcriptEvents.push({
+        type: "message",
+        version: 1,
+        sessionId: session.sessionId,
+        requestId,
+        uuid,
+        parentUuid,
+        seq: messageSeq++,
+        role: legacyMessage.role === "assistant" ? "assistant" : legacyMessage.role,
+        timestamp: legacyMessage.timestamp,
+        ...(legacyMessage.role === "assistant" && legacyMessage.thinking
+          ? { legacyDisplay: { thinking: legacyMessage.thinking } }
+          : {}),
+        message,
+      });
+      parentUuid = uuid;
+    }
 
-  await appendTranscriptEvent(projectRoot, {
-    type: "request_committed",
-    version: 1,
-    sessionId: session.sessionId,
-    requestId,
-    seq: seq++,
-    timestamp: session.updatedAt,
+    return [
+      {
+        type: "session_created",
+        version: 1,
+        sessionId: session.sessionId,
+        seq: sessionCreatedSeq,
+        timestamp: session.createdAt,
+        bookId: session.bookId,
+        title: session.title,
+        createdAt: session.createdAt,
+        updatedAt: session.updatedAt,
+      },
+      {
+        type: "request_started",
+        version: 1,
+        sessionId: session.sessionId,
+        requestId,
+        seq: requestStartedSeq,
+        timestamp: session.createdAt,
+        input: "",
+      },
+      ...transcriptEvents,
+      {
+        type: "request_committed",
+        version: 1,
+        sessionId: session.sessionId,
+        requestId,
+        seq: messageSeq,
+        timestamp: session.updatedAt,
+      },
+    ];
   });
 }

+ 66 - 42
packages/core/src/interaction/session-transcript.ts

@@ -54,18 +54,39 @@ export async function appendTranscriptEvent(
   projectRoot: string,
   event: TranscriptEvent,
 ): Promise<void> {
-  const key = `${projectRoot}:${event.sessionId}`;
+  await appendTranscriptEvents(projectRoot, event.sessionId, () => [event]);
+}
+
+export async function appendTranscriptEvents(
+  projectRoot: string,
+  sessionId: string,
+  buildEvents: (context: {
+    readonly events: ReadonlyArray<TranscriptEvent>;
+    readonly nextSeq: number;
+  }) => ReadonlyArray<TranscriptEvent> | Promise<ReadonlyArray<TranscriptEvent>>,
+): Promise<TranscriptEvent[]> {
+  const key = `${projectRoot}:${sessionId}`;
   const previous = appendQueues.get(key) ?? Promise.resolve();
+  let result: TranscriptEvent[] = [];
+
   const next = previous.then(async () => {
+    const events = await readTranscriptEvents(projectRoot, sessionId);
+    const nextSeq = events.reduce((max, event) => Math.max(max, event.seq), 0) + 1;
+    const built = await buildEvents({ events, nextSeq });
+    result = built.map((event) => TranscriptEventSchema.parse(event));
+    if (result.length === 0) return;
+
     await mkdir(sessionsDir(projectRoot), { recursive: true });
     await appendFile(
-      transcriptPath(projectRoot, event.sessionId),
-      `${JSON.stringify(event)}\n`,
+      transcriptPath(projectRoot, sessionId),
+      `${result.map((event) => JSON.stringify(event)).join("\n")}\n`,
       "utf-8",
     );
   });
+
   appendQueues.set(key, next.catch(() => undefined));
   await next;
+  return result;
 }
 
 function transcriptRoleForMessage(message: AgentMessage): TranscriptRole | null {
@@ -117,49 +138,52 @@ export async function appendManualSessionMessages(
   if (persistedMessages.length === 0) return;
 
   const requestId = randomUUID();
-  let seq = await nextTranscriptSeq(projectRoot, sessionId);
-  await appendTranscriptEvent(projectRoot, {
-    type: "request_started",
-    version: 1,
-    sessionId,
-    requestId,
-    seq: seq++,
-    timestamp: Date.now(),
-    input,
-  });
-
-  let parentUuid: string | null = null;
-  let lastAssistantUuid: string | null = null;
-  for (const { message, role } of persistedMessages) {
-    const uuid = randomUUID();
-    const isToolResult = role === "toolResult";
-    const toolCallId = toolCallIdForMessage(message);
-    await appendTranscriptEvent(projectRoot, {
-      type: "message",
+  await appendTranscriptEvents(projectRoot, sessionId, ({ nextSeq }) => {
+    let seq = nextSeq;
+    const events: TranscriptEvent[] = [{
+      type: "request_started",
       version: 1,
       sessionId,
       requestId,
-      uuid,
-      parentUuid: isToolResult && lastAssistantUuid ? lastAssistantUuid : parentUuid,
       seq: seq++,
-      role,
-      timestamp: messageTimestamp(message),
-      ...(toolCallId ? { toolCallId } : {}),
-      ...(isToolResult && lastAssistantUuid
-        ? { sourceToolAssistantUuid: lastAssistantUuid }
-        : {}),
-      message,
-    });
-    if (role === "assistant") lastAssistantUuid = uuid;
-    parentUuid = uuid;
-  }
+      timestamp: Date.now(),
+      input,
+    }];
+
+    let parentUuid: string | null = null;
+    let lastAssistantUuid: string | null = null;
+    for (const { message, role } of persistedMessages) {
+      const uuid = randomUUID();
+      const isToolResult = role === "toolResult";
+      const toolCallId = toolCallIdForMessage(message);
+      events.push({
+        type: "message",
+        version: 1,
+        sessionId,
+        requestId,
+        uuid,
+        parentUuid: isToolResult && lastAssistantUuid ? lastAssistantUuid : parentUuid,
+        seq: seq++,
+        role,
+        timestamp: messageTimestamp(message),
+        ...(toolCallId ? { toolCallId } : {}),
+        ...(isToolResult && lastAssistantUuid
+          ? { sourceToolAssistantUuid: lastAssistantUuid }
+          : {}),
+        message,
+      });
+      if (role === "assistant") lastAssistantUuid = uuid;
+      parentUuid = uuid;
+    }
 
-  await appendTranscriptEvent(projectRoot, {
-    type: "request_committed",
-    version: 1,
-    sessionId,
-    requestId,
-    seq,
-    timestamp: Date.now(),
+    events.push({
+      type: "request_committed",
+      version: 1,
+      sessionId,
+      requestId,
+      seq,
+      timestamp: Date.now(),
+    });
+    return events;
   });
 }