Skip to content

fix(slm): a healthy oneshot and a templated unit must not report unknown - #16027

Merged
mrveiss merged 22 commits into
Dev_new_guifrom
issue-16019-unit-status
Sep 8, 2026
Merged

mrveiss merged 22 commits into
Dev_new_guifrom
issue-16019-unit-status

Conversation

@mrveiss

@mrveiss mrveiss commented Sep 8, 2026

Copy link
Copy Markdown
Owner

Closes #16019.

Single-issue rationale: #16020 is an Ansible role change that runs against live fleet nodes; this is a Python status-mapping change in the SLM backend. Different blast radius. They were one commit until the pre-push sys.modules leak guard fired on the cross-tree collection (#16024).

Thinking Path

The operator reported /slm/orchestration/per-node showing redis, slm-admin-ui and postgres as unknown while all three were healthy. Measured on that node:

postgresql            LoadState=loaded     active/exited     <- oneshot wrapper, completed
postgresql@16-main    LoadState=loaded     active/running    <- the unit doing the work
slm-admin-ui          LoadState=loaded     active/exited     <- oneshot, completed
redis-stack-server    LoadState=loaded     active/running
redis                 LoadState=not-found  inactive
redis-server          LoadState=not-found  inactive

Three independent defects, one symptom.

1. Templated units were discarded. health_collector.py:233 returned None for any unit whose name contains @. postgresql@16-main.service is the running PostgreSQL — so the filter threw away the unit doing the work and kept the wrapper that exits. A @ means instantiated from a template, not not a real service.

2. active (exited) fell off the end of the mapping. A oneshot that ran to completion matched no branch and returned unknown. That is slm-admin-ui and postgresql on every healthy node.

3. The redis unit name was not in the map. services.py listed redis-server and redis; roles/redis installs redis-stack-server, pinning the repo to jammy on Noble because Noble ships no such build.

The reading error worth recording

I first reported defect 3 as "a stale redis.service stub exists and is inactive". It does not exist. dpkg -l shows only redis-stack-server 7.4.0-v8, and there is no redis.service unit file on the node.

systemctl is-active answers inactive for a unit that does not exist — identical to one that is merely stopped. I read that as evidence of a stopped stub. LoadState is the field that separates them: not-found vs loaded.

So the same defect this PR fixes — a state the instrument cannot express folded into a neighbouring one — was in my own diagnosis of it. The operator caught it: "we were not supposed to have redis ordinary." They were right, and my evidence had never said otherwise.

What Changed

  • health_collector.py — templated units are reported with their instance in the name; active/exited maps to completed; every other active, activating and deactivating sub-state maps to a live status instead of falling through.
  • services.py — redis-stack-server added. The two legacy names stay: roles/redis records that a host with no Redis Stack build falls back to stock redis-server, and such a node must still be identified.

unknown now means exactly one thing: the probe got no usable answer. It previously meant that and "systemd told me something I have no branch for", which the UI renders identically — so a oneshot that succeeded was indistinguishable from an unreachable node.

Verification

health_collector_unit_states_16019_test.py — 8 passed locally under the pre-push hook.

The mapping test walks 14 (active_state, sub_state) pairs systemd actually emits and asserts none returns unknown, with a floor on the pairs examined — a shrunken table would report "nothing maps to unknown" for the same reason a correct one does.

Beside it sits the contrast, which is the one that matters: failed → failed, auto-restart → crash-loop, inactive → stopped. A mapping that answered "running" to everything would pass the first test while destroying the only signal the page exists to show.

Same for the filter: a templated unit is now kept, and a not-found phantom is still dropped. Removing the @ filter must not weaken the LoadState one.

Model Used

Opus 5

@coderabbitai

coderabbitai Bot commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: ff6ce842-cce5-4577-bd42-eb04f5362424


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Applied Black, isort, and autoflake to match code-quality checks.
Triggered by workflow auto-fix-formatting.yml.
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

✅ SSOT Configuration Compliance: Passing

