fix(generate-schedule): switch to streaming AI path to avoid body-read abort - #515
Conversation
…d abort Production was returning 502 for restaurants with large AI responses because the non-streaming path attaches AbortSignal.timeout(30s) to the fetch and returns the Response without consuming the body. The signal stays bound to the body stream, so the caller's later `await response.json()` aborts with "Signal timed out." after 30s of body download. Three consecutive aborts (~90s) exhausted the wall-clock budget and returned a synthetic 502. Switch to the existing callModelWithStreaming primitive in _shared/streaming.ts which: - Uses AbortSignal.timeout(90s) on fetch. - Sends `stream: true` so OpenRouter ships SSE chunks instead of buffering the full body. - Consumes the stream inside the helper and returns the parsed content string, removing the caller-side body-read step that the bound signal was killing. Extract the model-chain loop into a new pure helper, _shared/schedule-ai-runner.ts, so it can be unit-tested with the streaming caller injected. Eight Vitest cases cover first-success, markdown-fence stripping, parse-error fallback, null-content fallback, all-fail returning null, wall-clock budget exhaustion, no extra retries at this layer, and restaurantId pass-through. Recalibrate MODEL_LOOP_BUDGET_MS from 90s to 130s. Each streaming attempt now has a 90s ceiling, so 130s lets one full attempt plus a fast-fail fallback fit inside the ~150s edge-function hard kill. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
✅ Deploy Preview for easyshifthq ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Updates to Preview Branch (fix/generate-schedule-streaming) ↗︎
Tasks are run on every commit but only new migration files are pushed.
View logs for this Workflow Run ↗︎. |
📝 WalkthroughWalkthroughThis PR extracts schedule generation's model-fallback logic into a reusable, streaming-based helper ChangesStreaming-based schedule generation refactor
Sequence DiagramsequenceDiagram
participant App as generate-schedule
participant Chain as runScheduleModelChain
participant Stream as callModelWithStreaming
App->>Chain: models[], budgetMs, callStreaming
loop each model (until success or budget exhausted)
Chain->>Stream: model, requestBody, apiKey, externalSignal
Stream-->>Chain: {content, finishReason} | or reject on abort/error
Chain->>Chain: stripMarkdownFences(content) & parse JSON
alt JSON parse succeeds
Chain-->>App: {data, model}
else parse fails or content null or finishReason==='length'
Chain->>Chain: continue to next model (or return null if budget expired)
end
end
Chain-->>App: null (all failed or budget exhausted)
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 01fb3775a6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| edgeFunction: "generate-schedule", | ||
| restaurantId: restaurant_id, | ||
| callStreaming: callModelWithStreaming, | ||
| budgetMs: 130_000, |
There was a problem hiding this comment.
Enforce remaining budget before starting next model call
runScheduleModelChain only checks elapsed > budgetMs before each attempt, but each callModelWithStreaming attempt can still block for up to 90s. With budgetMs: 130_000 in generate-schedule, a first slow attempt near 90s can still start a second attempt and run past Supabase’s ~150s hard limit, causing the function to be killed before it returns any response. This reintroduces the timeout-kill failure mode the budget is meant to prevent.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Addressed in commit f51db0e. runScheduleModelChain now passes a per-attempt AbortSignal set to fire after remaining = budgetMs - (now() - start), combined with the existing 90s ceiling via AbortSignal.any. When the chain budget elapses mid-call, the streaming fetch is aborted and the runner breaks (does not start the next model). New tests cover both the in-flight-abort and the insufficient-remaining-time-before-next-attempt cases.
| const data = JSON.parse(cleaned) as T; | ||
| return { data, model: model.name }; |
There was a problem hiding this comment.
Reject streaming responses that end due to token limit
The new streaming path accepts the first JSON-parseable payload, but it no longer checks whether generation stopped with finish_reason === "length" (the previous non-streaming loop explicitly skipped truncated outputs). Since callStreaming returns only text, a token-limited completion that still parses as JSON can now be treated as success, returning an incomplete schedule instead of falling back to the next model.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Addressed in commit f51db0e. processStreamedResponse now captures finish_reason from the last delta; callModelWithStreaming returns null on finish_reason === 'length', restoring the previous non-streaming loop's truncation behaviour so the chain falls through to the next model.
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
tests/unit/schedule-ai-runner.test.ts (1)
105-124: ⚡ Quick winAdd a budget-edge test for “insufficient remaining time before next attempt.”
Current budget coverage is good, but it doesn’t assert behavior when remaining budget is too small for another model attempt. A targeted case here would prevent regressions once remaining-budget handling is tightened in the helper.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/unit/schedule-ai-runner.test.ts` around lines 105 - 124, Add a new unit test that exercises the "insufficient remaining time before next attempt" edge: call runScheduleModelChain with models [M1, M2, M3], a mock callStreaming (ScheduleModelChainCallStreaming) that advances nowMs by the expected per-attempt duration and returns null, a controlled now() returning nowMs, and budgetMs set so that after two attempts the remaining budget is smaller than the minimum time required for another model attempt; assert the result is null and that callStreaming was called exactly twice to ensure the runner stops when remaining time is insufficient.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/superpowers/specs/2026-05-22-generate-schedule-streaming-design.md`:
- Around line 13-24: The fenced log block starting with the line
"[generate-schedule] Trying model: Gemini 2.5 Flash" should include a language
identifier to satisfy markdown lint and improve readability; update the opening
fence from ``` to ```text (or ```log) so the block becomes a labeled code block
(e.g., ```text) while leaving the contents (all lines beginning with
"[generate-schedule] ...") unchanged.
In `@supabase/functions/_shared/schedule-ai-runner.ts`:
- Around line 57-69: The current budget check before invoking each model (in the
for loop over models using now(), start and budgetMs) does not prevent an
in-flight streaming call (callStreaming) from exceeding the remaining wall-clock
budget; wrap or cancel each callStreaming invocation with an AbortController or
a Promise.race that enforces remainingMs = budgetMs - (now() - start) so the
request is aborted if it would exceed the chain budget, pass the abort signal
into callStreaming (or ensure callStreaming accepts a signal) and handle abort
errors by logging (using edgeFunction and model.name) and breaking out of the
loop to avoid overshooting the overall budget.
---
Nitpick comments:
In `@tests/unit/schedule-ai-runner.test.ts`:
- Around line 105-124: Add a new unit test that exercises the "insufficient
remaining time before next attempt" edge: call runScheduleModelChain with models
[M1, M2, M3], a mock callStreaming (ScheduleModelChainCallStreaming) that
advances nowMs by the expected per-attempt duration and returns null, a
controlled now() returning nowMs, and budgetMs set so that after two attempts
the remaining budget is smaller than the minimum time required for another model
attempt; assert the result is null and that callStreaming was called exactly
twice to ensure the runner stops when remaining time is insufficient.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 6d6cfb4d-ffc3-4e5c-a222-a8f8662fb9e4
📒 Files selected for processing (4)
docs/superpowers/specs/2026-05-22-generate-schedule-streaming-design.mdsupabase/functions/_shared/schedule-ai-runner.tssupabase/functions/generate-schedule/index.tstests/unit/schedule-ai-runner.test.ts
…t + reject truncated outputs
Addresses Codex P1 / CodeRabbit Major: runScheduleModelChain only
checked elapsed > budgetMs before each model attempt, leaving an
in-flight callModelWithStreaming free to run its full 90s timeout even
when the chain budget had already lapsed. Worst case: model 1 hangs
near 90s, the runner enters model 2 (elapsed=90s < 130s budget), model
2 hangs another 90s — total ~180s, past Supabase's ~150s hard kill.
- Thread an optional AbortSignal through ScheduleModelChainCallStreaming
and callModelWithStreaming. The runner creates a per-attempt
AbortController scheduled to abort after `remaining = budgetMs - elapsed`,
passes the signal into the streaming call, and combines it with the
existing 90s per-attempt timeout via AbortSignal.any.
- On AbortError caused by chain-budget elapse, break out of the loop
instead of starting the next model — the budget is already gone.
- Restore the finish_reason=length skip the non-streaming loop used to
perform: processStreamedResponse now returns { content, finishReason }
(internal change) and callModelWithStreaming returns null when the
model truncated at max_tokens, letting the chain fall through.
Adds two new unit tests:
- aborts in-flight call when remaining budget elapses (and stops)
- stops after second attempt when remaining budget is insufficient
Addresses CodeRabbit Minor nit on PR #515. Adds a `text` language hint to the fenced log block so markdownlint MD040 stops flagging the file and so rendered docs syntax-highlight the block as plain text.
Phase 9d triage — feedback from this roundPushed
Verification: |
|
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@supabase/functions/_shared/schedule-ai-runner.ts`:
- Around line 68-70: The console.log call that prints `[${edgeFunction}] Trying
model: ${model.name} (streaming, remaining=${remaining}ms)` must be removed from
production; either replace it with a proper logger call (e.g.,
processLogger.debug or processLogger.info) using the same context (edgeFunction,
model.name, remaining) or gate the existing message behind a dev-only check (if
import.meta.env.DEV) so it never runs in production; update the model-attempt
logging in the same block where edgeFunction, model, and remaining are available
(the current console.log) to use the chosen non-console path.
In `@supabase/functions/_shared/streaming.ts`:
- Around line 163-165: Remove the new unguarded console.log in streaming.ts (the
log that prints `${modelConfig.name} stream completed...`) or wrap it behind a
dev-only guard; specifically either delete the console.log call inside the
stream completion block or condition it with import.meta.env.DEV (or your
project's equivalent DEV flag) so it only runs in development. Locate the
console.log that references modelConfig.name, content.length and finishReason
and update it accordingly to comply with the "no console.log in prod" guideline.
- Around line 103-109: The function callModelWithStreaming currently uses
requestBody: any and emits production console.log statements; change requestBody
to a safer type such as Record<string, unknown> (or a concrete interface) and
narrow-access fields where used (e.g., cast/validate requestBody.messages as
Message[], and types for temperature and max_tokens before use) so TypeScript
catches misuse, and remove all direct console.log calls in
callModelWithStreaming (replace with the project logger or wrap them with
import.meta.env.DEV guards) including the attempt/start/completed and rate-limit
logs; update any helper variables or type assertions inside
callModelWithStreaming to reflect the new type and perform runtime checks where
necessary.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 820381fa-d2dc-4dc4-af6e-7b285b8a6930
📒 Files selected for processing (4)
docs/superpowers/specs/2026-05-22-generate-schedule-streaming-design.mdsupabase/functions/_shared/schedule-ai-runner.tssupabase/functions/_shared/streaming.tstests/unit/schedule-ai-runner.test.ts
| console.log( | ||
| `[${edgeFunction}] Trying model: ${model.name} (streaming, remaining=${remaining}ms)`, | ||
| ); |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win
Remove production console.log in model-attempt logging.
Use a non-console logging path or gate this behind a dev-only condition; this log was newly added and violates the TS logging rule.
As per coding guidelines, "NEVER leave console.logs in production code - remove or gate with import.meta.env.DEV check".
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@supabase/functions/_shared/schedule-ai-runner.ts` around lines 68 - 70, The
console.log call that prints `[${edgeFunction}] Trying model: ${model.name}
(streaming, remaining=${remaining}ms)` must be removed from production; either
replace it with a proper logger call (e.g., processLogger.debug or
processLogger.info) using the same context (edgeFunction, model.name, remaining)
or gate the existing message behind a dev-only check (if import.meta.env.DEV) so
it never runs in production; update the model-attempt logging in the same block
where edgeFunction, model, and remaining are available (the current console.log)
to use the chosen non-console path.
There was a problem hiding this comment.
Declining. The import.meta.env.DEV guard is a Vite/frontend convention — Deno's import.meta doesn't expose .env, so the suggested gate cannot run in this file. The 70+ Supabase edge functions in this repo (including _shared/streaming.ts, _shared/ai-caller.ts, process-receipt, process-bank-statement) consistently use console.* as the canonical observability sink — those logs land in Supabase Edge Function Logs, which is exactly how this PR's root cause was diagnosed. This specific log (which model the chain is attempting, with remaining-budget annotation) is intentional production observability for the new budget-enforcement path.
There was a problem hiding this comment.
@jdelgado2002, understood — thanks for the thorough context. You're right that import.meta.env.DEV is a Vite-specific convention with no equivalent in Deno, and given that console.* is the established, intentional observability pattern across all edge functions in this repo (landing directly in Supabase Edge Function Logs), this log is entirely appropriate. I'll withdraw the suggestion.
(ᵔᴥᵔ) 🐇
✏️ Learnings added
Learnt from: jdelgado2002
Repo: toyiyo/nimble-pnl PR: 515
File: supabase/functions/_shared/schedule-ai-runner.ts:68-70
Timestamp: 2026-05-23T14:20:10.774Z
Learning: In this repository's Deno/Supabase Edge Functions (under `supabase/functions/`), `console.log`, `console.warn`, and `console.error` are the canonical and intentional observability sink — logs surface directly in Supabase Edge Function Logs. Do NOT flag `console.*` calls in these files as violations; the frontend `import.meta.env.DEV` guard does not apply here and cannot run in the Deno runtime.
Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.
| modelConfig: ModelConfig, | ||
| requestBody: any, | ||
| openRouterApiKey: string, | ||
| edgeFunction: string = 'unknown', | ||
| restaurantId?: string | ||
| restaurantId?: string, | ||
| externalSignal?: AbortSignal | ||
| ): Promise<string | null> { |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="supabase/functions/_shared/streaming.ts"
# Ensure file exists
ls -la "$FILE"
# Show relevant sections around the reported line numbers
sed -n '80,140p' "$FILE" | cat -n
echo "----"
sed -n '140,220p' "$FILE" | cat -n
echo "----"
# Grep for any console.log usage in this file
rg -n "console\.log" "$FILE" || true
echo "----"
# Grep for explicit any typing in this file
rg -n ":\s*any\b|any\s*\)" "$FILE" || trueRepository: toyiyo/nimble-pnl
Length of output: 6279
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="supabase/functions/_shared/streaming.ts"
ls -la "$FILE"
sed -n '80,140p' "$FILE" | cat -n
echo "----"
sed -n '140,220p' "$FILE" | cat -n
echo "----"
rg -n "console\.log" "$FILE" || true
echo "----"
rg -n ":\s*any\b|any\s*\)" "$FILE" || trueRepository: toyiyo/nimble-pnl
Length of output: 6279
🏁 Script executed:
FILE="supabase/functions/_shared/streaming.ts"
sed -n '95,120p' "$FILE"
sed -n '155,180p' "$FILE"
rg -n "console\.log" "$FILE" || true
rg -n "requestBody\s*:\s*any|:\s*any\b" "$FILE" || trueRepository: toyiyo/nimble-pnl
Length of output: 2494
🏁 Script executed:
ls -la supabase/functions/_shared || true
ls -la supabase/functions/_shared/streaming.ts || true
nl -ba supabase/functions/_shared/streaming.ts | sed -n '95,120p'Repository: toyiyo/nimble-pnl
Length of output: 3015
Fix TypeScript any typing and remove production console.logs in callModelWithStreaming.
- Replace
requestBody: anywith a concrete request payload type (or a temporary typed boundary likeRecord<string, unknown>) and narrow where fields such asmessages,temperature, andmax_tokensare used. - The added
console.logcalls (e.g., attempt/start/completed and rate-limit logs) violate the “no console.logs in production” guideline—remove them or gate them behindimport.meta.env.DEV(or route through the existing logger utility).
🧰 Tools
🪛 ESLint
[error] 104-104: Unexpected any. Specify a different type.
(@typescript-eslint/no-explicit-any)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@supabase/functions/_shared/streaming.ts` around lines 103 - 109, The function
callModelWithStreaming currently uses requestBody: any and emits production
console.log statements; change requestBody to a safer type such as
Record<string, unknown> (or a concrete interface) and narrow-access fields where
used (e.g., cast/validate requestBody.messages as Message[], and types for
temperature and max_tokens before use) so TypeScript catches misuse, and remove
all direct console.log calls in callModelWithStreaming (replace with the project
logger or wrap them with import.meta.env.DEV guards) including the
attempt/start/completed and rate-limit logs; update any helper variables or type
assertions inside callModelWithStreaming to reflect the new type and perform
runtime checks where necessary.
There was a problem hiding this comment.
Declining both items. (1) requestBody: any predates this PR — it's on the existing callModelWithStreaming signature; tightening it touches callAIWithFallbackStreaming and every edge function that constructs an OpenRouter request body, out of scope for this hotfix. (2) For the console.log concern, see reply on schedule-ai-runner.ts:70 — import.meta.env.DEV is Vite-only and the codebase convention for Deno edge functions is console.* straight to Supabase Edge Function Logs.
There was a problem hiding this comment.
@jdelgado2002, understood on both counts — acknowledged and won't flag these again.
Noted for future reviews:
requestBody: anyis a pre-existing signature shared across edge functions; tightening it requires a broader refactor touchingcallAIWithFallbackStreamingand is intentionally deferred.- Deno edge functions on Supabase use
console.*directly (routed to Supabase Edge Function Logs);import.meta.env.DEVis Vite-only and does not apply here.
🐇 _(--)/
✏️ Learnings added
Learnt from: jdelgado2002
Repo: toyiyo/nimble-pnl PR: 515
File: supabase/functions/_shared/streaming.ts:103-109
Timestamp: 2026-05-23T14:20:15.967Z
Learning: In the toyiyo/nimble-pnl repository, `requestBody: any` in `callModelWithStreaming` (supabase/functions/_shared/streaming.ts) and related helpers like `callAIWithFallbackStreaming` is a pre-existing type that is intentionally broad because it is shared across all OpenRouter edge functions. Tightening it requires a separate, broader refactor and should not be flagged as a hotfix issue.
Learnt from: jdelgado2002
Repo: toyiyo/nimble-pnl PR: 515
File: supabase/functions/_shared/streaming.ts:103-109
Timestamp: 2026-05-23T14:20:15.967Z
Learning: In the toyiyo/nimble-pnl repository, Deno edge functions (under supabase/functions/) use `console.log`, `console.warn`, and `console.error` directly — this is the project convention, as output routes to Supabase Edge Function Logs. `import.meta.env.DEV` guards are Vite-only and do not apply to Deno/Supabase edge functions. Do not flag bare `console.*` calls in these files.
Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.
| console.log( | ||
| `✅ ${modelConfig.name} stream completed. Content length: ${content.length}, finish_reason: ${finishReason ?? 'null'}` | ||
| ); |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win
Drop the new production console.log for stream completion.
This newly added log should be removed or dev-gated to keep production logging policy compliant.
As per coding guidelines, "NEVER leave console.logs in production code - remove or gate with import.meta.env.DEV check".
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@supabase/functions/_shared/streaming.ts` around lines 163 - 165, Remove the
new unguarded console.log in streaming.ts (the log that prints
`${modelConfig.name} stream completed...`) or wrap it behind a dev-only guard;
specifically either delete the console.log call inside the stream completion
block or condition it with import.meta.env.DEV (or your project's equivalent DEV
flag) so it only runs in development. Locate the console.log that references
modelConfig.name, content.length and finishReason and update it accordingly to
comply with the "no console.log in prod" guideline.
There was a problem hiding this comment.
Declining. This log was already present in the file before this PR (prior text: ✅ ${modelConfig.name} stream completed. Content length: ${content.length}); this commit only extended it to include finish_reason so we can verify the new truncation-detection path in production logs. Same Deno-edge-function reasoning as schedule-ai-runner.ts:70.
There was a problem hiding this comment.
@jdelgado2002, understood — the log predates this PR and the extension adds meaningful observability for the new finish_reason=length detection path in production. Acknowledged and won't flag pre-existing logs extended for diagnostic purposes in this Deno edge-function context.
(ᴗ_ ᴗ。)
✏️ Learnings added
Learnt from: jdelgado2002
Repo: toyiyo/nimble-pnl PR: 515
File: supabase/functions/_shared/streaming.ts:163-165
Timestamp: 2026-05-23T14:20:16.991Z
Learning: In the `supabase/functions/_shared/streaming.ts` Deno edge-function file (and related edge functions in this repo), production `console.log` statements are intentional for observability. Pre-existing logs that are extended to surface new diagnostic fields (e.g., adding `finish_reason` to an existing stream-completion log) are acceptable and should not be flagged as guideline violations. The "no console.log in prod" guideline applies to newly introduced debug logs, not to extending pre-existing operational logs in Deno edge functions.
Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.
Summary
_shared/ai-caller.ts:77attachesAbortSignal.timeout(30000)to the OpenRouter fetch and returns theResponsewithout consuming the body. The signal stays bound to the body stream, so the caller'sawait response.json()aborts withDOMException("Signal timed out.")after 30s of body download. Three consecutive aborts (~90s) exhausted the wall-clock budget and returned a synthetic 502.generate-scheduleto the existingcallModelWithStreamingprimitive in_shared/streaming.ts, which uses a 90s fetch timeout, sendsstream: true, and consumes SSE chunks inside the helper so no caller-side body read is left exposed to the bound signal._shared/schedule-ai-runner.tsso it can be unit-tested from Vitest with the streaming caller injected.MODEL_LOOP_BUDGET_MSraised from 90s → 130s so one full streaming attempt (90s ceiling) plus a fast-fail fallback fit inside Supabase's ~150s edge-function hard kill.Root cause evidence (from production logs)
The misleading
✅ X succeededlog is real — only fetch headers were confirmed. The body never finished arriving before the 30s signal fired on the stream pull.Why streaming fixes it
processStreamedResponsereads SSE chunks continuously; an isolated slow chunk doesn't trigger the abort as long as the whole stream completes within 90s.Test plan
tests/unit/schedule-ai-runner.test.ts— 8 cases covering: first-success, null fallback, parse-error fallback, markdown-fence stripping, all-fail → null, wall-clock budget exhaustion, no extra retries at this layer, restaurantId pass-through.npm run typecheckclean.npm run test— 4085 unit tests pass (1 pre-existing skip).npm run buildsucceeds.npx eslintclean on all touched files.(streaming)markers in the edge function log.Out of scope
json_schemaconstraints unchanged.callModelare not touched._shared/braintrust.ts:4initLogger = null).🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
Refactor
Documentation
Tests