From ee13674b1844818e300b365afadac8dd84067475 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 22:12:37 +0000 Subject: [PATCH 1/6] fix(core): don't assume a 64-character idempotency key is pre-hashed on reset `resetIdempotencyKey` treated any 64-character string as an already-computed hash and sent it to the API verbatim. That short-circuit ran before the scope logic, so a user key that is itself a 64-character digest had an explicitly passed `scope` silently discarded and was sent un-hashed, matching no run. A 64-character string is now only passed through when there is evidence it is already a hash: the idempotency key catalog recognises it (so it came from `idempotencyKeys.create()`), or no `scope` was passed and the length is the only signal available. An explicit `scope` is an explicit request to derive the hash, so it is always honoured. This keeps both existing behaviours intact: a key from `idempotencyKeys.create()` is still forwarded unchanged, and 64-character key material passed straight to `trigger()` and reset without a scope is still sent verbatim. `isIdempotencyKey` is deliberately untouched, since the trigger path is self-consistent and changing it would invalidate already-stored keys. Co-Authored-By: Claude --- .changeset/reset-idempotency-key-64-char.md | 5 + packages/core/src/v3/idempotencyKeys.test.ts | 100 ++++++++++++++++++- packages/core/src/v3/idempotencyKeys.ts | 29 ++++-- 3 files changed, 123 insertions(+), 11 deletions(-) create mode 100644 .changeset/reset-idempotency-key-64-char.md diff --git a/.changeset/reset-idempotency-key-64-char.md b/.changeset/reset-idempotency-key-64-char.md new file mode 100644 index 00000000000..b203043a483 --- /dev/null +++ b/.changeset/reset-idempotency-key-64-char.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/core": patch +--- + +`idempotencyKeys.reset()` now works when your idempotency key is itself 64 characters long (for example if you use a hash of your own as the key). Previously any 64-character key was assumed to be already hashed, so passing one along with a `scope` silently ignored the scope and the reset never found a matching run. Keys returned by `idempotencyKeys.create()` continue to be reset exactly as before. diff --git a/packages/core/src/v3/idempotencyKeys.test.ts b/packages/core/src/v3/idempotencyKeys.test.ts index f511a85f869..632de04b7fc 100644 --- a/packages/core/src/v3/idempotencyKeys.test.ts +++ b/packages/core/src/v3/idempotencyKeys.test.ts @@ -1,9 +1,14 @@ -import { describe, it, expect } from "vitest"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { apiClientManager } from "./apiClientManager-api.js"; import { createIdempotencyKey, getIdempotencyKeyOptions, + resetIdempotencyKey, resetIdempotencyKeyCatalog, } from "./idempotencyKeys.js"; +import { digestSHA256 } from "./utils/crypto.js"; describe("idempotencyKeys metadata retention", () => { it("retains key/scope options for every key created in a run, even beyond 1000", async () => { @@ -40,3 +45,96 @@ describe("idempotencyKeys metadata retention", () => { expect(getIdempotencyKeyOptions(key)).toBeUndefined(); }); }); + +describe("resetIdempotencyKey", () => { + // A user key that is itself a 64-character digest, which is indistinguishable by + // length from a key returned by `idempotencyKeys.create()`. + const digestShapedKey = "a".repeat(64); + + let server: Server; + let resetKeys: string[] = []; + + /** The value `resetIdempotencyKey` put on the wire. */ + async function resetAndCaptureKey( + ...args: Parameters + ): Promise { + resetKeys = []; + await resetIdempotencyKey(...args); + expect(resetKeys).toHaveLength(1); + return resetKeys[0]!; + } + + beforeEach(async () => { + resetIdempotencyKeyCatalog(); + + server = createServer((req, res) => { + req.resume(); + req.on("end", () => { + const match = /^\/api\/v1\/idempotencyKeys\/(.+)\/reset$/.exec(req.url ?? ""); + if (!match) { + res.writeHead(404).end(); + return; + } + + resetKeys.push(decodeURIComponent(match[1]!)); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ id: "run_reset" })); + }); + }); + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve()); + }); + + apiClientManager.setGlobalAPIClientConfiguration({ + baseURL: `http://127.0.0.1:${(server.address() as AddressInfo).port}`, + accessToken: "tr_test_key", + }); + }); + + afterEach(async () => { + apiClientManager.disable(); + resetIdempotencyKeyCatalog(); + await new Promise((resolve) => server.close(() => resolve())); + }); + + it("hashes 64-character key material when an explicit scope is passed", async () => { + const created = await createIdempotencyKey(digestShapedKey, { scope: "global" }); + + // The reset happens in a different process from the trigger (e.g. from a + // lifecycle hook), so the catalog no longer knows the key. + resetIdempotencyKeyCatalog(); + + expect(await resetAndCaptureKey("my-task", digestShapedKey, { scope: "global" })).toBe(created); + }); + + it("hashes 64-character key material for run scope when an explicit scope is passed", async () => { + const parentRunId = "run_abc123"; + const expected = await digestSHA256(`${digestShapedKey}-${parentRunId}`); + + expect( + await resetAndCaptureKey("my-task", digestShapedKey, { scope: "run", parentRunId }) + ).toBe(expected); + }); + + it("sends a key created with idempotencyKeys.create() unchanged", async () => { + const created = await createIdempotencyKey("my-key", { scope: "global" }); + + expect(await resetAndCaptureKey("my-task", created)).toBe(created); + // An explicit scope must not hash an already-created key a second time. + expect(await resetAndCaptureKey("my-task", created, { scope: "global" })).toBe(created); + }); + + it("sends a 64-character key unchanged when no scope is passed", async () => { + // Passing 64-character material straight to `trigger()` stores it un-hashed, so + // resetting it without a scope must keep sending it verbatim. + expect(await resetAndCaptureKey("my-task", digestShapedKey)).toBe(digestShapedKey); + }); + + it("hashes key material that is not 64 characters", async () => { + const created = await createIdempotencyKey("my-key", { scope: "global" }); + resetIdempotencyKeyCatalog(); + + expect(await resetAndCaptureKey("my-task", "my-key", { scope: "global" })).toBe(created); + }); +}); diff --git a/packages/core/src/v3/idempotencyKeys.ts b/packages/core/src/v3/idempotencyKeys.ts index 585f38c1c30..5a7f701b77d 100644 --- a/packages/core/src/v3/idempotencyKeys.ts +++ b/packages/core/src/v3/idempotencyKeys.ts @@ -234,19 +234,28 @@ export async function resetIdempotencyKey( ): Promise<{ id: string }> { const client = apiClientManager.clientOrThrow(); - // If the key is already a 64-char hash, use it directly + // A 64-character string is ambiguous: it can be a hash returned by + // `idempotencyKeys.create()`, or it can be the caller's own key material (using + // a digest of some identity as the key is common). Send it through untouched + // only when we have evidence it is already a hash: + // + // - the catalog recognises it, so it came from `idempotencyKeys.create()`, or + // - no `scope` was passed, so there is nothing to derive a hash from and the + // length is the only signal available. + // + // An explicit `scope` is an explicit request to derive the hash, so we never + // short-circuit past it. Previously any 64-character key material was assumed to + // be pre-hashed and sent as-is, which matched no run. if (typeof idempotencyKey === "string" && idempotencyKey.length === 64) { - return client.resetIdempotencyKey(taskIdentifier, idempotencyKey, requestOptions); - } + const isCreatedKey = getIdempotencyKeyOptions(idempotencyKey) !== undefined; - // Try to extract options from an IdempotencyKey created with idempotencyKeys.create() - const attachedOptions = - typeof idempotencyKey === "string" ? getIdempotencyKeyOptions(idempotencyKey) : undefined; + if (isCreatedKey || options?.scope === undefined) { + return client.resetIdempotencyKey(taskIdentifier, idempotencyKey, requestOptions); + } + } - const scope = attachedOptions?.scope ?? options?.scope ?? "run"; - const keyArray = Array.isArray(idempotencyKey) - ? idempotencyKey - : [attachedOptions?.key ?? String(idempotencyKey)]; + const scope = options?.scope ?? "run"; + const keyArray = Array.isArray(idempotencyKey) ? idempotencyKey : [idempotencyKey]; // Build scope suffix based on scope type let scopeSuffix: string[] = []; From a92c878f6f6292c43a4f656e2915a96ca02f50b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 22:17:05 +0000 Subject: [PATCH 2/6] chore: trim comments Co-Authored-By: Claude --- packages/core/src/v3/idempotencyKeys.test.ts | 9 +-------- packages/core/src/v3/idempotencyKeys.ts | 13 +------------ 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/packages/core/src/v3/idempotencyKeys.test.ts b/packages/core/src/v3/idempotencyKeys.test.ts index 632de04b7fc..8960f04923d 100644 --- a/packages/core/src/v3/idempotencyKeys.test.ts +++ b/packages/core/src/v3/idempotencyKeys.test.ts @@ -47,14 +47,11 @@ describe("idempotencyKeys metadata retention", () => { }); describe("resetIdempotencyKey", () => { - // A user key that is itself a 64-character digest, which is indistinguishable by - // length from a key returned by `idempotencyKeys.create()`. const digestShapedKey = "a".repeat(64); let server: Server; let resetKeys: string[] = []; - /** The value `resetIdempotencyKey` put on the wire. */ async function resetAndCaptureKey( ...args: Parameters ): Promise { @@ -101,8 +98,7 @@ describe("resetIdempotencyKey", () => { it("hashes 64-character key material when an explicit scope is passed", async () => { const created = await createIdempotencyKey(digestShapedKey, { scope: "global" }); - // The reset happens in a different process from the trigger (e.g. from a - // lifecycle hook), so the catalog no longer knows the key. + // The reset can happen in a different process from the trigger resetIdempotencyKeyCatalog(); expect(await resetAndCaptureKey("my-task", digestShapedKey, { scope: "global" })).toBe(created); @@ -121,13 +117,10 @@ describe("resetIdempotencyKey", () => { const created = await createIdempotencyKey("my-key", { scope: "global" }); expect(await resetAndCaptureKey("my-task", created)).toBe(created); - // An explicit scope must not hash an already-created key a second time. expect(await resetAndCaptureKey("my-task", created, { scope: "global" })).toBe(created); }); it("sends a 64-character key unchanged when no scope is passed", async () => { - // Passing 64-character material straight to `trigger()` stores it un-hashed, so - // resetting it without a scope must keep sending it verbatim. expect(await resetAndCaptureKey("my-task", digestShapedKey)).toBe(digestShapedKey); }); diff --git a/packages/core/src/v3/idempotencyKeys.ts b/packages/core/src/v3/idempotencyKeys.ts index 5a7f701b77d..e2684491589 100644 --- a/packages/core/src/v3/idempotencyKeys.ts +++ b/packages/core/src/v3/idempotencyKeys.ts @@ -234,18 +234,7 @@ export async function resetIdempotencyKey( ): Promise<{ id: string }> { const client = apiClientManager.clientOrThrow(); - // A 64-character string is ambiguous: it can be a hash returned by - // `idempotencyKeys.create()`, or it can be the caller's own key material (using - // a digest of some identity as the key is common). Send it through untouched - // only when we have evidence it is already a hash: - // - // - the catalog recognises it, so it came from `idempotencyKeys.create()`, or - // - no `scope` was passed, so there is nothing to derive a hash from and the - // length is the only signal available. - // - // An explicit `scope` is an explicit request to derive the hash, so we never - // short-circuit past it. Previously any 64-character key material was assumed to - // be pre-hashed and sent as-is, which matched no run. + // A 64-char key is only assumed pre-hashed if the catalog knows it, or there's no scope to hash with if (typeof idempotencyKey === "string" && idempotencyKey.length === 64) { const isCreatedKey = getIdempotencyKeyOptions(idempotencyKey) !== undefined; From 1a28453c56f1e1763efd1ad01eec9dd78c27b5bc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 09:45:05 +0000 Subject: [PATCH 3/6] fix(core): retry a 64-character idempotency key verbatim when the derived hash misses A 64-character string passed to reset() with an explicit scope is ambiguous: it may be raw key material to hash, or a key already produced by create(). The catalog can only tell the two apart in-process, and workers clear it at each run boundary, so resetting a created key from another run or process while passing a scope would double-hash it and match nothing. Send the derived hash first, then fall back to the value verbatim on a 404. Non-404s propagate immediately, and a double miss surfaces the derived attempt's error. Co-Authored-By: Claude --- packages/core/src/v3/idempotencyKeys.test.ts | 98 +++++++++++++++++++- packages/core/src/v3/idempotencyKeys.ts | 24 ++++- 2 files changed, 118 insertions(+), 4 deletions(-) diff --git a/packages/core/src/v3/idempotencyKeys.test.ts b/packages/core/src/v3/idempotencyKeys.test.ts index 8960f04923d..95fafa90745 100644 --- a/packages/core/src/v3/idempotencyKeys.test.ts +++ b/packages/core/src/v3/idempotencyKeys.test.ts @@ -5,6 +5,7 @@ import { apiClientManager } from "./apiClientManager-api.js"; import { createIdempotencyKey, getIdempotencyKeyOptions, + makeIdempotencyKey, resetIdempotencyKey, resetIdempotencyKeyCatalog, } from "./idempotencyKeys.js"; @@ -51,6 +52,14 @@ describe("resetIdempotencyKey", () => { let server: Server; let resetKeys: string[] = []; + /** Keys the server has runs for. `undefined` means "accept every key". */ + let existingKeys: Set | undefined; + /** When set, every request fails with this status instead. */ + let forcedStatus: number | undefined; + + function notFoundMessage(key: string) { + return `No runs found with idempotency key: ${key}`; + } async function resetAndCaptureKey( ...args: Parameters @@ -63,6 +72,9 @@ describe("resetIdempotencyKey", () => { beforeEach(async () => { resetIdempotencyKeyCatalog(); + resetKeys = []; + existingKeys = undefined; + forcedStatus = undefined; server = createServer((req, res) => { req.resume(); @@ -73,7 +85,21 @@ describe("resetIdempotencyKey", () => { return; } - resetKeys.push(decodeURIComponent(match[1]!)); + const key = decodeURIComponent(match[1]!); + resetKeys.push(key); + + if (forcedStatus !== undefined) { + res.writeHead(forcedStatus, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: `request failed for ${key}` })); + return; + } + + if (existingKeys !== undefined && !existingKeys.has(key)) { + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: notFoundMessage(key) })); + return; + } + res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ id: "run_reset" })); }); @@ -113,17 +139,85 @@ describe("resetIdempotencyKey", () => { ).toBe(expected); }); - it("sends a key created with idempotencyKeys.create() unchanged", async () => { + it("sends a key created with idempotencyKeys.create() unchanged while the catalog knows it", async () => { const created = await createIdempotencyKey("my-key", { scope: "global" }); + existingKeys = new Set([created]); expect(await resetAndCaptureKey("my-task", created)).toBe(created); expect(await resetAndCaptureKey("my-task", created, { scope: "global" })).toBe(created); }); + it("sends a created key unchanged when no scope is passed and the catalog is cold", async () => { + const created = await createIdempotencyKey("my-key", { scope: "global" }); + + // The reset can happen in a different process from the create + resetIdempotencyKeyCatalog(); + existingKeys = new Set([created]); + + expect(await resetAndCaptureKey("my-task", created)).toBe(created); + }); + + it("falls back to the verbatim key when a created key is reset with a scope and the catalog is cold", async () => { + const created = await createIdempotencyKey("my-key", { scope: "global" }); + + resetIdempotencyKeyCatalog(); + existingKeys = new Set([created]); + + await resetIdempotencyKey("my-task", created, { scope: "global" }); + + // The derived hash misses, so the already-hashed key is retried verbatim + expect(resetKeys).toEqual([await digestSHA256(created), created]); + }); + it("sends a 64-character key unchanged when no scope is passed", async () => { expect(await resetAndCaptureKey("my-task", digestShapedKey)).toBe(digestShapedKey); }); + it("resets 64-character material that trigger stored verbatim when no scope is passed", async () => { + // trigger() forwards 64-character material as-is, so that is what the server stored + expect(await makeIdempotencyKey(digestShapedKey)).toBe(digestShapedKey); + existingKeys = new Set([digestShapedKey]); + + expect(await resetAndCaptureKey("my-task", digestShapedKey)).toBe(digestShapedKey); + }); + + it("does not fall back when the derived hash for 64-character material matches", async () => { + const created = await createIdempotencyKey(digestShapedKey, { scope: "global" }); + + resetIdempotencyKeyCatalog(); + existingKeys = new Set([created]); + + await resetIdempotencyKey("my-task", digestShapedKey, { scope: "global" }); + + expect(resetKeys).toEqual([created]); + }); + + it("does not fall back when the first attempt fails with a non-404", async () => { + forcedStatus = 500; + + await expect( + resetIdempotencyKey( + "my-task", + digestShapedKey, + { scope: "global" }, + { retry: { maxAttempts: 1 } } + ) + ).rejects.toMatchObject({ status: 500 }); + + expect(resetKeys).toHaveLength(1); + }); + + it("surfaces the derived key's error when both attempts 404", async () => { + const derived = await digestSHA256(digestShapedKey); + existingKeys = new Set(); + + await expect( + resetIdempotencyKey("my-task", digestShapedKey, { scope: "global" }) + ).rejects.toThrow(notFoundMessage(derived)); + + expect(resetKeys).toEqual([derived, digestShapedKey]); + }); + it("hashes key material that is not 64 characters", async () => { const created = await createIdempotencyKey("my-key", { scope: "global" }); resetIdempotencyKeyCatalog(); diff --git a/packages/core/src/v3/idempotencyKeys.ts b/packages/core/src/v3/idempotencyKeys.ts index e2684491589..123b3179a64 100644 --- a/packages/core/src/v3/idempotencyKeys.ts +++ b/packages/core/src/v3/idempotencyKeys.ts @@ -8,6 +8,7 @@ import { taskContext } from "./task-context-api.js"; import type { IdempotencyKey } from "./types/idempotencyKeys.js"; import { digestSHA256 } from "./utils/crypto.js"; import type { ZodFetchOptions } from "./apiClient/core.js"; +import { NotFoundError } from "./apiClient/errors.js"; // Re-export types from catalog for backwards compatibility export type { @@ -235,7 +236,9 @@ export async function resetIdempotencyKey( const client = apiClientManager.clientOrThrow(); // A 64-char key is only assumed pre-hashed if the catalog knows it, or there's no scope to hash with - if (typeof idempotencyKey === "string" && idempotencyKey.length === 64) { + const is64CharKey = typeof idempotencyKey === "string" && idempotencyKey.length === 64; + + if (is64CharKey) { const isCreatedKey = getIdempotencyKeyOptions(idempotencyKey) !== undefined; if (isCreatedKey || options?.scope === undefined) { @@ -275,5 +278,22 @@ export async function resetIdempotencyKey( // Generate the hash using the same algorithm as createIdempotencyKey const hash = await generateIdempotencyKey(keyArray.concat(scopeSuffix)); - return client.resetIdempotencyKey(taskIdentifier, hash, requestOptions); + if (!is64CharKey) { + return client.resetIdempotencyKey(taskIdentifier, hash, requestOptions); + } + + // A 64-char key we had to hash may still have been pre-hashed, so fall back to it verbatim + try { + return await client.resetIdempotencyKey(taskIdentifier, hash, requestOptions); + } catch (error) { + if (!(error instanceof NotFoundError)) { + throw error; + } + + try { + return await client.resetIdempotencyKey(taskIdentifier, idempotencyKey, requestOptions); + } catch (fallbackError) { + throw fallbackError instanceof NotFoundError ? error : fallbackError; + } + } } From 8744482e68e07ccfd6bdf361f1f0209207bdd0e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 09:54:26 +0000 Subject: [PATCH 4/6] fix(core): don't let a failed speculative reset attempt abort the fallback Two problems with the 64-character reset fallback: The server answers 503, not 404, when Postgres matched nothing and it could not check the buffer, so a miss could arrive as a non-404 and the NotFoundError-only catch skipped the verbatim retry. The speculative request is a guess by construction, so any failure now falls through to the verbatim key. A double miss still surfaces the derived attempt's error, and a non-404 from the fallback surfaces instead. A pre-hashed key with "run" or "attempt" scope and no parentRunId threw before any request was made, which used to work. Send those verbatim when the key is already 64 characters; shorter material still throws, since there is nothing useful to send. Co-Authored-By: Claude --- packages/core/src/v3/idempotencyKeys.test.ts | 90 ++++++++++++++++++-- packages/core/src/v3/idempotencyKeys.ts | 13 +-- 2 files changed, 89 insertions(+), 14 deletions(-) diff --git a/packages/core/src/v3/idempotencyKeys.test.ts b/packages/core/src/v3/idempotencyKeys.test.ts index 95fafa90745..7c9a8a9cf27 100644 --- a/packages/core/src/v3/idempotencyKeys.test.ts +++ b/packages/core/src/v3/idempotencyKeys.test.ts @@ -54,8 +54,8 @@ describe("resetIdempotencyKey", () => { let resetKeys: string[] = []; /** Keys the server has runs for. `undefined` means "accept every key". */ let existingKeys: Set | undefined; - /** When set, every request fails with this status instead. */ - let forcedStatus: number | undefined; + /** Per-key failure statuses, applied before the existence check. */ + let statusByKey: Map; function notFoundMessage(key: string) { return `No runs found with idempotency key: ${key}`; @@ -74,7 +74,7 @@ describe("resetIdempotencyKey", () => { resetIdempotencyKeyCatalog(); resetKeys = []; existingKeys = undefined; - forcedStatus = undefined; + statusByKey = new Map(); server = createServer((req, res) => { req.resume(); @@ -88,8 +88,9 @@ describe("resetIdempotencyKey", () => { const key = decodeURIComponent(match[1]!); resetKeys.push(key); - if (forcedStatus !== undefined) { - res.writeHead(forcedStatus, { "content-type": "application/json" }); + const failWith = statusByKey.get(key); + if (failWith !== undefined) { + res.writeHead(failWith, { "content-type": "application/json" }); res.end(JSON.stringify({ error: `request failed for ${key}` })); return; } @@ -192,8 +193,28 @@ describe("resetIdempotencyKey", () => { expect(resetKeys).toEqual([created]); }); - it("does not fall back when the first attempt fails with a non-404", async () => { - forcedStatus = 500; + it("falls back to the verbatim key when the derived hash fails with a 503", async () => { + // The server answers 503, not 404, when it cannot check the buffer for a miss + const created = await createIdempotencyKey("my-key", { scope: "global" }); + + resetIdempotencyKeyCatalog(); + statusByKey.set(await digestSHA256(created), 503); + existingKeys = new Set([created]); + + await resetIdempotencyKey( + "my-task", + created, + { scope: "global" }, + { retry: { maxAttempts: 1 } } + ); + + expect(resetKeys).toEqual([await digestSHA256(created), created]); + }); + + it("surfaces the derived key's error when it fails with a 503 and the fallback finds nothing", async () => { + const derived = await digestSHA256(digestShapedKey); + statusByKey.set(derived, 503); + existingKeys = new Set(); await expect( resetIdempotencyKey( @@ -202,9 +223,26 @@ describe("resetIdempotencyKey", () => { { scope: "global" }, { retry: { maxAttempts: 1 } } ) - ).rejects.toMatchObject({ status: 500 }); + ).rejects.toMatchObject({ status: 503 }); - expect(resetKeys).toHaveLength(1); + expect(resetKeys).toEqual([derived, digestShapedKey]); + }); + + it("surfaces the fallback's error when it fails with something other than a 404", async () => { + const derived = await digestSHA256(digestShapedKey); + statusByKey.set(derived, 404); + statusByKey.set(digestShapedKey, 503); + + await expect( + resetIdempotencyKey( + "my-task", + digestShapedKey, + { scope: "global" }, + { retry: { maxAttempts: 1 } } + ) + ).rejects.toMatchObject({ status: 503 }); + + expect(resetKeys).toEqual([derived, digestShapedKey]); }); it("surfaces the derived key's error when both attempts 404", async () => { @@ -224,4 +262,38 @@ describe("resetIdempotencyKey", () => { expect(await resetAndCaptureKey("my-task", "my-key", { scope: "global" })).toBe(created); }); + + it("sends a 64-character key verbatim when run scope cannot be derived", async () => { + const created = await createIdempotencyKey("my-key", { scope: "run" }); + + resetIdempotencyKeyCatalog(); + existingKeys = new Set([created]); + + // No parentRunId and no task context, so the hash is underivable + expect(await resetAndCaptureKey("my-task", created, { scope: "run" })).toBe(created); + }); + + it("sends a 64-character key verbatim when attempt scope cannot be derived", async () => { + existingKeys = new Set([digestShapedKey]); + + expect(await resetAndCaptureKey("my-task", digestShapedKey, { scope: "attempt" })).toBe( + digestShapedKey + ); + }); + + it("still throws for non-64-character material when run scope cannot be derived", async () => { + await expect(resetIdempotencyKey("my-task", "my-key", { scope: "run" })).rejects.toThrow( + "parentRunId is required for 'run' scope" + ); + + expect(resetKeys).toEqual([]); + }); + + it("still throws for non-64-character material when attempt scope cannot be derived", async () => { + await expect( + resetIdempotencyKey("my-task", "my-key", { scope: "attempt", parentRunId: "run_abc123" }) + ).rejects.toThrow("parentRunId and attemptNumber are required for 'attempt' scope"); + + expect(resetKeys).toEqual([]); + }); }); diff --git a/packages/core/src/v3/idempotencyKeys.ts b/packages/core/src/v3/idempotencyKeys.ts index 123b3179a64..643a22ae981 100644 --- a/packages/core/src/v3/idempotencyKeys.ts +++ b/packages/core/src/v3/idempotencyKeys.ts @@ -255,6 +255,10 @@ export async function resetIdempotencyKey( case "run": { const parentRunId = options?.parentRunId ?? taskContext?.ctx?.run.id; if (!parentRunId) { + // We can't derive a hash, but a 64-char key may already be one, so try it rather than fail + if (is64CharKey) { + return client.resetIdempotencyKey(taskIdentifier, idempotencyKey, requestOptions); + } throw new Error( "resetIdempotencyKey: parentRunId is required for 'run' scope when called outside a task context" ); @@ -266,6 +270,9 @@ export async function resetIdempotencyKey( const parentRunId = options?.parentRunId ?? taskContext?.ctx?.run.id; const attemptNumber = options?.attemptNumber ?? taskContext?.ctx?.attempt.number; if (!parentRunId || attemptNumber === undefined) { + if (is64CharKey) { + return client.resetIdempotencyKey(taskIdentifier, idempotencyKey, requestOptions); + } throw new Error( "resetIdempotencyKey: parentRunId and attemptNumber are required for 'attempt' scope when called outside a task context" ); @@ -282,14 +289,10 @@ export async function resetIdempotencyKey( return client.resetIdempotencyKey(taskIdentifier, hash, requestOptions); } - // A 64-char key we had to hash may still have been pre-hashed, so fall back to it verbatim + // Hashing a 64-char key is a guess, so if it fails at all, still try the key verbatim try { return await client.resetIdempotencyKey(taskIdentifier, hash, requestOptions); } catch (error) { - if (!(error instanceof NotFoundError)) { - throw error; - } - try { return await client.resetIdempotencyKey(taskIdentifier, idempotencyKey, requestOptions); } catch (fallbackError) { From fa7634f8c64d0191af0481dbc46a332dcc0c2042 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 19 Aug 2026 11:43:17 +0100 Subject: [PATCH 5/6] fix(core): try a 64-character reset key verbatim before the derived hash The scoped reset of an ambiguous 64-character key sent the derived hash first and only fell back to the verbatim value on failure. That reversed the precedence every previous version had: a key stored verbatim (the only case that used to work) now cost an extra request, error messages named a hash the caller never passed, and when runs existed under both values the derived one was reset instead of the verbatim one. Sending the verbatim key first keeps every previously working call byte-identical (same single request, same target run, same error) and makes the derived hash a pure fallback, so the newly fixed create() flow still resolves on the second attempt. A created key reset with a scope now also resolves in one request, since the created key is itself the stored value. --- packages/core/src/v3/idempotencyKeys.test.ts | 59 ++++++++++---------- packages/core/src/v3/idempotencyKeys.ts | 5 +- 2 files changed, 31 insertions(+), 33 deletions(-) diff --git a/packages/core/src/v3/idempotencyKeys.test.ts b/packages/core/src/v3/idempotencyKeys.test.ts index 7c9a8a9cf27..6528ef3ad00 100644 --- a/packages/core/src/v3/idempotencyKeys.test.ts +++ b/packages/core/src/v3/idempotencyKeys.test.ts @@ -122,22 +122,25 @@ describe("resetIdempotencyKey", () => { await new Promise((resolve) => server.close(() => resolve())); }); - it("hashes 64-character key material when an explicit scope is passed", async () => { + it("derives the hash for 64-character key material with an explicit scope when the verbatim key misses", async () => { const created = await createIdempotencyKey(digestShapedKey, { scope: "global" }); - // The reset can happen in a different process from the trigger resetIdempotencyKeyCatalog(); + existingKeys = new Set([created]); + + await resetIdempotencyKey("my-task", digestShapedKey, { scope: "global" }); - expect(await resetAndCaptureKey("my-task", digestShapedKey, { scope: "global" })).toBe(created); + expect(resetKeys).toEqual([digestShapedKey, created]); }); - it("hashes 64-character key material for run scope when an explicit scope is passed", async () => { + it("derives the run-scoped hash for 64-character key material when the verbatim key misses", async () => { const parentRunId = "run_abc123"; const expected = await digestSHA256(`${digestShapedKey}-${parentRunId}`); + existingKeys = new Set([expected]); + + await resetIdempotencyKey("my-task", digestShapedKey, { scope: "run", parentRunId }); - expect( - await resetAndCaptureKey("my-task", digestShapedKey, { scope: "run", parentRunId }) - ).toBe(expected); + expect(resetKeys).toEqual([digestShapedKey, expected]); }); it("sends a key created with idempotencyKeys.create() unchanged while the catalog knows it", async () => { @@ -158,16 +161,13 @@ describe("resetIdempotencyKey", () => { expect(await resetAndCaptureKey("my-task", created)).toBe(created); }); - it("falls back to the verbatim key when a created key is reset with a scope and the catalog is cold", async () => { + it("resolves a created key in one request when reset with a scope and the catalog is cold", async () => { const created = await createIdempotencyKey("my-key", { scope: "global" }); resetIdempotencyKeyCatalog(); existingKeys = new Set([created]); - await resetIdempotencyKey("my-task", created, { scope: "global" }); - - // The derived hash misses, so the already-hashed key is retried verbatim - expect(resetKeys).toEqual([await digestSHA256(created), created]); + expect(await resetAndCaptureKey("my-task", created, { scope: "global" })).toBe(created); }); it("sends a 64-character key unchanged when no scope is passed", async () => { @@ -182,38 +182,37 @@ describe("resetIdempotencyKey", () => { expect(await resetAndCaptureKey("my-task", digestShapedKey)).toBe(digestShapedKey); }); - it("does not fall back when the derived hash for 64-character material matches", async () => { + it("resets the verbatim run when runs exist under both the verbatim key and the derived hash", async () => { const created = await createIdempotencyKey(digestShapedKey, { scope: "global" }); resetIdempotencyKeyCatalog(); - existingKeys = new Set([created]); + existingKeys = new Set([digestShapedKey, created]); await resetIdempotencyKey("my-task", digestShapedKey, { scope: "global" }); - expect(resetKeys).toEqual([created]); + expect(resetKeys).toEqual([digestShapedKey]); }); - it("falls back to the verbatim key when the derived hash fails with a 503", async () => { - // The server answers 503, not 404, when it cannot check the buffer for a miss - const created = await createIdempotencyKey("my-key", { scope: "global" }); + it("falls back to the derived hash when the verbatim attempt fails with a 503", async () => { + const created = await createIdempotencyKey(digestShapedKey, { scope: "global" }); resetIdempotencyKeyCatalog(); - statusByKey.set(await digestSHA256(created), 503); + statusByKey.set(digestShapedKey, 503); existingKeys = new Set([created]); await resetIdempotencyKey( "my-task", - created, + digestShapedKey, { scope: "global" }, { retry: { maxAttempts: 1 } } ); - expect(resetKeys).toEqual([await digestSHA256(created), created]); + expect(resetKeys).toEqual([digestShapedKey, created]); }); - it("surfaces the derived key's error when it fails with a 503 and the fallback finds nothing", async () => { + it("surfaces the verbatim attempt's error when it fails with a 503 and the fallback finds nothing", async () => { const derived = await digestSHA256(digestShapedKey); - statusByKey.set(derived, 503); + statusByKey.set(digestShapedKey, 503); existingKeys = new Set(); await expect( @@ -225,13 +224,13 @@ describe("resetIdempotencyKey", () => { ) ).rejects.toMatchObject({ status: 503 }); - expect(resetKeys).toEqual([derived, digestShapedKey]); + expect(resetKeys).toEqual([digestShapedKey, derived]); }); it("surfaces the fallback's error when it fails with something other than a 404", async () => { const derived = await digestSHA256(digestShapedKey); - statusByKey.set(derived, 404); - statusByKey.set(digestShapedKey, 503); + statusByKey.set(digestShapedKey, 404); + statusByKey.set(derived, 503); await expect( resetIdempotencyKey( @@ -242,18 +241,18 @@ describe("resetIdempotencyKey", () => { ) ).rejects.toMatchObject({ status: 503 }); - expect(resetKeys).toEqual([derived, digestShapedKey]); + expect(resetKeys).toEqual([digestShapedKey, derived]); }); - it("surfaces the derived key's error when both attempts 404", async () => { + it("surfaces the verbatim key's error when both attempts 404", async () => { const derived = await digestSHA256(digestShapedKey); existingKeys = new Set(); await expect( resetIdempotencyKey("my-task", digestShapedKey, { scope: "global" }) - ).rejects.toThrow(notFoundMessage(derived)); + ).rejects.toThrow(notFoundMessage(digestShapedKey)); - expect(resetKeys).toEqual([derived, digestShapedKey]); + expect(resetKeys).toEqual([digestShapedKey, derived]); }); it("hashes key material that is not 64 characters", async () => { diff --git a/packages/core/src/v3/idempotencyKeys.ts b/packages/core/src/v3/idempotencyKeys.ts index 643a22ae981..60581e05f99 100644 --- a/packages/core/src/v3/idempotencyKeys.ts +++ b/packages/core/src/v3/idempotencyKeys.ts @@ -289,12 +289,11 @@ export async function resetIdempotencyKey( return client.resetIdempotencyKey(taskIdentifier, hash, requestOptions); } - // Hashing a 64-char key is a guess, so if it fails at all, still try the key verbatim try { - return await client.resetIdempotencyKey(taskIdentifier, hash, requestOptions); + return await client.resetIdempotencyKey(taskIdentifier, idempotencyKey, requestOptions); } catch (error) { try { - return await client.resetIdempotencyKey(taskIdentifier, idempotencyKey, requestOptions); + return await client.resetIdempotencyKey(taskIdentifier, hash, requestOptions); } catch (fallbackError) { throw fallbackError instanceof NotFoundError ? error : fallbackError; } From dc081e578bc40ca80666aa110952eb399fb5efb8 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 19 Aug 2026 11:59:32 +0100 Subject: [PATCH 6/6] fix(core): only fall back to the derived hash on a definitive not-found A transient failure (503, connection error) of the verbatim attempt leaves that key's state unknown. Issuing the derived-hash reset anyway is a write against a key the caller may not have targeted, and it reports success while the verbatim run stays deduplicated. Now only a 404, a definitive miss, unlocks the fallback; any other error surfaces unchanged, exactly as previous versions behaved. --- packages/core/src/v3/idempotencyKeys.test.ts | 19 ++----------------- packages/core/src/v3/idempotencyKeys.ts | 4 ++++ 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/packages/core/src/v3/idempotencyKeys.test.ts b/packages/core/src/v3/idempotencyKeys.test.ts index 6528ef3ad00..8749e1bc2c0 100644 --- a/packages/core/src/v3/idempotencyKeys.test.ts +++ b/packages/core/src/v3/idempotencyKeys.test.ts @@ -193,28 +193,13 @@ describe("resetIdempotencyKey", () => { expect(resetKeys).toEqual([digestShapedKey]); }); - it("falls back to the derived hash when the verbatim attempt fails with a 503", async () => { + it("does not reset the derived run when the verbatim attempt fails transiently", async () => { const created = await createIdempotencyKey(digestShapedKey, { scope: "global" }); resetIdempotencyKeyCatalog(); statusByKey.set(digestShapedKey, 503); existingKeys = new Set([created]); - await resetIdempotencyKey( - "my-task", - digestShapedKey, - { scope: "global" }, - { retry: { maxAttempts: 1 } } - ); - - expect(resetKeys).toEqual([digestShapedKey, created]); - }); - - it("surfaces the verbatim attempt's error when it fails with a 503 and the fallback finds nothing", async () => { - const derived = await digestSHA256(digestShapedKey); - statusByKey.set(digestShapedKey, 503); - existingKeys = new Set(); - await expect( resetIdempotencyKey( "my-task", @@ -224,7 +209,7 @@ describe("resetIdempotencyKey", () => { ) ).rejects.toMatchObject({ status: 503 }); - expect(resetKeys).toEqual([digestShapedKey, derived]); + expect(resetKeys).toEqual([digestShapedKey]); }); it("surfaces the fallback's error when it fails with something other than a 404", async () => { diff --git a/packages/core/src/v3/idempotencyKeys.ts b/packages/core/src/v3/idempotencyKeys.ts index 60581e05f99..97a21aa9d6e 100644 --- a/packages/core/src/v3/idempotencyKeys.ts +++ b/packages/core/src/v3/idempotencyKeys.ts @@ -292,6 +292,10 @@ export async function resetIdempotencyKey( try { return await client.resetIdempotencyKey(taskIdentifier, idempotencyKey, requestOptions); } catch (error) { + if (!(error instanceof NotFoundError)) { + throw error; + } + try { return await client.resetIdempotencyKey(taskIdentifier, hash, requestOptions); } catch (fallbackError) {