Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion lib/rendi/doordash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
47 changes: 40 additions & 7 deletions lib/rendi/doordash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,18 @@ export function intentFor(goal: string): string {
].join("\n");
}

export async function runDd(args: string[], goal: string): Promise<unknown> {
export async function runDd(
args: string[],
goal: string,
opts?: { timeoutMs?: number },
): Promise<unknown> {
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 {
Expand All @@ -64,9 +69,7 @@ export async function runDd(args: string[], goal: string): Promise<unknown> {
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");
Expand Down Expand Up @@ -97,8 +100,9 @@ async function parsed<T extends z.ZodType>(
schema: T,
args: string[],
goal: string,
opts?: { timeoutMs?: number },
): Promise<z.infer<T>> {
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
Expand Down Expand Up @@ -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<string>,
goal: string,
): Promise<OrderReconciliation> {
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,
Expand Down Expand Up @@ -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) {
Expand Down
14 changes: 9 additions & 5 deletions trigger/rendi/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
133 changes: 97 additions & 36 deletions trigger/tools/doordash-submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> {
const history = await dd
.orderHistory({ max: 5, days: 1 }, goal)
.catch(() => null);
if (!history) return null;
const known = new Set(
async function knownOrderUuids(): Promise<Set<string>> {
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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand Down