Skip to content

Commit e48a0b8

Browse files
committed
fix: ignore moved subagent children on stale parents
1 parent 33e9e48 commit e48a0b8

7 files changed

Lines changed: 377 additions & 1 deletion

File tree

src/agents/openclaw-tools.sessions.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1068,6 +1068,94 @@ describe("sessions tools", () => {
10681068
expect(details.text).not.toContain("active (waiting on 2 children)");
10691069
});
10701070

1071+
it("subagents list does not keep childSessions attached to a stale older parent", async () => {
1072+
resetSubagentRegistryForTests();
1073+
const now = Date.now();
1074+
const oldParentKey = "agent:main:subagent:old-parent";
1075+
const newParentKey = "agent:main:subagent:new-parent";
1076+
const childKey = "agent:main:subagent:shared-child";
1077+
1078+
addSubagentRunForTests({
1079+
runId: "run-old-parent",
1080+
childSessionKey: oldParentKey,
1081+
requesterSessionKey: "agent:main:main",
1082+
requesterDisplayKey: "main",
1083+
task: "old parent task",
1084+
cleanup: "keep",
1085+
createdAt: now - 10_000,
1086+
startedAt: now - 9_000,
1087+
});
1088+
addSubagentRunForTests({
1089+
runId: "run-new-parent",
1090+
childSessionKey: newParentKey,
1091+
requesterSessionKey: "agent:main:main",
1092+
requesterDisplayKey: "main",
1093+
task: "new parent task",
1094+
cleanup: "keep",
1095+
createdAt: now - 8_000,
1096+
startedAt: now - 7_000,
1097+
});
1098+
addSubagentRunForTests({
1099+
runId: "run-shared-child-stale-parent",
1100+
childSessionKey: childKey,
1101+
requesterSessionKey: oldParentKey,
1102+
requesterDisplayKey: oldParentKey,
1103+
controllerSessionKey: oldParentKey,
1104+
task: "shared child stale parent",
1105+
cleanup: "keep",
1106+
createdAt: now - 6_000,
1107+
startedAt: now - 5_000,
1108+
endedAt: now - 4_000,
1109+
outcome: { status: "ok" },
1110+
});
1111+
addSubagentRunForTests({
1112+
runId: "run-shared-child-current-parent",
1113+
childSessionKey: childKey,
1114+
requesterSessionKey: newParentKey,
1115+
requesterDisplayKey: newParentKey,
1116+
controllerSessionKey: newParentKey,
1117+
task: "shared child current parent",
1118+
cleanup: "keep",
1119+
createdAt: now - 2_000,
1120+
startedAt: now - 1_500,
1121+
});
1122+
1123+
const tool = createOpenClawTools({
1124+
agentSessionKey: "agent:main:main",
1125+
}).find((candidate) => candidate.name === "subagents");
1126+
expect(tool).toBeDefined();
1127+
if (!tool) {
1128+
throw new Error("missing subagents tool");
1129+
}
1130+
1131+
const result = await tool.execute("call-subagents-list-stale-parent", { action: "list" });
1132+
const details = result.details as {
1133+
status?: string;
1134+
active?: Array<{
1135+
runId?: string;
1136+
childSessions?: string[];
1137+
pendingDescendants?: number;
1138+
status?: string;
1139+
}>;
1140+
};
1141+
1142+
expect(details.status).toBe("ok");
1143+
const oldParent = details.active?.find((entry) => entry.runId === "run-old-parent");
1144+
const newParent = details.active?.find((entry) => entry.runId === "run-new-parent");
1145+
expect(oldParent).toMatchObject({
1146+
runId: "run-old-parent",
1147+
pendingDescendants: 0,
1148+
status: "running",
1149+
});
1150+
expect(oldParent?.childSessions).toBeUndefined();
1151+
expect(newParent).toMatchObject({
1152+
runId: "run-new-parent",
1153+
childSessions: [childKey],
1154+
pendingDescendants: 1,
1155+
status: "active (waiting on 1 child)",
1156+
});
1157+
});
1158+
10711159
it("subagents list dedupes stale rows for the same child session", async () => {
10721160
resetSubagentRegistryForTests();
10731161
const now = Date.now();

src/agents/subagent-control.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,105 @@ describe("killControlledSubagentRun", () => {
620620
});
621621
expect(getSubagentRunByChildSessionKey(leafSessionKey)?.endedAt).toBeTypeOf("number");
622622
});
623+
624+
it("does not cascade through a child session that moved to a newer parent", async () => {
625+
const oldParentSessionKey = "agent:main:subagent:old-parent";
626+
const newParentSessionKey = "agent:main:subagent:new-parent";
627+
const childSessionKey = "agent:main:subagent:shared-child";
628+
const leafSessionKey = `${childSessionKey}:subagent:leaf`;
629+
630+
addSubagentRunForTests({
631+
runId: "run-old-parent-current",
632+
childSessionKey: oldParentSessionKey,
633+
controllerSessionKey: "agent:main:main",
634+
requesterSessionKey: "agent:main:main",
635+
requesterDisplayKey: "main",
636+
task: "old parent task",
637+
cleanup: "keep",
638+
createdAt: Date.now() - 8_000,
639+
startedAt: Date.now() - 7_000,
640+
endedAt: Date.now() - 6_000,
641+
outcome: { status: "ok" },
642+
});
643+
addSubagentRunForTests({
644+
runId: "run-new-parent-current",
645+
childSessionKey: newParentSessionKey,
646+
controllerSessionKey: "agent:main:main",
647+
requesterSessionKey: "agent:main:main",
648+
requesterDisplayKey: "main",
649+
task: "new parent task",
650+
cleanup: "keep",
651+
createdAt: Date.now() - 5_000,
652+
startedAt: Date.now() - 4_000,
653+
});
654+
addSubagentRunForTests({
655+
runId: "run-child-stale-old-parent",
656+
childSessionKey,
657+
controllerSessionKey: oldParentSessionKey,
658+
requesterSessionKey: oldParentSessionKey,
659+
requesterDisplayKey: oldParentSessionKey,
660+
task: "stale shared child task",
661+
cleanup: "keep",
662+
createdAt: Date.now() - 4_000,
663+
startedAt: Date.now() - 3_500,
664+
endedAt: Date.now() - 3_000,
665+
outcome: { status: "ok" },
666+
});
667+
addSubagentRunForTests({
668+
runId: "run-child-current-new-parent",
669+
childSessionKey,
670+
controllerSessionKey: newParentSessionKey,
671+
requesterSessionKey: newParentSessionKey,
672+
requesterDisplayKey: newParentSessionKey,
673+
task: "current shared child task",
674+
cleanup: "keep",
675+
createdAt: Date.now() - 2_000,
676+
startedAt: Date.now() - 1_500,
677+
});
678+
addSubagentRunForTests({
679+
runId: "run-leaf-active",
680+
childSessionKey: leafSessionKey,
681+
controllerSessionKey: childSessionKey,
682+
requesterSessionKey: childSessionKey,
683+
requesterDisplayKey: childSessionKey,
684+
task: "leaf task",
685+
cleanup: "keep",
686+
createdAt: Date.now() - 1_000,
687+
startedAt: Date.now() - 900,
688+
});
689+
690+
const result = await killControlledSubagentRun({
691+
cfg: {} as OpenClawConfig,
692+
controller: {
693+
controllerSessionKey: "agent:main:main",
694+
callerSessionKey: "agent:main:main",
695+
callerIsSubagent: false,
696+
controlScope: "children",
697+
},
698+
entry: {
699+
runId: "run-old-parent-current",
700+
childSessionKey: oldParentSessionKey,
701+
requesterSessionKey: "agent:main:main",
702+
requesterDisplayKey: "main",
703+
controllerSessionKey: "agent:main:main",
704+
task: "old parent task",
705+
cleanup: "keep",
706+
createdAt: Date.now() - 8_000,
707+
startedAt: Date.now() - 7_000,
708+
endedAt: Date.now() - 6_000,
709+
outcome: { status: "ok" },
710+
},
711+
});
712+
713+
expect(result).toEqual({
714+
status: "done",
715+
runId: "run-old-parent-current",
716+
sessionKey: oldParentSessionKey,
717+
label: "old parent task",
718+
text: "old parent task is already finished.",
719+
});
720+
expect(getSubagentRunByChildSessionKey(leafSessionKey)?.endedAt).toBeUndefined();
721+
});
623722
});
624723

