Skip to content

Commit a1dc8c0

Browse files
vincentkocanyechclawsweeper[bot]
authored
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 23ae624. - Required merge gates passed before the squash merge. Prepared head SHA: 23ae624 Review: #75019 (comment) Co-authored-by: anyech <anyech@gmail.com> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
1 parent 08ce17c commit a1dc8c0

4 files changed

Lines changed: 402 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,7 @@ Docs: https://docs.openclaw.ai
549549
- 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.
550550
- 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.
551551
- 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.
552+
- 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.
552553
- 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.
553554
- 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.
554555
- 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.
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
buildSubagentRunReadIndexFromRuns,
4+
countActiveDescendantRunsFromRuns,
5+
getSubagentRunByChildSessionKeyFromRuns,
6+
listRunsForControllerFromRuns,
7+
type SubagentRunReadIndex,
8+
} from "./subagent-registry-queries.js";
9+
import type { SubagentRunRecord } from "./subagent-registry.types.js";
10+
11+
function makeRun(overrides: Partial<SubagentRunRecord>): SubagentRunRecord {
12+
const runId = overrides.runId ?? "run-default";
13+
const childSessionKey = overrides.childSessionKey ?? `agent:main:subagent:${runId}`;
14+
const requesterSessionKey = overrides.requesterSessionKey ?? "agent:main:main";
15+
return {
16+
runId,
17+
childSessionKey,
18+
controllerSessionKey: overrides.controllerSessionKey,
19+
requesterSessionKey,
20+
requesterDisplayKey: requesterSessionKey,
21+
task: "test task",
22+
cleanup: "keep",
23+
createdAt: overrides.createdAt ?? Date.now(),
24+
...overrides,
25+
};
26+
}
27+
28+
function toRunMap(runs: SubagentRunRecord[]): Map<string, SubagentRunRecord> {
29+
return new Map(runs.map((run) => [run.runId, run]));
30+
}
31+
32+
function listRunsForController(
33+
index: SubagentRunReadIndex,
34+
controllerSessionKey: string,
35+
): readonly SubagentRunRecord[] {
36+
return index.runsByControllerSessionKey.get(controllerSessionKey.trim()) ?? [];
37+
}
38+
39+
describe("subagent registry read index", () => {
40+
it("matches existing query helpers while reusing one indexed snapshot", () => {
41+
const now = Date.now();
42+
const root = "agent:main:main";
43+
const parent = "agent:main:subagent:parent";
44+
const liveChild = "agent:main:subagent:parent:subagent:live-child";
45+
const movedChild = "agent:main:subagent:moved-child";
46+
const runs = toRunMap([
47+
makeRun({
48+
runId: "run-parent",
49+
childSessionKey: parent,
50+
controllerSessionKey: root,
51+
requesterSessionKey: root,
52+
createdAt: now - 5_000,
53+
startedAt: now - 4_500,
54+
endedAt: now - 2_500,
55+
}),
56+
makeRun({
57+
runId: "run-live-child",
58+
childSessionKey: liveChild,
59+
controllerSessionKey: parent,
60+
requesterSessionKey: parent,
61+
createdAt: now - 2_000,
62+
startedAt: now - 1_500,
63+
}),
64+
makeRun({
65+
runId: "run-moved-old",
66+
childSessionKey: movedChild,
67+
controllerSessionKey: root,
68+
requesterSessionKey: root,
69+
createdAt: now - 4_000,
70+
startedAt: now - 3_500,
71+
}),
72+
makeRun({
73+
runId: "run-moved-new",
74+
childSessionKey: movedChild,
75+
controllerSessionKey: "agent:main:other-controller",
76+
requesterSessionKey: "agent:main:other-controller",
77+
createdAt: now - 1_000,
78+
startedAt: now - 900,
79+
}),
80+
]);
81+
82+
const index = buildSubagentRunReadIndexFromRuns({ runs, now });
83+
84+
expect(listRunsForController(index, root)).toEqual(listRunsForControllerFromRuns(runs, root));
85+
expect(index.getDisplaySubagentRun(parent)).toEqual(
86+
getSubagentRunByChildSessionKeyFromRuns(runs, parent),
87+
);
88+
expect(index.countActiveDescendantRuns(root)).toBe(
89+
countActiveDescendantRunsFromRuns(runs, root),
90+
);
91+
expect(index.countActiveDescendantRuns(root)).toBe(1);
92+
});
93+
94+
it("handles empty registry snapshots", () => {
95+
const runs = new Map<string, SubagentRunRecord>();
96+
const index = buildSubagentRunReadIndexFromRuns({ runs });
97+
98+
expect(listRunsForController(index, "agent:main:main")).toEqual([]);
99+
expect(index.getDisplaySubagentRun("agent:main:subagent:missing")).toBeNull();
100+
expect(index.countActiveDescendantRuns("agent:main:main")).toBe(0);
101+
});
102+
103+
it("uses requesterSessionKey when controllerSessionKey is missing", () => {
104+
const root = "agent:main:main";
105+
const run = makeRun({
106+
runId: "run-controller-fallback",
107+
childSessionKey: "agent:main:subagent:fallback-child",
108+
requesterSessionKey: root,
109+
controllerSessionKey: undefined,
110+
});
111+
const runs = toRunMap([run]);
112+
const index = buildSubagentRunReadIndexFromRuns({ runs });
113+
114+
expect(listRunsForController(index, root)).toEqual(listRunsForControllerFromRuns(runs, root));
115+
expect(listRunsForController(index, root)).toEqual([run]);
116+
});
117+
118+
it("keeps moved middle descendants under the latest requester", () => {
119+
const now = Date.now();
120+
const root = "agent:main:root";
121+
const otherRoot = "agent:main:other-root";
122+
const middle = "agent:main:subagent:middle";
123+
const grandchild = "agent:main:subagent:grandchild";
124+
const runs = toRunMap([
125+
makeRun({
126+
runId: "run-middle-old",
127+
childSessionKey: middle,
128+
controllerSessionKey: root,
129+
requesterSessionKey: root,
130+
createdAt: now - 3_000,
131+
startedAt: now - 2_900,
132+
}),
133+
makeRun({
134+
runId: "run-grandchild",
135+
childSessionKey: grandchild,
136+
controllerSessionKey: middle,
137+
requesterSessionKey: middle,
138+
createdAt: now - 2_000,
139+
startedAt: now - 1_900,
140+
}),
141+
makeRun({
142+
runId: "run-middle-moved",
143+
childSessionKey: middle,
144+
controllerSessionKey: otherRoot,
145+
requesterSessionKey: otherRoot,
146+
createdAt: now - 1_000,
147+
startedAt: now - 900,
148+
}),
149+
]);
150+
const index = buildSubagentRunReadIndexFromRuns({ runs, now });
151+
152+
expect(index.countActiveDescendantRuns(root)).toBe(
153+
countActiveDescendantRunsFromRuns(runs, root),
154+
);
155+
expect(index.countActiveDescendantRuns(root)).toBe(0);
156+
expect(index.countActiveDescendantRuns(otherRoot)).toBe(
157+
countActiveDescendantRunsFromRuns(runs, otherRoot),
158+
);
159+
expect(index.countActiveDescendantRuns(otherRoot)).toBe(2);
160+
});
161+
162+
it("keeps one snapshot stable for the lifetime of the context", () => {
163+
const root = "agent:main:main";
164+
const runs = toRunMap([
165+
makeRun({
166+
runId: "run-original",
167+
childSessionKey: "agent:main:subagent:original",
168+
requesterSessionKey: root,
169+
controllerSessionKey: root,
170+
}),
171+
]);
172+
const index = buildSubagentRunReadIndexFromRuns({ runs });
173+
174+
runs.set(
175+
"run-added-after-context",
176+
makeRun({
177+
runId: "run-added-after-context",
178+
childSessionKey: "agent:main:subagent:added",
179+
requesterSessionKey: root,
180+
controllerSessionKey: root,
181+
}),
182+
);
183+
184+
expect(listRunsForController(index, root).map((run) => run.runId)).toEqual(["run-original"]);
185+
expect(
186+
listRunsForController(buildSubagentRunReadIndexFromRuns({ runs }), root).map(
187+
(run) => run.runId,
188+
),
189+
).toEqual(["run-original", "run-added-after-context"]);
190+
});
191+
192+
it("normalizes display lookup keys for whitespace-padded child session keys", () => {
193+
const normalizedChildSessionKey = "agent:main:subagent:whitespace-child";
194+
const run = makeRun({
195+
runId: "run-whitespace-child",
196+
childSessionKey: ` ${normalizedChildSessionKey} `,
197+
requesterSessionKey: "agent:main:main",
198+
});
199+
const runs = toRunMap([run]);
200+
const index = buildSubagentRunReadIndexFromRuns({ runs });
201+
202+
expect(index.getDisplaySubagentRun(normalizedChildSessionKey)).toBe(run);
203+
});
204+
205+
it("keeps the display-row preference for in-memory records over persisted snapshots", () => {
206+
const childSessionKey = "agent:main:subagent:display-child";
207+
const persistedRuns = toRunMap([
208+
makeRun({
209+
runId: "run-persisted-newer",
210+
childSessionKey,
211+
requesterSessionKey: "agent:main:main",
212+
createdAt: 200,
213+
startedAt: 200,
214+
}),
215+
]);
216+
const inMemoryRuns = toRunMap([
217+
makeRun({
218+
runId: "run-memory-older-ended",
219+
childSessionKey,
220+
requesterSessionKey: "agent:main:main",
221+
createdAt: 100,
222+
startedAt: 100,
223+
endedAt: 150,
224+
}),
225+
]);
226+
227+
const index = buildSubagentRunReadIndexFromRuns({
228+
runs: persistedRuns,
229+
inMemoryRuns: inMemoryRuns.values(),
230+
});
231+
232+
expect(index.getDisplaySubagentRun(childSessionKey)?.runId).toBe("run-memory-older-ended");
233+
});
234+
});

