From a1dc8c066347f35623bcb7d5e96535ab3daebd4d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 2 May 2026 13:07:10 -0700 Subject: [PATCH] fix(gateway): reuse subagent registry snapshot in session listing (#75019) Summary: - The branch reuses a request-scoped subagent registry read index across Gateway `sessions.list` `spawnedBy` filtering and row enrichment, with focused regression tests and a changelog entry. - Reproducibility: yes. On current main, `spawnedBy` filtering still calls registry read helpers independently ... ce inspection gives a high-confidence reproduction path for snapshot drift during active registry mutation. ClawSweeper fixups: - Included follow-up commit: fix(gateway): reuse subagent registry snapshot in session listing Validation: - ClawSweeper review passed for head 23ae6243747e5e0f55cad8691e78ac6265fb4e00. - Required merge gates passed before the squash merge. Prepared head SHA: 23ae6243747e5e0f55cad8691e78ac6265fb4e00 Review: https://github.com/openclaw/openclaw/pull/75019#issuecomment-4351613760 Co-authored-by: anyech Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + .../subagent-registry-read-context.test.ts | 234 ++++++++++++++++++ src/gateway/session-utils.subagent.test.ts | 128 ++++++++++ src/gateway/session-utils.ts | 52 +++- 4 files changed, 402 insertions(+), 13 deletions(-) create mode 100644 src/agents/subagent-registry-read-context.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index eb39b22d065..208145d9a40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -549,6 +549,7 @@ Docs: https://docs.openclaw.ai - Active Memory: clarify the deprecated `modelFallbackPolicy` warning and config help so `modelFallback` is described as a chain-resolution last resort, not runtime failover. (#74602) Thanks @jeffrey701. - Channels/Discord: keep read-only allowlist/default-target accessors from resolving SecretRef-backed bot tokens, so status and channel summaries no longer fail when tokens are only available in gateway runtime. (#74737) Thanks @eusine. - Gateway/sessions: align session abort wait semantics across `chat`, `agent`, and `sessions` server methods so abort RPCs return after the targeted sessions actually halt instead of resolving early while runs are still draining. (#74751) Thanks @BunsDev. +- Gateway/sessions: reuse one subagent registry read index for `sessions.list` `spawnedBy` filtering and row enrichment so subagent ownership stays consistent without repeated registry reloads. Carries forward #75013. Thanks @anyech. - Agents/output: drop copied inbound metadata-only assistant replay turns before provider replay instead of synthesizing a placeholder, so Telegram and other channels cannot receive `[assistant copied inbound metadata omitted]` as model output. Fixes #74745. Thanks @adamwdear and @Marvae. - Doctor/memory: suppress skipped embedding-readiness warnings for key-optional providers such as Ollama and LM Studio while preserving timeout and not-ready diagnostics. Fixes #74608 and #73882. Thanks @hclsys. - Channels/groups: preserve observe-only turn suppression for prepared dispatch paths and restore deprecated channel turn runtime aliases, so passive observer/group flows stay silent while older plugins keep compiling. Thanks @vincentkoc. diff --git a/src/agents/subagent-registry-read-context.test.ts b/src/agents/subagent-registry-read-context.test.ts new file mode 100644 index 00000000000..77cbc430e18 --- /dev/null +++ b/src/agents/subagent-registry-read-context.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from "vitest"; +import { + buildSubagentRunReadIndexFromRuns, + countActiveDescendantRunsFromRuns, + getSubagentRunByChildSessionKeyFromRuns, + listRunsForControllerFromRuns, + type SubagentRunReadIndex, +} from "./subagent-registry-queries.js"; +import type { SubagentRunRecord } from "./subagent-registry.types.js"; + +function makeRun(overrides: Partial): SubagentRunRecord { + const runId = overrides.runId ?? "run-default"; + const childSessionKey = overrides.childSessionKey ?? `agent:main:subagent:${runId}`; + const requesterSessionKey = overrides.requesterSessionKey ?? "agent:main:main"; + return { + runId, + childSessionKey, + controllerSessionKey: overrides.controllerSessionKey, + requesterSessionKey, + requesterDisplayKey: requesterSessionKey, + task: "test task", + cleanup: "keep", + createdAt: overrides.createdAt ?? Date.now(), + ...overrides, + }; +} + +function toRunMap(runs: SubagentRunRecord[]): Map { + return new Map(runs.map((run) => [run.runId, run])); +} + +function listRunsForController( + index: SubagentRunReadIndex, + controllerSessionKey: string, +): readonly SubagentRunRecord[] { + return index.runsByControllerSessionKey.get(controllerSessionKey.trim()) ?? []; +} + +describe("subagent registry read index", () => { + it("matches existing query helpers while reusing one indexed snapshot", () => { + const now = Date.now(); + const root = "agent:main:main"; + const parent = "agent:main:subagent:parent"; + const liveChild = "agent:main:subagent:parent:subagent:live-child"; + const movedChild = "agent:main:subagent:moved-child"; + const runs = toRunMap([ + makeRun({ + runId: "run-parent", + childSessionKey: parent, + controllerSessionKey: root, + requesterSessionKey: root, + createdAt: now - 5_000, + startedAt: now - 4_500, + endedAt: now - 2_500, + }), + makeRun({ + runId: "run-live-child", + childSessionKey: liveChild, + controllerSessionKey: parent, + requesterSessionKey: parent, + createdAt: now - 2_000, + startedAt: now - 1_500, + }), + makeRun({ + runId: "run-moved-old", + childSessionKey: movedChild, + controllerSessionKey: root, + requesterSessionKey: root, + createdAt: now - 4_000, + startedAt: now - 3_500, + }), + makeRun({ + runId: "run-moved-new", + childSessionKey: movedChild, + controllerSessionKey: "agent:main:other-controller", + requesterSessionKey: "agent:main:other-controller", + createdAt: now - 1_000, + startedAt: now - 900, + }), + ]); + + const index = buildSubagentRunReadIndexFromRuns({ runs, now }); + + expect(listRunsForController(index, root)).toEqual(listRunsForControllerFromRuns(runs, root)); + expect(index.getDisplaySubagentRun(parent)).toEqual( + getSubagentRunByChildSessionKeyFromRuns(runs, parent), + ); + expect(index.countActiveDescendantRuns(root)).toBe( + countActiveDescendantRunsFromRuns(runs, root), + ); + expect(index.countActiveDescendantRuns(root)).toBe(1); + }); + + it("handles empty registry snapshots", () => { + const runs = new Map(); + const index = buildSubagentRunReadIndexFromRuns({ runs }); + + expect(listRunsForController(index, "agent:main:main")).toEqual([]); + expect(index.getDisplaySubagentRun("agent:main:subagent:missing")).toBeNull(); + expect(index.countActiveDescendantRuns("agent:main:main")).toBe(0); + }); + + it("uses requesterSessionKey when controllerSessionKey is missing", () => { + const root = "agent:main:main"; + const run = makeRun({ + runId: "run-controller-fallback", + childSessionKey: "agent:main:subagent:fallback-child", + requesterSessionKey: root, + controllerSessionKey: undefined, + }); + const runs = toRunMap([run]); + const index = buildSubagentRunReadIndexFromRuns({ runs }); + + expect(listRunsForController(index, root)).toEqual(listRunsForControllerFromRuns(runs, root)); + expect(listRunsForController(index, root)).toEqual([run]); + }); + + it("keeps moved middle descendants under the latest requester", () => { + const now = Date.now(); + const root = "agent:main:root"; + const otherRoot = "agent:main:other-root"; + const middle = "agent:main:subagent:middle"; + const grandchild = "agent:main:subagent:grandchild"; + const runs = toRunMap([ + makeRun({ + runId: "run-middle-old", + childSessionKey: middle, + controllerSessionKey: root, + requesterSessionKey: root, + createdAt: now - 3_000, + startedAt: now - 2_900, + }), + makeRun({ + runId: "run-grandchild", + childSessionKey: grandchild, + controllerSessionKey: middle, + requesterSessionKey: middle, + createdAt: now - 2_000, + startedAt: now - 1_900, + }), + makeRun({ + runId: "run-middle-moved", + childSessionKey: middle, + controllerSessionKey: otherRoot, + requesterSessionKey: otherRoot, + createdAt: now - 1_000, + startedAt: now - 900, + }), + ]); + const index = buildSubagentRunReadIndexFromRuns({ runs, now }); + + expect(index.countActiveDescendantRuns(root)).toBe( + countActiveDescendantRunsFromRuns(runs, root), + ); + expect(index.countActiveDescendantRuns(root)).toBe(0); + expect(index.countActiveDescendantRuns(otherRoot)).toBe( + countActiveDescendantRunsFromRuns(runs, otherRoot), + ); + expect(index.countActiveDescendantRuns(otherRoot)).toBe(2); + }); + + it("keeps one snapshot stable for the lifetime of the context", () => { + const root = "agent:main:main"; + const runs = toRunMap([ + makeRun({ + runId: "run-original", + childSessionKey: "agent:main:subagent:original", + requesterSessionKey: root, + controllerSessionKey: root, + }), + ]); + const index = buildSubagentRunReadIndexFromRuns({ runs }); + + runs.set( + "run-added-after-context", + makeRun({ + runId: "run-added-after-context", + childSessionKey: "agent:main:subagent:added", + requesterSessionKey: root, + controllerSessionKey: root, + }), + ); + + expect(listRunsForController(index, root).map((run) => run.runId)).toEqual(["run-original"]); + expect( + listRunsForController(buildSubagentRunReadIndexFromRuns({ runs }), root).map( + (run) => run.runId, + ), + ).toEqual(["run-original", "run-added-after-context"]); + }); + + it("normalizes display lookup keys for whitespace-padded child session keys", () => { + const normalizedChildSessionKey = "agent:main:subagent:whitespace-child"; + const run = makeRun({ + runId: "run-whitespace-child", + childSessionKey: ` ${normalizedChildSessionKey} `, + requesterSessionKey: "agent:main:main", + }); + const runs = toRunMap([run]); + const index = buildSubagentRunReadIndexFromRuns({ runs }); + + expect(index.getDisplaySubagentRun(normalizedChildSessionKey)).toBe(run); + }); + + it("keeps the display-row preference for in-memory records over persisted snapshots", () => { + const childSessionKey = "agent:main:subagent:display-child"; + const persistedRuns = toRunMap([ + makeRun({ + runId: "run-persisted-newer", + childSessionKey, + requesterSessionKey: "agent:main:main", + createdAt: 200, + startedAt: 200, + }), + ]); + const inMemoryRuns = toRunMap([ + makeRun({ + runId: "run-memory-older-ended", + childSessionKey, + requesterSessionKey: "agent:main:main", + createdAt: 100, + startedAt: 100, + endedAt: 150, + }), + ]); + + const index = buildSubagentRunReadIndexFromRuns({ + runs: persistedRuns, + inMemoryRuns: inMemoryRuns.values(), + }); + + expect(index.getDisplaySubagentRun(childSessionKey)?.runId).toBe("run-memory-older-ended"); + }); +}); diff --git a/src/gateway/session-utils.subagent.test.ts b/src/gateway/session-utils.subagent.test.ts index c7ce3b9dfe4..293f1a07e12 100644 --- a/src/gateway/session-utils.subagent.test.ts +++ b/src/gateway/session-utils.subagent.test.ts @@ -787,6 +787,134 @@ describe("listSessionsFromStore subagent metadata", () => { } }); + test("reuses one subagent registry disk snapshot across sessions.list filtering and row enrichment", () => { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "openclaw-session-utils-subagent-cache-"), + ); + const stateDir = path.join(tempRoot, "state"); + const registryPath = path.join(stateDir, "subagents", "runs.json"); + fs.mkdirSync(path.dirname(registryPath), { recursive: true }); + const now = Date.now(); + const controllerSessionKey = "agent:main:main"; + const childKeys = [ + "agent:main:subagent:cache-child-a", + "agent:main:subagent:cache-child-b", + "agent:main:subagent:cache-child-c", + ]; + fs.writeFileSync( + registryPath, + JSON.stringify( + { + version: 2, + runs: Object.fromEntries( + childKeys.map((childSessionKey, index) => [ + `run-cache-child-${index}`, + { + runId: `run-cache-child-${index}`, + childSessionKey, + controllerSessionKey, + requesterSessionKey: controllerSessionKey, + requesterDisplayKey: "main", + task: "cache test child", + cleanup: "keep", + createdAt: now - 5_000 + index, + startedAt: now - 4_000 + index, + }, + ]), + ), + }, + null, + 2, + ), + "utf-8", + ); + + const store: Record = { + [controllerSessionKey]: { + updatedAt: now, + } as SessionEntry, + [childKeys[0]]: { + updatedAt: now - 1_000, + spawnedBy: controllerSessionKey, + } as SessionEntry, + [childKeys[1]]: { + updatedAt: now - 2_000, + spawnedBy: controllerSessionKey, + } as SessionEntry, + [childKeys[2]]: { + updatedAt: now - 3_000, + spawnedBy: controllerSessionKey, + } as SessionEntry, + }; + + const statSpy = vi.spyOn(fs, "statSync"); + try { + const result = withEnv( + { + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_TEST_READ_SUBAGENT_RUNS_FROM_DISK: "1", + }, + () => + listSessionsFromStore({ + cfg, + storePath: "/tmp/sessions.json", + store, + opts: { spawnedBy: controllerSessionKey }, + }), + ); + + expect(result.sessions.map((session) => session.key)).toEqual(childKeys); + const registryStatCount = statSpy.mock.calls.filter( + ([pathname]) => path.normalize(String(pathname)) === path.normalize(registryPath), + ).length; + expect(registryStatCount).toBe(1); + } finally { + statSpy.mockRestore(); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + test("does not read the subagent registry when raw filters drop every session", () => { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "openclaw-session-utils-subagent-cache-empty-"), + ); + const stateDir = path.join(tempRoot, "state"); + const registryPath = path.join(stateDir, "subagents", "runs.json"); + fs.mkdirSync(path.dirname(registryPath), { recursive: true }); + fs.writeFileSync(registryPath, JSON.stringify({ version: 2, runs: {} }, null, 2), "utf-8"); + + const statSpy = vi.spyOn(fs, "statSync"); + try { + const result = withEnv( + { + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_TEST_READ_SUBAGENT_RUNS_FROM_DISK: "1", + }, + () => + listSessionsFromStore({ + cfg, + storePath: "/tmp/sessions.json", + store: { + "agent:main:filtered-out": { + label: "keep-me-out", + updatedAt: Date.now(), + } as SessionEntry, + }, + opts: { label: "wanted-label" }, + }), + ); + + expect(result.sessions).toEqual([]); + const registryStatCount = statSpy.mock.calls.filter( + ([pathname]) => path.normalize(String(pathname)) === path.normalize(registryPath), + ).length; + expect(registryStatCount).toBe(0); + } finally { + statSpy.mockRestore(); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + test("includes explicit parentSessionKey relationships for dashboard child sessions", () => { resetSubagentRegistryForTests({ persist: false }); const now = Date.now(); diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 8bde5ee269b..519682cd4e1 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -381,9 +381,9 @@ function resolveRuntimeChildSessionKeys( ): string[] | undefined { const childSessionKeys = new Set(); const controllerKey = controllerSessionKey.trim(); - const runs = - subagentRuns?.runsByControllerSessionKey.get(controllerKey) ?? - listSubagentRunsForController(controllerSessionKey); + const runs = subagentRuns + ? (subagentRuns.runsByControllerSessionKey.get(controllerKey) ?? []) + : listSubagentRunsForController(controllerSessionKey); for (const entry of runs) { const childSessionKey = normalizeOptionalString(entry.childSessionKey); if (!childSessionKey) { @@ -1811,8 +1811,10 @@ export function filterAndSortSessionEntries(params: { store: Record; opts: import("./protocol/index.js").SessionsListParams; now: number; + rowContext?: SessionListRowContext; }): [string, SessionEntry][] { const { store, opts, now } = params; + const rowContext = params.rowContext; const includeGlobal = opts.includeGlobal === true; const includeUnknown = opts.includeUnknown === true; const spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : ""; @@ -1854,7 +1856,9 @@ export function filterAndSortSessionEntries(params: { if (key === "unknown" || key === "global") { return false; } - const latest = getSessionDisplaySubagentRunByChildSessionKey(key); + const latest = rowContext + ? rowContext.subagentRuns.getDisplaySubagentRun(key) + : getSessionDisplaySubagentRunByChildSessionKey(key); if (latest) { const latestControllerSessionKey = normalizeOptionalString(latest.controllerSessionKey) || @@ -1862,7 +1866,9 @@ export function filterAndSortSessionEntries(params: { return ( latestControllerSessionKey === spawnedBy && shouldKeepSubagentRunChildLink(latest, { - activeDescendants: countActiveDescendantRuns(key), + activeDescendants: rowContext + ? rowContext.subagentRuns.countActiveDescendantRuns(key) + : countActiveDescendantRuns(key), now, }) ); @@ -1913,11 +1919,21 @@ export function listSessionsFromStore(params: { const now = Date.now(); const sessionListTranscriptUsageMaxBytes = 64 * 1024; const sessionListTranscriptFieldRows = 100; - const rowContext = buildSessionListRowContext({ store, now }); + let rowContext: SessionListRowContext | undefined; + const getRowContext = () => { + rowContext ??= buildSessionListRowContext({ store, now }); + return rowContext; + }; const includeDerivedTitles = opts.includeDerivedTitles === true; const includeLastMessage = opts.includeLastMessage === true; + const hasSpawnedByFilter = typeof opts.spawnedBy === "string" && opts.spawnedBy.length > 0; - const entries = filterAndSortSessionEntries({ store, opts, now }); + const entries = filterAndSortSessionEntries({ + store, + opts, + now, + rowContext: hasSpawnedByFilter ? getRowContext() : undefined, + }); const sessions = entries.map(([key, entry], index) => { const includeTranscriptFields = index < sessionListTranscriptFieldRows; @@ -1932,8 +1948,8 @@ export function listSessionsFromStore(params: { includeDerivedTitles: includeTranscriptFields && includeDerivedTitles, includeLastMessage: includeTranscriptFields && includeLastMessage, transcriptUsageMaxBytes: sessionListTranscriptUsageMaxBytes, - storeChildSessionsByKey: rowContext.storeChildSessionsByKey, - rowContext, + storeChildSessionsByKey: getRowContext().storeChildSessionsByKey, + rowContext: getRowContext(), }); }); @@ -1967,11 +1983,21 @@ export async function listSessionsFromStoreAsync(params: { const now = Date.now(); const sessionListTranscriptUsageMaxBytes = 64 * 1024; const sessionListTranscriptFieldRows = 100; - const rowContext = buildSessionListRowContext({ store, now }); + let rowContext: SessionListRowContext | undefined; + const getRowContext = () => { + rowContext ??= buildSessionListRowContext({ store, now }); + return rowContext; + }; const includeDerivedTitles = opts.includeDerivedTitles === true; const includeLastMessage = opts.includeLastMessage === true; + const hasSpawnedByFilter = typeof opts.spawnedBy === "string" && opts.spawnedBy.length > 0; - const entries = filterAndSortSessionEntries({ store, opts, now }); + const entries = filterAndSortSessionEntries({ + store, + opts, + now, + rowContext: hasSpawnedByFilter ? getRowContext() : undefined, + }); const sessions: GatewaySessionRow[] = []; for (let i = 0; i < entries.length; i++) { @@ -1988,8 +2014,8 @@ export async function listSessionsFromStoreAsync(params: { includeDerivedTitles: false, includeLastMessage: false, transcriptUsageMaxBytes: sessionListTranscriptUsageMaxBytes, - storeChildSessionsByKey: rowContext.storeChildSessionsByKey, - rowContext, + storeChildSessionsByKey: getRowContext().storeChildSessionsByKey, + rowContext: getRowContext(), }); if ( entry?.sessionId &&