diff --git a/src/agents/subagent-control.ts b/src/agents/subagent-control.ts index 63cd2992a19..ab29fd2a58c 100644 --- a/src/agents/subagent-control.ts +++ b/src/agents/subagent-control.ts @@ -643,7 +643,8 @@ export async function steerControlledSubagentRun(params: { error: "Leaf subagents cannot control other sessions.", }; } - if (params.entry.endedAt) { + const targetHasPendingDescendants = countPendingDescendantRuns(params.entry.childSessionKey) > 0; + if (params.entry.endedAt && !targetHasPendingDescendants) { return { status: "done", runId: params.entry.runId, @@ -660,7 +661,13 @@ export async function steerControlledSubagentRun(params: { }; } const currentEntry = getSubagentRunByChildSessionKey(params.entry.childSessionKey); - if (!currentEntry || currentEntry.runId !== params.entry.runId || currentEntry.endedAt) { + const currentHasPendingDescendants = + currentEntry && countPendingDescendantRuns(currentEntry.childSessionKey) > 0; + if ( + !currentEntry || + currentEntry.runId !== params.entry.runId || + (currentEntry.endedAt && !currentHasPendingDescendants) + ) { return { status: "done", runId: params.entry.runId, diff --git a/src/auto-reply/reply/commands-subagents/action-send.ts b/src/auto-reply/reply/commands-subagents/action-send.ts index e360a2e975c..bba1c8f5373 100644 --- a/src/auto-reply/reply/commands-subagents/action-send.ts +++ b/src/auto-reply/reply/commands-subagents/action-send.ts @@ -33,9 +33,6 @@ export async function handleSubagentsSendAction( if ("reply" in targetResolution) { return targetResolution.reply; } - if (steerRequested && targetResolution.entry.endedAt) { - return stopWithText(`${formatRunLabel(targetResolution.entry)} is already finished.`); - } const controller = resolveCommandSubagentController(params, ctx.requesterKey); diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index ab0f846047d..172722c1a95 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -2186,6 +2186,63 @@ describe("handleCommands subagents", () => { expect(trackedRuns[0].endedAt).toBeUndefined(); }); + it("steers ended orchestrators that are still waiting on active descendants", async () => { + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as { method?: string }; + if (request.method === "agent") { + return { runId: "run-steer-ended-parent" }; + } + return {}; + }); + const parentKey = "agent:main:subagent:orchestrator-ended"; + const childKey = "agent:main:subagent:orchestrator-ended:subagent:child"; + const storePath = path.join(testWorkspaceDir, "sessions-subagents-steer-ended-parent.json"); + await updateSessionStore(storePath, (store) => { + store[parentKey] = { + sessionId: "ended-parent-session", + updatedAt: Date.now(), + }; + store[childKey] = { + sessionId: "active-child-session", + updatedAt: Date.now(), + }; + }); + addSubagentRunForTests({ + runId: "run-ended-parent", + childSessionKey: parentKey, + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "orchestrate child workers", + cleanup: "keep", + createdAt: Date.now() - 120_000, + startedAt: Date.now() - 120_000, + endedAt: Date.now() - 110_000, + outcome: { status: "ok" }, + }); + addSubagentRunForTests({ + runId: "run-active-child", + childSessionKey: childKey, + requesterSessionKey: parentKey, + requesterDisplayKey: "subagent:orchestrator-ended", + task: "child worker still running", + cleanup: "keep", + createdAt: Date.now() - 60_000, + startedAt: Date.now() - 60_000, + }); + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: storePath }, + } as OpenClawConfig; + const params = buildParams("/steer 1 regroup around the remaining child work", cfg); + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("steered"); + const trackedRuns = listSubagentRunsForRequester("agent:main:main"); + expect(trackedRuns[0].runId).toBe("run-steer-ended-parent"); + }); + it("restores announce behavior when /steer replacement dispatch fails", async () => { callGatewayMock.mockImplementation(async (opts: unknown) => { const request = opts as { method?: string };