Skip to content

Commit 0d93874

Browse files
committed
refactor(sessions): clarify duplicate session resolution
1 parent a0c6ea5 commit 0d93874

4 files changed

Lines changed: 205 additions & 93 deletions

File tree

src/agents/command/session.resolve-session-key.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,35 @@ describe("resolveSessionKeyForRequest", () => {
6464
expect(result.sessionStore).toBe(mainStore);
6565
expect(result.storePath).toBe("/stores/main.json");
6666
});
67+
68+
it("keeps a cross-store structural winner over a newer local fuzzy duplicate", () => {
69+
const mainStore = {
70+
"agent:main:main": { sessionId: "sid", updatedAt: 20 },
71+
} satisfies Record<string, SessionEntry>;
72+
const otherStore = {
73+
"agent:other:acp:sid": { sessionId: "sid", updatedAt: 10 },
74+
} satisfies Record<string, SessionEntry>;
75+
hoisted.loadSessionStoreMock.mockImplementation((storePath) => {
76+
if (storePath === "/stores/main.json") {
77+
return mainStore;
78+
}
79+
if (storePath === "/stores/other.json") {
80+
return otherStore;
81+
}
82+
return {};
83+
});
84+
85+
const result = resolveSessionKeyForRequest({
86+
cfg: {
87+
session: {
88+
store: "/stores/{agentId}.json",
89+
},
90+
} satisfies OpenClawConfig,
91+
sessionId: "sid",
92+
});
93+
94+
expect(result.sessionKey).toBe("agent:other:acp:sid");
95+
expect(result.sessionStore).toBe(otherStore);
96+
expect(result.storePath).toBe("/stores/other.json");
97+
});
6798
});

src/agents/command/session.ts

Lines changed: 66 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
type SessionEntry,
2121
} from "../../config/sessions.js";
2222
import { normalizeMainKey } from "../../routing/session-key.js";
23-
import { resolvePreferredSessionKeyForSessionIdMatches } from "../../sessions/session-id-resolution.js";
23+
import { resolveSessionIdMatchSelection } from "../../sessions/session-id-resolution.js";
2424
import { listAgentIds } from "../agent-scope.js";
2525
import { clearBootstrapSnapshotOnSessionRollover } from "../bootstrap-cache.js";
2626

@@ -41,6 +41,56 @@ type SessionKeyResolution = {
4141
storePath: string;
4242
};
4343

