Repository navigation
Conversation
The store can now list the report runs and simulation runs that are running under a recorded Modal call and started before a given time, and mark one failed only while it is still running under the call it was listed with. A run that finished, or that a restarted coordinator moved to a new call since it was listed, is left as it is, so two sweeps at once fail each run once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sweep lists the report runs and simulation runs that have been running under a recorded Modal call for at least ten minutes, asks Modal for each call's call graph once, and marks a run failed only when every input of its call has finished: succeeded, failed, terminated, timed out or failed to start. A call with a pending input is running; Modal reports a pre-empted call pending until it runs again under the same call ID. A call Modal cannot describe, or any error asking, leaves the run as it is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The new Modal application policyengine-simulation-run-sweep runs the sweep on a cron schedule every ten minutes, one container at a time, with the Modal worker role's database secret and the locked packages of the v2 images, without the bundle's data. It is not versioned with the .py bundle, so one copy runs per Modal environment; an operator deploys it. A sweep that overlaps another is still safe. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sweep's app module imports src.modal at module level, and Modal imports that module again in the container, so the image now adds the src.modal sources as the project's other Modal apps do. Without them every scheduled run failed with No module named 'src'. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Modal returns the whole call graph a call belongs to, and nests a call spawned inside another call's input under that input, so the sweep now reads only the inputs whose call ID is the recorded one, wherever they are, and treats a call missing from its graph as unknown. Before, a worker's simulation run was judged by its coordinator's status. A simulation run is now listed only while no report run that combines it is running: that report run's coordinator records its children's outcomes, and after a pre-emption it cancels them and starts them again under new calls, so a sweep in that gap could fail a run that would have finished. The sweep handles report runs first, so a dead coordinator's children are reached in the same sweep. Listing has no row limit, so runs that stay unknown can't hide newer ones. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Modal reports a call it won't retry after an internal failure as pending, stops describing old calls, and a run can be marked running with no call recorded; none of these could be decided, so such a run, and the simulation runs it names, stayed running for good. The sweep now also fails every run still running 24 hours after its first claim, whatever Modal reports and with or without a recorded call, a time no attempt of the v2 functions can approach. A run listed without a call is failed only while it still has none. The summary now also counts runs left because they finished or moved to another call, and runs failed for their age, and the listing of simulation runs is tested with pending, several and finished report runs naming them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sweep now fails runs past the maximum age before it asks Modal about any call, so lookups that stall can't use up the scheduled function's time before the check that exists for undecidable runs. The update that fails a simulation run repeats the condition that no running report run combines it, so a report run that starts combining it after the listing keeps it. The failing write is renamed fail_stuck_run, the age message no longer names a fixed age, and the changelog fragment and docstrings say that a run past the maximum age is failed whatever Modal reports. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sweep checked simulation runs' age only after every report run's Modal lookup, so lookups that stalled could still keep simulation runs past the maximum age from being failed. It now fails report runs, then simulation runs, past the maximum age before any lookup, then asks about each kind's calls, report runs first. A refused failure in the age check counts as left, and the docstrings say a write is refused also when a running report run started combining the simulation run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The simulation service now records the Modal call it starts on a run while the run is still pending, and a run whose call was never started stays pending. The store therefore lists pending runs as well as running ones, with their status: a running run counts from its first claim and a pending one from its creation, so the 24-hour limit also covers pending runs. A pending report run now keeps the simulation runs it combines out of the listing, as a running one does: its coordinator, queued in Modal, will start their calls. Failing a listed run requires its listed status as well as its listed call, so a run that was claimed, or that the simulation service recorded a call on, after the listing is left as it is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A pending run with no Modal call recorded ten minutes after its creation is failed with `run_not_started`, without asking Modal: the simulation service stopped between inserting the run and starting its call, so nothing will run it. A pending run with a recorded call is judged by its call, exactly as a running one: Modal may still be queueing it, or it may have ended without claiming the run. A running run with no call recorded is still left to the 24-hour limit, which now counts a never-claimed run from its creation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The `deploy_executor` job of the reusable deployment workflow now runs `modal deploy` on `src/modal/run_sweep_app.py` after the versioned executor, with only the Modal tokens in the step's environment, as the executor's own deploy step has them. The sweep reads its database URL at run time from the Modal secret `policyengine-modal-worker-database`, so this step fails until an operator has created that secret in the Modal environment; the secret must exist before this merges. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sweep's module docstring and `run_not_started` message gave one cause for a pending run with no call; it also follows a call the simulation service started but failed to record, whose claim is then refused. The scheduled function and the store protocol described their old scope. The sweep tests' stand-in store now tells the maximum-age listing by the exact cutoff the sweep asks for, instead of by a fixed hour. New cases: a pending run is failed for age like a running one, and a dead coordinator's never-started simulation runs are listed once its report run has failed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A report run names its baseline and reform simulation runs in its own columns, so the sweep's docstrings now say a pending or running report run names the simulation runs it keeps out, not that it combines them. The store tests also check that a report run keeps its reform run, not only its baseline, out of the sweep until it finishes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sweep's store tests now also check that a simulation run a pending or running report run names only as its reform is not failed, and that a report run with no reform run keeps only its baseline out of the sweep. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…'t see - A running simulation run that no report run names, as one the simulation service starts on its own would be, is listed and failed by the sweep. The rebuilt history lost that case when the member rows it deleted went away. - `fail_stuck_run` says a report run naming the simulation run counts once its insert has committed before the update starts: PostgreSQL makes neither statement wait for the other. - The tests' extra simulation run copies only the columns that say what it is, and a second report run that is failed gets its completion time, as the table's check requires. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… docs The test of a simulation run no report run names now fails the run the sweep listed, so it also checks the listing's status and call. The persistence package's README and the run tables' module docstring name the stuck-run sweep among the Modal worker role's readers and writers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk
force-pushed
the
v2plan/b3-4-stuck-run-sweep
branch
from
November 6, 2026 18:18
5cf46f9 to
c07edcc
Compare
This branch has not been deployed
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.
Fixes #993
Same-Repository Draft PR Check
PolicyEngine/policyengine-sim-api, not a personal fork.Summary
A scheduled Modal function now marks the Public API's report runs and simulation runs failed when nothing will finish them: the run is pending or running but the Modal call recorded for it has ended, for example because its container ran out of memory before it could record the outcome, or the run is still pending with no Modal call recorded ten minutes after it was created, because the simulation service stopped before starting its call. It runs every ten minutes, never fails a run whose call is still running or queued, or whose state Modal cannot report, and two sweeps at once fail each run once. The deployment workflow deploys it with the executor.
This builds on #992, which records each run's Modal call ID when the coordinator or a worker claims the run; review only the commits after it. The simulation service also records, on a pending report run, the Modal call it starts.
How it tells that a call has ended
Every ten minutes the sweep lists the report runs, and then the simulation runs, that have been pending or running for at least ten minutes, counted from their first claim (
started_at) or, while pending, from their creation (created_at). A pending run with no call recorded is failed without asking Modal:run_not_started: The run stayed pending with no Modal call recorded for it.Usually the simulation service stopped before starting its call; if it started one but failed to record it, that call's claim is refused once the run has failed. A running run with no call recorded is left to the 24-hour limit. For each recorded call, on a pending or a running run, it asks Modal once for the call graph the call is part of (modal.FunctionCall.from_id(call_id).get_call_graph()) and reads the status of the inputs whose call ID is the recorded one, wherever they are in the graph: a worker spawned inside a coordinator's input is nested under it.PENDINGinput: the call is still running, queued, or waiting to run again; Modal restarts a pre-empted call under the same call ID and reports it pending meanwhile;SUCCESS,FAILURE,TERMINATED,TIMEOUTorINIT_FAILURE: the call has ended, and the run is marked failed withmodal_call_ended: The Modal call recorded for this run ended without recording the run's outcome.;A simulation run is listed only while no pending or running report run names it as its baseline or reform (
report_runs.baseline_simulation_run_idorreform_simulation_run_id). That report run's coordinator starts its children's calls once Modal runs it, and records their outcomes itself; after a pre-emption it cancels them and starts them again under new calls, and a sweep while it is queued or in that gap would otherwise fail a run that is about to start. Report runs are swept first, so a dead coordinator's simulation runs are reached in the same sweep.A run still unfinished 24 hours after its first claim, or after its creation if it was never claimed, is failed whatever Modal reports, with or without a recorded call (
run_exceeded_maximum_age). No attempt of these functions can run nearly that long (the coordinator's own time limit is 90 minutes), however often Modal pre-empts and restarts it; the limit stops a run staying running for good when Modal keeps reporting a call it won't retry as pending, no longer describes a call, or no call was recorded.The failure is one conditional
UPDATE … WHERE id = … AND status = <the listed status> AND modal_call_id = <the listed call>(orIS NULL), setting only the status, error, completion time andupdated_at, so a run that was claimed, finished, moved to a new call, or had its call recorded by the simulation service since it was listed is left alone, and a second sweep running at the same time changes nothing the first changed.Where it runs
A new Modal application,
policyengine-simulation-run-sweep(src/modal/run_sweep_app.py), with one function,sweep_stuck_runs, on the cron schedule*/10 * * * *, at most one container at a time, with the Modal worker role's database secret (policyengine-modal-worker-database) and the locked package group the v2 images install, without the bundle's data. It is not versioned with the .py bundle, so one copy runs per Modal environment rather than one per deployed v2 application. Thedeploy_executorjob ofsimulation-deploy.reusable.ymldeploys it after the versioned executor, withuv run --frozen modal deploy --env="${{ inputs.modal_environment }}" src/modal/run_sweep_app.py. Modal refuses that deployment until the Modal secret exists in the environment, so the job fails until then.Not covered: household jobs, until the simulation service records their Modal calls. A run marked running with no recorded call is failed only by the 24-hour limit.
Tests
The store's listing and conditional failure run against a disposable Postgres migrated with the Public API's v2 chain, as the Modal worker role, with the worker's
UPDATElimited to its columns, including two sweeps started together in threads, pending runs listed from their creation, a pending run whose call was recorded or claimed after the listing, a run with no recorded call, and simulation runs named, as a baseline or a reform, by pending, running, finished or several report runs, by a report run with no reform run, or by none; they skip whenREPORT_RUN_TEST_DATABASE_URLandREPORT_RUN_TEST_SEED_DATABASE_URLare unset, as in CI. The sweep's decisions and the call-graph reading use stand-ins for Modal and the store. The Modal application's declaration is checked with the repository's fake Modal module, and the deployment step by reading the workflow.Environment variables
MODAL_WORKER_DATABASE_URL: read bysweep_stuck_runs. Provider: the Modal secretpolicyengine-modal-worker-database, created by an operator in thestagingandmainModal environments (the step Record report runs and simulation runs from the v2 Modal coordinator and workers #992 waits for). Wiring:modal.Secret.from_name(..., required_keys=["MODAL_WORKER_DATABASE_URL"])attached throughsecrets=tosweep_stuck_runs. Not verified: the secret does not exist yet.MODAL_TOKEN_ID,MODAL_TOKEN_SECRET: read by the new deployment step. Provider: GitHub Actions repository secrets (both exist; thebetaandprodenvironments don't override them). Wiring: thebetaandprodjobs ofsimulation-deploy.ymland ofstage12-deploy.ymlcall the reusable workflow withsecrets: inherit, and the step'senv:readssecrets.MODAL_TOKEN_IDandsecrets.MODAL_TOKEN_SECRET, as the executor's deploy step does.What this waits for before merging
policyengine-modal-worker-databasein thestagingandmainModal environments. Without the secret, the deployment's executor job fails.After the first deployment, check that
modal app listshows the app in each environment and that its first scheduled run returned a summary (checked,failed,running,unknown,left,failed_for_age,failed_without_call) without errors inmodal app logs policyengine-simulation-run-sweep.🤖 Generated with Claude Code