fix: preserve cleanup hooks after subagent register failure

This commit is contained in:
Tak Hoffman 2026-03-24 11:32:19 -05:00
parent 49e3f2db06
commit 79ef86c305
No known key found for this signature in database
2 changed files with 82 additions and 3 deletions

View File

@ -770,7 +770,11 @@ export async function spawnSubagentDirect(
try {
await callGateway({
method: "sessions.delete",
params: { key: childSessionKey, deleteTranscript: true, emitLifecycleHooks: false },
params: {
key: childSessionKey,
deleteTranscript: true,
emitLifecycleHooks: threadBindingReady,
},
timeoutMs: 10_000,
});
} catch {

View File

@ -19,6 +19,10 @@ const hoisted = vi.hoisted(() => ({
callGatewayMock: vi.fn(),
configOverride: {} as Record<string, unknown>,
registerSubagentRunMock: vi.fn(),
hookRunner: {
hasHooks: vi.fn(() => false),
runSubagentSpawning: vi.fn(),
},
}));
let spawnSubagentDirect: typeof import("./subagent-spawn.js").spawnSubagentDirect;
@ -68,7 +72,7 @@ vi.mock("./sandbox/runtime-status.js", () => ({
}));
vi.mock("../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: () => ({ hasHooks: () => false }),
getGlobalHookRunner: () => hoisted.hookRunner,
}));
vi.mock("../utils/delivery-context.js", () => ({
@ -144,7 +148,7 @@ async function loadFreshSubagentSpawnWorkspaceModuleForTest() {
resolveSandboxRuntimeStatus: () => ({ sandboxed: false }),
}));
vi.doMock("../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: () => ({ hasHooks: () => false }),
getGlobalHookRunner: () => hoisted.hookRunner,
}));
vi.doMock("../utils/delivery-context.js", () => ({
normalizeDeliveryContext: (value: unknown) => value,
@ -196,6 +200,9 @@ describe("spawnSubagentDirect workspace inheritance", () => {
await loadFreshSubagentSpawnWorkspaceModuleForTest();
hoisted.callGatewayMock.mockClear();
hoisted.registerSubagentRunMock.mockClear();
hoisted.hookRunner.hasHooks.mockReset();
hoisted.hookRunner.hasHooks.mockImplementation(() => false);
hoisted.hookRunner.runSubagentSpawning.mockReset();
hoisted.configOverride = createConfigOverride();
setupGatewayMock();
});
@ -289,4 +296,72 @@ describe("spawnSubagentDirect workspace inheritance", () => {
emitLifecycleHooks: false,
});
});
it("keeps lifecycle hooks enabled when registerSubagentRun fails after thread binding succeeds", async () => {
hoisted.hookRunner.hasHooks.mockImplementation((name?: string) => name === "subagent_spawning");
hoisted.hookRunner.runSubagentSpawning.mockResolvedValue({
status: "ok",
threadBindingReady: true,
});
hoisted.registerSubagentRunMock.mockImplementation(() => {
throw new Error("registry unavailable");
});
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") {
return { runId: "run-thread-register-fail" };
}
if (request.method === "sessions.delete") {
return { ok: true };
}
return {};
},
);
const result = await spawnSubagentDirect(
{
task: "fail after register with thread binding",
thread: true,
mode: "session",
},
{
agentSessionKey: "agent:main:main",
agentChannel: "discord",
agentAccountId: "acct-1",
agentTo: "user-1",
workspaceDir: "/tmp/requester-workspace",
},
);
expect(result).toMatchObject({
status: "error",
error: "Failed to register subagent run: registry unavailable",
childSessionKey: expect.stringMatching(/^agent:main:subagent:/),
runId: "run-thread-register-fail",
});
const deleteCall = hoisted.callGatewayMock.mock.calls.findLast(
([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: true,
});
});
});