Skip to content

fix(deploy): report deploy state with drift, and refuse destructive resolve mid-play (#13913) - #13946

Merged
mrveiss merged 7 commits into
Dev_new_guifrom
issue-13913
Aug 11, 2026
Merged

mrveiss merged 7 commits into
Dev_new_guifrom
issue-13913

Conversation

@mrveiss

@mrveiss mrveiss commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Closes #13913

Thinking Path

The endpoint was not wrong about the checksums — during a play the two trees genuinely differ. It was wrong about what that means, and it had no way to say so. So the fix is not a smarter comparison but an honest annotation plus a guard on the one path that does damage.

Three candidate signals, two rejected:

signal why not
an in-process flag Play 1 restarts this backend halfway through the run it would be tracking
the self-update log complete=False means both still running and died — opposite answers for a caller deciding whether to trust a reading. Same reason /status reported self_update_incomplete: false throughout the measured window: that verdict describes the previous run
the transient systemd unit forked by PID 1 (#11492/#12596), so it is not in this backend's cgroup and survives the restart

What Changed

services/deploy_activity.py (new) — read_deploy_activity() returns in_progress, a reason, and last_completed_play_at.

Unknown is a third state. A query that could not run reports None, never False. "Nobody could check" and "checked, nothing running" are different claims, and only the second makes a drift reading trustworthy — collapsing them would restore the exact silence this issue is about.

GET /drift carries deploy_in_progress / deploy_state_reason / last_completed_play_at. All optional with None defaults, so no existing caller breaks. The activity is read after the comparison: a play that starts mid-scan still makes the reading unstable, and checking afterwards catches that ordering.

POST /drift/resolve and /resolve-async 409 while a play is running. One shared helper, not two copies — a test asserts exactly two call sites.

Two decisions worth reviewing

Unknown does not block resolve. On a host where the unit cannot be queried, refusing would permanently disable remediation — a regression against behaviour that works today — whereas proceeding is exactly the pre-existing behaviour. Logged, and surfaced in the drift response.

The probe is wrapped in try/except. read_deploy_activity is written never to raise, so this is defence in depth; it earns its four lines because a probe added to make a destructive operation safer must never be the thing that takes it down.

Verification

9 mutations applied, 9 killed — the fix reverted piece by piece:

mutation killed by
None → False on systemctl-missing test_a_query_that_could_not_run_is_unknown_not_false
None → False on timeout same
incomplete run dated as a completion test_an_unfinished_run_is_not_dated_as_a_completion
readings_are_unstable treats unknown as unstable 3 tests
unit prefix hardcoded instead of imported test_the_unit_pattern_is_derived_from_the_executor_not_restated
guard removed from the async resolve path test_both_resolve_paths_share_one_guard
guard downgraded to a log test_resolve_refuses_while_a_play_is_running
unknown collapsed to False at the endpoint test_an_unknown_deploy_state_reaches_the_caller_as_none
schema field made required test_the_schema_declares_the_three_fields_as_optional

Two things the first drafts got wrong, worth recording because both were vacuous passes:

  1. from services import deploy_activity returns a conftest MagicMock, under which _last_completed_play_at(...) is None passed while asserting nothing. Real-loaded by path instead.
  2. The unit-prefix guard originally compared substrings; substituting the already-interpolated "autobot-selfupdate-*.service" sailed past it. It now reads the assignment's AST and asks whether the value is built from the imported name.
tests/services/test_deploy_activity_13913.py ........... 11 passed
tests/api/test_drift_reports_deploy_state_13913.py ....... 8 passed
tests/api + tests/services ................ 843 passed, 1 skipped

Not in this PR

The issue's "separate finding" — autobot-slm-agent reporting a permanent 2-of-8 false-positive rate because the role deliberately never ships its *_test.py files — is a deployed_checksum: null class, the mirror of #13851's two. Fixing it properly means deriving each component's shipped set from the role's copy: tasks rather than assuming the whole source tree deploys, which is a different module and a different risk. Spun out rather than bolted on.

Model Used

claude-opus-5[1m]

…esolve mid-play (#13913)

A drift reading taken while ansible is mid-write reports files that are simply
being copied. Measured across one self-update: 28 of 30 reported drifts
evaporated once the play settled — same host, same endpoint, ten minutes apart,
nothing resolved in between. Both checksums were populated throughout, so the
source_checksum: null heuristic from #13851 does not filter them out; they look
exactly like genuine content drift.

The window where the answer is wrong is the window where operators look, and
the remediation is a delete-style rsync, so acting on a mid-deploy reading is
destructive against files that are mid-write.

The signal is the transient systemd service the detached run lives in
(#11492/#12596). It is forked by PID 1 rather than by this backend, so unlike
any in-process flag it survives the 'systemctl restart autobot-slm-backend'
that Play 1 performs halfway through the run being tracked. The self-update log
cannot answer this: complete=False means both 'still running' and 'died', which
are opposite answers for a caller deciding whether to trust a reading — the
same reason /status reported self_update_incomplete: false throughout.

Unknown is a third state. A query that could not run reports None, never False:
'nobody could check' and 'checked, nothing running' are different claims and
only the second makes a reading trustworthy.

Unknown does NOT block resolve. On a host where the unit cannot be queried,
refusing would permanently disable remediation — a regression against working
behaviour — whereas proceeding is exactly what happened before this guard. It
is logged and surfaced in the response instead. The probe is also wrapped: a
safety check must never be the thing that 500s the operation it protects.

Verification: 9 mutations killed (unknown->False in each of the four failure
paths, an incomplete run dated as a completion, unstable-when-unknown, a
hardcoded unit prefix, the guard removed from the async resolve path, a
required schema field). The unit-prefix guard is asserted through the AST
because the first, substring-based version let the mutation through.
843 existing tests in tests/api and tests/services pass unchanged.
Applied Black, isort, and autoflake to match code-quality checks.
Triggered by workflow auto-fix-formatting.yml.
@github-actions

Copy link
Copy Markdown
Contributor

✅ SSOT Configuration Compliance: Passing

🎉 No hardcoded values detected that have SSOT config equivalents!

…ds (#13913)

verify-generated-types was red: adding deploy_in_progress, deploy_state_reason
and last_completed_play_at to FileDriftReport changes the OpenAPI schema, and
the checked-in TypeScript was still describing the old shape.

Regenerated rather than hand-edited, and the diff is confined to the three
fields — additive only, no unrelated drift picked up from the base:

    openapi.json    33 insertions, 1 deletion
    api.ts           6 insertions, 0 deletions
@mrveiss

mrveiss commented Aug 10, 2026

Copy link
Copy Markdown
Owner Author

Two failures, two different causes.

verify-generated-types — fixed here. Adding deploy_in_progress, deploy_state_reason and last_completed_play_at to FileDriftReport changes the OpenAPI schema, and the checked-in TypeScript still described the old shape. Regenerated rather than hand-edited; the diff is confined to the three fields, additive only, with no unrelated drift picked up from the base:

autobot-slm-frontend/openapi.json    33 +
autobot-slm-frontend/src/types/generated/api.ts    6 +

code-quality — blocked on #13962, not fixable here. It reports 33 violations in autobot-slm-backend/api/code_sync.py, none of them on a line this PR touches. git blame on the first one gives db441315f, which is neither of this branch's commits. Most of the 33 are the /opt/autobot/ path literals tracked by #13149:

VIOLATION api/code_sync.py:1375   cmd.append(f"/opt/autobot/{component}/")
VIOLATION api/code_sync.py:1504   "/opt/autobot/autobot-backend/requirements.txt"
VIOLATION api/code_sync.py:1553   "autobot-frontend": "/opt/autobot/autobot-frontend"
...

Fixing them inline would mean doing #13149 inside an unrelated PR, against the paths that decide where deploys write. I checked for a baseline or exemption mechanism first — there is none; the hook's allowlist is path-pattern only.

This is the fifth PR hit by the file-vs-line scoping in one session, and it is the one where the inherited backlog is too large and too dangerous to absorb. #13962 scopes the guard to changed lines (opt-in, fails closed, 3 mutations killed) and unblocks this and #13945.

@mrveiss

mrveiss commented Aug 11, 2026

Copy link
Copy Markdown
Owner Author

Follow-up filed from this PR: #14006 — tests/api/_code_sync_import.py binds response-model stand-ins with no fields, so no api test in that package can assert what an endpoint returns.

That is why the response-shape tests here capture the kwargs passed to FileDriftReport and check the real schema declaration via AST, rather than asserting on the returned object. Two indirect assertions standing in for one direct one — worth fixing at the harness rather than re-deriving in every future test.

@mrveiss
mrveiss merged commit fabdc14 into Dev_new_gui Aug 11, 2026
61 of 63 checks passed
@mrveiss
mrveiss deleted the issue-13913 branch August 11, 2026 09:03
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