Skip to content

Reach past Modal's 150s ceiling with background jobs - #184

Merged
vahid-ahmadi merged 2 commits into
mainfrom
fix/modal-150s-long-running-tools
Aug 26, 2026
Merged

Reach past Modal's 150s ceiling with background jobs#184
vahid-ahmadi merged 2 commits into
mainfrom
fix/modal-150s-long-running-tools

Conversation

@vahid-ahmadi

Copy link
Copy Markdown
Contributor

score_reform has never worked on the hosted server. /connect tells people to add the MCP server, and their first real call fails.

The mechanism

Modal abandons any HTTP request to a web endpoint after 150 seconds. score_reform over its default five-year window is two full 372-equation solves plus one PolicyEngine static costing per year, and does not fit.

Modal's documented escape hatch does not work for an MCP server. Past 150s it returns a 303 to a polling URL — but 303 means "re-issue as GET", and that URL answers GET with 400 modal-http: bad redirect method. Measured on the live deployment:

client behaviour result
curl -L (follows redirect, converts to GET) 400 modal-http: bad redirect method
curl (no -L) bare 303, zero bytes, at 150.3s

Every correctly-behaving client fails. There is no client-side fix, and no amount of warming helps — the ceiling is a property of the transport, not the compute.

Measured hosted timings:

obr_shock     ~103s warm  -> succeeds;  cold -> exceeds 150s, fails
score_reform   111s local, more on Modal -> exceeds 150s, always fails

The fix

Stop doing the work inside the request. start_job hands an allow-listed adapter call to a worker with a 30-minute budget and returns a handle immediately; get_job_result polls it, blocking at most 120s so the poll itself stays inside the ceiling.

⚠️ Strictly additive, and why

No existing tool changes behaviour. There is no Modal auth on a dev machine, so this ships to production verified only by CI. That constraint is exactly why nothing existing was touched — if the new path is broken, everything that works today still works — and why the post-deploy smoke test now exercises the real path.

Why CI never caught this

The existing hosted bridge test calls score_reform with years=1 to keep its runtime bounded. Nothing in the smoke suite ever exercised the default window a real caller gets. The new remote test uses the default and polls to completion, bounded at 10 minutes so a broken job path fails the deploy fast rather than sitting on the runner.

Details worth review

  • tool is caller-supplied, so it resolves against an allow-list rather than getattr onto core — which would expose every callable there to anyone who can reach the server.
  • The allow-list holds adapter names, not MCP tool names. test_every_allow_listed_tool_resolves_to_a_real_adapter caught two I had wrong (dynamic_reform_impact, population_reform_impact are core.dynamic_population_reform_impact and core.pe_population_impact).
  • The backend is installed by modal_app.serve() rather than at import, so the local stdio server and CLI keep no Modal dependency. They raise NoBackend telling the caller to run the tool directly — locally there is no ceiling. Handing back a job id that nothing will ever run is the one outcome worse than refusing.
  • Tool surface 26 → 28. That is a published contract; edited deliberately here, as tests/tool_surface.py requires.

Testing

  • 15 new unit tests in tests/test_jobs.py (allow-list, handle contract, no-backend behaviour, wait clamping).
  • 2 new post-deploy tests in test_remote_mcp.py — the full default-window round-trip, and the allow-list refusal.
  • 293 integration tests pass; site suite 1342.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Lcj9DDqam9KmVCfhEdnJcJ

score_reform has never worked on the hosted server. Modal abandons any
HTTP request to a web endpoint after 150 seconds; score_reform over its
default five-year window is two full 372-equation solves plus one
PolicyEngine static costing per year, and does not fit.

Modal's documented escape hatch does not work for an MCP server. Past
150s it returns a 303 to a polling URL, but 303 means "re-issue as GET"
and that URL answers GET with `400 modal-http: bad redirect method`.
Measured on the live deployment: curl -L gets the 400, curl without -L
gets a bare 303 and no body. Every correctly-behaving client fails.
There is no client-side fix, and no amount of warming helps -- the
ceiling is a property of the transport, not of the compute.

Measured hosted:
  obr_shock     ~103s warm -> succeeds; cold -> exceeds 150s, fails
  score_reform  111s local, more on Modal -> exceeds 150s, always fails

So stop doing the work inside the request. start_job hands an
allow-listed adapter call to a worker with a 30-minute budget and
returns a handle immediately; get_job_result polls it, blocking for at
most 120s so the poll itself stays inside the ceiling.

STRICTLY ADDITIVE. No existing tool changes behaviour. There is no Modal
auth on a dev machine, so this ships to production verified only by CI --
which is exactly why nothing existing was touched, and why the
post-deploy smoke test now exercises the real path.

`tool` is caller-supplied, so it resolves against an allow-list rather
than getattr onto `core`, which would expose every callable there to
anyone who can reach the server. The allow-list holds ADAPTER names, not
MCP tool names -- test_every_allow_listed_tool_resolves_to_a_real_adapter
caught two that did not exist (dynamic_reform_impact,
population_reform_impact).

The backend is installed by modal_app.serve() rather than at import, so
the local stdio server and the CLI keep no Modal dependency: they raise
NoBackend telling the caller to run the tool directly, since locally
there is no ceiling. Handing back a job id nothing would ever run is the
one outcome worse than refusing.

WHY CI NEVER CAUGHT THIS: the existing hosted bridge test calls
score_reform with years=1 to keep its runtime bounded, so nothing in the
smoke suite ever exercised the default a real caller gets. The new
remote test uses the default window and polls to completion, bounded at
10 minutes so a broken job path fails the deploy fast.

Tool surface goes 26 -> 28. That is a published contract, edited
deliberately here as tests/tool_surface.py requires.

293 integration tests pass; site suite 1342.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lcj9DDqam9KmVCfhEdnJcJ
@vercel

vercel Bot commented Aug 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
macromod Ready Ready Preview Aug 26, 2026 10:35am

Request Review

site_contract.py cross-checks the count in integration/README.md and the
modal_app docstring against the number of @mcp.tool functions the server
actually defines. Adding start_job and get_job_result took it to 28.
@vahid-ahmadi
vahid-ahmadi merged commit fb3892f into main Aug 26, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant