From 0be2a85e43edc4ce10902e07c1cd98ff14f95cca Mon Sep 17 00:00:00 2001 From: jiashuoz Date: Tue, 14 Jul 2026 20:13:00 -0700 Subject: [PATCH 1/3] fix(cli): treat async accepted sends as success --- cli/src/__tests__/send.test.ts | 27 +++++++++++++++++++++++++-- cli/src/bin/e2a.ts | 2 +- cli/src/commands/send.ts | 18 +++++++++++------- cli/src/exit.ts | 15 +++++++-------- 4 files changed, 44 insertions(+), 18 deletions(-) diff --git a/cli/src/__tests__/send.test.ts b/cli/src/__tests__/send.test.ts index 977e6771..9cbff105 100644 --- a/cli/src/__tests__/send.test.ts +++ b/cli/src/__tests__/send.test.ts @@ -63,13 +63,25 @@ describe("send/reply commands", () => { expect(mockSend.mock.calls[0][2]).toEqual({ idempotencyKey: "evt-42" }); }); - it("treats any non-'sent' status as HELD (open-set status contract)", async () => { + it("treats an unknown status as an unexpected outcome, not a review hold", async () => { mockSend.mockResolvedValue({ messageId: "msg_u", status: "some_future_hold" }); const { send } = await import("../commands/send.js"); await send({ to: ["you@example.com"], subject: "s", body: "b" }); expect(mockStderr).toHaveBeenCalledWith(expect.stringContaining("some_future_hold")); - expect(process.exitCode).toBe(3); + expect(mockStderr).not.toHaveBeenCalledWith(expect.stringContaining("pending_review means")); + expect(process.exitCode).toBe(1); + }); + + it("exits OK when an async send is durably accepted", async () => { + mockSend.mockResolvedValue({ messageId: "msg_accepted", status: "accepted" }); + const { send } = await import("../commands/send.js"); + + await send({ to: ["you@example.com"], subject: "hi", body: "hello" }); + + expect(mockStdout).toHaveBeenCalledWith("msg_accepted\n"); + expect(mockStderr).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(0); }); it("sets exit code HELD (3) with a warning when the send is pending_review", async () => { @@ -149,6 +161,17 @@ describe("send/reply commands", () => { expect(process.exitCode).toBe(3); }); + it("exits OK when an async reply is durably accepted", async () => { + mockReply.mockResolvedValue({ messageId: "msg_reply_accepted", status: "accepted" }); + const { reply } = await import("../commands/send.js"); + + await reply("msg_orig", { body: "answer" }); + + expect(mockStdout).toHaveBeenCalledWith("msg_reply_accepted\n"); + expect(mockStderr).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(0); + }); + it("sends markup-only HTML whose derived text fallback is empty", async () => { mockReadFileSync.mockReturnValue('
'); mockSend.mockResolvedValue({ messageId: "msg_img", status: "sent" }); diff --git a/cli/src/bin/e2a.ts b/cli/src/bin/e2a.ts index 71e205c0..d3773e30 100644 --- a/cli/src/bin/e2a.ts +++ b/cli/src/bin/e2a.ts @@ -96,7 +96,7 @@ Exit codes (stable scripting contract): 0 success 1 transient error (network / 5xx / rate limit) — retry may help 2 usage error (bad flags or arguments) - 3 send accepted but HELD for review (pending_review) — not delivered + 3 send HELD for review (pending_review) — not delivered 4 bad credentials or wrong key scope 5 permanent request error (not found / invalid / conflict) — do NOT retry 6 bounded wait (listen --once --until) expired with no matching message diff --git a/cli/src/commands/send.ts b/cli/src/commands/send.ts index b23b668e..40bea6e4 100644 --- a/cli/src/commands/send.ts +++ b/cli/src/commands/send.ts @@ -104,20 +104,24 @@ function emitSendResult(result: SendResultView, json?: boolean): void { } else { process.stdout.write(result.messageId + "\n"); } - // `status` is an OPEN SET per the spec ("tolerate unknown values") — so the - // delivered check is `=== "sent"`, inverted. A future held-variant status - // must fail loud (exit HELD), never slip through as exit 0: an unknown - // outcome is "not known to be delivered". - if (result.status !== "sent") { + if (result.status === "pending_review") { process.stderr.write( - `WARNING: send returned status "${result.status}" — the message did NOT go out now. ` + - "pending_review means it is held for approval: disable outbound protection on this " + + "WARNING: send returned status \"pending_review\" — the message did NOT go out now. " + + "It is held for approval: disable outbound protection on this " + "agent or approve it in the review queue.\n", ); // exitCode + return, NOT process.exit(): a hard exit can truncate piped // stdout before the message id above flushes, and scripts need that id to // approve the held message. process.exitCode = EXIT.HELD; + } else if (result.status !== "sent" && result.status !== "accepted") { + // Response status values are an open set. Do not silently report an + // unfamiliar outcome as success, but do not misclassify it as a known + // review hold either. ERROR is retry-safe for existing shell wrappers. + process.stderr.write( + `WARNING: send returned status "${result.status}"; the CLI cannot confirm delivery.\n`, + ); + process.exitCode = EXIT.ERROR; } } diff --git a/cli/src/exit.ts b/cli/src/exit.ts index 5f94ae34..6d19137c 100644 --- a/cli/src/exit.ts +++ b/cli/src/exit.ts @@ -2,20 +2,19 @@ // CI lanes) branch on these instead of parsing output, so the values are // frozen once published — add new codes, never renumber. // -// HELD exists because a held send is an HTTP 2xx success (202 Accepted): the API -// accepted the message but parked it in the review queue (`status: -// "pending_review"`), so the recipient got nothing. Scripts that treat "exit 0" -// as "delivered" would silently report into a queue nobody reads — the exact -// failure mode that bit the tether harness twice. The CLI branches on the -// response body's `status`, not the HTTP code, so this holds regardless of -// whether the outcome came back 200 (sent) or 202 (accepted/pending_review). +// HELD exists because a review-held send is an HTTP 2xx success with response +// status `pending_review`, but the recipient got nothing. Scripts that treat +// every 2xx as success would silently report into a queue nobody reads — the +// exact failure mode that bit the tether harness twice. The distinct async +// response status `accepted` means the message is durably queued for delivery +// and exits OK. The CLI branches on the response body's status, not HTTP status. export const EXIT = { OK: 0, /** Network, server, or unexpected error. */ ERROR: 1, /** Bad flags or arguments. */ USAGE: 2, - /** Send accepted but held for review (pending_review) — NOT delivered. */ + /** Send held for review (pending_review) — NOT delivered. */ HELD: 3, /** Bad credentials or wrong key scope for the operation. */ AUTH: 4, From 40fd364103cb41a09bc888d6941101c6064e9cff Mon Sep 17 00:00:00 2001 From: jiashuoz Date: Tue, 14 Jul 2026 20:25:04 -0700 Subject: [PATCH 2/3] fix(cli): make unknown send outcomes non-retryable --- cli/src/__tests__/send.test.ts | 7 ++++--- cli/src/bin/e2a.ts | 1 + cli/src/commands/send.ts | 9 ++++++--- cli/src/exit.ts | 5 +++++ 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/cli/src/__tests__/send.test.ts b/cli/src/__tests__/send.test.ts index 9cbff105..58d33cd0 100644 --- a/cli/src/__tests__/send.test.ts +++ b/cli/src/__tests__/send.test.ts @@ -63,14 +63,15 @@ describe("send/reply commands", () => { expect(mockSend.mock.calls[0][2]).toEqual({ idempotencyKey: "evt-42" }); }); - it("treats an unknown status as an unexpected outcome, not a review hold", async () => { + it("treats an unknown status as non-retryable without calling it a review hold", async () => { mockSend.mockResolvedValue({ messageId: "msg_u", status: "some_future_hold" }); const { send } = await import("../commands/send.js"); await send({ to: ["you@example.com"], subject: "s", body: "b" }); expect(mockStderr).toHaveBeenCalledWith(expect.stringContaining("some_future_hold")); - expect(mockStderr).not.toHaveBeenCalledWith(expect.stringContaining("pending_review means")); - expect(process.exitCode).toBe(1); + expect(mockStderr).toHaveBeenCalledWith(expect.stringContaining("do NOT retry")); + expect(mockStderr).toHaveBeenCalledWith(expect.stringContaining("msg_u")); + expect(process.exitCode).toBe(7); }); it("exits OK when an async send is durably accepted", async () => { diff --git a/cli/src/bin/e2a.ts b/cli/src/bin/e2a.ts index d3773e30..b05262af 100644 --- a/cli/src/bin/e2a.ts +++ b/cli/src/bin/e2a.ts @@ -100,6 +100,7 @@ Exit codes (stable scripting contract): 4 bad credentials or wrong key scope 5 permanent request error (not found / invalid / conflict) — do NOT retry 6 bounded wait (listen --once --until) expired with no matching message + 7 unrecognized persisted send outcome — do NOT retry; inspect the message id `; function parseArgs(argv: string[]): { command: string; args: string[] } { diff --git a/cli/src/commands/send.ts b/cli/src/commands/send.ts index 40bea6e4..3c192cb9 100644 --- a/cli/src/commands/send.ts +++ b/cli/src/commands/send.ts @@ -117,11 +117,14 @@ function emitSendResult(result: SendResultView, json?: boolean): void { } else if (result.status !== "sent" && result.status !== "accepted") { // Response status values are an open set. Do not silently report an // unfamiliar outcome as success, but do not misclassify it as a known - // review hold either. ERROR is retry-safe for existing shell wrappers. + // review hold or a retryable transport error either: this successful API + // response carries a message id and may already be durably persisted. process.stderr.write( - `WARNING: send returned status "${result.status}"; the CLI cannot confirm delivery.\n`, + `WARNING: send returned unrecognized status "${result.status}" for message "${result.messageId}". ` + + `The message may already be durably persisted; do NOT retry automatically. ` + + `Inspect it with: e2a messages get ${result.messageId}\n`, ); - process.exitCode = EXIT.ERROR; + process.exitCode = EXIT.UNKNOWN_OUTCOME; } } diff --git a/cli/src/exit.ts b/cli/src/exit.ts index 6d19137c..a833b8f7 100644 --- a/cli/src/exit.ts +++ b/cli/src/exit.ts @@ -8,6 +8,9 @@ // exact failure mode that bit the tether harness twice. The distinct async // response status `accepted` means the message is durably queued for delivery // and exits OK. The CLI branches on the response body's status, not HTTP status. +// An unknown 2xx outcome gets its own non-retry exit: the returned message id +// proves the server created an observable result, so a fresh retry could send a +// duplicate. Callers should inspect that message instead. export const EXIT = { OK: 0, /** Network, server, or unexpected error. */ @@ -26,6 +29,8 @@ export const EXIT = { REQUEST: 5, /** A deadline-bounded wait (`listen --once --until`) expired unmatched. */ TIMEOUT: 6, + /** A 2xx send returned an unknown outcome. It may be persisted; do not retry. */ + UNKNOWN_OUTCOME: 7, } as const; /** Write a message to stderr and exit with the given code. */ From 66891265018ddcbe4deda459428e2afcb59af8a9 Mon Sep 17 00:00:00 2001 From: jiashuoz Date: Tue, 14 Jul 2026 20:26:36 -0700 Subject: [PATCH 3/3] fix(cli): distinguish terminal send failures --- cli/src/__tests__/send.test.ts | 12 ++++++++++++ cli/src/bin/e2a.ts | 2 +- cli/src/commands/send.ts | 9 ++++++++- cli/src/exit.ts | 10 +++++----- 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/cli/src/__tests__/send.test.ts b/cli/src/__tests__/send.test.ts index 58d33cd0..e3981728 100644 --- a/cli/src/__tests__/send.test.ts +++ b/cli/src/__tests__/send.test.ts @@ -74,6 +74,18 @@ describe("send/reply commands", () => { expect(process.exitCode).toBe(7); }); + it("reports a failed status as terminal and non-retryable", async () => { + mockSend.mockResolvedValue({ messageId: "msg_failed", status: "failed" }); + const { send } = await import("../commands/send.js"); + await send({ to: ["you@example.com"], subject: "s", body: "b" }); + + expect(mockStderr).toHaveBeenCalledWith(expect.stringContaining("terminal status \"failed\"")); + expect(mockStderr).toHaveBeenCalledWith(expect.stringContaining("do NOT retry")); + expect(mockStderr).toHaveBeenCalledWith(expect.stringContaining("msg_failed")); + expect(mockStderr).not.toHaveBeenCalledWith(expect.stringContaining("unrecognized")); + expect(process.exitCode).toBe(7); + }); + it("exits OK when an async send is durably accepted", async () => { mockSend.mockResolvedValue({ messageId: "msg_accepted", status: "accepted" }); const { send } = await import("../commands/send.js"); diff --git a/cli/src/bin/e2a.ts b/cli/src/bin/e2a.ts index b05262af..dc100a5a 100644 --- a/cli/src/bin/e2a.ts +++ b/cli/src/bin/e2a.ts @@ -100,7 +100,7 @@ Exit codes (stable scripting contract): 4 bad credentials or wrong key scope 5 permanent request error (not found / invalid / conflict) — do NOT retry 6 bounded wait (listen --once --until) expired with no matching message - 7 unrecognized persisted send outcome — do NOT retry; inspect the message id + 7 failed or unrecognized persisted send outcome — do NOT retry; inspect its id `; function parseArgs(argv: string[]): { command: string; args: string[] } { diff --git a/cli/src/commands/send.ts b/cli/src/commands/send.ts index 3c192cb9..d820c454 100644 --- a/cli/src/commands/send.ts +++ b/cli/src/commands/send.ts @@ -114,6 +114,13 @@ function emitSendResult(result: SendResultView, json?: boolean): void { // stdout before the message id above flushes, and scripts need that id to // approve the held message. process.exitCode = EXIT.HELD; + } else if (result.status === "failed") { + process.stderr.write( + `WARNING: send reached terminal status "failed" for message "${result.messageId}". ` + + `The server persisted the failure outcome; do NOT retry automatically. ` + + `Inspect it with: e2a messages get ${result.messageId}\n`, + ); + process.exitCode = EXIT.SEND_OUTCOME; } else if (result.status !== "sent" && result.status !== "accepted") { // Response status values are an open set. Do not silently report an // unfamiliar outcome as success, but do not misclassify it as a known @@ -124,7 +131,7 @@ function emitSendResult(result: SendResultView, json?: boolean): void { `The message may already be durably persisted; do NOT retry automatically. ` + `Inspect it with: e2a messages get ${result.messageId}\n`, ); - process.exitCode = EXIT.UNKNOWN_OUTCOME; + process.exitCode = EXIT.SEND_OUTCOME; } } diff --git a/cli/src/exit.ts b/cli/src/exit.ts index a833b8f7..5b56e430 100644 --- a/cli/src/exit.ts +++ b/cli/src/exit.ts @@ -8,9 +8,9 @@ // exact failure mode that bit the tether harness twice. The distinct async // response status `accepted` means the message is durably queued for delivery // and exits OK. The CLI branches on the response body's status, not HTTP status. -// An unknown 2xx outcome gets its own non-retry exit: the returned message id -// proves the server created an observable result, so a fresh retry could send a -// duplicate. Callers should inspect that message instead. +// A terminal failed or unknown 2xx outcome gets its own non-retry exit: the +// returned message id proves the server created an observable result, so a +// fresh retry could send a duplicate. Callers should inspect that message. export const EXIT = { OK: 0, /** Network, server, or unexpected error. */ @@ -29,8 +29,8 @@ export const EXIT = { REQUEST: 5, /** A deadline-bounded wait (`listen --once --until`) expired unmatched. */ TIMEOUT: 6, - /** A 2xx send returned an unknown outcome. It may be persisted; do not retry. */ - UNKNOWN_OUTCOME: 7, + /** A persisted send failed or returned an unknown outcome; do not retry. */ + SEND_OUTCOME: 7, } as const; /** Write a message to stderr and exit with the given code. */