Reach past Modal's 150s ceiling with background jobs - #184
Merged
Conversation
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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
score_reformhas never worked on the hosted server./connecttells 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_reformover 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(follows redirect, converts to GET)modal-http: bad redirect methodcurl(no-L)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:
The fix
Stop doing the work inside the request.
start_jobhands an allow-listed adapter call to a worker with a 30-minute budget and returns a handle immediately;get_job_resultpolls it, blocking at most 120s so the poll itself stays inside the ceiling.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_reformwithyears=1to 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
toolis caller-supplied, so it resolves against an allow-list rather thangetattrontocore— which would expose every callable there to anyone who can reach the server.test_every_allow_listed_tool_resolves_to_a_real_adaptercaught two I had wrong (dynamic_reform_impact,population_reform_impactarecore.dynamic_population_reform_impactandcore.pe_population_impact).modal_app.serve()rather than at import, so the local stdio server and CLI keep no Modal dependency. They raiseNoBackendtelling 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.tests/tool_surface.pyrequires.Testing
tests/test_jobs.py(allow-list, handle contract, no-backend behaviour, wait clamping).test_remote_mcp.py— the full default-window round-trip, and the allow-list refusal.🤖 Generated with Claude Code
https://claude.ai/code/session_01Lcj9DDqam9KmVCfhEdnJcJ