625724
describe("killAllControlledSubagentRuns", () => {

src/agents/subagent-control.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,15 @@ export function buildSubagentList(params: {
298298
});
299299
const childSessions = Array.from(
300300
new Set(
301-
listSubagentRunsForController(entry.childSessionKey).map((run) => run.childSessionKey),
301+
listSubagentRunsForController(entry.childSessionKey)
302+
.map((run) => run.childSessionKey?.trim())
303+
.filter((childSessionKey): childSessionKey is string => Boolean(childSessionKey))
304+
.filter((childSessionKey) => {
305+
const latest = getLatestSubagentRunByChildSessionKey(childSessionKey);
306+
const latestControllerSessionKey =
307+
latest?.controllerSessionKey?.trim() || latest?.requesterSessionKey?.trim();
308+
return latestControllerSessionKey === entry.childSessionKey;
309+
}),
302310
),
303311
);
304312
const runtime = formatDurationCompact(runtimeMs);
@@ -419,6 +427,16 @@ async function cascadeKillChildren(params: {
419427
if (!childKey) {
420428
continue;
421429
}
430+
const latest = getLatestSubagentRunByChildSessionKey(childKey);
431+
const latestControllerSessionKey =
432+
latest?.controllerSessionKey?.trim() || latest?.requesterSessionKey?.trim();
433+
if (
434+
!latest ||
435+
latest.runId !== run.runId ||
436+
latestControllerSessionKey !== params.parentChildSessionKey
437+
) {
438+
continue;
439+
}
422440
const existing = childRunsBySessionKey.get(childKey);
423441
if (!existing || run.createdAt >= existing.createdAt) {
424442
childRunsBySessionKey.set(childKey, run);

src/agents/subagent-registry-queries.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,44 @@ describe("subagent registry query regressions", () => {
144144
expect(countPendingDescendantRunsFromRuns(runs, parentSessionKey)).toBe(2);
145145
});
146146

147+
it("ignores stale older parent rows when a child session moved to a newer controller", () => {
148+
const oldParentSessionKey = "agent:main:subagent:old-parent";
149+
const newParentSessionKey = "agent:main:subagent:new-parent";
150+
const childSessionKey = "agent:main:subagent:shared-child";
151+
const runs = toRunMap([
152+
makeRun({
153+
runId: "run-old-parent",
154+
childSessionKey: oldParentSessionKey,
155+
requesterSessionKey: "agent:main:main",
156+
createdAt: 100,
157+
}),
158+
makeRun({
159+
runId: "run-new-parent",
160+
childSessionKey: newParentSessionKey,
161+
requesterSessionKey: "agent:main:main",
162+
createdAt: 200,
163+
}),
164+
makeRun({
165+
runId: "run-child-stale-parent",
166+
childSessionKey,
167+
requesterSessionKey: oldParentSessionKey,
168+
controllerSessionKey: oldParentSessionKey,
169+
createdAt: 300,
170+
endedAt: 350,
171+
}),
172+
makeRun({
173+
runId: "run-child-current-parent",
174+
childSessionKey,
175+
requesterSessionKey: newParentSessionKey,
176+
controllerSessionKey: newParentSessionKey,
177+
createdAt: 400,
178+
}),
179+
]);
180+
181+
expect(countPendingDescendantRunsFromRuns(runs, oldParentSessionKey)).toBe(0);
182+
expect(countPendingDescendantRunsFromRuns(runs, newParentSessionKey)).toBe(1);
183+
});
184+
147185
it("regression excluding current run, countPendingDescendantRunsExcludingRun keeps sibling gating intact", () => {
148186
// Regression guard: excluding the currently announcing run must not hide sibling pending work.
149187
const runs = toRunMap([

src/agents/subagent-registry-queries.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,14 @@ function forEachDescendantRun(
188188
}
189189
}
190190
for (const [runId, entry] of latestByChildSessionKey.values()) {
191+
const latestForChildSession = findLatestRunForChildSession(runs, entry.childSessionKey);
192+
if (
193+
!latestForChildSession ||
194+
latestForChildSession.runId !== runId ||
195+
latestForChildSession.requesterSessionKey !== requester
196+
) {
197+
continue;
198+
}
191199
visitor(runId, entry);
192200
const childKey = entry.childSessionKey.trim();
193201
if (!childKey || visited.has(childKey)) {

0 commit comments

Comments
 (0)