Skip to content

Commit 2b37b38

Browse files
fix(sessions): keep list polling lightweight (#76090)
Co-authored-by: rolandrscheel <20336324+rolandrscheel@users.noreply.github.com>
1 parent d228b0d commit 2b37b38

7 files changed

Lines changed: 305 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
1313

1414
- Control UI/sessions: bound the default Sessions tab query to recent activity and fewer rows, avoiding expensive full-history loads while keeping filters editable. Fixes #76050. (#76051) Thanks @Neomail2.
1515
- Gateway/channels: cap startup fanout at four channel/account handoffs and recover from Bonjour ciao self-probe races, reducing Windows startup stalls with many Telegram accounts. Fixes #75687.
16+
- Gateway/sessions: keep `sessions.list` polling responsive on large session stores by reusing list-safe session cache/indexes and returning a lightweight compaction checkpoint preview instead of heavyweight summaries. Thanks @rolandrscheel.
1617
- CLI/update: treat inherited Gateway service markers as origin hints and only block package replacement when the managed Gateway is still live, so self-updates can stop the service and continue safely. (#75729) Thanks @hxy91819.
1718
- Agents/failover: exempt run-level timeouts that fire during tool execution from model fallback, timeout-triggered compaction, and generic timeout payload synthesis. Long `process(poll)`, browser, or `exec` tool calls that exceed `agents.defaults.timeoutSeconds` previously rotated auth profiles, switched to a fallback model, and surfaced a misleading "LLM request timed out" error even though the primary model had already responded. Mirrors the existing `timedOutDuringCompaction` precedent (#46889). Fixes #52147. (#75873) Thanks @simonusa.
1819

src/agents/subagent-registry-queries.ts

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,191 @@ export function listRunsForControllerFromRuns(
5050
return [...runs.values()].filter((entry) => resolveControllerSessionKey(entry) === key);
5151
}
5252

53+
type LatestRunPair = {
54+
runId: string;
55+
entry: SubagentRunRecord;
56+
};
57+
58+
export type SubagentRunReadIndex = {
59+
getDisplaySubagentRun(childSessionKey: string): SubagentRunRecord | null;
60+
countActiveDescendantRuns(rootSessionKey: string): number;
61+
runsByControllerSessionKey: ReadonlyMap<string, readonly SubagentRunRecord[]>;
62+
};
63+
64+
function rememberLatestRunEntry(
65+
map: Map<string, SubagentRunRecord>,
66+
key: string,
67+
entry: SubagentRunRecord,
68+
): void {
69+
const existing = map.get(key);
70+
if (!existing || entry.createdAt > existing.createdAt) {
71+
map.set(key, entry);
72+
}
73+
}
74+
75+
function rememberLatestRunPair(
76+
map: Map<string, LatestRunPair>,
77+
key: string,
78+
runId: string,
79+
entry: SubagentRunRecord,
80+
): void {
81+
const existing = map.get(key);
82+
if (!existing || entry.createdAt > existing.entry.createdAt) {
83+
map.set(key, { runId, entry });
84+
}
85+
}
86+
87+
export function buildSubagentRunReadIndexFromRuns(params: {
88+
runs: Map<string, SubagentRunRecord>;
89+
inMemoryRuns?: Iterable<SubagentRunRecord>;
90+
now?: number;
91+
}): SubagentRunReadIndex {
92+
const { runs } = params;
93+
const now = params.now ?? Date.now();
94+
const inMemoryDisplayByChildSessionKey = new Map<
95+
string,
96+
{
97+
latestInMemoryActive: SubagentRunRecord | null;
98+
latestInMemoryEnded: SubagentRunRecord | null;
99+
}
100+
>();
101+
const latestSnapshotActiveByChildSessionKey = new Map<string, SubagentRunRecord>();
102+
const latestSnapshotEndedByChildSessionKey = new Map<string, SubagentRunRecord>();
103+
const latestRunByChildSessionKey = new Map<string, LatestRunPair>();
104+
const runsByControllerSessionKey = new Map<string, SubagentRunRecord[]>();
105+
const latestRunByRequesterAndChildSessionKey = new Map<string, Map<string, LatestRunPair>>();
106+
const activeDescendantCountBySessionKey = new Map<string, number>();
107+
108+
for (const entry of params.inMemoryRuns ?? []) {
109+
const childSessionKey = entry.childSessionKey.trim();
110+
if (!childSessionKey) {
111+
continue;
112+
}
113+
let display = inMemoryDisplayByChildSessionKey.get(childSessionKey);
114+
if (!display) {
115+
display = { latestInMemoryActive: null, latestInMemoryEnded: null };
116+
inMemoryDisplayByChildSessionKey.set(childSessionKey, display);
117+
}
118+
if (hasSubagentRunEnded(entry)) {
119+
if (!display.latestInMemoryEnded || entry.createdAt > display.latestInMemoryEnded.createdAt) {
120+
display.latestInMemoryEnded = entry;
121+
}
122+
continue;
123+
}
124+
if (!display.latestInMemoryActive || entry.createdAt > display.latestInMemoryActive.createdAt) {
125+
display.latestInMemoryActive = entry;
126+
}
127+
}
128+
129+
for (const [runId, entry] of runs.entries()) {
130+
const childSessionKey = entry.childSessionKey.trim();
131+
const controllerSessionKey = resolveControllerSessionKey(entry);
132+
if (controllerSessionKey) {
133+
let controllerRuns = runsByControllerSessionKey.get(controllerSessionKey);
134+
if (!controllerRuns) {
135+
controllerRuns = [];
136+
runsByControllerSessionKey.set(controllerSessionKey, controllerRuns);
137+
}
138+
controllerRuns.push(entry);
139+
}
140+
if (!childSessionKey) {
141+
continue;
142+
}
143+
if (isLiveUnendedSubagentRun(entry, now)) {
144+
rememberLatestRunEntry(latestSnapshotActiveByChildSessionKey, childSessionKey, entry);
145+
} else {
146+
rememberLatestRunEntry(latestSnapshotEndedByChildSessionKey, childSessionKey, entry);
147+
}
148+
rememberLatestRunPair(latestRunByChildSessionKey, childSessionKey, runId, entry);
149+
150+
const requesterSessionKey = entry.requesterSessionKey;
151+
if (!requesterSessionKey) {
152+
continue;
153+
}
154+
let latestByChild = latestRunByRequesterAndChildSessionKey.get(requesterSessionKey);
155+
if (!latestByChild) {
156+
latestByChild = new Map<string, LatestRunPair>();
157+
latestRunByRequesterAndChildSessionKey.set(requesterSessionKey, latestByChild);
158+
}
159+
rememberLatestRunPair(latestByChild, childSessionKey, runId, entry);
160+
}
161+
162+
const getDisplaySubagentRun = (childSessionKey: string): SubagentRunRecord | null => {
163+
const key = childSessionKey.trim();
164+
if (!key) {
165+
return null;
166+
}
167+
const inMemoryDisplay = inMemoryDisplayByChildSessionKey.get(key);
168+
if (inMemoryDisplay) {
169+
const latestInMemoryEnded = inMemoryDisplay.latestInMemoryEnded;
170+
const latestInMemoryActive = inMemoryDisplay.latestInMemoryActive;
171+
if (latestInMemoryEnded || latestInMemoryActive) {
172+
if (
173+
latestInMemoryEnded &&
174+
(!latestInMemoryActive || latestInMemoryEnded.createdAt > latestInMemoryActive.createdAt)
175+
) {
176+
return latestInMemoryEnded;
177+
}
178+
return latestInMemoryActive ?? latestInMemoryEnded;
179+
}
180+
}
181+
return (
182+
latestSnapshotActiveByChildSessionKey.get(key) ??
183+
latestSnapshotEndedByChildSessionKey.get(key) ??
184+
null
185+
);
186+
};
187+
188+
const countActiveDescendantRuns = (rootSessionKey: string): number => {
189+
const root = rootSessionKey.trim();
190+
if (!root) {
191+
return 0;
192+
}
193+
if (activeDescendantCountBySessionKey.has(root)) {
194+
return activeDescendantCountBySessionKey.get(root) ?? 0;
195+
}
196+
let count = 0;
197+
const pending = [root];
198+
const visited = new Set<string>([root]);
199+
for (let index = 0; index < pending.length; index += 1) {
200+
const requester = pending[index];
201+
if (!requester) {
202+
continue;
203+
}
204+
const latestByChild = latestRunByRequesterAndChildSessionKey.get(requester);
205+
if (!latestByChild) {
206+
continue;
207+
}
208+
for (const [childSessionKey, pair] of latestByChild.entries()) {
209+
const latestForChildSession = latestRunByChildSessionKey.get(childSessionKey);
210+
if (
211+
!latestForChildSession ||
212+
latestForChildSession.runId !== pair.runId ||
213+
latestForChildSession.entry.requesterSessionKey !== requester
214+
) {
215+
continue;
216+
}
217+
if (isLiveUnendedSubagentRun(pair.entry, now)) {
218+
count += 1;
219+
}
220+
if (!childSessionKey || visited.has(childSessionKey)) {
221+
continue;
222+
}
223+
visited.add(childSessionKey);
224+
pending.push(childSessionKey);
225+
}
226+
}
227+
activeDescendantCountBySessionKey.set(root, count);
228+
return count;
229+
};
230+
231+
return {
232+
getDisplaySubagentRun,
233+
countActiveDescendantRuns,
234+
runsByControllerSessionKey,
235+
};
236+
}
237+
53238
function findLatestRunForChildSession(
54239
runs: Map<string, SubagentRunRecord>,
55240
childSessionKey: string,

src/agents/subagent-registry-read.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { getAgentRunContext } from "../infra/agent-events.js";
22
import { subagentRuns } from "./subagent-registry-memory.js";
33
import {
4+
buildSubagentRunReadIndexFromRuns,
45
countActiveDescendantRunsFromRuns,
56
getSubagentRunByChildSessionKeyFromRuns,
67
listDescendantRunsForRequesterFromRuns,
78
listRunsForControllerFromRuns,
9+
type SubagentRunReadIndex,
810
} from "./subagent-registry-queries.js";
911
import { getSubagentRunsSnapshotForRead } from "./subagent-registry-state.js";
1012
import type { SubagentRunRecord } from "./subagent-registry.types.js";
@@ -20,6 +22,14 @@ export {
2022
resolveSubagentSessionStatus,
2123
} from "./subagent-session-metrics.js";
2224

25+
export function buildSubagentRunReadIndex(now = Date.now()): SubagentRunReadIndex {
26+
return buildSubagentRunReadIndexFromRuns({
27+
runs: getSubagentRunsSnapshotForRead(subagentRuns),
28+
inMemoryRuns: subagentRuns.values(),
29+
now,
30+
});
31+
}
32+
2333
export function listSubagentRunsForController(controllerSessionKey: string): SubagentRunRecord[] {
2434
return listRunsForControllerFromRuns(
2535
getSubagentRunsSnapshotForRead(subagentRuns),

src/gateway/server.sessions.compaction.test.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const { createSessionStoreDir, openClient } = setupGatewaySessionsTestHarness();
2323
test("sessions.compaction.* lists checkpoints and branches or restores from pre-compaction snapshots", async () => {
2424
const { dir, storePath } = await createSessionStoreDir();
2525
const fixture = await createCheckpointFixture(dir);
26+
const checkpointCreatedAt = Date.now();
2627
const { SessionManager } = await getSessionManagerModule();
2728
await writeSessionStore({
2829
entries: {
@@ -33,7 +34,7 @@ test("sessions.compaction.* lists checkpoints and branches or restores from pre-
3334
checkpointId: "checkpoint-1",
3435
sessionKey: "agent:main:main",
3536
sessionId: fixture.sessionId,
36-
createdAt: Date.now(),
37+
createdAt: checkpointCreatedAt,
3738
reason: "manual",
3839
tokensBefore: 123,
3940
tokensAfter: 45,
@@ -64,7 +65,9 @@ test("sessions.compaction.* lists checkpoints and branches or restores from pre-
6465
compactionCheckpointCount?: number;
6566
latestCompactionCheckpoint?: {
6667
checkpointId: string;
68+
createdAt: number;
6769
reason: string;
70+
summary?: string;
6871
tokensBefore?: number;
6972
tokensAfter?: number;
7073
};
@@ -75,8 +78,11 @@ test("sessions.compaction.* lists checkpoints and branches or restores from pre-
7578
(session) => session.key === "agent:main:main",
7679
);
7780
expect(main?.compactionCheckpointCount).toBe(1);
78-
expect(main?.latestCompactionCheckpoint?.checkpointId).toBe("checkpoint-1");
79-
expect(main?.latestCompactionCheckpoint?.reason).toBe("manual");
81+
expect(main?.latestCompactionCheckpoint).toEqual({
82+
checkpointId: "checkpoint-1",
83+
createdAt: checkpointCreatedAt,
84+
reason: "manual",
85+
});
8086

8187
const listedCheckpoints = await rpcReq<{
8288
ok: true;

0 commit comments

Comments
 (0)