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 23ae624374.
- Required merge gates passed before the squash merge.

Prepared head SHA: 23ae624374
Review: https://github.com/openclaw/openclaw/pull/75019#issuecomment-4351613760

Co-authored-by: anyech <anyech@gmail.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
This commit is contained in:
Vincent Koc
2026-05-02 13:07:10 -07:00
committed by GitHub
parent 08ce17c33d
commit a1dc8c0663
4 changed files with 402 additions and 13 deletions

View File

@@ -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.

View File

@@ -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>): 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<string, SubagentRunRecord> {
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<string, SubagentRunRecord>();
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");
});
});

View File

@@ -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<string, SessionEntry> = {
[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();

View File

@@ -381,9 +381,9 @@ function resolveRuntimeChildSessionKeys(
): string[] | undefined {
const childSessionKeys = new Set<string>();
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<string, SessionEntry>;
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 &&