🎉 No new hardcoded values of either class — ssot and other both block.

Known backlog in pipeline-scripts/hardcoded_values_baseline.txt is suppressed and tracked in #14371.

Applied Black, isort, and autoflake to match code-quality checks.
Triggered by workflow auto-fix-formatting.yml.
@mrveiss

mrveiss commented Sep 8, 2026

Copy link
Copy Markdown
Owner Author

Review: three real defects, correctly separated, and the diagnosis is right. The new vocabulary stops one layer short of the number the operator reads.

The three-defects-one-symptom split is the right decomposition, and @ meaning instantiated from a template, not not a real service, is the kind of reading error that survives precisely because the filter looks deliberate. Recording your own is-active/LoadState misreading in the PR body — a state the instrument cannot express folded into a neighbouring one, inside the diagnosis of that same bug — is worth more than the fix.

I also checked the pytest_ignored change in tools/git-hooks/pre-push, because a hook edit that makes a guard stop firing deserves scrutiny. It is correct: --ignore genuinely does not apply to paths named on the command line, the ignore list is two narrow entries, and the change rests on pipeline-scripts/detect-agent-code-drift.sh keeping the mirror byte-identical — which it does, diff -r over the pair with a PAIRS_COMPARED floor. Aligning the hook with what CI actually collects, not weakening it.

1. ServiceStatus does not contain the values this now emits

autobot-slm-backend/models/database.py:302:

class ServiceStatus(str, enum.Enum):
    RUNNING = "running"; STOPPED = "stopped"; FAILED = "failed"
    CRASH_LOOP = "crash-loop"; UNKNOWN = "unknown"

_map_status_from_states now returns completed, starting and stopping. None are in the enum, and the PR does not touch the file.

Nothing will tell you, because the column is status = Column(String(20), ...) (:499) rather than an Enum column. All three fit in 20 characters, so they store, round-trip, and render with no error anywhere. The enum is the declared vocabulary for this field and it is now a description of what the collector used to emit.

Adding them is in scope here — this PR is what makes them reachable.

2. A completed oneshot is counted as neither running nor failed

api/monitoring.py:286 and again at :426:

if row.status == ServiceStatus.RUNNING.value:
    services_by_node[row.node_id]["running"] = row.count
elif row.status == ServiceStatus.FAILED.value:
    services_by_node[row.node_id]["failed"] = row.count

Two buckets, no else. slm-admin-ui and the postgresql wrapper report completed on every healthy node, so they land in neither.

This is not a regression — they reported unknown before and were excluded then too, so the aggregate is unchanged. That is exactly why it is worth raising: the per-node view now correctly says completed, while the count the operator reads still says those services are not running. The symptom that opened #16019 was a healthy service reported as not-healthy, and after this PR that is still true one layer up.

It is the same shape as the bug being fixed — an if/elif over an enumerated set, with everything unenumerated falling into a silent bucket. A check that enumerates conditions is blind to the conditions it does not enumerate, and completed is now one of them.

Whether that belongs in this PR is your call: it is a small, clearly-correct change (completed counts toward healthy, starting/stopping are transitional), but it is the monitoring API rather than the collector, and you have already argued blast radius for splitting #16020. I am happy to file it if you would rather keep this one narrow — say the word and I will, cross-linked to #16019.

3. Minor: the ignore filter fails open silently

ignores=$(grep -oE '^[[:space:]]*--ignore=[^[:space:]]+' pytest.ini 2>/dev/null | sed 's/.*--ignore=//')
if [ -z "$ignores" ]; then cat; return 0; fi

pytest.ini is read bare from the working directory while the hook computes REPO_ROOT="$(git rev-parse --show-toplevel)" at :15 and uses $REPO_ROOT/$f elsewhere. Git runs hooks from the toplevel so this works today, but if it ever does not, ignores is empty and the function passes everything through — restoring precisely the behaviour this change removes, with no message. $REPO_ROOT/pytest.ini costs nothing; if the file is genuinely missing, saying so is better than silently reverting.

