fix: clean up failed non-thread subagent spawns

This commit is contained in:
Tak Hoffman 2026-03-24 08:24:32 -05:00
parent 0a04ef494d
commit b72d0c8459
No known key found for this signature in database
2 changed files with 73 additions and 15 deletions

View File

@ -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 {

View File

@ -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,
});
});
});