Skip to content

Commit caeb344

Browse files
fix(sessions): fall back to reset archive for missing async transcripts
Co-authored-by: Masato Hoshino <[email protected]> Co-authored-by: Hu Yitao <[email protected]>
1 parent d68de3f commit caeb344

5 files changed

Lines changed: 170 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai
2626
- Channels and delivery: preserve account-scoped DM channel send policy, rich Telegram final replies, rich Telegram tables and lists, Telegram thread-create CLI remapping, Slack outbound `message_sent` hooks, contributed message-tool schema optionality, same-channel generated media completions, and channel chunking around surrogate pairs and Infinity limits. (#92788, #92679, #89421, #89943, #91137, #91246, #92735) Thanks @yetval, @obviyus, @spacegeologist, @rishitamrakar, @lundog, @TurboTheTurtle, and @yhterrance.
2727
- Discord: give generated auto-thread titles a 60-second timeout and 4,096-token reasoning-model output budget, clamped to the selected model output cap. (#64734) Thanks @hanamizuki.
2828
- Agent, cron, and Gateway runtime: mark active main sessions before restart shutdown aborts, pause yielded subagent runs whose terminal also signals abort, preserve yielded media completions, de-duplicate main-session heartbeat events, expose session identity in runtime prompts, reject unknown OpenAI agent selectors, keep generated media completions in WebChat, and require admin privileges for HTTP session/model override surfaces. (#91357, #92631, #92146, #91287, #92468, #92510, #91246, #92651, #92646) Thanks @ooiuuii, @openperf, @IWhatsskill, @ZengWen-DT, @zhangguiping-xydt, and @TurboTheTurtle.
29+
- Gateway/Sessions: let `chat.history` and HTTP session-history reads recover from the newest `.jsonl.reset.*` archive when the active transcript is missing, restoring reset-archived history snapshots and message counts. (#45003, #56131, #60409, #76134) Thanks @masatohoshino and @CadanHu.
2930
- Providers and model replay: preserve storeless OpenAI Responses replay compatibility, avoid eager tool streaming for Claude 4.5 in Copilot, honor profile auth for SecretRef model entries, bound model browsing, strip provider prefixes where runtimes need bare IDs, and surface nested embedding fetch failures. (#90706, #75393, #90686, #92247, #92627, #91218, #92628) Thanks @snowzlm, @Kailigithub, @rohitjavvadi, @samson910022, @liuhao1024, @bymle, and @mushuiyu886.
3031
- Memory, state, diagnostics, and config: split header-too-large embedding batches, keep QMD memory search enabled in transient mode, avoid SQLite WAL on NFS volumes, preserve recovery scheduling outside stuck-session warning backoff, and keep shell environment fallbacks contained in config write tests. (#92650, #92618, #92639, #91247, #92752) Thanks @mushuiyu886, @TurboTheTurtle, @849261680, and @gnanam1990.
3132
- UI/mobile/TUI: preserve dashboard session parent lineage, WebChat backscroll, reset soft command args, sidebar session picker interactivity, collapsed workspace files, resolved `/model` confirmation refs, and stale foreground iOS Gateway reconnects. (#90658, #92622, #91353, #92705, #92779, #92773, #92552) Thanks @luoyanglang, @TurboTheTurtle, @zhouhe-xydt, @NianJiuZst, @shakkernerd, @NarahariRaghava, and @Solvely-Colin.

src/gateway/session-transcript-files.fs.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,52 @@ export function resolveSessionTranscriptCandidates(
129129
return uniqueStrings(candidates);
130130
}
131131

132+
function resolveLatestResetArchiveForTranscript(transcriptPath: string): string | undefined {
133+
const base = path.basename(transcriptPath);
134+
if (!base.endsWith(".jsonl")) {
135+
return undefined;
136+
}
137+
const dir = path.dirname(transcriptPath);
138+
let latest: { name: string; timestamp: number } | undefined;
139+
try {
140+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
141+
if (!entry.isFile() || !entry.name.startsWith(`${base}.reset.`)) {
142+
continue;
143+
}
144+
const timestamp = parseSessionArchiveTimestamp(entry.name, "reset");
145+
if (timestamp == null) {
146+
continue;
147+
}
148+
if (
149+
!latest ||
150+
timestamp > latest.timestamp ||
151+
(timestamp === latest.timestamp && entry.name > latest.name)
152+
) {
153+
latest = { name: entry.name, timestamp };
154+
}
155+
}
156+
} catch {
157+
return undefined;
158+
}
159+
return latest ? path.join(dir, latest.name) : undefined;
160+
}
161+
162+
export function resolveSessionTranscriptResetArchiveCandidates(
163+
sessionId: string,
164+
storePath: string | undefined,
165+
sessionFile?: string,
166+
agentId?: string,
167+
): string[] {
168+
return uniqueStrings(
169+
resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile, agentId).flatMap(
170+
(candidate) => {
171+
const archive = resolveLatestResetArchiveForTranscript(candidate);
172+
return archive ? [archive] : [];
173+
},
174+
),
175+
);
176+
}
177+
132178
export function archiveFileOnDisk(filePath: string, reason: ArchiveFileReason): string {
133179
const ts = formatSessionArchiveTimestamp();
134180
const archived = `${filePath}.${reason}.${ts}`;

src/gateway/session-utils.fs.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,17 @@ function writeTranscript(tmpDir: string, sessionId: string, lines: unknown[]): s
7979
return transcriptPath;
8080
}
8181

82+
function writeResetArchive(
83+
tmpDir: string,
84+
sessionId: string,
85+
timestamp: string,
86+
lines: unknown[],
87+
): string {
88+
const archivePath = path.join(tmpDir, `${sessionId}.jsonl.reset.${timestamp}`);
89+
fs.writeFileSync(archivePath, lines.map((line) => JSON.stringify(line)).join("\n"), "utf-8");
90+
return archivePath;
91+
}
92+
8293
function appendBlockedUserMessageWithSessionManager(params: {
8394
sessionFile: string;
8495
originalText?: string;
@@ -782,6 +793,42 @@ describe("readSessionMessages", () => {
782793
}
783794
});
784795

796+
test("falls back to the latest reset archive when the active transcript is missing", async () => {
797+
const sessionId = "test-session-reset-archive-fallback";
798+
writeResetArchive(tmpDir, sessionId, "2026-02-16T22-26-33.000Z", [
799+
{ type: "session", version: 1, id: sessionId },
800+
{ message: { role: "assistant", content: "older archive" } },
801+
]);
802+
writeResetArchive(tmpDir, sessionId, "2026-02-16T22-26-34.000Z", [
803+
{ type: "session", version: 1, id: sessionId },
804+
{ message: { role: "user", content: "restored prompt" } },
805+
{ message: { role: "assistant", content: "restored archive" } },
806+
]);
807+
clearSessionTranscriptIndexCache();
808+
809+
const fullMessages = await readSessionMessagesAsync(sessionId, storePath, undefined, {
810+
mode: "full",
811+
reason: "test reset archive fallback",
812+
});
813+
expect(fullMessages.map((message) => (message as { content?: unknown }).content)).toEqual([
814+
"restored prompt",
815+
"restored archive",
816+
]);
817+
await expect(readSessionMessageCountAsync(sessionId, storePath)).resolves.toBe(2);
818+
819+
const recent = await readRecentSessionMessagesWithStatsAsync(sessionId, storePath, undefined, {
820+
maxMessages: 1,
821+
maxBytes: 2048,
822+
});
823+
expect(recent.totalMessages).toBe(2);
824+
expect(recent.messages).toHaveLength(1);
825+
expectMessageFields(recent.messages[0], {
826+
role: "assistant",
827+
content: "restored archive",
828+
openclaw: { seq: 2 },
829+
});
830+
});
831+
785832
test("keeps async active branch rows when imported parent links are incomplete", async () => {
786833
const sessionId = "test-session-tree-async-incomplete-parent";
787834
writeTranscript(tmpDir, sessionId, [

src/gateway/session-utils.fs.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ import { estimateStringChars, estimateTokensFromChars } from "../utils/cjk-chars
1616
import { stripInlineDirectiveTagsForDisplay } from "../utils/directive-tags.js";
1717
import { extractToolCallNames, hasToolCall } from "../utils/transcript-tools.js";
1818
import { stripEnvelope } from "./chat-sanitize.js";
19-
import { resolveSessionTranscriptCandidates } from "./session-transcript-files.fs.js";
19+
import {
20+
resolveSessionTranscriptCandidates,
21+
resolveSessionTranscriptResetArchiveCandidates,
22+
} from "./session-transcript-files.fs.js";
2023
import {
2124
readSessionTranscriptIndex,
2225
type IndexedTranscriptEntry,
@@ -153,9 +156,7 @@ export function readSessionMessages(
153156
storePath: string | undefined,
154157
sessionFile?: string,
155158
): unknown[] {
156-
const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile);
157-
158-
const filePath = candidates.find((p) => fs.existsSync(p));
159+
const filePath = findExistingTranscriptPath(sessionId, storePath, sessionFile);
159160
if (!filePath) {
160161
return [];
161162
}
@@ -820,6 +821,7 @@ export {
820821
archiveSessionTranscripts,
821822
cleanupArchivedSessionTranscripts,
822823
resolveSessionTranscriptCandidates,
824+
resolveSessionTranscriptResetArchiveCandidates,
823825
} from "./session-transcript-files.fs.js";
824826

825827
export function capArrayByJsonBytes<T>(
@@ -855,8 +857,7 @@ export function readSessionTitleFieldsFromTranscript(
855857
agentId?: string,
856858
opts?: { includeInterSession?: boolean },
857859
): SessionTitleFields {
858-
const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile, agentId);
859-
const filePath = candidates.find((p) => fs.existsSync(p));
860+
const filePath = findExistingTranscriptPath(sessionId, storePath, sessionFile, agentId);
860861
if (!filePath) {
861862
return { firstUserMessage: null, lastMessagePreview: null };
862863
}
@@ -927,8 +928,7 @@ export async function readSessionTitleFieldsFromTranscriptAsync(
927928
agentId?: string,
928929
opts?: { includeInterSession?: boolean },
929930
): Promise<SessionTitleFields> {
930-
const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile, agentId);
931-
const filePath = candidates.find((p) => fs.existsSync(p));
931+
const filePath = findExistingTranscriptPath(sessionId, storePath, sessionFile, agentId);
932932
if (!filePath) {
933933
return { firstUserMessage: null, lastMessagePreview: null };
934934
}
@@ -1065,7 +1065,15 @@ function findExistingTranscriptPath(
10651065
agentId?: string,
10661066
): string | null {
10671067
const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile, agentId);
1068-
return candidates.find((p) => fs.existsSync(p)) ?? null;
1068+
const activePath = candidates.find((p) => fs.existsSync(p));
1069+
if (activePath) {
1070+
return activePath;
1071+
}
1072+
return (
1073+
resolveSessionTranscriptResetArchiveCandidates(sessionId, storePath, sessionFile, agentId).find(
1074+
(p) => fs.existsSync(p),
1075+
) ?? null
1076+
);
10691077
}
10701078

10711079
function withOpenTranscriptFd<T>(filePath: string, read: (fd: number) => T | null): T | null {

src/gateway/sessions-history-http.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,26 @@ async function seedSession(params?: { text?: string }) {
6767
return { storePath };
6868
}
6969

70+
async function writeResetArchiveTranscript(params: {
71+
dir: string;
72+
sessionId: string;
73+
timestamp: string;
74+
texts: string[];
75+
}) {
76+
await fs.writeFile(
77+
path.join(params.dir, `${params.sessionId}.jsonl.reset.${params.timestamp}`),
78+
[
79+
JSON.stringify({ type: "session", version: 1, id: params.sessionId }),
80+
...params.texts.map((text) =>
81+
JSON.stringify({
82+
message: { role: "assistant", content: [{ type: "text", text }] },
83+
}),
84+
),
85+
].join("\n"),
86+
"utf-8",
87+
);
88+
}
89+
7090
function makeTranscriptAssistantMessage(params: {
7191
text: string;
7292
content?: AssistantMessage["content"];
@@ -369,6 +389,45 @@ describe("session history HTTP endpoints", () => {
369389
});
370390
});
371391

392+
test("returns session history from the latest reset archive when the active transcript is missing", async () => {
393+
const storePath = await createSessionStoreFile();
394+
const sessionId = "sess-reset-main";
395+
const dir = path.dirname(storePath);
396+
await writeResetArchiveTranscript({
397+
dir,
398+
sessionId,
399+
timestamp: "2026-02-16T22-26-33.000Z",
400+
texts: ["older archived history"],
401+
});
402+
await writeResetArchiveTranscript({
403+
dir,
404+
sessionId,
405+
timestamp: "2026-02-16T22-26-34.000Z",
406+
texts: ["restored first", "restored latest"],
407+
});
408+
await writeSessionStoreForTestAsync(storePath, {
409+
"agent:main:main": {
410+
sessionId,
411+
updatedAt: 1,
412+
},
413+
});
414+
415+
await withGatewayHarness(async (harness) => {
416+
const body = await readSessionHistoryBody(harness.port, "agent:main:main", {
417+
query: "?limit=1",
418+
});
419+
expect(body.sessionKey).toBe("agent:main:main");
420+
expect(body.messages?.map((message) => message.content?.[0]?.text)).toEqual([
421+
"restored latest",
422+
]);
423+
expect(body.hasMore).toBe(true);
424+
expect(body.nextCursor).toBe("2");
425+
expectOpenClawMetadata(body.messages?.[0]?.["__openclaw"], {
426+
seq: 2,
427+
});
428+
});
429+
});
430+
372431
test("matches direct REST history paths without trusting malformed Host headers", async () => {
373432
await seedSession({ text: "history with bad host" });
374433
await withGatewayHarness(async (harness) => {

0 commit comments

Comments
 (0)