Skip to content

bug(update): the SLM self-update fires without executing, and the status surface replays a stale verdict from an earlier run #15522

Open

Description

@mrveiss

What happens

The SLM self-update fires and silently does nothing, while the status surface keeps replaying a stale verdict from an earlier run — so the UI reports a failure that did not come from the run you just triggered.

Observed on the live SLM node today. Two firings:

time what the logs show
11:36:04 Firing Ansible self-update … (fire-and-forget) → inventory written → Executing Ansible playbook → Self-update run detached into transient systemd service → ansible log written until 11:38 → 1 failed task
12:47:04 Firing Ansible self-update … (fire-and-forget) → nothing at all

For the 12:47 run there is: no playbook_executor line (the last one is 11:42), no write to the ansible log (mtime still 11:38), no transient systemd unit, and no inventory file. The run never reached the executor.

Meanwhile, every ~60 seconds since:

services.self_update_log_reader - ERROR - self-update verdict: self-update finished with 1 failed task(s)

That verdict is the 11:36 run's result, re-read from a log last written at 11:38 and re-emitted indefinitely. It is logged twice per cycle. The deployed marker confirms nothing advanced: .deployed_commit still reads the 11:36 value, while code_source moved on at 11:42.

Why it is hard to notice

  1. "fire-and-forget" means a launch failure is invisible. The code logs that it fired and returns. If the detached run never starts, nothing distinguishes that from a run in progress.
  2. The stale verdict fills the gap with a wrong answer. Because the reader re-reports the previous run's outcome, the surface is never blank — it shows a plausible-looking failure. An operator who triggers an update and sees "1 failed task" will reasonably believe that is their run's result. It is six hours old.
  3. Together these mean an update can appear to have run and failed, when in fact it never executed.

Acceptance criteria

  • Establish why the 12:47 firing never reached playbook_executor. The 11:36 path wrote its inventory under the deploy root and 11:42 wrote to a temp dir — determine whether the inventory directory being unwritable, or the transient-unit launch failing, is the cause.
  • A fired self-update that does not start must fail loudly rather than return. Fire-and-forget is acceptable for the run's duration; it is not acceptable for the launch itself.
  • The verdict reader must not report a run older than the firing it is being asked about. Bind the verdict to a run identity (timestamp or run id) and report "no result yet" rather than the previous run's outcome. A stale answer is worse than no answer here, because it is indistinguishable from a fresh one.
  • Establish which task failed in the 11:36 run, and whether it is the same failure that has been recurring. The ansible log is mode 0600 owned by the service account, so this needs either a readable location or a surfaced summary — an operator cannot currently see which task failed.
  • Verified by MUTATION: prevent the transient unit from starting, fire an update, and confirm the operator-visible surface says the launch failed rather than showing the prior run's verdict.

Not in scope here

Two other errors recur at the same 60-second cadence and are filed separately — they are a different subsystem, but they share this issue's cadence in the log and will be seen alongside it.

Evidence

All from the live node: logs/slm-backend.log, logs/slm-backend-error.log, .deployed_commit, and the mtimes of self-update-ansible.log. No addresses or internal paths beyond component-relative ones are quoted, per #15516.