Verdict

Fix 1 before merge — it is a few lines and the enum is the only place the vocabulary is written down. 2 either here or as a follow-up I will file. 3 is a one-word path change. The fix itself is right and the pre-push change is justified.

@mrveiss

mrveiss commented Sep 8, 2026

Copy link
Copy Markdown
Owner Author

Review: both findings from my earlier pass are properly addressed, and the extraction is sound. One finding, and it is on the part most likely to regress.

The = → += change is the good catch here, and it is easy to miss. Once two statuses feed one bucket, assignment silently keeps whichever row arrives last — a node with 3 running and 2 completed would have reported 2. Nothing in the issue asked for that; you found it by doing the change properly.

The extraction reasoning is right and well recorded. models/__init__.py pulling in user_management → autobot_shared, which the migration runner lacks, is exactly the kind of constraint that is invisible until it fires, and putting it in the module docstring means the next person moving this file does not rediscover it by breaking Postgres 16. Re-exporting from models.database so the six importers are untouched is the right call.

test_every_state_the_collector_emits_is_a_declared_status is the closure test I would have asked for — collector output ⊆ declared enum, so a fifth state cannot appear without failing something.

The finding: the aggregates have no behavioural test at all

autobot-slm-backend/tests/api/test_completed_services_are_counted_16019.py imports exactly two things:

import ast
from pathlib import Path

It is entirely static, and _get_services_by_node / get_node_metrics are called by nothing in the test suite — git grep -l "_get_services_by_node" -- autobot-slm-backend/tests returns empty.

So the assertion protecting the aggregate fix is:

assert source.count("ServiceStatus.COMPLETED.value") == 2

Revert += to = and that still passes. The source still mentions COMPLETED twice; only the arithmetic changed. The subtlest and most valuable part of this PR — the one thing here that a careful person could plausibly undo while "simplifying" — is the one thing no test can see.

This is the shape you and I have both been holding each other to today, and I would not raise it except that the fix is genuinely good and deserves a test that can defend it. The behavioural test is small: feed _get_services_by_node a fake result with running=3, completed=2, failed=1 for one node and assert {"running": 5, "failed": 1}. That fails on =, fails if COMPLETED is dropped from the tuple, and passes only on what you actually wrote.

Question, not a finding: STARTING and STOPPING

Both are now declared and emitted, and both are counted in neither bucket. A node mid-restart reports services that are neither running nor failed, so the aggregate does not sum to the total.

That may be exactly right — a starting service genuinely is not running yet. But the code path being fixed here is one that dropped a state into a silent bucket, and the fix enumerates one of the three new states while leaving two in the same position. If it is deliberate, a line saying so where the buckets are built would stop the next reader re-deriving it.

Minor

The bucket is named running and now contains completed oneshots, so the operator-facing label is slightly untrue — it means "healthy". Not worth renaming a response field for, but worth knowing it is a count of health, not of resident processes.

Verdict

Merge-ready on correctness. I would add the four-line behavioural test first, because without it the += is undefended and it is the part I would most expect to be lost.

