Skip to content

Commit d5f82f4

Browse files
committed
fix: defer locked transcript update publishes
1 parent 8a87609 commit d5f82f4

2 files changed

Lines changed: 100 additions & 7 deletions

File tree

src/plugin-sdk/session-transcript-runtime.test.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import fs from "node:fs";
22
import os from "node:os";
33
import path from "node:path";
4-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
55
import { appendTranscriptEvent, upsertSessionEntry } from "../config/sessions/session-accessor.js";
66
import { loadSessionStore } from "../config/sessions/store.js";
77
import { withOwnedSessionTranscriptWrites } from "../config/sessions/transcript-write-context.js";
8+
import * as transcriptEvents from "../sessions/transcript-events.js";
89
import {
910
appendSessionTranscriptMessageByIdentity,
1011
formatSessionTranscriptMemoryHitKey,
@@ -26,6 +27,7 @@ describe("session transcript runtime SDK", () => {
2627
});
2728

2829
afterEach(() => {
30+
vi.restoreAllMocks();
2931
fs.rmSync(tempDir, { force: true, recursive: true });
3032
});
3133

@@ -221,6 +223,89 @@ describe("session transcript runtime SDK", () => {
221223
]);
222224
});
223225

226+
it("publishes queued locked updates after callback appends are visible", async () => {
227+
const scope = {
228+
agentId: "main",
229+
sessionFile: path.join(tempDir, "queued-publish-target.jsonl"),
230+
sessionId: "queued-publish-session",
231+
sessionKey: "agent:main:main",
232+
storePath,
233+
};
234+
const observedUpdates: Array<{
235+
callbackCompleted: boolean;
236+
fileText: string;
237+
update: unknown;
238+
}> = [];
239+
let callbackCompleted = false;
240+
const emitSpy = vi
241+
.spyOn(transcriptEvents, "emitSessionTranscriptUpdate")
242+
.mockImplementation((update) => {
243+
observedUpdates.push({
244+
callbackCompleted,
245+
fileText: fs.readFileSync(scope.sessionFile, "utf8"),
246+
update,
247+
});
248+
});
249+
250+
const result = await withSessionTranscriptWriteLock(scope, async (locked) => {
251+
await locked.appendMessage({
252+
message: {
253+
role: "assistant",
254+
content: [{ type: "text", text: "queued publish" }],
255+
timestamp: 1,
256+
},
257+
});
258+
await locked.publishUpdate({
259+
messageId: "message-from-callback",
260+
sessionKey: scope.sessionKey,
261+
});
262+
expect(emitSpy).not.toHaveBeenCalled();
263+
callbackCompleted = true;
264+
return "complete";
265+
});
266+
267+
expect(result).toBe("complete");
268+
expect(emitSpy).toHaveBeenCalledTimes(1);
269+
expect(observedUpdates).toEqual([
270+
{
271+
callbackCompleted: true,
272+
fileText: expect.stringContaining("queued publish"),
273+
update: expect.objectContaining({
274+
messageId: "message-from-callback",
275+
sessionFile: scope.sessionFile,
276+
sessionKey: scope.sessionKey,
277+
}),
278+
},
279+
]);
280+
});
281+
282+
it("does not publish queued locked updates when the callback throws", async () => {
283+
const scope = {
284+
agentId: "main",
285+
sessionFile: path.join(tempDir, "failed-queued-publish-target.jsonl"),
286+
sessionId: "failed-queued-publish-session",
287+
sessionKey: "agent:main:main",
288+
storePath,
289+
};
290+
const emitSpy = vi.spyOn(transcriptEvents, "emitSessionTranscriptUpdate");
291+
292+
await expect(
293+
withSessionTranscriptWriteLock(scope, async (locked) => {
294+
await locked.appendMessage({
295+
message: {
296+
role: "assistant",
297+
content: [{ type: "text", text: "durable but failed" }],
298+
timestamp: 1,
299+
},
300+
});
301+
await locked.publishUpdate({ sessionKey: scope.sessionKey });
302+
throw new Error("stop before commit");
303+
}),
304+
).rejects.toThrow("stop before commit");
305+
expect(emitSpy).not.toHaveBeenCalled();
306+
expect(fs.readFileSync(scope.sessionFile, "utf8")).toContain("durable but failed");
307+
});
308+
224309
it("round-trips encoded memory hit keys with opaque session ids", () => {
225310
const key = formatSessionTranscriptMemoryHitKey({
226311
agentId: "SECONDARY",

src/plugin-sdk/session-transcript-runtime.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,10 @@ export async function withSessionTranscriptWriteLock<T>(
203203
...params,
204204
sessionFile: storageTarget.sessionFile,
205205
};
206-
return await runSessionTranscriptAppendTransaction(
206+
// Treat publishUpdate as a post-commit callback: future transactional stores
207+
// must not expose updates when the scoped write callback fails.
208+
const queuedUpdates: Array<TranscriptUpdatePayload | undefined> = [];
209+
const result = await runSessionTranscriptAppendTransaction(
207210
{
208211
config: params.config,
209212
transcriptPath: storageTarget.sessionFile,
@@ -217,13 +220,18 @@ export async function withSessionTranscriptWriteLock<T>(
217220
...options,
218221
sessionId: params.sessionId,
219222
}),
220-
publishUpdate: (update) =>
221-
publishSessionTranscriptUpdateByIdentity({
222-
...boundScope,
223-
update,
224-
}),
223+
publishUpdate: async (update) => {
224+
queuedUpdates.push(update ? { ...update } : undefined);
225+
},
225226
}),
226227
);
228+
for (const update of queuedUpdates) {
229+
await publishSessionTranscriptUpdateByIdentity({
230+
...boundScope,
231+
update,
232+
});
233+
}
234+
return result;
227235
}
228236

229237
/**

0 commit comments

Comments
 (0)