Activity

  1. mrveiss commented on Sep 2, 2026

    @mrveiss
    OwnerAuthor

    Root cause: the self-update task is created without retaining a reference

    autobot-slm-backend/api/code_sync.py:5217

    _stage_log(stage, f"Firing Ansible self-update for {slm_node.node_id} (fire-and-forget)")
    stage.message = "Ansible SLM self-update queued; service will restart"
    asyncio.create_task(_ansible_self_update(slm_node.node_id))
    return True

    The task object is discarded. CPython's asyncio documentation states this directly: "Save a reference to the result of this function, to avoid a task disappearing mid-execution. The event loop only keeps weak references to tasks." An unreferenced task can be garbage-collected before it completes — or before it starts.

    This is not the file's usual pattern, which is what makes it a defect rather than a style choice. Of the eight create_task calls in code_sync.py, five retain a reference:

    line retained? call
    1162 yes — task = ... _run_component_resolve_job
    3559 yes — task = ... _sync_single_node
    3935 yes — task = ... _run_fleet_sync_job
    4418 yes — task = ... _run_fleet_sync_job
    5933 yes — _resume_task = ... _execute_resume
    3447 no _ansible_self_update
    3527 no _ansible_self_update
    5217 no _ansible_self_update

    All three unreferenced sites are the same function — the one that silently failed to execute.

    Why this produces exactly the observed behaviour

    1. It is intermittent, not deterministic. Whether a weakly-referenced task survives depends on GC timing. That matches the observation: 11:36 executed and reached the playbook executor; 12:47 logged the same "Firing" line and produced nothing — no executor call, no inventory file, no transient unit, no ansible log write.
    2. The failure is silent by construction. Nothing awaits the task and no add_done_callback is attached anywhere in the file, so an exception inside _ansible_self_update has nowhere to surface. The caller has already return True'd.
    3. A service restart races it. stage.message says "service will restart" — the self-update restarts the very process holding the loop. A task that has not yet run dies with it, and the restart makes the disappearance look like a normal consequence of updating.

    Together: the caller reports success, the task may never run, and nothing anywhere records that it didn't.

    Fix shape

    Retain the reference for the task's lifetime — the pattern the same file already uses five times. A module-level set with an add_done_callback(...discard) is the idiomatic form, and the callback is also where a launch failure should be logged rather than swallowed.

    That addresses the launch half. The stale-verdict half in the acceptance criteria above is separate and still stands: even with the task retained, a verdict reader that replays the previous run's outcome will still misreport.

    Method note

    Found by reading the call path rather than by re-running the update — the deployed node was left untouched. The distinction matters here because the bug is timing-dependent: a successful re-run would have proved nothing, and a failed one would still not have shown the cause.

  2. github-actions commented on Sep 2, 2026

    @github-actions
    Contributor

    PR #15525 (merged to Dev_new_gui) references this issue with a close keyword.

    fix(slm): retain self-update tasks, bind the verdict to its run, unbreak the reconciler (#15522, #15523)

    If this issue is fully resolved, close it manually. If work remains, no action is needed.

  3. mrveiss commented on Sep 2, 2026

    @mrveiss
    OwnerAuthor

    PR #15525 merged as 2abc18de3b. Not closing — 2 of 5 criteria are unmet, and one new residual is worth recording.

    AC verdict evidence
    1 — establish why the 12:47 firing never reached playbook_executor MET, with a caveat Root cause recorded: the three _ansible_self_update create_task sites discarded the task, and the loop holds only a weak reference. The caveat is that this is a third cause — neither hypothesis this AC named (inventory-directory writability, transient-unit launch failure) was tested or ruled out, and no host evidence was gathered.
    2 — a fired self-update that does not start must fail loudly MET autobot_shared/async_compat.py:105 _BACKGROUND_TASKS; :118 .add(task); :119 add_done_callback; :134 .discard; :140 logger.error(... exc_info=exc). All three firings converted at api/code_sync.py:3460, :3533, :5215; git grep asyncio.create_task(_ansible_self_update returns nothing.
    3 — verdict bound to a run identity; report "no result yet" rather than the prior outcome MET services/self_update_log_reader.py:87 _RUN_STARTED; :213 _run_is_at_or_after (direction correct — the executor stamps after the fire, playbook_executor.py:745); :262 returns no self-update result for this run yet. Bound at code_sync.py:5692.
    4 — establish which task failed; a readable location or a surfaced summary UNMET The reader still parses counts only: self_update_log_reader.py:35 _RECAP_COUNTS matches failed= / unreachable= and nothing else, so _describe can only say "finished with N failed task(s)". No permissions or location change to the 0600 ansible log in 2abc18de3b. An operator still cannot see which task failed — which is the thing this AC exists for.
    5 — verified by mutation on a running node UNVERIFIABLE Host behaviour. The PR body explicitly disclaims it: nothing was deployed and no ansible was run.

    The retention proof is sound — I checked the specific doubt

    The implementing agent flagged that test_the_task_survives_the_caller_dropping_its_reference still passed under its own mutation, because a forced gc.collect() does not deterministically reclaim in that window. That test is redundant, not load-bearing. The proof rests on async_compat_test.py:127 test_the_task_is_retained_while_pending_and_released_when_done, which asserts membership while pending and absence after completion — deterministic, and it kills both the .add and the callback's .discard under mutation. :159 asserts positively on caplog content, so a broken capture fails rather than passing vacuously.

    A residual this fix newly permits

    test_an_unstamped_log_is_no_answer_to_a_bound_caller pins that an unstamped failing log returns degraded is False to a bound caller. The only bound consumer branches on if verdict.degraded: (code_sync.py:5693) — so "I cannot tell which run this is" is consumed as "not degraded", i.e. as success. Before this change the same log produced a FAILED stage.

    It is mitigated twice — _await_self_update_completion requires a completion newer than the firing, and _resume_verify_slm_stage verifies the deployed commit at code_sync.py:5716 before SUCCESS — so it is narrow. But it is a direction the fix opened, and the whole point of this issue is that an unknown state must not read as a good one. Worth an explicit decision rather than leaving it implicit.

    Remaining to close: AC4 (surface the failing task name — the reader has the recap in hand and could name it), and either satisfying AC5 on a node or restating it as something a diff can show.

  4. mrveiss commented on Sep 3, 2026

    @mrveiss
    OwnerAuthor

    Your AC4 reproduced live, from the operator's side.

    A self-update ran today and reported self-update finished with 1 failed task(s). Which task failed is not obtainable, exactly as this issue predicts:

    Route Result
    /var/log/autobot/self-update-ansible.log mode 0600, owned by the service account — unreadable as the operator user
    GET /api/code-sync/status 401 — behind the service-management gate
    GET /api/health status: healthy — carries no self-update verdict at all

    That last row is the sharpest part. The health surface reported healthy immediately after a run that had a failed task. An operator looking at the obvious place sees nothing wrong.

    So AC4 — "an operator cannot currently see which task failed" — is confirmed rather than inferred. It needs either a readable location or a surfaced summary, and neither exists today.

    Two further host facts from the same run, both relevant to this issue's other criteria:

    autobot-slm-backend up   16:15:14
    run started              16:17:28
    .deployed_commit         16:18:15  -> d2325ae4
    
    1. The process performing the self-update predated by 181 seconds the code it deployed. Any verdict that run produced describes old code.
    2. Nothing restarted during the run — services have been up since before it started, so the synced code is on disk and not loaded. That is the deployed-but-not-restarted shape of bug(tts): synthesis 404s — deployed worker lacks /tts/synthesize/stream because autobot-tts-worker has NO resolve path AND NO drift visibility (silent skew on every backend update); /tts/clone-voice is a dead call that exists nowhere #12886 / fix(deploy): restart celery workers so they run the code the update just synced (#13539) #13802, still live.

    Both are the reason #15323 exists, and both are visible only by comparing timestamps by hand — which is itself an argument for the surfaced summary this AC asks for. A verdict that cannot say which release produced it is not much better than no verdict.

  5. added this to the Backlog milestone on Sep 12, 2026
  6. mrveiss commented on Sep 12, 2026

    @mrveiss
    OwnerAuthor

    Stale-issue sweep (never auto-closed — Dev_new_gui wasn't GitHub's default branch at merge time). Verified against current origin/main; leaving open (matches this issue's own existing post-merge audit comment, independently re-confirmed here).

    MET: fire-and-forget scheduling via autobot_shared/async_compat.py fire_and_forget() at all 3 _ansible_self_update call sites in code_sync.py (lines 3405, 3513, 5187); stale-verdict replay fixed via self_update_log_reader.py:207-262 (_run_started_at, _run_is_at_or_after), bound at code_sync.py:861.

    NOT MET: surfacing which task failed (still only aggregate failed=N/unreachable=N counts via the _RECAP_COUNTS regex) — reproduced live on a real node per the issue's own comment thread ("GET /api/health reported healthy immediately after a run that had a failed task"). Host-behavior AC is UNVERIFIABLE from this repo-only audit; the delivering PR (#15525) itself disclaims running any live update.

  7. modified the milestones: Backlog, v0.14.0 on Sep 14, 2026
  8. added
    blocked: observation-gatedCode remains, but cannot be written until one observation answers a question
    on Oct 3, 2026
  9. mrveiss commented on Oct 3, 2026

    @mrveiss
    OwnerAuthor

    The log obstacle this issue names no longer exists — and a recurring failure pair is visible in it

    One criterion states the blocker:

    The ansible log is mode 0600 owned by the service account, so this needs either a readable location or a surfaced summary — an operator cannot currently see which task failed.

    That is no longer true. On the deployed host today:

    -rw-r--r--+  1 autobot autobot  559135  ansible.log
    -rw-r--r--+  1 autobot autobot  809917  ansible.log.1
    drwxrwsr-x   2 autobot autobot          (the log directory)
    

    Mode 0644, world-readable, in a group-writable directory with an ACL. So the "readable location" half of that criterion is already satisfied — the file can be read without becoming the service account. Whether that was always so or changed since filing I have not established; what matters is that the obstacle as stated does not block the two Establish… criteria any more, and they are now answerable by reading rather than by provisioning access.

    What the current log shows, and what it does not

    Four FAILED! entries today, from two separate runs 37 minutes apart (distinct pids), each producing the same pair:

    fatal: [NODE]: FAILED! => {"changed": false, "msg": "file not found: …/autobot_shared/.autobot_sync_deletions_commit"}
    fatal: [NODE]: FAILED! => {"changed": false, "msg": "file not found: …/autobot_shared/.deployed_commit"}
    

    So for the criterion "whether it is the same failure that has been recurring" — yes, there is a recurring failure, and it is the same two missing marker files across both runs.

    But it is not evidence about this issue's main symptom, and I will not present it as such. Both of today's runs executed — they went on to TASK [Check SLM health], TASK [Clean up deploy archives] and TASK [Display update summary]. This issue is about a self-update that fires without executing. A run that reaches its final tasks is a different condition from one that never starts, so these four failures speak to the recurrence question and say nothing about the launch question.

    The 11:36 and 12:47 firings this issue names are not in the current log — either rotated out or from a day not retained. Establishing why the 12:47 firing never reached playbook_executor still needs either those rotated logs or a fresh reproduction, and it remains the open half.

    Cross-reference worth having

    The two missing markers are not incidental to this repository's other open work: .autobot_sync_deletions_commit is the subject of #16787 ("A way to tell, for a target already holding .autobot_sync_deletions_commit, whether the bootstrap that wrote it ran against a shallow or full-depth clone"), and the deployed-commit marker is in #16310's territory. A deploy path that fails twice in 37 minutes on two markers that two other open issues are about is plausibly one cause rather than three, though I have not established that and am recording it as a lead rather than a finding.

    What changes for this issue's state

    It carries blocked: observation-gated. On the evidence above, the observation that was thought to need an owner — reading the log — is a read any session can do. The remaining gated part is narrower: a reproduction of the non-executing firing, which is a mutation of the live system and genuinely an owner action. Worth re-scoping the label to that rather than to log access.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions