diff --git a/lib/rendi/doordash.test.ts b/lib/rendi/doordash.test.ts index 99a29f6..ed3e8df 100644 --- a/lib/rendi/doordash.test.ts +++ b/lib/rendi/doordash.test.ts @@ -8,7 +8,13 @@ import { paymentMethodsFixture, searchFixture, } from "./doordash.fixtures.ts"; -import { intentFor, runDd, search } from "./doordash.ts"; +import { + findUnrecordedOrder, + intentFor, + orderSubmit, + runDd, + search, +} from "./doordash.ts"; import { ddAddressList, ddItemDetails, @@ -172,6 +178,49 @@ describe("search location doctrine", () => { }); }); +describe("orderSubmit", () => { + it("consents for the headless worker and waits out payment auth", async () => { + impl.mockResolvedValueOnce(envelope({ success: true, order_uuid: "o-1" })); + await orderSubmit({ cartUuid: "c-1", tipCents: 142 }, "g"); + const [, args, options] = impl.mock.calls[0] as [ + string, + string[], + { timeout: number }, + ]; + expect(args).toContain("--yes"); + expect(options.timeout).toBe(120_000); + }); +}); + +describe("findUnrecordedOrder", () => { + it("an unreachable history is not an empty one", async () => { + impl.mockRejectedValueOnce({ code: 1, stderr: "network is down" }); + const rec = await findUnrecordedOrder("s-1", new Set(), "g"); + expect(rec).toEqual({ unavailable: true }); + }); + + it("an empty history answers found null", async () => { + impl.mockResolvedValueOnce(envelope({ orders: [], success: true })); + const rec = await findUnrecordedOrder("s-1", new Set(), "g"); + expect(rec).toEqual({ found: null }); + }); + + it("adopts only orders the ledger has never seen, at this store", async () => { + impl.mockResolvedValueOnce( + envelope({ + success: true, + orders: [ + { order_uuid: "known-1", store_id: "s-1" }, + { order_uuid: "other-store", store_id: "s-2" }, + { order_uuid: "fresh-1", store_id: "s-1" }, + ], + }), + ); + const rec = await findUnrecordedOrder("s-1", new Set(["known-1"]), "g"); + expect(rec).toEqual({ found: "fresh-1" }); + }); +}); + // promisify is imported so the custom-symbol mock stays anchored to the // same node:util instance the wrapper uses. void promisify; diff --git a/lib/rendi/doordash.ts b/lib/rendi/doordash.ts index 21c46ff..2eecd3e 100644 --- a/lib/rendi/doordash.ts +++ b/lib/rendi/doordash.ts @@ -48,13 +48,18 @@ export function intentFor(goal: string): string { ].join("\n"); } -export async function runDd(args: string[], goal: string): Promise { +export async function runDd( + args: string[], + goal: string, + opts?: { timeoutMs?: number }, +): Promise { + const timeoutMs = opts?.timeoutMs ?? TIMEOUT_MS; let stdout: string; try { ({ stdout } = await run( cliPath(), ["--json-output", ...args, "--intent", intentFor(goal)], - { timeout: TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 }, + { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, )); } catch (error) { const failure = error as { @@ -64,9 +69,7 @@ export async function runDd(args: string[], goal: string): Promise { code?: string | number; }; if (failure.killed) { - throw new DdUncertainError( - `dd-cli timed out after ${TIMEOUT_MS / 1000}s`, - ); + throw new DdUncertainError(`dd-cli timed out after ${timeoutMs / 1000}s`); } if (failure.code === "ENOENT") { throw new Error("dd-cli is not installed; nothing was sent to DoorDash"); @@ -97,8 +100,9 @@ async function parsed( schema: T, args: string[], goal: string, + opts?: { timeoutMs?: number }, ): Promise> { - return schema.parse(await runDd(args, goal)); + return schema.parse(await runDd(args, goal, opts)); } // The saved default address is the CLI's own location doctrine for "near @@ -224,6 +228,29 @@ export async function orderHistory( return parsed(ddOrderHistory, args, goal); } +// After an uncertain submit, order history is the truth, but only when +// it actually answers: an unreachable history proves nothing, and +// conflating it with an empty one is how a live order gets orphaned. +export type OrderReconciliation = + | { found: string | null } + | { unavailable: true }; + +export async function findUnrecordedOrder( + storeId: string, + known: Set, + goal: string, +): Promise { + const history = await orderHistory({ max: 5, days: 1 }, goal).catch( + () => null, + ); + if (!history) return { unavailable: true }; + const found = history.orders.find( + (order) => + String(order.store_id ?? "") === storeId && !known.has(order.order_uuid), + ); + return { found: found?.order_uuid ?? null }; +} + export async function orderReceipt(orderUuid: string, goal: string) { return parsed( ddLenient, @@ -381,11 +408,17 @@ export async function orderSubmit( input.cartUuid, "--tip-cents", String(input.tipCents), + // A headless worker has no keypress to give: without --yes the CLI + // waits on its interactive confirmation forever. The human yes + // already happened, in the owner's inbox. + "--yes", ]; if (input.fulfillment) args.push("--fulfillment", input.fulfillment); if (input.scheduledTime) args.push("--scheduled-time", input.scheduledTime); if (input.priority) args.push("--priority"); - return parsed(ddSubmitResult, args, goal); + // Payment authorization is the CLI's slowest call; the read-call + // timeout starved it during the first ceremony. + return parsed(ddSubmitResult, args, goal, { timeoutMs: 120_000 }); } export async function orderStatus(orderUuid: string, goal: string) { diff --git a/trigger/rendi/agent.md b/trigger/rendi/agent.md index ffc1abf..7373764 100644 --- a/trigger/rendi/agent.md +++ b/trigger/rendi/agent.md @@ -184,11 +184,15 @@ message wakes you with the approval id; call doordash-submit with exactly that id and narrate the outcome honestly, including action_required and failed, where the answer is never to resubmit. An unknown outcome means the response was lost, not that the order -failed: wait a minute, check order-history, and reconcile honestly -before saying anything definitive. If the owner says they entered the -code but no [order approved] message ever arrived, calling -doordash-submit with the approval id from your own request is safe; -the machine refuses unless the code was truly verified. +failed: the cart stays sealed, and calling doordash-submit again with +the SAME approval id after a minute is the settle path. The machine +checks order history itself, adopts the order if it landed, or reopens +the cart once a successful look confirms nothing ever did; never +request a fresh approval while an unknown is unsettled. If the owner +says they entered the code but no [order approved] message ever +arrived, calling doordash-submit with the approval id from your own +request is equally safe; the machine refuses unless the code was truly +verified. Spend caps are enforced below you; when a request is denied for caps, say so plainly. Anything changed after the code was sent voids it, and a fresh approval is one call away. diff --git a/trigger/tools/doordash-submit.ts b/trigger/tools/doordash-submit.ts index 840da7c..ae8fba4 100644 --- a/trigger/tools/doordash-submit.ts +++ b/trigger/tools/doordash-submit.ts @@ -32,27 +32,43 @@ async function recordOutcome( .where(eq(ddOrders.id, orderId)); } -// After an uncertain submit, order history is the truth: an order at -// this store that our ledger has never seen is the one whose response -// was lost. Returns its uuid, or null when nothing landed. -async function reconcileFromHistory( - storeId: string, - goal: string, -): Promise { - const history = await dd - .orderHistory({ max: 5, days: 1 }, goal) - .catch(() => null); - if (!history) return null; - const known = new Set( +async function knownOrderUuids(): Promise> { + return new Set( (await getDb().select({ orderUuid: ddOrders.orderUuid }).from(ddOrders)) .map((row) => row.orderUuid) - .filter(Boolean), + .filter((uuid): uuid is string => Boolean(uuid)), ); - const found = history.orders.find( - (order) => - String(order.store_id ?? "") === storeId && !known.has(order.order_uuid), +} + +// Poll an accepted order to a terminal state, bounded, and leave the +// ledger and cart telling the same story. +async function settleOrder( + orderRowId: number, + orderUuid: string, + cartUuid: string, + goal: string, +): Promise<{ status: string; errorMessage: string | null }> { + let status = "pending"; + let errorMessage: string | null = null; + for (let attempt = 0; attempt < STATUS_POLLS; attempt++) { + const check = await dd.orderStatus(orderUuid, goal).catch(() => null); + if (check) { + status = check.status; + errorMessage = check.error_message ?? null; + if (status !== "pending") break; + } + await new Promise((resolve) => setTimeout(resolve, STATUS_POLL_MS)); + } + await recordOutcome(orderRowId, { status, errorMessage }); + await setCartStatus( + cartUuid, + status === "successful" + ? "placed" + : status === "pending" + ? "placing" + : "failed", ); - return found?.order_uuid ?? null; + return { status, errorMessage }; } // The finalizer, with zero degrees of freedom: everything about the @@ -108,6 +124,50 @@ export const doordashSubmit = tool({ }; } } + // Unknown with no uuid is the timed-out submit whose truth was + // still unwritten. Re-calling this tool IS the settle loop: adopt + // the order if history shows one landed, release the cart once a + // successful look confirms nothing ever did. + if (existing.status === "unknown" && !existing.orderUuid) { + const snapshot = await getCartSnapshot(existing.cartUuid); + if (snapshot) { + const rec = await dd.findUnrecordedOrder( + snapshot.storeId, + await knownOrderUuids(), + goal, + ); + if ("unavailable" in rec) { + return { + outcome: "unknown", + note: "order history is unreachable right now; the cart stays sealed, try this same approval id again shortly", + }; + } + if (rec.found) { + await recordOutcome(existing.id, { orderUuid: rec.found }); + const settled = await settleOrder( + existing.id, + rec.found, + existing.cartUuid, + goal, + ); + return { + outcome: settled.status, + orderUuid: rec.found, + ...(settled.errorMessage ? { error: settled.errorMessage } : {}), + note: "the lost submission had landed after all; this is its live status", + }; + } + await recordOutcome(existing.id, { + status: "failed", + errorMessage: "no order ever appeared in history", + }); + await setCartStatus(existing.cartUuid, "open"); + return { + outcome: "failed", + note: "nothing ever landed at DoorDash; the cart is open again, request a fresh approval when ready", + }; + } + } return { outcome: existing.status, orderUuid: existing.orderUuid, @@ -242,17 +302,26 @@ export const doordashSubmit = tool({ // and charged before the transport died. Only a structured error // from the CLI proves the order was refused. if (error instanceof dd.DdUncertainError) { - orderUuid = await reconcileFromHistory(snapshot.storeId, goal); - if (!orderUuid) { + const rec = await dd.findUnrecordedOrder( + snapshot.storeId, + await knownOrderUuids(), + goal, + ); + if ("found" in rec && rec.found) { + orderUuid = rec.found; + } else { await recordOutcome(orderRow.id, { status: "unknown", errorMessage: error.message, }); + // The cart stays sealed: an immediate miss proves nothing, + // since creation can lag a timed-out submit and the history + // call itself can fail. The settle loop above releases it. return { outcome: "unknown", storeName: snapshot.storeName, error: error.message, - note: "the submission may or may not have reached DoorDash; never resubmit and never check out elsewhere until order history answers. Check the order history in a minute and reconcile honestly.", + note: "the submission may or may not have reached DoorDash; the cart stays sealed and nothing else may be ordered. Wait a minute, then call doordash-submit again with this same approval id: it settles against order history, adopting the order if it landed or reopening the cart if it never did.", }; } } else { @@ -275,23 +344,15 @@ export const doordashSubmit = tool({ let status = "pending"; let errorMessage: string | null = null; if (orderUuid) { - for (let attempt = 0; attempt < STATUS_POLLS; attempt++) { - const check = await dd.orderStatus(orderUuid, goal); - status = check.status; - errorMessage = check.error_message ?? null; - if (status !== "pending") break; - await new Promise((resolve) => setTimeout(resolve, STATUS_POLL_MS)); - } + ({ status, errorMessage } = await settleOrder( + orderRow.id, + orderUuid, + approval.cartUuid, + goal, + )); + } else { + await recordOutcome(orderRow.id, { status, errorMessage }); } - await recordOutcome(orderRow.id, { status, errorMessage }); - await setCartStatus( - approval.cartUuid, - status === "successful" - ? "placed" - : status === "pending" - ? "placing" - : "failed", - ); emitSpan({ conversationId: approval.conversationId, turn: turn?.turn ?? 0,