Skip to content

Commit ceee4c6

Browse files
committed
fix(sessions): mark transcript rewrites in registry
1 parent e22e857 commit ceee4c6

8 files changed

Lines changed: 282 additions & 50 deletions

File tree

src/agents/cli-session.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,13 @@ export function clearCliSession(entry: SessionEntry, provider: string): void {
138138
}
139139
}
140140

141+
type MutableCliSessionFields = Pick<
142+
SessionEntry,
143+
"cliSessionBindings" | "cliSessionIds" | "claudeCliSessionId"
144+
>;
145+
141146
/** Remove every CLI session binding from a session entry. */
142-
export function clearAllCliSessions(entry: SessionEntry): void {
147+
export function clearAllCliSessions(entry: Partial<MutableCliSessionFields>): void {
143148
entry.cliSessionBindings = undefined;
144149
entry.cliSessionIds = undefined;
145150
entry.claudeCliSessionId = undefined;

src/agents/command/session.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
import { resolveSessionIdMatchSelection } from "../../sessions/session-id-resolution.js";
3838
import { listAgentIds, resolveDefaultAgentId } from "../agent-scope.js";
3939
import { clearBootstrapSnapshotOnSessionRollover } from "../bootstrap-cache.js";
40+
import { clearAllCliSessions } from "../cli-session.js";
4041

4142
/** Resolved command session identity plus backing store metadata. */
4243
export type SessionResolution = {
@@ -62,7 +63,7 @@ function clearRotatedTerminalMainSessionMetadata(
6263
if (!entry) {
6364
return undefined;
6465
}
65-
return {
66+
const next = {
6667
...entry,
6768
sessionFile: undefined,
6869
status: undefined,
@@ -73,6 +74,8 @@ function clearRotatedTerminalMainSessionMetadata(
7374
sessionStartedAt: undefined,
7475
lastInteractionAt: undefined,
7576
};
77+
clearAllCliSessions(next);
78+
return next;
7679
}
7780

7881
type SessionIdMatchSet = {

src/commands/agent.session.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,15 @@ describe("agent session resolution", () => {
189189
lastInteractionAt: registryUpdatedAt - 30_000,
190190
startedAt: registryUpdatedAt - 1_000,
191191
endedAt: registryUpdatedAt - 100,
192+
cliSessionBindings: {
193+
"claude-cli": { sessionId: "old-claude-cli-session" },
194+
"codex-cli": { sessionId: "old-codex-cli-session" },
195+
},
196+
cliSessionIds: {
197+
"claude-cli": "old-claude-cli-session",
198+
"codex-cli": "old-codex-cli-session",
199+
},
200+
claudeCliSessionId: "old-claude-cli-session",
192201
},
193202
});
194203
const cfg = mockConfig(home, store);
@@ -205,6 +214,9 @@ describe("agent session resolution", () => {
205214
expect(resolution.sessionEntry?.runtimeMs).toBeUndefined();
206215
expect(resolution.sessionEntry?.sessionStartedAt).toBeUndefined();
207216
expect(resolution.sessionEntry?.lastInteractionAt).toBeUndefined();
217+
expect(resolution.sessionEntry?.cliSessionBindings).toBeUndefined();
218+
expect(resolution.sessionEntry?.cliSessionIds).toBeUndefined();
219+
expect(resolution.sessionEntry?.claudeCliSessionId).toBeUndefined();
208220

209221
const sessionStore = {
210222
[scenario.sessionKey]: resolution.sessionEntry!,
@@ -245,6 +257,9 @@ describe("agent session resolution", () => {
245257
expect(persisted?.startedAt).toBeUndefined();
246258
expect(persisted?.endedAt).toBeUndefined();
247259
expect(persisted?.runtimeMs).toBeUndefined();
260+
expect(persisted?.cliSessionBindings).toBeUndefined();
261+
expect(persisted?.cliSessionIds).toBeUndefined();
262+
expect(persisted?.claudeCliSessionId).toBeUndefined();
248263
expect(persisted?.sessionStartedAt).toBeGreaterThan(registryUpdatedAt);
249264
expect(persisted?.lastInteractionAt).toBeGreaterThan(registryUpdatedAt);
250265
});

src/config/sessions/transcript.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,7 @@ export async function appendExactAssistantMessageToSessionTranscript(params: {
302302
let transcriptMarkerUpdatedAt: number | undefined;
303303
const result = await runWithOwnedSessionTranscriptWriteLock<SessionTranscriptAppendResult>(
304304
{ sessionFile, sessionKey: resolved.normalizedKey },
305-
async () => {
305+
async (): Promise<SessionTranscriptAppendResult> => {
306306
const explicitIdempotencyKey =
307307
params.idempotencyKey ??
308308
((params.message as { idempotencyKey?: unknown }).idempotencyKey as string | undefined);

src/gateway/server-methods/agent.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -760,6 +760,15 @@ describe("gateway agent handler", () => {
760760
startedAt: now - 20_000,
761761
endedAt: now - 15_000,
762762
runtimeMs: 5_000,
763+
cliSessionBindings: {
764+
"claude-cli": { sessionId: "old-claude-cli-session" },
765+
"codex-cli": { sessionId: "old-codex-cli-session" },
766+
},
767+
cliSessionIds: {
768+
"claude-cli": "old-claude-cli-session",
769+
"codex-cli": "old-codex-cli-session",
770+
},
771+
claudeCliSessionId: "old-claude-cli-session",
763772
},
764773
canonicalKey: "agent:main:main",
765774
});
@@ -776,11 +785,80 @@ describe("gateway agent handler", () => {
776785
expect(capturedEntry?.endedAt).toBeUndefined();
777786
expect(capturedEntry?.runtimeMs).toBeUndefined();
778787
expect(capturedEntry?.sessionFile).toBeUndefined();
788+
expect(capturedEntry?.cliSessionBindings).toBeUndefined();
789+
expect(capturedEntry?.cliSessionIds).toBeUndefined();
790+
expect(capturedEntry?.claudeCliSessionId).toBeUndefined();
779791
},
780792
);
781793
},
782794
);
783795

796+
it("reuses terminal main sessions when the fresh store row has the transcript marker", async () => {
797+
const now = Date.parse("2026-05-18T09:47:30.000Z");
798+
vi.useFakeTimers({ toFake: ["Date"] });
799+
dateOnlyFakeClockActive = true;
800+
vi.setSystemTime(now);
801+
802+
await withTempDir({ prefix: "openclaw-gateway-terminal-main-fresh-marker-" }, async (root) => {
803+
const sessionsDir = `${root}/sessions`;
804+
await fs.mkdir(sessionsDir, { recursive: true });
805+
const sessionFile = "terminal-main-session.jsonl";
806+
const transcriptPath = `${sessionsDir}/${sessionFile}`;
807+
await fs.writeFile(
808+
transcriptPath,
809+
`${JSON.stringify({ type: "session", id: "terminal-main-session" })}\n`,
810+
"utf8",
811+
);
812+
await fs.utimes(transcriptPath, new Date(now - 1_000), new Date(now - 1_000));
813+
const staleEntry = {
814+
sessionId: "terminal-main-session",
815+
sessionFile,
816+
status: "done",
817+
updatedAt: now - 10_000,
818+
cliSessionBindings: {
819+
"claude-cli": { sessionId: "existing-claude-cli-session" },
820+
},
821+
cliSessionIds: {
822+
"claude-cli": "existing-claude-cli-session",
823+
},
824+
claudeCliSessionId: "existing-claude-cli-session",
825+
};
826+
mocks.loadSessionEntry.mockReturnValue({
827+
cfg: {},
828+
storePath: `${sessionsDir}/sessions.json`,
829+
entry: staleEntry,
830+
canonicalKey: "agent:main:main",
831+
});
832+
let capturedEntry: Record<string, unknown> | undefined;
833+
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
834+
const store = {
835+
"agent:main:main": {
836+
...staleEntry,
837+
updatedAt: now,
838+
},
839+
};
840+
const result = await updater(store);
841+
capturedEntry = result as Record<string, unknown>;
842+
return result;
843+
});
844+
mocks.agentCommand.mockResolvedValue({
845+
payloads: [{ text: "ok" }],
846+
meta: { durationMs: 100 },
847+
});
848+
849+
await runMainAgent("hi", "test-idem-terminal-main-fresh-marker");
850+
851+
const call = await waitForAgentCommandCall<{ sessionId?: string }>();
852+
expect(call.sessionId).toBe("terminal-main-session");
853+
expect(capturedEntry?.sessionId).toBe("terminal-main-session");
854+
expect(capturedEntry?.sessionFile).toBe(sessionFile);
855+
expect(capturedEntry?.cliSessionIds).toEqual({
856+
"claude-cli": "existing-claude-cli-session",
857+
});
858+
expect(capturedEntry?.claudeCliSessionId).toBe("existing-claude-cli-session");
859+
});
860+
});
861+
784862
it("honors explicit gateway session-id resumes for terminal main rows", async () => {
785863
const now = Date.parse("2026-05-18T09:48:00.000Z");
786864
vi.useFakeTimers({ toFake: ["Date"] });

src/gateway/server-methods/agent.ts

Lines changed: 96 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
consumeExecApprovalFollowupRuntimeHandoff,
3131
parseExecApprovalFollowupApprovalId,
3232
} from "../../agents/bash-tools.exec-approval-followup-state.js";
33+
import { clearAllCliSessions } from "../../agents/cli-session.js";
3334
import type { AgentCommandOpts } from "../../agents/command/types.js";
3435
import { isTimeoutError } from "../../agents/failover-error.js";
3536
import {
@@ -51,7 +52,7 @@ import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
5152
import { agentCommandFromIngress } from "../../commands/agent.js";
5253
import {
5354
evaluateSessionFreshness,
54-
hasTerminalMainSessionTranscriptNewerThanRegistry,
55+
hasTerminalMainSessionTranscriptNewerThanRegistrySync,
5556
mergeSessionEntry,
5657
resolveTerminalMainSessionTranscriptRegistryCheck,
5758
resolveChannelResetConfig,
@@ -1636,28 +1637,33 @@ export const agentHandlers: GatewayRequestHandlers = {
16361637
agentId: canonicalSessionAgentId,
16371638
})
16381639
: undefined;
1639-
const freshness = entry
1640+
let freshness = entry
16401641
? evaluateSessionFreshness({
16411642
updatedAt: entry.updatedAt,
16421643
...lifecycleTimestamps,
16431644
now,
16441645
policy: resetPolicy,
16451646
})
16461647
: undefined;
1647-
let failedSessionTranscriptMissing = false;
1648-
if (entry?.status === "failed" && entry.sessionId?.trim()) {
1648+
const resolveFailedSessionTranscriptMissingForEntry = (
1649+
candidateEntry: SessionEntry | undefined,
1650+
) => {
1651+
if (candidateEntry?.status !== "failed" || !candidateEntry.sessionId?.trim()) {
1652+
return false;
1653+
}
16491654
try {
16501655
const sessionPathOpts = resolveSessionFilePathOptions({
16511656
storePath,
16521657
agentId: canonicalSessionAgentId,
16531658
});
1654-
failedSessionTranscriptMissing = !existsSync(
1655-
resolveSessionFilePath(entry.sessionId, entry, sessionPathOpts),
1659+
return !existsSync(
1660+
resolveSessionFilePath(candidateEntry.sessionId, candidateEntry, sessionPathOpts),
16561661
);
16571662
} catch {
1658-
failedSessionTranscriptMissing = true;
1663+
return true;
16591664
}
1660-
}
1665+
};
1666+
const failedSessionTranscriptMissing = resolveFailedSessionTranscriptMissingForEntry(entry);
16611667
const mainSessionKeyForRequest = resolveAgentMainSessionKey({
16621668
cfg: cfgLocal,
16631669
agentId: canonicalSessionAgentId,
@@ -1680,7 +1686,7 @@ export const agentHandlers: GatewayRequestHandlers = {
16801686
storePath,
16811687
});
16821688
const terminalMainTranscriptNewerThanRegistry = terminalMainTranscriptCheck
1683-
? await hasTerminalMainSessionTranscriptNewerThanRegistry({
1689+
? hasTerminalMainSessionTranscriptNewerThanRegistrySync({
16841690
entry,
16851691
sessionScope: cfgLocal.session?.scope,
16861692
sessionKey: canonicalKey,
@@ -1694,7 +1700,7 @@ export const agentHandlers: GatewayRequestHandlers = {
16941700
(freshness?.fresh ?? false) &&
16951701
!failedSessionTranscriptMissing &&
16961702
!terminalMainTranscriptNewerThanRegistry;
1697-
const usableRequestedSessionId =
1703+
let usableRequestedSessionId =
16981704
requestedSessionId && (!entry?.sessionId || canReuseSession)
16991705
? requestedSessionId
17001706
: undefined;
@@ -1705,7 +1711,7 @@ export const agentHandlers: GatewayRequestHandlers = {
17051711
!entry ||
17061712
(!canReuseSession && !usableRequestedSessionId) ||
17071713
Boolean(usableRequestedSessionId && entry?.sessionId !== usableRequestedSessionId);
1708-
const rotatedSessionId = Boolean(entry?.sessionId && entry.sessionId !== sessionId);
1714+
let rotatedSessionId = Boolean(entry?.sessionId && entry.sessionId !== sessionId);
17091715
const touchInteraction = !isSystemGatewayRun && !request.internalEvents?.length;
17101716
const sessionAgent = canonicalSessionAgentId;
17111717
type AgentSessionPatchBuild = {
@@ -1715,6 +1721,10 @@ export const agentHandlers: GatewayRequestHandlers = {
17151721
groupChannel: string | undefined;
17161722
groupSpace: string | undefined;
17171723
freshSessionRotatedSinceLoad: boolean;
1724+
isNewSession: boolean;
1725+
rotatedSessionId: boolean;
1726+
usableRequestedSessionId: string | undefined;
1727+
freshness: typeof freshness;
17181728
};
17191729
const requestDeliveryHint = normalizeDeliveryContext({
17201730
channel: request.channel?.trim(),
@@ -1807,12 +1817,69 @@ export const agentHandlers: GatewayRequestHandlers = {
18071817
const freshSessionRotatedSinceLoad = Boolean(
18081818
entry?.sessionId && freshEntry?.sessionId && freshEntry.sessionId !== entry.sessionId,
18091819
);
1810-
const patchSessionId = freshSessionRotatedSinceLoad ? freshEntry?.sessionId : sessionId;
1811-
const shouldClearRotatedState = rotatedSessionId && !freshSessionRotatedSinceLoad;
1820+
const freshLifecycleTimestamps = freshEntry
1821+
? resolveSessionLifecycleTimestamps({
1822+
entry: freshEntry,
1823+
storePath,
1824+
agentId: sessionAgent,
1825+
})
1826+
: undefined;
1827+
const freshFreshness = freshEntry
1828+
? evaluateSessionFreshness({
1829+
updatedAt: freshEntry.updatedAt,
1830+
...freshLifecycleTimestamps,
1831+
now,
1832+
policy: resetPolicy,
1833+
})
1834+
: undefined;
1835+
const freshRequestedSessionMatchesEntry = Boolean(
1836+
requestedSessionId && freshEntry?.sessionId?.trim() === requestedSessionId,
1837+
);
1838+
const freshTerminalMainTranscriptNewerThanRegistry =
1839+
isSystemGatewayRun || freshRequestedSessionMatchesEntry
1840+
? false
1841+
: hasTerminalMainSessionTranscriptNewerThanRegistrySync({
1842+
entry: freshEntry,
1843+
sessionScope: cfgLocal.session?.scope,
1844+
sessionKey: canonicalKey,
1845+
agentId: sessionAgent,
1846+
mainKey: cfgLocal.session?.mainKey,
1847+
storePath,
1848+
});
1849+
const freshFailedSessionTranscriptMissing =
1850+
resolveFailedSessionTranscriptMissingForEntry(freshEntry);
1851+
const freshCanReuseSession =
1852+
Boolean(freshEntry?.sessionId) &&
1853+
(freshFreshness?.fresh ?? false) &&
1854+
!freshFailedSessionTranscriptMissing &&
1855+
!freshTerminalMainTranscriptNewerThanRegistry;
1856+
const freshUsableRequestedSessionId =
1857+
requestedSessionId && (!freshEntry?.sessionId || freshCanReuseSession)
1858+
? requestedSessionId
1859+
: undefined;
1860+
const freshSessionId = freshUsableRequestedSessionId
1861+
? freshUsableRequestedSessionId
1862+
: ((freshCanReuseSession ? freshEntry?.sessionId : undefined) ?? sessionId);
1863+
const freshIsNewSession =
1864+
!freshEntry ||
1865+
(!freshCanReuseSession && !freshUsableRequestedSessionId) ||
1866+
Boolean(
1867+
freshUsableRequestedSessionId &&
1868+
freshEntry?.sessionId !== freshUsableRequestedSessionId,
1869+
);
1870+
const freshRotatedSessionId = Boolean(
1871+
freshEntry?.sessionId && freshEntry.sessionId !== freshSessionId,
1872+
);
1873+
const patchSessionId = freshSessionRotatedSinceLoad
1874+
? freshEntry?.sessionId
1875+
: freshSessionId;
1876+
const shouldClearRotatedState = freshRotatedSessionId && !freshSessionRotatedSinceLoad;
18121877
const patch: Partial<SessionEntry> = {
18131878
sessionId: patchSessionId,
18141879
updatedAt: now,
1815-
...(isNewSession && !freshSessionRotatedSinceLoad ? { sessionStartedAt: now } : {}),
1880+
...(freshIsNewSession && !freshSessionRotatedSinceLoad
1881+
? { sessionStartedAt: now }
1882+
: {}),
18161883
...(touchInteraction ? { lastInteractionAt: now } : {}),
18171884
...(effectiveDeliveryFields.route ? { route: effectiveDeliveryFields.route } : {}),
18181885
...(effectiveDeliveryFields.deliveryContext
@@ -1846,16 +1913,27 @@ export const agentHandlers: GatewayRequestHandlers = {
18461913
}
18471914
: {}),
18481915
};
1916+
if (shouldClearRotatedState) {
1917+
clearAllCliSessions(patch);
1918+
}
18491919
return {
18501920
patch,
18511921
spawnedBy: freshSpawnedBy,
18521922
groupId: nextGroup.groupId,
18531923
groupChannel: nextGroup.groupChannel,
18541924
groupSpace: nextGroup.groupSpace,
18551925
freshSessionRotatedSinceLoad,
1926+
isNewSession: freshIsNewSession,
1927+
rotatedSessionId: freshRotatedSessionId,
1928+
usableRequestedSessionId: freshUsableRequestedSessionId,
1929+
freshness: freshFreshness,
18561930
};
18571931
};
18581932
let patchBuild = buildSessionPatch(entry);
1933+
isNewSession = patchBuild.isNewSession;
1934+
rotatedSessionId = patchBuild.rotatedSessionId;
1935+
usableRequestedSessionId = patchBuild.usableRequestedSessionId;
1936+
freshness = patchBuild.freshness;
18591937
sessionEntry = mergeSessionEntry(entry, patchBuild.patch);
18601938
resolvedSessionId = sessionEntry?.sessionId ?? sessionId;
18611939
const canonicalSessionKey = canonicalKey;
@@ -1961,6 +2039,10 @@ export const agentHandlers: GatewayRequestHandlers = {
19612039
return;
19622040
}
19632041
}
2042+
isNewSession = patchBuild.isNewSession;
2043+
rotatedSessionId = patchBuild.rotatedSessionId;
2044+
usableRequestedSessionId = patchBuild.usableRequestedSessionId;
2045+
freshness = patchBuild.freshness;
19642046
spawnedByValue = patchBuild.spawnedBy;
19652047
resolvedGroupId = patchBuild.groupId;
19662048
resolvedGroupChannel = patchBuild.groupChannel;

0 commit comments

Comments
 (0)