Guard the operator's cap on fallback hops and reap dead summary promises

- The provider-chain walker now checks the day's recorded spend against
  OPERATOR_COST_CAP before invoking an operator-funded admin-* entry. A
  request whose primary provider runs on the user's own key reserves
  under the generous user-plan ceiling; a fallback hop (or health-ledger
  reorder) onto the operator's key could then spend past the operator's
  cap unchecked. An exhausted cap skips the candidate like a failed hop.
- summaryState PENDING gets a giver-upper: the detail serialiser
  degrades a PENDING older than an hour to UNAVAILABLE (which offers the
  manual generate action), and an hourly reaper persists the same heal
  following the orphan-ImportJob reconcile precedent — a job dead past
  its retry budget no longer leaves the sheet saying "generating"
  forever.
This commit is contained in:
Marc-André Bombeck committed 2026-08-14 05:53:17 +02:00
1 parent f67fcec1c9
commit 6ebd5e8711
8 files changed
+333 -4

No files matched your search

@@ -68,7 +68,7 @@ describe("serialiseDocumentDetail — summary state", () => {
expect(dto.summaryGeneratedAt).toBe("2026-02-15T09:00:00.000Z");
});
it.each(["NONE", "PENDING", "WITHHELD", "UNAVAILABLE"] as const)(
it.each(["NONE", "WITHHELD", "UNAVAILABLE"] as const)(
"passes %s through with no summary",
(summaryState) => {
const dto = serialiseDocumentDetail(row({ summaryState }), []);
@@ -78,6 +78,30 @@ describe("serialiseDocumentDetail — summary state", () => {
},
);
it("passes a FRESH PENDING through — the job is credibly in flight", () => {
const dto = serialiseDocumentDetail(
row({ summaryState: "PENDING", updatedAt: new Date() }),
[],
);
expect(dto.summaryState).toBe("PENDING");
});
// Watched red: with the stale-PENDING branch removed from
// `serialiseDocumentDetail` (the pre-v1.37.19 serialiser), this case fails —
// a summary job dead past its retry budget left the sheet saying
// "generating" forever, with no manual action offered.
it("degrades a PENDING older than the TTL to UNAVAILABLE", () => {
const dto = serialiseDocumentDetail(
row({
summaryState: "PENDING",
updatedAt: new Date(Date.now() - 2 * 60 * 60 * 1000),
}),
[],
);
expect(dto.summary).toBeNull();
expect(dto.summaryState).toBe("UNAVAILABLE");
});
it("degrades an undecryptable READY row to UNAVAILABLE", () => {
// A rotated-away key. There is no summary to show, so the DTO must not
// keep promising one — and the ciphertext is never returned either.