From 683a2dec507faaa22f0002064a6e63f4ff0d2144 Mon Sep 17 00:00:00 2001 From: Eva Date: Fri, 1 May 2026 20:24:43 +0700 Subject: [PATCH] fix: harden finalize retry metadata --- .../hooks.before-agent-finalize.test.ts | 35 +++++++++++++++++++ src/plugins/hooks.ts | 2 +- src/plugins/host-hook-runtime.ts | 3 ++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/plugins/hooks.before-agent-finalize.test.ts b/src/plugins/hooks.before-agent-finalize.test.ts index e6b60f0bae8..7985818c5cb 100644 --- a/src/plugins/hooks.before-agent-finalize.test.ts +++ b/src/plugins/hooks.before-agent-finalize.test.ts @@ -97,6 +97,41 @@ describe("before_agent_finalize hook runner", () => { }); }); + it("skips malformed retry instructions when merging revise decisions", async () => { + const runner = createHookRunner( + createMockPluginRegistry([ + { + hookName: "before_agent_finalize", + handler: vi.fn().mockResolvedValue({ + action: "revise", + reason: "malformed retry payload should not crash", + retry: { instruction: 123, idempotencyKey: "bad-retry" } as never, + }), + }, + { + hookName: "before_agent_finalize", + handler: vi.fn().mockResolvedValue({ + action: "revise", + reason: "valid retry still applies", + retry: { + instruction: " rerun the focused tests ", + idempotencyKey: "valid-retry", + }, + }), + }, + ]), + ); + + await expect(runner.runBeforeAgentFinalize(EVENT, TEST_PLUGIN_AGENT_CTX)).resolves.toEqual({ + action: "revise", + reason: "malformed retry payload should not crash\n\nvalid retry still applies", + retry: { + instruction: "rerun the focused tests", + idempotencyKey: "valid-retry", + }, + }); + }); + it("lets finalize override earlier revise decisions", async () => { const runner = createHookRunner( createMockPluginRegistry([ diff --git a/src/plugins/hooks.ts b/src/plugins/hooks.ts index 2c81893fb12..5e1a092363e 100644 --- a/src/plugins/hooks.ts +++ b/src/plugins/hooks.ts @@ -322,7 +322,7 @@ export function createHookRunner( const normalizeRetry = ( retry: PluginHookBeforeAgentFinalizeResult["retry"] | undefined, ): PluginHookBeforeAgentFinalizeResult["retry"] | undefined => { - const instruction = retry?.instruction.trim(); + const instruction = typeof retry?.instruction === "string" ? retry.instruction.trim() : ""; if (!instruction) { return undefined; } diff --git a/src/plugins/host-hook-runtime.ts b/src/plugins/host-hook-runtime.ts index 7005cf9423d..12ef63143e8 100644 --- a/src/plugins/host-hook-runtime.ts +++ b/src/plugins/host-hook-runtime.ts @@ -115,6 +115,9 @@ function waitForTerminalEventHandlers(params: { } let timeout: NodeJS.Timeout | undefined; const settled = Promise.allSettled(pendingHandlers).then(() => "settled" as const); + // Promise.race bounds the host wait; JavaScript cannot cancel the plugin + // promises themselves, so timeout also marks the run expired to block late + // run-context resurrection by handlers that eventually settle. const timedOut = new Promise<"timeout">((resolve) => { timeout = setTimeout(() => { markTerminalEventCleanupExpired(runId);