diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index d75a8717a22..5a5a177c64d 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -685,6 +685,7 @@ export async function spawnSubagentDirect( // Best-effort cleanup only. } } + let emitLifecycleHooks = false; if (threadBindingReady) { const hasEndedHook = hookRunner?.hasHooks("subagent_ended") === true; let endedHookEmitted = false; @@ -712,21 +713,22 @@ export async function spawnSubagentDirect( // Spawn should still return an actionable error even if cleanup hooks fail. } } - // Always delete the provisional child session after a failed spawn attempt. - // If we already emitted subagent_ended above, suppress a duplicate lifecycle hook. - try { - await callGateway({ - method: "sessions.delete", - params: { - key: childSessionKey, - deleteTranscript: true, - emitLifecycleHooks: !endedHookEmitted, - }, - timeoutMs: 10_000, - }); - } catch { - // Best-effort only. - } + emitLifecycleHooks = !endedHookEmitted; + } + // Always delete the provisional child session after a failed spawn attempt. + // If we already emitted subagent_ended above, suppress a duplicate lifecycle hook. + try { + await callGateway({ + method: "sessions.delete", + params: { + key: childSessionKey, + deleteTranscript: true, + emitLifecycleHooks, + }, + timeoutMs: 10_000, + }); + } catch { + // Best-effort only. } const messageText = summarizeError(err); return { diff --git a/src/agents/subagent-spawn.workspace.test.ts b/src/agents/subagent-spawn.workspace.test.ts index 13796c9d03e..dcf0e079b01 100644 --- a/src/agents/subagent-spawn.workspace.test.ts +++ b/src/agents/subagent-spawn.workspace.test.ts @@ -231,4 +231,60 @@ describe("spawnSubagentDirect workspace inheritance", () => { expectedWorkspaceDir: "/tmp/requester-workspace", }); }); + + it("deletes the provisional child session when a non-thread subagent start fails", async () => { + hoisted.callGatewayMock.mockImplementation(async (request: { + method?: string; + params?: { key?: string; deleteTranscript?: boolean; emitLifecycleHooks?: boolean }; + }) => { + if (request.method === "sessions.patch") { + return { ok: true }; + } + if (request.method === "agent") { + throw new Error("spawn startup failed"); + } + if (request.method === "sessions.delete") { + return { ok: true }; + } + return {}; + }); + + const result = await spawnSubagentDirect( + { + task: "fail after provisional session creation", + }, + { + agentSessionKey: "agent:main:main", + agentChannel: "discord", + agentAccountId: "acct-1", + agentTo: "user-1", + workspaceDir: "/tmp/requester-workspace", + }, + ); + + expect(result).toMatchObject({ + status: "error", + error: "spawn startup failed", + }); + expect(result.childSessionKey).toMatch(/^agent:main:subagent:/); + expect(hoisted.registerSubagentRunMock).not.toHaveBeenCalled(); + + const deleteCall = hoisted.callGatewayMock.mock.calls.find( + ([request]) => (request as { method?: string }).method === "sessions.delete", + )?.[0] as + | { + params?: { + key?: string; + deleteTranscript?: boolean; + emitLifecycleHooks?: boolean; + }; + } + | undefined; + + expect(deleteCall?.params).toMatchObject({ + key: result.childSessionKey, + deleteTranscript: true, + emitLifecycleHooks: false, + }); + }); });