@mrveiss
mrveiss merged commit f92ed0c into Dev_new_gui Sep 8, 2026
80 checks passed
@mrveiss
mrveiss deleted the issue-16019-unit-status branch September 8, 2026 15:52
mrveiss added a commit that referenced this pull request Sep 8, 2026
…own (#16027)

* fix(slm): a healthy oneshot and a templated unit must not report unknown (#16019)

* Auto-fix: code formatting

Applied Black, isort, and autoflake to match code-quality checks.
Triggered by workflow auto-fix-formatting.yml.

* fix(slm): mirror the agent status fix into the slm_agent role files (#16019)

* fix(hooks): pre-push must not collect trees pytest.ini ignores (#16019)

* refactor(slm): extract the service port map so services.py stays under its ceiling (#16019)

* Auto-fix: code formatting

Applied Black, isort, and autoflake to match code-quality checks.
Triggered by workflow auto-fix-formatting.yml.

* fix(slm): a completed oneshot is counted as healthy, not dropped from the aggregates (#16019)

* Auto-fix: code formatting

Applied Black, isort, and autoflake to match code-quality checks.
Triggered by workflow auto-fix-formatting.yml.

* refactor(slm): extract ServiceStatus so database.py stays under its ceiling (#16019)

* fix(slm): register models.service_status with the conftest stub (#16019)

* fix(slm): move ServiceStatus out of the models package so the migration runner can import it (#16019)

* test(slm): assert the aggregate sums rather than overwrites, and share one bucketing helper (#16019 review)

* Auto-fix: code formatting

Applied Black, isort, and autoflake to match code-quality checks.
Triggered by workflow auto-fix-formatting.yml.

* chore(ratchet): lower monitoring.py's ceiling to its post-extraction size (#16019)

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
mrveiss added a commit that referenced this pull request Sep 8, 2026
) (#15996)

* test(hooks): guard that a non-deny permissionDecision exits 0 (#15956)

PreToolUse parses a hook's decision JSON from stdout and only on exit 0. Exit 2
is a blocking error whose reason comes from stderr. So a hook printing
"permissionDecision":"ask" and then exiting 2 has its payload discarded: the
call is denied and the operator sees a refusal with no reason.

Two hooks do this. protect-files.sh's ask() helper -- guarding both settings
files, its only two ask sites against five deny sites -- and scan-secrets.sh at
its single non-zero exit, where the author's intent is on the record in the
reason string itself.

The guard reads source rather than behaviour, because a runtime test would have
to know which decision was intended before it could tell a denial from a
discarded ask -- and that knowledge is what the bug destroys. It sweeps every
hook rather than the two known ones, since a fix limited to the reported site is
indistinguishable from one that checked its neighbours.

This test FAILS until the two exit codes are corrected. That is deliberate: the
defect is currently invisible and the guard is what makes it loud.

Refs #15956

* fix(hooks): a non-deny permissionDecision must exit 0 to be read (#15956)

PreToolUse parses a hook's decision JSON from stdout and only on exit 0. Exit 2
is a blocking error whose reason it takes from stderr. Both hooks below printed
"permissionDecision":"ask" and then exited 2, so the payload was discarded: the
call was denied outright and the operator saw a refusal with no reason, because
nothing had been written to stderr.

protect-files.sh: ask() now exits 0. Its two ask sites -- against five deny
sites -- guard settings.json and settings.local.json, so the only path in that
hook capable of reaching a human never did. Every deny() keeps exit 2; all five
call sites verified unchanged.

scan-secrets.sh: the same defect at its only non-zero exit, which made the whole
scanner fail-hard rather than fail-ask. The author's intent is on the record in
the reason string -- "Review carefully before allowing" is written for a human
being asked a question, and none was ever shown it.

It survived because a denial and a discarded ask produce an identical
observable, so the hooks looked like they were working.

Post-fix sweep over every hook: 6 decision emissions, 0 mismatches. The guard
added in f05335a keeps that true for hooks added later.

Refs #15956

* fix(hooks): a secret denies, and its reason goes to stderr (#15956, #12513)

The previous commit made scan-secrets.sh honour its declared `ask` payload by
exiting 0. That converted credential BLOCKS into ASKS for double-quoted and
single-quoted literals, AWS access key ids and connection strings, and
repo_tests/scan_secrets_hook_test.py caught it: 4 failures, assert 0 == 2.

The test is a recorded ruling, not an artefact. Its docstring under #12513 reads
"the secret-scan PreToolUse hook must block secrets and allow indirection" and
"a secret scanner that fails open, silently, is worse than none: it is trusted".
So the payload was the bug in this file, not the exit code -- the mirror image
of protect-files.sh, where the exit code was the bug and `ask` was right. Two
files, one symptom, opposite fixes; treating the shapes as the same defect is
what produced the regression.

`deny` + exit 2 alone would have restored the block and left the operator with
"No stderr output" -- the very symptom #15956 exists to remove. PreToolUse takes
an exit-2 reason from STDERR, so the reason is written there. Both rulings hold
at once; they were never in conflict except through the channel.

scan_secrets_hook_test.py 11 passed. Guard 7 passed. Pipe test: EXIT=2 with the
reason on stderr.

Refs #15956, #12513

* fix(hooks): a blocking decision writes its reason to stderr (#15956)

The first pass fixed `ask` and left every `deny` discarding its reason. The
comment stating the rule sat three lines below the function that broke it, and
scan-secrets.sh in the same PR already did it correctly -- one file carried the
remedy the other needed.

`ask` and `deny` differ in EXIT CODE. They do not differ in which stream carries
the explanation: on exit 2 the harness takes the reason from stderr, so a deny
that writes only to stdout blocks with no explanation. Five deny sites in
protect-files.sh plus its jq branch, which is the worst of them -- no jq means
every edit is blocked and that sentence was the only explanation.

The structural half is why this blocked the PR. EXPECTED_EXIT measures the exit
code; once `ask` was corrected the defect moved into the CHANNEL, so the guard
could no longer tell the fixed file from the broken one and pinned the broken
half as correct. When a fix moves a defect from one dimension to another, the
guard has to move with it.

The new channel check found a THIRD hook on its first run:
block-dangerous-commands.sh, both deny sites, never reported. Fixed here.

Pipe-verified: a .env deny now exits 2 with its reason on stderr; the settings
ask still exits 0 with stdout as its channel.

Refs #15956

* test(llc): the blocked-by refusal reaches the caller as a 409 (#15931) (#16059)

* test(llc): the blocked-by refusal reaches the caller as a 409 (#15931)

The rule's whole history is a correct implementation nobody reached. The
service-level tests in test_blocked_by_rule_fires_15931.py prove it fires
when called the way production calls it; they cannot distinguish "the route
enforces this" from "the route swallows the exception and returns 200",
which is the failure that would restore the original defect with those tests
still green.

work_items.py:644 sits inside a try whose handlers translate exceptions, so
the refusal reaching a caller is a claim about the handler, not the service.
These assertions go through POST /work-items/{id}/transition with a real
session and real rows, and assert on the status code and message a client
sees. The service is not called directly anywhere in the file.

Three cases: the refusal (409, reason intact, item unmoved), a resolved
blocker (200), and no relations at all (200). The last two are the contrast
- a refusal that fires for every BLOCKED item would pass the first
assertion alone.

* Auto-fix: code formatting

Applied Black, isort, and autoflake to match code-quality checks.
Triggered by workflow auto-fix-formatting.yml.

* test(llc): the blocker fixture uses a status the enum defines (#15931)

WorkItemStatus has no TODO member -- backlog/ready/in_progress/in_review/
done/cancelled/blocked. READY is the right choice: has_unresolved_blockers
treats {done, cancelled} as terminal, so a READY blocker is genuinely
unresolved and the refusal fires for the reason the test claims.

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(llc): a status transition's comment reaches the store, not the floor (#16054)

* fix(llc): a status transition's comment reaches the store, not the floor (#16017)

* test(llc): assert the comment reaches the database, not that the handler mentions it (#16017)

* Auto-fix: code formatting

Applied Black, isort, and autoflake to match code-quality checks.
Triggered by workflow auto-fix-formatting.yml.

* style(llc): condense the #16017 rationale to keep agent_api.py under its ceiling

* test(llc): pin the ordering against the FIRST commit, and record the docstring hazard (#16017)

* test(llc): a failed commit must take the comment with it (#16017 review)

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(slm): a healthy oneshot and a templated unit must not report unknown (#16027)

* fix(slm): a healthy oneshot and a templated unit must not report unknown (#16019)

* Auto-fix: code formatting

Applied Black, isort, and autoflake to match code-quality checks.
Triggered by workflow auto-fix-formatting.yml.

* fix(slm): mirror the agent status fix into the slm_agent role files (#16019)

* fix(hooks): pre-push must not collect trees pytest.ini ignores (#16019)

* refactor(slm): extract the service port map so services.py stays under its ceiling (#16019)

* Auto-fix: code formatting

Applied Black, isort, and autoflake to match code-quality checks.
Triggered by workflow auto-fix-formatting.yml.

* fix(slm): a completed oneshot is counted as healthy, not dropped from the aggregates (#16019)

* Auto-fix: code formatting

Applied Black, isort, and autoflake to match code-quality checks.
Triggered by workflow auto-fix-formatting.yml.

* refactor(slm): extract ServiceStatus so database.py stays under its ceiling (#16019)

* fix(slm): register models.service_status with the conftest stub (#16019)

* fix(slm): move ServiceStatus out of the models package so the migration runner can import it (#16019)

* test(slm): assert the aggregate sums rather than overwrites, and share one bucketing helper (#16019 review)

* Auto-fix: code formatting

Applied Black, isort, and autoflake to match code-quality checks.
Triggered by workflow auto-fix-formatting.yml.

* chore(ratchet): lower monitoring.py's ceiling to its post-extraction size (#16019)

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(slm): manage Redis by the unit the role installs, and fail a restore that cannot stop it (#16061)

* fix(slm): manage Redis by the unit the role installs, and fail a restore that cannot stop it (#16060)

* Auto-fix: code formatting

Applied Black, isort, and autoflake to match code-quality checks.
Triggered by workflow auto-fix-formatting.yml.

* fix(slm): keep the redis-unit fix inside the size ceilings (#16060)

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* test(guards): record the hook-decision guard's *.sh sweep (#15956)

repo_tests/hook_decision_exit_codes_15956_test.py sweeps `.claude/hooks/*.sh`,
so it declares the root-relative `*.sh` glob and belongs in the record that
tracks which guards depend on trees the python filter does not reach.

Without the entry, glob_declared_reads_15900_test.py reports it as
`declaring-but-not-recorded` -- correctly: a guard reading a tree nothing
records is exactly what that record exists to surface.

This is an ADDITION of a dependency this branch introduced, not a deletion.
The `*.min.js` entries this branch was also failing on were stale base: #16012
removed them and the merge in this branch's parent commit took that in. Deleting
them here would have re-litigated a landed cleanup, which is why the two
failures needed opposite actions despite sharing a test file and a shard.

Refs #15956

* test(hooks): read the decision's value, not just its exit and channel (#15956, #16079)

The guard measured two dimensions of a hook decision and a third was
unguarded: the value itself. DECISION_RE enumerated ask|allow|deny -- the
valid values -- so an invalid one was invisible. A detector keyed on the
set of correct answers cannot report a wrong answer; it reports absence.

Found block-dangerous-commands.sh:347 emitting permissionDecision "warn",
which is not in the contract and so does nothing, on stderr on an exit-0
path where stderr is not surfaced. Inert twice over, well-formed, beside
three working siblings. Filed as #16079 and pinned as a ratchet entry
rather than fixed: the remedy is a behaviour decision about what that
advisory should do, and a guard that picked one would be answering a
question nobody ruled on. The ratchet asserts the entry is still present
and still invalid, so it fails when #16079 lands.

Also refuses a pairing when the first exit is indented deeper than its
decision. Taking the first textual exit lets a conditional exit 0 be
credited to a decision that falls through to a later exit 2 -- proximity
standing in for structure, in a guard whose whole subject is which exit
follows which decision. Indentation rather than collecting every exit in
the window, which false-positives on scan-secrets.sh:150, whose window
holds both the deny's exit 2 and the script's terminal exit 0.

Merges _decisions_with_exits and _emissions_with_context into one _scan:
they computed the same window in two places with nothing forcing them to
agree. Adds six detector fixtures -- matches a real emission, ignores
prose, sees an out-of-contract value, refuses the fall-through shape,
still pairs the same-indent shape the hooks use, closes the stderr window
at the paired exit.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
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