mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 18:30:44 +00:00
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.
This commit is contained in:
@@ -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.
|
||||
|
||||
234
src/agents/subagent-registry-read-context.test.ts
Normal file
234
src/agents/subagent-registry-read-context.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
@@ -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++) {
|
||||
|
||||
Reference in New Issue
Block a user