Repository navigation
fix(slm): retire a stalled update-all job instead of 409ing every future run (#14703) - #14704
Conversation
…ure run (#14703) `POST /code-sync/update-all` refused while a job was "pending" or "running", and a job's status is only ever advanced by its own orchestration. One that stopped advancing therefore stayed "running" forever, and the router has no cancel, reset or expiry path -- so a single wedged job disabled the builtin updater until someone restarted the process. Recovering from a stuck update should not need an out-of-band action on the host. Staleness is judged on progress, never on age. A fleet update across many nodes can legitimately run for hours, and reaping a job that is still working would be worse than the lockout it cures, so the job now stamps `last_progress_at` at every stage transition and once per fleet node. What is bounded is the gap between two signs of life, not the total duration. An unparseable or missing timestamp is deliberately NOT stale: a bad reading must never retire a job that may still be running. Mutation-checked -- making the rule always report stale takes it from 6/6 to 4/6, failing exactly `live_not_stale` and `old_but_progressing`, which are the two that guard the dangerous direction.
Applied Black, isort, and autoflake to match code-quality checks. Triggered by workflow auto-fix-formatting.yml.
✅ SSOT Configuration Compliance: Passing🎉 No hardcoded values detected that have SSOT config equivalents! |
Auto-regenerated (py3.14) to match the backend and/or SLM backend schema so the required verify-generated-types gate(s) pass. Triggered by auto-fix-generated-types.yml.
|
Both critical findings confirmed and fixed in Finding 1 — retirement deleted the evidence the next guard readsVerified in the diff: The scenario that makes it live rather than theoretical is your finding 3: the Fix: the resume plan is read before any staleness decision, and a live Finding 2 — no lockAdded Finding 5 — function lengthExtracted The remaining 73 is mostly job construction and the Finding 4 — tests that actually drive the endpointThree added, all calling
VerificationI modelled the control flow the same way you did, and it caught two errors in
With the model faithful: The last row matters: the ordering fix alone does not close the race, so Finding 3 — not fixed hereThe self-update stage still cannot stamp progress after it detaches. Treating a Finding 6 — noted, not changedAgreed the retired job is not queryable afterwards, since |
Applied Black, isort, and autoflake to match code-quality checks. Triggered by workflow auto-fix-formatting.yml.
#14704 landed in base and touched the same two files. Both conflicts were each side appending its own material at the same anchor, so both sides are kept: - the self-update watch constants (#14683) alongside the stale-job constant (#14703) - the reconcile tests alongside the staleness tests, with the shared import block merged and deduplicated Checked rather than assumed: no duplicate definitions in either file, every symbol from both sides present exactly once, and the orchestration carries both edits — #14703's per-stage progress stamps and #14683's reconcile call on the slm_fired path.
Closes #14703
Thinking Path
Came out of the constraint that a test install still gets fixed in code and has to work out of the box. My plan for clearing the wedged job on the live box was "restart the service" — which is an out-of-band action, and if that is the only way out, the gap is in the code, not in the procedure.
Checking, it is:
POST /code-sync/update-all409s while a job ispending/running, a job status is only ever advanced by its own orchestration, and the router has no cancel, reset or expiry path at all. So one wedged job disables the builtin updater permanently.The obvious fix — expire on job age — is wrong here. A fleet update across many nodes can legitimately run a long time, and reaping a job that is still working is worse than the lockout, because it would interrupt real deployments rather than a stuck one. So the job reports progress and what is bounded is the gap between two signs of life.
I also considered only adding a cancel endpoint. Rejected as insufficient on its own: it requires someone to know the state exists and go clear it, which is the same out-of-band recovery in a nicer shape.
What Changed
autobot-slm-backend/api/code_sync.py:last_progress_atonUpdateAllJob, stamped at every stage transition and once per fleet node._job_is_stale()— no progress within_UPDATE_ALL_STALE_SECONDS(env-backed, floored, default 1800s).An unparseable or missing timestamp is deliberately not stale, and the rule falls back to
created_atfor a job that never stamped.Verification
Mutation-checked in an extracted scratch copy — nothing run from the repository. Making the rule always report stale:
Those two are the ones that matter: they are what stops the fix from reaping live work. The extraction asserts the mutation target exists before mutating, so a missing target fails loudly instead of reporting a false pass.
Tests added to the file that already owns update-all job tests, reusing its harness rather than duplicating its tuned
sys.modulesrestore. Covered: a stalled job is stale; a progressing one is not; a job created six hours ago but still stamping is not; fallback tocreated_at; unreadable and empty timestamps are never stale; and the fleet stage really does stamp before each node — without that last one the field would exist and nothing would set it during the one stage long enough to matter.Relationship to #14685
Different defects, same symptom. #14685 stops the self-update stage hanging when the play does not restart the service. This one stops any wedged job — whatever the cause, including a process killed mid-stage — from locking out every future update. Neither subsumes the other.
Model Used
Claude Opus 5