diff --git a/integration/src/policyengine_macro/jobs.py b/integration/src/policyengine_macro/jobs.py index 68bcc6d..4d257dc 100644 --- a/integration/src/policyengine_macro/jobs.py +++ b/integration/src/policyengine_macro/jobs.py @@ -53,10 +53,17 @@ "pe_population_impact", ) -# The longest a get_job_result call may block. The transport ceiling is 150s; -# this leaves room for request overhead so the poll itself never becomes the -# thing that times out. -MAX_WAIT_SECONDS = 120 +# How long a get_job_result call blocks. +# +# The hard ceiling is 150s, but staying under it is not sufficient: measured +# end-to-end through a real Claude Code session, polls at 60-120s repeatedly +# hit transport timeouts and one Modal `InternalFailure: Server has lost track +# of input`, while 30s polls came back reliably. The flakiness scales with how +# long the connection is held open, not with how close it gets to 150s -- so +# the default is short, and a caller asking for a long block gets capped well +# below the ceiling rather than at it. +DEFAULT_WAIT_SECONDS = 30 +MAX_WAIT_SECONDS = 60 _spawn: Callable[[str, dict], str] | None = None _poll: Callable[[str, int], Any] | None = None @@ -112,13 +119,15 @@ def start(tool: str, arguments: dict | None = None) -> dict: "status": "running", "next_step": ( f"Call get_job_result(job_id={job_id!r}). It blocks until the job " - f"finishes or up to wait_seconds (max {MAX_WAIT_SECONDS}); if it " - "returns status 'running', call it again with the same job_id." + f"finishes or up to wait_seconds (default {DEFAULT_WAIT_SECONDS}, " + f"max {MAX_WAIT_SECONDS}); if it returns status 'running', call it " + "again with the same job_id. Short polls are more reliable than " + "long ones -- prefer the default over asking for a long block." ), } -def result(job_id: str, wait_seconds: int = 60) -> dict: +def result(job_id: str, wait_seconds: int = DEFAULT_WAIT_SECONDS) -> dict: """Poll a job. Blocks up to wait_seconds, then reports back either way.""" if not backend_available(): raise NoBackend(_NO_BACKEND_MESSAGE.format(tools=", ".join(JOB_TOOLS))) @@ -132,7 +141,10 @@ def result(job_id: str, wait_seconds: int = 60) -> dict: "waited_seconds": wait, "next_step": ( "Not finished yet. Call get_job_result again with the same " - "job_id; a score_reform over the default five-year window " - "typically needs two or three polls." + "job_id -- polling repeatedly is expected and cheap. A " + "score_reform over the default five-year window typically needs " + "several polls. If a poll errors rather than returning, retry it: " + "the job keeps running regardless of what happens to the " + "connection watching it." ), } diff --git a/integration/src/policyengine_macro/mcp_server.py b/integration/src/policyengine_macro/mcp_server.py index 533fda2..0237ee5 100644 --- a/integration/src/policyengine_macro/mcp_server.py +++ b/integration/src/policyengine_macro/mcp_server.py @@ -49,14 +49,19 @@ def start_job( def get_job_result( job_id: Annotated[str, Field(description="Job id from start_job")], wait_seconds: Annotated[ - int, Field(description="Seconds to block waiting, max 120") - ] = 60, + int, Field(description="Seconds to block waiting, max 60") + ] = 30, ) -> dict: """Fetch a started job's result, waiting up to wait_seconds for it. Returns status 'done' with the result, or status 'running' -- in which case call again with the same job_id. A score_reform over the default - five-year window typically needs two or three polls. + five-year window typically needs several polls; that is expected. + + Keep wait_seconds short. Long blocking polls hit transport timeouts far + more often than short ones, and a poll that errors is not a job that + failed -- the job runs on regardless of the connection watching it, so + just call again with the same job_id. """ return jobs.result(job_id, wait_seconds) diff --git a/integration/tests/test_jobs.py b/integration/tests/test_jobs.py index 80dd0eb..5add74d 100644 --- a/integration/tests/test_jobs.py +++ b/integration/tests/test_jobs.py @@ -128,13 +128,29 @@ def test_unfinished_job_tells_the_caller_to_poll_again(): assert "again" in out["next_step"] -def test_wait_is_capped_below_the_transport_ceiling(): - """A poll that outlived the 150s ceiling would be the bug it works around.""" +def test_wait_is_capped_well_below_the_transport_ceiling(): + """A poll that outlived the 150s ceiling would be the bug it works around. + + The cap sits far below it, not just inside it. Measured end-to-end through + a real Claude Code session: polls at 60-120s repeatedly hit transport + timeouts and one Modal `InternalFailure: Server has lost track of input`, + while 30s polls came back reliably. The flakiness tracks how long the + connection is held open rather than how close it gets to 150s. + """ seen = [] jobs.set_backend(lambda t, a: "fc-1", lambda j, w: (seen.append(w), (False, None))[1]) jobs.result("fc-1", wait_seconds=10_000) assert seen == [jobs.MAX_WAIT_SECONDS] - assert jobs.MAX_WAIT_SECONDS < 150, "the cap must sit inside the ceiling" + assert jobs.MAX_WAIT_SECONDS <= 60, "long blocking polls are unreliable" + assert jobs.DEFAULT_WAIT_SECONDS <= 30, "the default poll must be short" + + +def test_default_wait_is_the_short_one(): + """Callers who pass nothing get the interval that actually works.""" + seen = [] + jobs.set_backend(lambda t, a: "fc-1", lambda j, w: (seen.append(w), (False, None))[1]) + jobs.result("fc-1") + assert seen == [jobs.DEFAULT_WAIT_SECONDS] def test_negative_wait_is_clamped_not_passed_through(): diff --git a/integration/tests/test_remote_mcp.py b/integration/tests/test_remote_mcp.py index a2e809f..c7edf51 100644 --- a/integration/tests/test_remote_mcp.py +++ b/integration/tests/test_remote_mcp.py @@ -422,7 +422,7 @@ async def test_score_reform_default_window_works_through_a_job(): out = None while waited < deadline: out = await asyncio.wait_for( - _call("get_job_result", {"job_id": job_id, "wait_seconds": 120}), + _call("get_job_result", {"job_id": job_id, "wait_seconds": 30}), timeout=180, ) if out["status"] == "done": diff --git a/notes/releases/2026-08-26-uk_average_weekly_earnings/index.html b/notes/releases/2026-08-26-uk_average_weekly_earnings/index.html index 00c95d6..f684c78 100644 --- a/notes/releases/2026-08-26-uk_average_weekly_earnings/index.html +++ b/notes/releases/2026-08-26-uk_average_weekly_earnings/index.html @@ -21,7 +21,6 @@