From e345f76b13aae33fbebb7502dfdb95238e084198 Mon Sep 17 00:00:00 2001 From: anyech Date: Thu, 30 Apr 2026 09:41:08 +0000 Subject: [PATCH] fix(gateway): reuse subagent registry snapshot in session listing Add a request-scoped subagent registry read context and pass it through sessions.list row construction. This avoids rebuilding subagent registry snapshots and descendant indexes for each row while preserving existing subagent display, spawnedBy, and child-session semantics. Cover parity, moved-child, snapshot-boundary, and registry read-count behavior with focused tests. --- CHANGELOG.md | 1 + .../subagent-registry-read-context.test.ts | 234 ++++++++++++++++++ src/gateway/session-utils.subagent.test.ts | 128 ++++++++++ src/gateway/session-utils.ts | 14 +- 4 files changed, 373 insertions(+), 4 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..2f89a956b44 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -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, }) ); @@ -1917,7 +1923,7 @@ export function listSessionsFromStore(params: { const includeDerivedTitles = opts.includeDerivedTitles === true; const includeLastMessage = opts.includeLastMessage === true; - const entries = filterAndSortSessionEntries({ store, opts, now }); + const entries = filterAndSortSessionEntries({ store, opts, now, rowContext }); const sessions = entries.map(([key, entry], index) => { const includeTranscriptFields = index < sessionListTranscriptFieldRows; @@ -1971,7 +1977,7 @@ export async function listSessionsFromStoreAsync(params: { const includeDerivedTitles = opts.includeDerivedTitles === true; const includeLastMessage = opts.includeLastMessage === true; - const entries = filterAndSortSessionEntries({ store, opts, now }); + const entries = filterAndSortSessionEntries({ store, opts, now, rowContext }); const sessions: GatewaySessionRow[] = []; for (let i = 0; i < entries.length; i++) {