src/gateway/session-utils.subagent.test.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -787,6 +787,134 @@ describe("listSessionsFromStore subagent metadata", () => {
787787
}
788788
});
789789

790+
test("reuses one subagent registry disk snapshot across sessions.list filtering and row enrichment", () => {
791+
const tempRoot = fs.mkdtempSync(
792+
path.join(os.tmpdir(), "openclaw-session-utils-subagent-cache-"),
793+
);
794+
const stateDir = path.join(tempRoot, "state");
795+
const registryPath = path.join(stateDir, "subagents", "runs.json");
796+
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
797+
const now = Date.now();
798+
const controllerSessionKey = "agent:main:main";
799+
const childKeys = [
800+
"agent:main:subagent:cache-child-a",
801+
"agent:main:subagent:cache-child-b",
802+
"agent:main:subagent:cache-child-c",
803+
];
804+
fs.writeFileSync(
805+
registryPath,
806+
JSON.stringify(
807+
{
808+
version: 2,
809+
runs: Object.fromEntries(
810+
childKeys.map((childSessionKey, index) => [
811+
`run-cache-child-${index}`,
812+
{
813+
runId: `run-cache-child-${index}`,
814+
childSessionKey,
815+
controllerSessionKey,
816+
requesterSessionKey: controllerSessionKey,
817+
requesterDisplayKey: "main",
818+
task: "cache test child",
819+
cleanup: "keep",
820+
createdAt: now - 5_000 + index,
821+
startedAt: now - 4_000 + index,
822+
},
823+
]),
824+
),
825+
},
826+
null,
827+
2,
828+
),
829+
"utf-8",
830+
);
831+
832+
const store: Record<string, SessionEntry> = {
833+
[controllerSessionKey]: {
834+
updatedAt: now,
835+
} as SessionEntry,
836+
[childKeys[0]]: {
837+
updatedAt: now - 1_000,
838+
spawnedBy: controllerSessionKey,
839+
} as SessionEntry,
840+
[childKeys[1]]: {
841+
updatedAt: now - 2_000,
842+
spawnedBy: controllerSessionKey,
843+
} as SessionEntry,
844+
[childKeys[2]]: {
845+
updatedAt: now - 3_000,
846+
spawnedBy: controllerSessionKey,
847+
} as SessionEntry,
848+
};
849+
850+
const statSpy = vi.spyOn(fs, "statSync");
851+
try {
852+
const result = withEnv(
853+
{
854+
OPENCLAW_STATE_DIR: stateDir,
855+
OPENCLAW_TEST_READ_SUBAGENT_RUNS_FROM_DISK: "1",
856+
},
857+
() =>
858+
listSessionsFromStore({
859+
cfg,
860+
storePath: "/tmp/sessions.json",
861+
store,
862+
opts: { spawnedBy: controllerSessionKey },
863+
}),
864+
);
865+
866+
expect(result.sessions.map((session) => session.key)).toEqual(childKeys);
867+
const registryStatCount = statSpy.mock.calls.filter(
868+
([pathname]) => path.normalize(String(pathname)) === path.normalize(registryPath),
869+
).length;
870+
expect(registryStatCount).toBe(1);
871+
} finally {
872+
statSpy.mockRestore();
873+
fs.rmSync(tempRoot, { recursive: true, force: true });
874+
}
875+
});
876+
877+
test("does not read the subagent registry when raw filters drop every session", () => {
878+
const tempRoot = fs.mkdtempSync(
879+
path.join(os.tmpdir(), "openclaw-session-utils-subagent-cache-empty-"),
880+
);
881+
const stateDir = path.join(tempRoot, "state");
882+
const registryPath = path.join(stateDir, "subagents", "runs.json");
883+
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
884+
fs.writeFileSync(registryPath, JSON.stringify({ version: 2, runs: {} }, null, 2), "utf-8");
885+
886+
const statSpy = vi.spyOn(fs, "statSync");
887+
try {
888+
const result = withEnv(
889+
{
890+
OPENCLAW_STATE_DIR: stateDir,
891+
OPENCLAW_TEST_READ_SUBAGENT_RUNS_FROM_DISK: "1",
892+
},
893+
() =>
894+
listSessionsFromStore({
895+
cfg,
896+
storePath: "/tmp/sessions.json",
897+
store: {
898+
"agent:main:filtered-out": {
899+
label: "keep-me-out",
900+
updatedAt: Date.now(),
901+
} as SessionEntry,
902+
},
903+
opts: { label: "wanted-label" },
904+
}),
905+
);
906+
907+
expect(result.sessions).toEqual([]);
908+
const registryStatCount = statSpy.mock.calls.filter(
909+
([pathname]) => path.normalize(String(pathname)) === path.normalize(registryPath),
910+
).length;
911+
expect(registryStatCount).toBe(0);
912+
} finally {
913+
statSpy.mockRestore();
914+
fs.rmSync(tempRoot, { recursive: true, force: true });
915+
}
916+
});
917+
790918
test("includes explicit parentSessionKey relationships for dashboard child sessions", () => {
791919
resetSubagentRegistryForTests({ persist: false });
792920
const now = Date.now();

0 commit comments

Comments
 (0)