44+
type SessionIdMatchSet = {
45+
matches: Array<[string, SessionEntry]>;
46+
primaryStoreMatches: Array<[string, SessionEntry]>;
47+
storeByKey: Map<string, SessionKeyResolution>;
48+
};
49+
50+
function collectSessionIdMatchesForRequest(opts: {
51+
cfg: OpenClawConfig;
52+
sessionStore: Record<string, SessionEntry>;
53+
storePath: string;
54+
storeAgentId?: string;
55+
sessionId: string;
56+
}): SessionIdMatchSet {
57+
const matches: Array<[string, SessionEntry]> = [];
58+
const primaryStoreMatches: Array<[string, SessionEntry]> = [];
59+
const storeByKey = new Map<string, SessionKeyResolution>();
60+
61+
const addMatches = (
62+
candidateStore: Record<string, SessionEntry>,
63+
candidateStorePath: string,
64+
options?: { primary?: boolean },
65+
): void => {
66+
for (const [candidateKey, candidateEntry] of Object.entries(candidateStore)) {
67+
if (candidateEntry?.sessionId !== opts.sessionId) {
68+
continue;
69+
}
70+
matches.push([candidateKey, candidateEntry]);
71+
if (options?.primary) {
72+
primaryStoreMatches.push([candidateKey, candidateEntry]);
73+
}
74+
storeByKey.set(candidateKey, {
75+
sessionKey: candidateKey,
76+
sessionStore: candidateStore,
77+
storePath: candidateStorePath,
78+
});
79+
}
80+
};
81+
82+
addMatches(opts.sessionStore, opts.storePath, { primary: true });
83+
for (const agentId of listAgentIds(opts.cfg)) {
84+
if (agentId === opts.storeAgentId) {
85+
continue;
86+
}
87+
const candidateStorePath = resolveStorePath(opts.cfg.session?.store, { agentId });
88+
addMatches(loadSessionStore(candidateStorePath), candidateStorePath);
89+
}
90+
91+
return { matches, primaryStoreMatches, storeByKey };
92+
}
93+
4494
export function resolveSessionKeyForRequest(opts: {
4595
cfg: OpenClawConfig;
4696
to?: string;
@@ -75,51 +125,24 @@ export function resolveSessionKeyForRequest(opts: {
75125
!explicitSessionKey &&
76126
(!sessionKey || sessionStore[sessionKey]?.sessionId !== opts.sessionId)
77127
) {
78-
const matches: Array<[string, SessionEntry]> = [];
79-
const primaryStoreMatches: Array<[string, SessionEntry]> = [];
80-
const storeByKey = new Map<string, SessionKeyResolution>();
81-
const addMatches = (
82-
candidateStore: Record<string, SessionEntry>,
83-
candidateStorePath: string,
84-
options?: { primary?: boolean },
85-
): void => {
86-
for (const [candidateKey, candidateEntry] of Object.entries(candidateStore)) {
87-
if (candidateEntry?.sessionId !== opts.sessionId) {
88-
continue;
89-
}
90-
matches.push([candidateKey, candidateEntry]);
91-
if (options?.primary) {
92-
primaryStoreMatches.push([candidateKey, candidateEntry]);
93-
}
94-
storeByKey.set(candidateKey, {
95-
sessionKey: candidateKey,
96-
sessionStore: candidateStore,
97-
storePath: candidateStorePath,
98-
});
99-
}
100-
};
101-
102-
addMatches(sessionStore, storePath, { primary: true });
103-
const allAgentIds = listAgentIds(opts.cfg);
104-
for (const agentId of allAgentIds) {
105-
if (agentId === storeAgentId) {
106-
continue;
107-
}
108-
const altStorePath = resolveStorePath(sessionCfg?.store, { agentId });
109-
const altStore = loadSessionStore(altStorePath);
110-
addMatches(altStore, altStorePath);
111-
}
112-
113-
const preferredKey = resolvePreferredSessionKeyForSessionIdMatches(matches, opts.sessionId);
114-
const currentStorePreferredKey =
115-
preferredKey ??
116-
resolvePreferredSessionKeyForSessionIdMatches(primaryStoreMatches, opts.sessionId);
117-
if (currentStorePreferredKey) {
118-
const preferred = storeByKey.get(currentStorePreferredKey);
128+
const { matches, primaryStoreMatches, storeByKey } = collectSessionIdMatchesForRequest({
129+
cfg: opts.cfg,
130+
sessionStore,
131+
storePath,
132+
storeAgentId,
133+
sessionId: opts.sessionId,
134+
});
135+
const preferredSelection = resolveSessionIdMatchSelection(matches, opts.sessionId);
136+
const currentStoreSelection =
137+
preferredSelection.kind === "selected"
138+
? preferredSelection
139+
: resolveSessionIdMatchSelection(primaryStoreMatches, opts.sessionId);
140+
if (currentStoreSelection.kind === "selected") {
141+
const preferred = storeByKey.get(currentStoreSelection.sessionKey);
119142
if (preferred) {
120143
return preferred;
121144
}
122-
sessionKey = currentStorePreferredKey;
145+
sessionKey = currentStoreSelection.sessionKey;
123146
}
124147
}
125148

src/sessions/session-id-resolution.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { describe, expect, it } from "vitest";
22
import type { SessionEntry } from "../config/sessions/types.js";
3-
import { resolvePreferredSessionKeyForSessionIdMatches } from "./session-id-resolution.js";
3+
import {
4+
resolvePreferredSessionKeyForSessionIdMatches,
5+
resolveSessionIdMatchSelection,
6+
} from "./session-id-resolution.js";
47

58
function entry(updatedAt: number, sessionId = "s1"): SessionEntry {
69
return { sessionId, updatedAt };
@@ -41,6 +44,18 @@ describe("resolvePreferredSessionKeyForSessionIdMatches", () => {
4144
expect(resolvePreferredSessionKeyForSessionIdMatches(matches, "s1")).toBeUndefined();
4245
});
4346

47+
it("reports ambiguity for fuzzy-only matches with tied timestamps", () => {
48+
const matches: Array<[string, SessionEntry]> = [
49+
["agent:main:beta", entry(10)],
50+
["agent:main:alpha", entry(10)],
51+
];
52+
53+
expect(resolveSessionIdMatchSelection(matches, "s1")).toEqual({
54+
kind: "ambiguous",
55+
sessionKeys: ["agent:main:beta", "agent:main:alpha"],
56+
});
57+
});
58+
4459
it("prefers the freshest structural match over a fresher fuzzy match", () => {
4560
const matches: Array<[string, SessionEntry]> = [
4661
["agent:main:other", entry(999, "run-dup")],

src/sessions/session-id-resolution.ts

Lines changed: 92 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,66 @@ import type { SessionEntry } from "../config/sessions.js";
22
import { toAgentRequestSessionKey } from "../routing/session-key.js";
33

44
type SessionIdMatch = [string, SessionEntry];
5+
type NormalizedSessionIdMatch = {
6+
sessionKey: string;
7+
entry: SessionEntry;
8+
normalizedSessionKey: string;
9+
normalizedRequestKey: string;
10+
isCanonicalSessionKey: boolean;
11+
isStructural: boolean;
12+
};
513

6-
function compareUpdatedAtDescending(a: SessionIdMatch, b: SessionIdMatch): number {
7-
return (b[1]?.updatedAt ?? 0) - (a[1]?.updatedAt ?? 0);
14+
export type SessionIdMatchSelection =
15+
| { kind: "none" }
16+
| { kind: "ambiguous"; sessionKeys: string[] }
17+
| { kind: "selected"; sessionKey: string };
18+
19+
function normalizeLookupKey(value: string): string {
20+
return value.trim().toLowerCase();
21+
}
22+
23+
function compareNormalizedUpdatedAtDescending(
24+
a: NormalizedSessionIdMatch,
25+
b: NormalizedSessionIdMatch,
26+
): number {
27+
return (b.entry?.updatedAt ?? 0) - (a.entry?.updatedAt ?? 0);
828
}
929

1030
function compareStoreKeys(a: string, b: string): number {
1131
return a < b ? -1 : a > b ? 1 : 0;
1232
}
1333

14-
function collapseAliasMatches(matches: SessionIdMatch[]): SessionIdMatch[] {
15-
const grouped = new Map<string, SessionIdMatch[]>();
34+
function normalizeSessionIdMatches(
35+
matches: SessionIdMatch[],
36+
normalizedSessionId: string,
37+
): NormalizedSessionIdMatch[] {
38+
return matches.map(([sessionKey, entry]) => {
39+
const normalizedSessionKey = normalizeLookupKey(sessionKey);
40+
const normalizedRequestKey = normalizeLookupKey(
41+
toAgentRequestSessionKey(sessionKey) ?? sessionKey,
42+
);
43+
return {
44+
sessionKey,
45+
entry,
46+
normalizedSessionKey,
47+
normalizedRequestKey,
48+
isCanonicalSessionKey: sessionKey === normalizedSessionKey,
49+
isStructural:
50+
normalizedSessionKey.endsWith(`:${normalizedSessionId}`) ||
51+
normalizedRequestKey === normalizedSessionId ||
52+
normalizedRequestKey.endsWith(`:${normalizedSessionId}`),
53+
};
54+
});
55+
}
56+
57+
function collapseAliasMatches(matches: NormalizedSessionIdMatch[]): NormalizedSessionIdMatch[] {
58+
const grouped = new Map<string, NormalizedSessionIdMatch[]>();
1659
for (const match of matches) {
17-
const requestKey = toAgentRequestSessionKey(match[0]) ?? match[0];
18-
const normalizedRequestKey = requestKey.trim().toLowerCase();
19-
const bucket = grouped.get(normalizedRequestKey);
60+
const bucket = grouped.get(match.normalizedRequestKey);
2061
if (bucket) {
2162
bucket.push(match);
2263
} else {
23-
grouped.set(normalizedRequestKey, [match]);
64+
grouped.set(match.normalizedRequestKey, [match]);
2465
}
2566
}
2667

@@ -29,66 +70,68 @@ function collapseAliasMatches(matches: SessionIdMatch[]): SessionIdMatch[] {
2970
return group[0];
3071
}
3172
return [...group].toSorted((a, b) => {
32-
const timeDiff = compareUpdatedAtDescending(a, b);
73+
const timeDiff = compareNormalizedUpdatedAtDescending(a, b);
3374
if (timeDiff !== 0) {
3475
return timeDiff;
3576
}
36-
const aNormalizedKey = a[0].trim().toLowerCase();
37-
const bNormalizedKey = b[0].trim().toLowerCase();
38-
const aIsCanonical = a[0] === aNormalizedKey;
39-
const bIsCanonical = b[0] === bNormalizedKey;
40-
if (aIsCanonical !== bIsCanonical) {
41-
return aIsCanonical ? -1 : 1;
77+
if (a.isCanonicalSessionKey !== b.isCanonicalSessionKey) {
78+
return a.isCanonicalSessionKey ? -1 : 1;
4279
}
43-
return compareStoreKeys(aNormalizedKey, bNormalizedKey);
80+
return compareStoreKeys(a.normalizedSessionKey, b.normalizedSessionKey);
4481
})[0];
4582
});
4683
}
4784

48-
export function resolvePreferredSessionKeyForSessionIdMatches(
85+
function selectFreshestUniqueMatch(
86+
matches: NormalizedSessionIdMatch[],
87+
): NormalizedSessionIdMatch | undefined {
88+
if (matches.length === 1) {
89+
return matches[0];
90+
}
91+
const sortedMatches = [...matches].toSorted(compareNormalizedUpdatedAtDescending);
92+
const [freshest, secondFreshest] = sortedMatches;
93+
if ((freshest?.entry?.updatedAt ?? 0) > (secondFreshest?.entry?.updatedAt ?? 0)) {
94+
return freshest;
95+
}
96+
return undefined;
97+
}
98+
99+
export function resolveSessionIdMatchSelection(
49100
matches: Array<[string, SessionEntry]>,
50101
sessionId: string,
51-
): string | undefined {
102+
): SessionIdMatchSelection {
52103
if (matches.length === 0) {
53-
return undefined;
54-
}
55-
if (matches.length === 1) {
56-
return matches[0][0];
104+
return { kind: "none" };
57105
}
58106

59-
const loweredSessionId = sessionId.trim().toLowerCase();
60-
const canonicalMatches = collapseAliasMatches(matches);
107+
const canonicalMatches = collapseAliasMatches(
108+
normalizeSessionIdMatches(matches, normalizeLookupKey(sessionId)),
109+
);
61110
if (canonicalMatches.length === 1) {
62-
return canonicalMatches[0][0];
63-
}
64-
const structuralMatches = canonicalMatches.filter(([storeKey]) => {
65-
const requestKey = toAgentRequestSessionKey(storeKey)?.toLowerCase();
66-
return (
67-
storeKey.toLowerCase().endsWith(`:${loweredSessionId}`) ||
68-
requestKey === loweredSessionId ||
69-
requestKey?.endsWith(`:${loweredSessionId}`) === true
70-
);
71-
});
72-
if (structuralMatches.length === 1) {
73-
return structuralMatches[0][0];
111+
return { kind: "selected", sessionKey: canonicalMatches[0].sessionKey };
74112
}
75113

76-
const structuralSorted = [...structuralMatches].toSorted(compareUpdatedAtDescending);
77-
const [freshestStructural, secondFreshestStructural] = structuralSorted;
114+
const structuralMatches = canonicalMatches.filter((match) => match.isStructural);
115+
const selectedStructuralMatch = selectFreshestUniqueMatch(structuralMatches);
116+
if (selectedStructuralMatch) {
117+
return { kind: "selected", sessionKey: selectedStructuralMatch.sessionKey };
118+
}
78119
if (structuralMatches.length > 1) {
79-
if (
80-
(freshestStructural?.[1]?.updatedAt ?? 0) > (secondFreshestStructural?.[1]?.updatedAt ?? 0)
81-
) {
82-
return freshestStructural[0];
83-
}
84-
return undefined;
120+
return { kind: "ambiguous", sessionKeys: structuralMatches.map((match) => match.sessionKey) };
85121
}
86122

87-
const sortedMatches = [...canonicalMatches].toSorted(compareUpdatedAtDescending);
88-
const [freshest, secondFreshest] = sortedMatches;
89-
if ((freshest?.[1]?.updatedAt ?? 0) > (secondFreshest?.[1]?.updatedAt ?? 0)) {
90-
return freshest[0];
123+
const selectedCanonicalMatch = selectFreshestUniqueMatch(canonicalMatches);
124+
if (selectedCanonicalMatch) {
125+
return { kind: "selected", sessionKey: selectedCanonicalMatch.sessionKey };
91126
}
92127

93-
return undefined;
128+
return { kind: "ambiguous", sessionKeys: canonicalMatches.map((match) => match.sessionKey) };
129+
}
130+
131+
export function resolvePreferredSessionKeyForSessionIdMatches(
132+
matches: Array<[string, SessionEntry]>,
133+
sessionId: string,
134+
): string | undefined {
135+
const selection = resolveSessionIdMatchSelection(matches, sessionId);
136+
return selection.kind === "selected" ? selection.sessionKey : undefined;
94137
}

0 commit comments

Comments
 (0)