From c8d8177424fa2d0edeeafdc49aa3b3a642ece00d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 22:12:37 +0000 Subject: [PATCH 1/4] 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 0000000000..b203043a48 --- /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 f511a85f86..632de04b7f 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 585f38c1c3..5a7f701b77 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 601698703792f6bf3b54a5c3953333eeab522409 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 22:17:05 +0000 Subject: [PATCH 2/4] 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 632de04b7f..8960f04923 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 5a7f701b77..e268449158 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 34057ba7f4c2342e2c760046a1266d6f14b3c48c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 09:45:05 +0000 Subject: [PATCH 3/4] 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 8960f04923..95fafa9074 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 e268449158..123b3179a6 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 d38e1b8ec2d3a813f30090448fb44f96997adb49 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 09:54:26 +0000 Subject: [PATCH 4/4] 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 95fafa9074..7c9a8a9cf2 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 123b3179a6..643a22ae98 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) {