Skip to content

EAI-7404: make runtime activation and rollback transactional - #424

Open
nowycondro wants to merge 1 commit into
mainfrom
EAI-7404-fix-transactional-runtime-activation
Open

nowycondro wants to merge 1 commit into
mainfrom
EAI-7404-fix-transactional-runtime-activation

Conversation

@nowycondro

@nowycondro nowycondro commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

rocm runtimes activate (and rollback, and the --activate tail of rocm update) was transactional in neither of the two senses it needed to be.

1. It never looked at the running services. The report ended with one fixed sentence — note: running services keep their recorded runtime until they are restarted — emitted unconditionally, whether or not a single local server existed. It could not name a server that really was left behind on the old runtime, and it could not stay quiet when none was. There was no way to move those servers either, so "switch runtime" silently meant "switch runtime for the next launch, and leave whatever is serving now on the old one, unnamed".

2. It had no rollback across its two persisted writes. Activation writes two independent files: config.save(...) and then write_active_runtime_marker(...). The marker write was a bare ?. If it failed, the config already named the new runtime while the marker still named the old one — and the marker feeds both runtime resolution and the storage retention holds, so the disagreement is load-bearing, not cosmetic. On the rollback path the same tear could drop previous_runtime_key, which is the only state that makes a retry possible. RocmCliConfig::save compounded it: a bare fs::write truncates the live config.json before writing, and load hard-errors on a file it cannot parse, so an interrupted save loses every setting the user has.

Root cause of (1) is that the report was a string constant rather than anything derived from state; root cause of (2) is two unguarded sequential writes with no captured pre-state.

What changed

  • reconcile_services_for_runtime reads the live managed-service records and classifies each against the runtime being activated. The report now prints a real count — including services_on_previous_runtime: 0 — and names each stale server with its engine and recorded runtime. Live records that name neither a runtime nor an environment are reported apart under services_with_unrecorded_runtime, since what they loaded cannot be read back.
  • One class is deliberately never stale: engines that manage their own runtime (they record an engine-private key like lemonade-embeddable-<version>, which is not a ROCm runtime key, so comparing would mark every lemonade server permanently stale). An env_id on a record is not a pin and does not exempt a server — see reviewer focus (a).
  • --restart-services on activate/rollback moves the stale servers onto the newly active runtime. It requires --yes and never prompts, matching every other service mutation; the refusal is raised before anything is written, so declining leaves the previous runtime in place rather than switching and then skipping the half the user asked for. Restart is best effort per server: a failure restores that record to the runtime it actually ran on, the remaining servers still proceed, every failure is named in the report, and the command exits non-zero.
  • ActivationSnapshot captures the three replaced config fields plus the marker's bytes before the first write. A failed marker write restores both (and the caller's in-memory config, or the next config.save would rewrite the state the restore just undid) and fails with an error stating the previous runtime is still active and nothing changed. If restoring itself fails, the error says so and names the commands that show and rewrite both files.
  • RocmCliConfig::save now writes a sibling temp file and renames it over the real one, mirroring the marker write. A reader sees either the old config or the new one, never a half-written file.
  • README, docs/testing.md and docs/manual-testing.md describe the new flags, the report shape, and which servers are never counted.

Risk: medium. The behaviour change is confined to the runtime-activation paths, but it adds a new failure mode to them (see below) and --restart-services stops and respawns live servers.

Reviewer focus

(a) The record is rewritten before the restart, on purpose — and env_id is cleared in the same write. restart_service_onto_runtime writes record.runtime_id = <new key> and only then calls restart_internal_managed_service. The order is load-bearing: restart_internal_managed_service rebuilds the child's argv from the record on disk and passes record.runtime_id verbatim, so restarting first would bring the server back up on the runtime it was already using and report success — precisely the bug this path exists to fix. The restart itself still goes through the unchanged managed-serve path, so device policy (including gpu_required) is validated exactly as for a fresh rocm serve. Please sanity-check the failure branch: it reloads the record and puts the pin back, so a server is never described by a runtime it never loaded.

Round-1 review found the half that was missing: env_id on a service record is not a user pin. engines/vllm writes it unconditionally on every launch and refresh_from_engine_state adopts whatever the engine reports, so a record acquires an env_id whether or not the user ever asked for one. And builtin_engine_serve_http_args omits --runtime-id from the child's argv whenever env_id is set (if env_id.is_none() { ... }), after which env_root_for_service hands the child no engine environment root either. So pinning a record to a runtime must also drop its env pin — otherwise the restarted child gets no runtime pin at all and comes back on whatever the engine resolves for itself (the most recently installed runtime, not the one just activated) while the record claims the new key. pin_service_record_to_runtime now clears both and returns a ServiceRuntimePin capturing both, and the failure path restores both. Nothing is lost by dropping env_id: the engine writes its own back into its state on the next launch.

(b) A recorded runtime_id may be a family id, not an exact versioned key. Every launch path records an exact key, but refresh_from_engine_state afterwards adopts whatever the engine reports, and an engine may report the manifest's runtime_id — the family form (therock-release:gfx120X-all) that every installed version of that family shares. A plain string comparison therefore reports a server already running on the runtime being activated as left behind, and --restart-services would stop and respawn it for nothing. classify_service_runtime_state now resolves the recorded value through runtime_manifest_for_selector (the same resolution every other selector in that file gets) before calling it stale. One case worth a second opinion: an ambiguous family — two installs of the same family — makes the resolver return None, so the record still classifies as stale. That is the correct answer for the upgrade case this feature exists for, and records written after this change carry the exact key anyway, so the ambiguity is a shrinking legacy-record concern rather than a steady state.

(c) A failed restart leaves the server stopped, not running on its old runtime. restart_internal_managed_service stops the server before respawning it, so a restart that fails partway leaves nothing serving; only the record goes back to the runtime it last actually ran on. Round-1 review found all three surfaces (the error text, README, and the manual-testing doc) claiming the server was "left on the runtime it was actually running", which would send a reader looking for a healthy server instead of an outage. The bail message now says the servers were stopped, are no longer serving, and names rocm services restart <id> --yes as the way back; the docs and the manual test's observable check match.

(d) New failure mode before the writes. reconcile_services_for_runtime runs before config.save / write_active_runtime_marker, deliberately, so an unreadable services folder refuses the activation while the previous runtime is still fully in place. The consequence is that a services folder that cannot be read can now newly fail rocm runtimes activate, rocm runtimes rollback, the finalization step of rocm install sdk, and rocm update --apply --activate — all of which previously did not touch service state at all. This is the intended trade (fail clean rather than half-apply), but it is the change most likely to surprise someone, so it deserves a second opinion. Note also that load_managed_services refreshes records against real process state and may rewrite the manifest, so reading service state here can touch disk; that is safe on this path because nothing in activation reads manifest mtimes (unlike build_service_prune_plan, whose age gate does).

Test plan

Run locally at the current head (rust-toolchain.toml pins 1.96.0). The branch is one squashed commit on top of origin/main @ 8788394, zero commits behind.

Command Result
cargo fmt --all -- --check clean, exit 0
cargo clippy -p rocm -p rocm-core -p e2e-cucumber --all-targets -- -D warnings clean, exit 0
cargo test -p rocm --bins 786 passed, 0 failed, 1 ignored
cargo test -p e2e-cucumber --test feature_naming 4 passed, 0 failed
cargo test -p rocm-core 415 passed, 0 failed

comfyui::tests::status_reports_stopped_when_saved_comfyui_pid_is_gone fails intermittently under the full parallel run and passes alone and on a clean re-run. It picks a free port and then asserts nothing serves it, so a sibling test binding that port in between flips it. Pre-existing, in a subsystem this branch does not touch, and left alone rather than folded into an unrelated commit.

The new rocm-core test saving_the_config_replaces_the_file_rather_than_rewriting_it_in_place was checked by mutation: replacing save's temp-file-and-rename with a plain fs::write makes it fail, while the sibling "no temp file left behind" test still passes. That is the gap it exists to close.

Two further tests were checked the same way. Collapsing service_restart_audit_outcome's warn back to info fails two of its four unit tests — the severity inversion they exist to catch. Deleting the active_runtime_key restore from ActivationSnapshot::restore_in_memory fails runtime-lifecycle-14 — and no other scenario, which is what makes it non-vacuous. It is not the only thing guarding that line, though: the same mutation also fails five unit tests (runtime_activation_rolls_back_when_marker_write_fails, runtime_rollback_rolls_back_when_marker_write_fails, a_failed_activation_restores_the_marker_it_replaced, a_failed_activation_removes_a_marker_that_was_not_there_before, a_failed_config_save_leaves_the_caller_holding_the_old_runtime). The scenario earns its place as the user-facing layer, not as unique coverage.

What scenario 14 does not cover: planting the directory before the run makes capture read Unreadable, so the MarkerSnapshot::Contents byte-restore arm is never entered. Afterwards the marker names nothing at all rather than naming the wrong runtime.

Second-reviewer findings (pr-review-watcher, at ff738803). One blocking, now fixed, plus two of its five non-blocking:

  • Blocking — runtime-lifecycle-11 asserted only half its own guarantee. The step checked the marker but not the config, while the scenario's comment says the point of reading services first is that the pair never disagree. Moving the services read below config.save would have left the config on the new runtime and the marker on the old one, and the scenario would have stayed green. It now asserts both files. Verified by applying exactly that refactor: the scenario fails, and it is the new config assertion that fires.
  • The restore's own write was not tested for atomicity. Swapping write_file_atomically for a bare fs::write in the Contents arm passed every test — the arm that runs after something has already failed. restoring_a_marker_replaces_the_file_rather_than_rewriting_it_in_place now catches it; its byte-equality sibling still passes under the mutation, which is what made the gap invisible. Unix-only, for the same inode reason recorded in the residual below.
  • classify_service_runtime_state's exact-match arm had only indirect coverage. Now tested directly, along with the stale, ambiguous-family and unrecorded arms.

Not taken: a unit test for the reconcile-before-writes ordering (the scenario above now gates it where it is user-visible), and the Windows half of the config-atomicity gap, which stays a documented residual.

Module placement. The reconciliation subsystem lives in apps/rocm/src/runtime_services.rs, following the full-domain-extraction default in docs/architecture.md — it owns ServiceRuntimeState, RuntimeServiceEntry, FailedServiceRestart, RuntimeServiceReconciliation and ServiceRuntimePin. RuntimesCommand and fn runtimes() stay in main.rs, which that doc names as the exception for a subsystem with its own clap subcommand. ActivationSnapshot/MarkerSnapshot stay beside activate_runtime for the same reason. main.rs grows by ~327 production lines rather than ~793, and the module map in docs/architecture.md lists the new file.

E2E scenarios. Eight scenarios cover this change. The full mock suite runs clean: 134 scenarios and 0 unexpected failures in CI, 136 locally (this host resolves two more as runnable).

  • runtime-lifecycle-08 — activation names the servers left on the previous runtime, and stays silent when none is.
  • runtime-lifecycle-09 — --restart-services is refused without --yes, and the runtime does not move.
  • runtime-lifecycle-11 — an unreadable service record refuses the activation and the marker still names the previous runtime. Covers the ordering half: services are read before either write.
  • runtime-lifecycle-12 — rollback --restart-services is refused without --yes, the refusal names the rollback form rather than the activate one, and the runtime does not move.
  • runtime-lifecycle-13 — re-activating the already-active runtime keeps the rollback target, and rolling back afterwards still reaches it.
  • runtime-lifecycle-14 — a marker write that fails after the config write succeeded rolls the config back, so the pair never names two different runtimes. The transactional claim reaching the user.
  • runtime-lifecycle-15 — a live server whose record names no runtime is counted under services_with_unrecorded_runtime: rather than folded into the stale count.
  • runtime-lifecycle-10 — the --restart-services --yes success path. Tagged @requires-gpu @requires-engine:vllm; see the residual below for why the mock lane cannot reach it.

The first seven are GPU-free and run on the hosted mock E2E tests lane on every PR. runtime-lifecycle-10 ran and passed every step on MI300X at this head (c4ecf6b, the lane reporting 0 unexpected failure(s)), including services_restarted and the endpoint check, and passed on MI350P at an earlier head — that lane is queued again for this one. It does not run on rad3 R9700: the lane resolves the scenario's @requires-engine:vllm tag to a skip, and the scenario name appears nowhere in that lane's log at either head. An earlier revision of this section cited rad3 as evidence for the restart success path; that was wrong, and the claim is now the two lanes that actually ran it.

Scenario hygiene: cargo test -p e2e-cucumber --test feature_naming passes (ids unique, indexes sequential, every scenario id feature-qualified). expectations.toml has no rows referencing EAI-7404, so there is no stale xfail to narrow.

Manual verification steps for a machine with real runtimes and a live server are in docs/manual-testing.md (section 4) and have not been executed here.

Known residuals / follow-ups

  • rocm services restart <id> --yes still replays the old runtime_id. That path is unchanged, so restarting a service by hand after switching runtimes still brings it back on the old runtime. Fixing it means deciding whether a bare services restart should silently re-target the active runtime — a user-visible semantic change affecting every restart, not only the ones activation asks for. Proposed as a follow-up. It is also why the report's note names rocm runtimes activate <key> --restart-services --yes: that is currently the only command that moves a running server onto the active runtime.
  • Reconciliation runs before the writes and does liveness plus inference probes. As covered in reviewer focus (d), a service-directory I/O error can newly fail — and slow probes can newly slow — the finalization of rocm install sdk and rocm update --apply --activate, neither of which previously touched service state. Degrading gracefully there (report the services as unknown and continue) rather than aborting is worth a maintainer's opinion, and is a deliberate open question rather than a settled decision.
  • The --restart-services --yes success path is covered only on the GPU lanes. restart_internal_managed_service stops the recorded supervisor pid — which on the mock lane is the cucumber process itself — then spawns a real engine child and waits on a real HTTP readiness probe, so no mock-lane scenario can reach it without new harness machinery. AGENTS.md §3 allows this provided the lane is named, which it is.
  • env_id cannot distinguish user intent from engine-reported state. Both engines write env_id on every launch and refresh_from_engine_state adopts it, so an explicit --env-id pin is indistinguishable from an engine default and is not honoured across a runtime switch — --restart-services moves such a server and clears its env_id. The symmetric fix is for engines to write requested_env_id the way they already write requested_runtime_id. That changes a contract surface across both engines, so it is a follow-up rather than folded in here.
  • render_runtime_service_reconciliation does not go through cli_report.rs::ActionReport. ActionReport models flat key: value pairs; the reconciliation emits variable-length nested lists. AGENTS.md §6 does not stop at "reuse it" — it asks for the shared component to be extended where it does not yet cover the case, so this follow-up is owed rather than optional. Extending ActionReport is its own change.
  • RocmCliConfig::save still has its own atomic write. write_active_runtime_marker now goes through therock::write_file_atomically, which on Windows uses ReplaceFileW as the primary path whenever the destination exists. The gap that leaves is narrower than "rename cannot replace an open file": an ordinary reader does not block fs::rename at all, because Rust opens with FILE_SHARE_READ | WRITE | DELETE and MoveFileExW replaces a destination whose open handles all share delete. It is refused when another process holds the destination without FILE_SHARE_DELETE — the antivirus/indexer case — which raises a sharing violation. fs::rename's SetFileInformationByHandle fallback does not rescue that: it is gated on ACCESS_DENIED and exists to get past a readonly attribute, not an open handle. ReplaceFileW survives by renaming the destination aside first. ReplaceFileW also preserves the destination's ACLs, which a rename does not. rocm-core cannot depend on apps/rocm, so save keeps its own copy and the doc comment now says so instead of claiming parity. Closing it means moving the helper down into rocm-core. crates/rocm-core/Cargo.toml already carries windows-sys under [target.'cfg(target_os = "windows")'.dependencies] and Win32_Storage_FileSystem is the only missing feature — and since ReplaceFileW is the sole windows_sys:: reference in all of apps/rocm/src/, that crate could drop the dependency afterwards. The move itself is the whole staging/publishing family (around ten functions, their #[cfg] arms and their existing call sites), not a single function — and apps/rocmd carries a second, already-drifted copy that the same follow-up should fold in. Its own change, reviewed against the Windows lane.
  • The double-fault arm of restore_after_failed_activation has no test, and cannot get one from filesystem setup alone. Both writes the restore performs go through the paths the forward activation just used, so the conditions are mutually exclusive: a config path broken before the run fails save_activated_config first and never reaches the restore, and a marker path broken before the run makes ActivationSnapshot::capture read Unreadable, whose restore is a no-op that cannot fail. Reaching it needs an injection seam in the write helpers. The reasoning is recorded next to the tests that do cover the other three arms.

Commit signing

One commit, Verified, with a Signed-off-by trailer. Earlier revisions of this branch carried 18 commits, some of them unsigned; they are squashed and the gate passes.

Ticket: EAI-7404


🤖 by agent-hub on AMD AgentHub

@nowycondro
nowycondro force-pushed the EAI-7404-fix-transactional-runtime-activation branch 2 times, most recently from 847a013 to f2984db Compare September 22, 2026 14:02
@nowycondro nowycondro added the agent-hub-reviewing agent-hub review in progress label Sep 22, 2026
@nowycondro
nowycondro force-pushed the EAI-7404-fix-transactional-runtime-activation branch from d7da211 to 40666ee Compare September 22, 2026 19:26
@nowycondro nowycondro added agent-hub-reviewing agent-hub review in progress and removed agent-hub-reviewing agent-hub review in progress labels Sep 23, 2026

@nowycondro nowycondro left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Change type: bug fix + feature — makes rocm runtimes activate / rollback transactional across its two persisted writes, and adds live-service reconciliation with an opt-in --restart-services.

Overall assessment: Needs work. The transaction half (snapshot / restore / atomic writes) is careful and I could not break it. The service-reconciliation half has one real correctness gap, and the rocm install sdk path pays this PR's new failure mode without receiving any of its benefit.

Reviewed against AGENTS.md. No Rust toolchain exists in this sandbox, so nothing below was compiled or executed — findings are static, traced through source. CI is the authority on build/test, and CI is green (see the note on the PR body at the end).


Blocking

1. The "env-pinned services are never moved" guarantee does not hold for any live vLLM service

apps/rocm/src/main.rs:8278

None if record.env_id.is_some() => ServiceRuntimeState::Matches,

The arm fires only when record.runtime_id is None. For a real service that state does not survive first contact with the engine:

  1. resolve_engine_selection (main.rs:21388) sets runtime_id: None, env_id: Some(..) for a --env-id launch, so the record starts out matching the arm.
  2. vLLM's write_running_state (engines/vllm/src/lib.rs:2015) writes "runtime_id": runtime.runtime_id into the engine state file. VllmRuntime.runtime_id is a plain String, not an Option (engines/vllm/src/lib.rs:132), and every construction site fills it — falling back to literals like "external-vllm" (lib.rs:1166) when nothing else resolves. It is never empty.
  3. refresh_from_engine_state (crates/rocm-core/src/lib.rs:7740) then does self.runtime_id = Some(..) from that value. It never clears the field.

So the moment an env-pinned vLLM server writes its state file, record.runtime_id becomes Some(..), the match takes the Some(recorded) arm, and the service classifies as Stale.

Failure scenario. User runs rocm serve --env-id my-env, then rocm runtimes activate <other-runtime> --restart-services --yes. The env-pinned server is reported as left behind, stopped, and re-pinned — and pin_service_record_to_runtime (main.rs:8540) deliberately clears env_id, so the user's explicit environment pin is destroyed. The doc comment at main.rs:8237-8240, the README, and the PR body all promise the opposite.

Why this was not caught: runtime_activation_ignores_self_managed_engine_services (main.rs:31985) plants svc-env-pinned and asserts it is not stale. It passes because plant_service_record_on_runtime (main.rs:27669) writes an engine state file containing only {"status": ...} — no runtime_id key — so refresh_from_engine_state leaves runtime_id at None. No real engine writes a state file that sparse. The test asserts the arm, not the behaviour.

Note the code's own doc comment is precise — "pinned to an explicit env_id with no recorded runtime" — while the PR body and README drop that qualifier. The comment describes what the code does; the user-facing text describes what it was meant to do. Those have diverged.

Suggested fix. Decide what an env pin means and make one thing true everywhere. Either (a) treat env_id.is_some() as never-stale regardless of runtime_id, moving the guard above the runtime_id match — but that reintroduces the problem reviewer-focus (a) identified, since vLLM sets env_id on every launch, so it would exclude everything; or (b) accept that env_id is not a user pin (which is what round 1 concluded) and correct the doc comment, the README and the PR body to say so, dropping the now-unreachable arm. (b) looks right and is consistent with the rest of the PR. Either way, give the test a fixture whose engine state resembles what vLLM actually writes, or it will keep passing whichever answer is chosen.

2. rocm install sdk runs the reconciliation, inherits its new failure mode, and throws the result away

apps/rocm/src/main.rs:11224, 11232, 11193

finalize_successful_sdk_install calls activate_runtime twice and discards activation.services both times — once via let _ =, once by building a SdkInstallFinalization that has no services field. render_sdk_install_success never prints a reconciliation section.

This is the direct answer to your reviewer-focus (d). The "fail clean rather than half-apply" trade is defensible on activate / rollback / update --apply --activate, because the user sees the report that the reconciliation produced. On install sdk the user sees nothing, so the path pays the entire cost — an unreadable services folder can now fail the finalization of a first-run SDK install, plus per-service liveness and inference probe latency — for zero benefit.

It is also contrary to the stated intent right next door: the ROLLBACK_RECOVERY_HINT doc comment (main.rs:18432) says every activation path, "install sdk" named explicitly, "gives the same advice" and lists render_sdk_install_success as a call site. Service reconciliation is shown on two of the three.

Suggested fix. Either carry services into SdkInstallFinalization and render it (restores parity, keeps the failure mode justified), or give activate_runtime a way to skip reconciliation for this path so a fresh install cannot be failed by unrelated service-directory I/O. The first is more consistent; the second is safer for a setup path.

3. No automated coverage of the --restart-services --yes success path

AGENTS.md §3: "User-observable behavior needs a scenario, not only a unit test... a unit test asserting the internal helper does NOT discharge this." The escape hatch it offers is a gated CI lane, named in the PR text.

restart_stale_runtime_services is exercised only through its failure branches (pre-stop refusal, post-stop failure/restore). Nothing — unit, mock e2e, or gated lane — reaches restart_service_onto_runtime returning Ok(()). The observable behaviour with no automated coverage: the services_restarted: line, the restarted service dropping off services_on_previous_runtime, and exit 0.

The PR discloses this honestly and points at docs/manual-testing.md. A manual doc is not "another CI level", so as written it does not discharge §3 — though a maintainer may reasonably accept the disclosure given a real restart needs a spawned process. Flagging it so that acceptance is a decision rather than an oversight.


Non-blocking

  • config.save runs twice on the update path. main.rs:18417-18418: activate_runtime already persisted via save_activated_config, then config.save(paths)? runs again with no mutation in between. A failure there reports an error after an activation that fully succeeded, sending the user to re-run a command that already worked. Drop the second save, or comment what it is for.

  • A failed restart can block for a long time before the error surfaces. service_still_serving (main.rs:8460) → load_managed_service → refresh_managed_service_runtime_liveness, which performs a live endpoint probe. With several failed restarts this is serialised per service before bail_on_failed_service_restarts ever prints. Consider the PID check before the HTTP probe on this path, or a shorter timeout when all you need is "is it still up".

  • The restore path writes the marker non-atomically. ActivationSnapshot::restore (main.rs:8687) uses a bare fs::write, while the forward path in write_active_runtime_marker uses temp-file + rename. The restore is the less likely path but the more costly one to get wrong — it runs when something has already failed. Reuse the same helper.

  • Report rendering bypasses the shared component. render_runtime_service_reconciliation (main.rs:8331) hand-rolls writeln! in the key: value shape that apps/rocm/src/cli_report.rs::ActionReport exists to own. AGENTS.md §6 asks for the shared component to be extended rather than the pattern duplicated inline. The output is correct; this is drift.

  • Dead parameter. pin_service_record_to_runtime (main.rs:8530) takes runtime_key: Option<&str> and has a None arm that sets record.runtime_id = None. Every call site passes Some(..). Take &str.

  • README omits a step the error text includes. README.md:415 gives rocm services restart <service-id> --yes as the recovery; the actual bail message (main.rs:8603) says to check rocm services logs <id> first. Worth matching, since a restart that just failed is likely to fail again unread.

  • Rollback test is weaker than its activate twin. runtime_rollback_reports_live_service_on_previous_runtime (main.rs:32104) asserts the formatted entry line but not services_on_previous_runtime: 1; the activate version (main.rs:31927) asserts both. Removing the count line would leave the rollback test green.


Tradeoffs

  • Ambiguous family id classifies as stale (reviewer-focus b). Your reasoning holds: runtime_manifest_for_selector returning None means the record genuinely does not say which version, and stale is the conservative direction for the upgrade case. Worth being explicit that the cost is a needless stop/respawn under --restart-services, and that the mitigation is refresh_from_engine_state now preferring requested_runtime_id — which shrinks the window only for records written after this change.

  • update --apply --activate reports stale services but cannot act on them. --restart-services exists only on Activate and Rollback, so that path names the servers and requires a second command. Reasonable scoping; worth one line in the PR body so it reads as a choice.

  • --yes without --restart-services parses and does nothing. Already in your known residuals. A clap requires = "restart_services" is a two-line fix if you want it closed here.


Reviewer-focus answers

(a) Write-before-restart ordering. Correct, and the reasoning checks out. restart_internal_managed_service (main.rs:18013) calls load_managed_service first and builds serve_args from the record on disk (main.rs:18045), and builtin_engine_serve_http_args (main.rs:20889) gates --runtime-id behind if env_id.is_none() — so clearing env_id is what makes the pin reach the child. env_root_for_service (main.rs:4604) agrees. The failure branch restores both fields from the pre-write read, so the record never describes a runtime the server did not load. Device policy is re-validated: parse_device_policy at main.rs:18033 still bails on cpu_only, so no CPU fallback is introduced.

(c) Failed restart leaves the server stopped. Verified across all three surfaces — bail message (main.rs:8603), README, and the manual test's observable check. They agree with each other and with the code.

(d) See blocking #2.


Positive signals

  • ActivationSnapshot captures exactly the three fields activate_runtime and rollback_runtime mutate — default_runtime_id, active_runtime_key, previous_runtime_key — with no field mutated but uncaptured, and the capture genuinely precedes the first write on both paths.
  • MarkerSnapshot distinguishing Absent / Unreadable is the right shape: restoring Absent removes the file it wrote, and Unreadable correctly does nothing rather than overwriting bytes it never read.
  • Temp files are created as siblings of their targets in both RocmCliConfig::save and write_active_runtime_marker, so the rename cannot cross a filesystem, and both clean up the temp on a failed rename.
  • restore_in_memory on a failed config.save is a subtle one to have found — without it the caller's next config.save would re-persist the activation that was just refused.
  • The --restart-services approval gate is placed before every write on both handlers, so a refusal leaves the previous runtime fully in place.
  • Commit messages say what changed and why, all 14 carry DCO sign-off, and the two new @id: tags follow the file's existing slug convention and are unique across the feature set.

Note on the PR description

Two claims in the body are now stale and contradict live CI on this head:

  • "Real-hardware lanes have not reported... Nothing in this change has therefore been exercised on real hardware yet." All self-hosted GPU lanes now pass — MI300X, MI350P, rad3 R9700, Strix Halo Ubuntu and Windows. Only Strix Halo WSL2 is still pending.
  • "The commits on this branch are unsigned, so the blocking commit-signatures CI gate fails and will keep failing." Commit signatures + sign-off passes.

AGENTS.md §4 asks for live state before any external claim; worth refreshing both before this leaves draft.

The rocm install sdk behaviour in blocking #2 is also undescribed — the body's reviewer-focus (d) lists install sdk among the commands that can newly fail, without noting it is the one path that gains nothing in exchange.


🤖 Automated review by agent-hub — silo-review-and-fix on AMD AgentHub.
Model claude-opus-5[1m] · took 17m · diff caf925ea51cd · run
Not a human review. Reply here if a finding is wrong.

@nowycondro nowycondro added agent-hub-reviewed agent-hub has reviewed this agent-hub-reviewing agent-hub review in progress agent-hub-needs-human-input agent-hub cannot resolve this; a person must act and removed agent-hub-reviewing agent-hub review in progress agent-hub-reviewed agent-hub has reviewed this labels Sep 23, 2026
@nowycondro

Copy link
Copy Markdown
Collaborator Author

Round-2 fixes: all three blocking findings addressed

Each blocking finding from the review above was re-verified against source before being fixed — both code findings confirmed, with one correction to the reasoning noted below.

# Finding Commit
1 env-pin guarantee does not hold for any live vLLM service bf2556f
2 install sdk runs the reconciliation and throws the result away bf40b79
3 no automated coverage of the --restart-services --yes success path 038c37a
— lint fallout from #3 (collapsible_if) 5eb9e80

1. env_id is not a pin — bf2556f

Confirmed, and option (b) taken as recommended. The evidence that settles it against option (a): both engines write env_id unconditionally on every launch — engines/vllm/src/lib.rs:2017 and engines/lemonade/src/lib.rs:3666 are both "env_id": request.env_id.as_deref().unwrap_or(runtime.env_id.as_str()), where the fallback is a non-Option String that is never empty — and refresh_from_engine_state (crates/rocm-core/src/lib.rs:7747) adopts it and never clears it. So a record acquires an env_id whether or not the user ever passed --env-id, and widening the guard would have excluded every service from reconciliation.

One correction to the review's mechanism: refresh_from_engine_state does prefer requested_runtime_id, but for an --env-id launch request.runtime_id is None, so that key serialises as JSON null, Value::as_str() rejects it, and the fallback runtime_id — the engine's own non-empty resolved value — is adopted instead. The preference does not rescue this case; the conclusion stands.

The arm is deleted. A record with no recorded runtime now falls through to Unknown, reported under services_with_unrecorded_runtime and never restarted, since restart_stale_runtime_services iterates services.stale only.

On the test: your diagnosis was exactly right. plant_service_record_on_runtime writes {"status": ...} and nothing else, so refresh_from_engine_state found no runtime_id to adopt and the arm held. That test is scoped to the self-managed-engine case it still describes, and two were added — runtime_activation_reports_env_pinned_service_the_engine_recorded_a_runtime_for plants the realistic state file a real launch produces and fails without this change, and runtime_activation_reports_service_with_no_recorded_runtime_as_unknown pins the Unknown path. The doc comment, README, docs/testing.md and docs/manual-testing.md now say env_id is not a pin and that moving a service clears it.

Also corrected while there: the unknown bucket was described everywhere as "names neither a runtime nor an environment". env_id no longer participates in that decision, so all four surfaces now say "names no runtime".

2. install sdk — bf40b79

Confirmed, including that reconcile_services_for_runtime's only Err path is load_managed_services(paths)?, so an unreadable services folder really is fatal to the finalization. Took the first of your two suggestions — parity rather than a skip — so the failure mode stays justified: services is carried through SdkInstallFinalization and rendered before ROLLBACK_RECOVERY_HINT, matching the ordering runtimes activate and append_update_activate_summary already use. The count line is unconditional, so "looked and found nothing" is now distinguishable from "never looked", and the ROLLBACK_RECOVERY_HINT doc comment's claim about all three call sites is true again.

The other activate_runtime discard at the alternate-root branch is left in place — it does not feed the returned finalization — now with a comment saying why.

3. Restart success path — 038c37a

Confirmed as a genuine gap, and confirmed unreachable on the mock lane: restart_internal_managed_service stops record.supervisor_pid, which on that lane is std::process::id() — the cucumber process itself — then spawns a real engine child and waits up to 45 s on a real HTTP readiness probe. No mock engine or substitution hook exists for any of the three, and adding one is materially more than this PR.

AGENTS.md §3's own escape hatch covers this: "if the scenario can only run on a gated lane (@requires-gpu, @nightly), say so in the PR text and name the lane that will exercise it". So runtime-lifecycle-10 is tagged @requires-gpu and runs on the self-hosted lanes in e2e-selfhosted.yml. @id tags remain unique; expectations.toml needs no entry, since entries there are for known bugs rather than capability skips.

Verification

Nothing was compiled or run locally — this sandbox has no C linker and no root, so cargo cannot build. cargo fmt --all --check does run (toolchain pinned at 1.96.0) and is clean, but rustfmt only proves the source parses. CI is the authority here, and CI at 5eb9e80 is green: clippy, build-and-test, windows-build-and-test, Test (affected crates) (1804 passed, 0 failed, including all four new tests), E2E tests (129 scenarios, 0 unexpected failures), Sphinx docs build (-W), CodeQL, prek, Coverage, License header check, Third-party notices. The self-hosted GPU lanes are queued for this head and had not reported when this was written.

The honest caveat: 5eb9e80 exists because the first push of the scenario tripped clippy::collapsible_if under -D warnings. That is precisely the class of error a sandbox without a compiler cannot catch, and the GPU-lane scenario itself is still unexecuted anywhere — it is written against the established idiom, but nothing has run it.

Not fixed — needs a decision

The commits on this branch are now unsigned, and Commit signatures + sign-off fails. It passed before this push. The four commits above were made through the GitHub API, which is what kept the earlier commits on this branch GitHub-"Verified"; the credentials available to this run are not web-flow signed, so the same route produced verified=false this time. DCO Signed-off-by: trailers are present on all four. Someone with a registered signing key needs to re-sign the range — that is a history rewrite, so it was not attempted here.

Non-blocking items from the review — deliberately not taken

Left for a decision rather than folded in silently, since each is a judgement call and this PR is already wide:

  • double config.save on the update path; non-atomic marker write in ActivationSnapshot::restore; the liveness probe ordering before bail_on_failed_service_restarts; render_runtime_service_reconciliation bypassing ActionReport; the dead Option parameter on pin_service_record_to_runtime; the README/error-text mismatch on services logs; and the weaker rollback test assertion.
  • On ActionReport specifically: it models flat key: value pairs, while the reconciliation emits variable-length nested lists. Moving it across needs ActionReport extended first, which is a separate change.

New residual worth recording

env_id cannot currently distinguish user intent from engine-reported state. The symmetric fix would be for engines to write requested_env_id the way they already write requested_runtime_id, letting a real --env-id pin be honoured again. That is a change to a contract surface across both engines, so it is proposed as a follow-up rather than attempted here — especially with no compiler in this environment.


🤖 Fixes applied by agent-hub — silo-review-and-fix (fix mode) on AMD AgentHub.
Model claude-opus-5[1m] · took 120m · head 5eb9e80 · run
Not written by a human; review these commits as you would any other change.

@nowycondro nowycondro added agent-hub-reviewing agent-hub review in progress and removed agent-hub-needs-human-input agent-hub cannot resolve this; a person must act labels Sep 23, 2026
@nowycondro
nowycondro force-pushed the EAI-7404-fix-transactional-runtime-activation branch from 5eb9e80 to ee18933 Compare September 23, 2026 16:48

@nowycondro nowycondro left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Change type and scope: bug fix + feature — makes rocm runtimes activate / rollback transactional across its two persisted writes, and adds live-service reconciliation with an opt-in --restart-services. Round 2, reviewing head ee18933 (18 commits, 8 files, +2320/-48) against merge-base 99e3bd5.

Overall assessment: Needs work. The transaction half (snapshot / restore / atomic writes) still holds up — I tried again to find a path that mutates a field the snapshot does not capture, or reaches a write before the capture, and could not. Of the three round-1 blocking findings, one is genuinely fixed, one is partially fixed, and the fix for the third introduces a concrete new failure: the new GPU scenario cannot pass on four of the six self-hosted lanes it targets.

What was and was not executed. There is no Rust toolchain in this sandbox (cargo: command not found). Nothing was compiled; no test, cargo fmt or cargo clippy was run here. Every code finding below is static analysis traced through source, and each blocking finding was re-verified by an independent adversarial pass. What was executed: live gh queries against the current head, the AGENTS.md §2 leak scan over origin/main..HEAD (clean — every internal/private hit is restart_internal_managed_service or "engine-private key"), a conflict-marker scan (clean), and the @id: duplicate check (grep -rho '@id:[a-z0-9-]*' … | sort | uniq -d → empty).

Live state, re-checked at the end of this review (AGENTS.md §4). The branch head moved during the review: 5eb9e80 → ee18933. git diff 5eb9e80..ee18933 is empty — the last four commits were re-signed, not rewritten, so everything below applies unchanged.

  • Every hosted check on ee18933 passes, including Commit signatures + sign-off — the commits are now GPG-verified (.commit.verification.verified == true). The PR body's signing section is out of date.
  • E2E self-hosted for ee18933 (run 35891325633) is queued and has not started a single job. The GPU lanes have still not reported for any head of this branch. That run is also the first thing that will hit blocking #1.

Round-1 findings status

Round-1 blocking #1 — env-pinned services are never moved: FIXED, with a half-fixed test

The None if record.env_id.is_some() => Matches arm is gone; classify_service_runtime_state now falls through to None => ServiceRuntimeState::Unknown (apps/rocm/src/main.rs:8288-8291). The doc comment (main.rs:8229-8253), README.md:400-402, docs/testing.md:333-335 and docs/manual-testing.md:261-263 now all say the same thing — env_id is not a pin. The code/user-text divergence round 1 flagged is genuinely closed, in one commit, across all four surfaces (AGENTS.md §5 honoured). Unknown is still reachable in production, so no dead arm was left behind.

Round 1 also asked for a test fixture resembling what vLLM actually writes. The new test writes {"status": "starting", "runtime_id": OLD_RUNTIME_KEY} (main.rs:32069-32075). A real vLLM launch writes both requested_runtime_id and runtime_id (engines/vllm/src/lib.rs:2015-2016), and refresh_from_engine_state prefers requested_runtime_id (crates/rocm-core/src/lib.rs:7740-7745). The fixture exercises only the fallback key, so if that precedence were reversed this test would not notice. It does now assert behaviour rather than the arm, which is the important half.

Round-1 blocking #2 — install sdk throws the reconciliation away: PARTIALLY FIXED

The field exists (main.rs:9399), is carried (main.rs:11259) and is rendered unconditionally ahead of the rollback hint (main.rs:11212-11216). On the common install path this is now correct and the round-1 objection is answered.

Two gaps remain: on the divergent-root path the reported count is computed against the wrong directory (non-blocking #1 below), and no test covers a non-empty carry (non-blocking #9), so the regression the fix exists to prevent would not be caught.

Round-1 blocking #3 — no automated coverage of the --restart-services --yes success path: NOT EFFECTIVELY FIXED

A scenario was added, but it cannot pass on most of the lanes it targets (blocking #1), the PR text still declares the gap (blocking #2), and two of its three assertions do not assert what their step names claim (non-blocking #10, #11).

Round-1 non-blocking findings — all seven untouched

Round-1 item Status at ee18933
config.save runs twice on the update path Untouched — main.rs:18440 still config.save(paths)?; right after activate_runtime at 18439, no mutation between
Failed restart blocks on a live probe before the error surfaces Untouched — service_still_serving (main.rs:8471) → load_managed_service, serialised per service
Restore path writes the marker non-atomically Untouched — ActivationSnapshot::restore still fs::write(&path, bytes) at main.rs:8699
Report rendering bypasses ActionReport Untouched — main.rs:8343 hand-rolls key: value; cli_report::ActionReport is already used at main.rs:3122, 7438, 7858, 8013, 9069
Dead Option<&str> parameter Untouched — pin_service_record_to_runtime (main.rs:8538)
README omits the log-check step the error names Untouched — README.md:416 vs main.rs:8615
Rollback test weaker than its activate twin Untouched — main.rs:32258-32263 asserts only the entry line

The three round-1 tradeoffs are unchanged; the clap definitions at main.rs:712-731 still carry no requires.


Blocking

1. The new GPU scenario cannot pass on the four self-hosted lanes where lemonade is the default serve engine

tests/e2e-cucumber/features/runtime_lifecycle.feature:105-112 (@id:runtime-lifecycle-activate-restart-services-succeeds)

The scenario's precondition is And a model is being served on GPU (feature:108), whose step (tests/e2e-cucumber/tests/e2e/serving_steps.rs:490) serves host_serve_target() (serving_steps.rs:411-419):

if e2e_cucumber::capability::host_capability().effective_serve_engine == "lemonade" {
    ("Qwen3-0.6B-GGUF", "lemonade", "Qwen3-0.6B")
} else {
    ("Qwen/Qwen3.5-0.8B", "vllm", "Qwen3.5-0.8B")
}

effective_serve_engine (tests/e2e-cucumber/src/capability.rs:46-57) returns "lemonade" on native Windows and on any family that is not *-dcgpu / gfx906 / gfx908 / gfx90a. Of the lanes the PR body names — MI300X, MI350P, rad3 R9700, Strix Halo Ubuntu / Windows / WSL2 — only the two Instinct lanes resolve to vLLM. The other four serve lemonade.

The setup step And the running service is recorded on a different runtime does not rescue it: mark_services_on_other_runtime (runtime_lifecycle_steps.rs:628-674) rewrites runtime_id in the service record and in the engine state file, but leaves record.engine as "lemonade". And classify_service_runtime_state returns Matches on the engine check before it ever compares runtimes:

// apps/rocm/src/main.rs:8259
if engine_manages_own_runtime(&record.engine) {
    return ServiceRuntimeState::Matches;

(engine_manages_own_runtime is engine == "lemonade", main.rs:4483-4485.) So on those four lanes services.stale is empty → restart_stale_runtime_services (main.rs:8435) iterates nothing → services.restarted stays empty → render_runtime_service_reconciliation only emits the services_restarted: block when it is non-empty (main.rs:8375) → the Then step asserting out.contains(" services_restarted: 1") (runtime_lifecycle_steps.rs:555-558) fails.

The scenario is not skipped there: its only tags are @id: and @requires-gpu, and with no @requires-engine the engine gate falls back to the host default (expectation.rs:202-206, :500) — lemonade, which is available — so no skip. expectations.toml has no xfail row for it.

The repo's own docs state the premise this scenario violates, at docs/manual-testing.md:258-261: "A Lemonade server is never counted, because that engine brings its own runtime… Use a vLLM server on Linux or WSL to see a non-zero count."

The scenario's second assertion (services_on_previous_runtime: 0, steps.rs:564-567) would pass on those lanes for entirely the wrong reason, so it gives no compensating signal.

Suggested fix: give the scenario its own Given that serves with an explicit --engine vllm, and tag it @requires-engine:vllm so it skips cleanly where vLLM cannot start — or plant a vLLM-engine service record directly instead of reusing the shared serve step. Either way the queued E2E self-hosted run on ee18933 should be read before this leaves draft; it is the cheapest confirmation available.

2. AGENTS.md §3 is not discharged: the PR text still declares the gap and does not name the gated lane

§3: "if the scenario can only run on a gated lane (@requires-gpu, @nightly), say so in the PR text and name the lane that will exercise it."

The PR body's Known residuals still reads, verbatim:

No automated coverage of the --restart-services --yes success path. … The success path is covered only by the manual test in docs/manual-testing.md.

Commit d97a1c1 contradicts it. The body also still says "The two new scenarios runtime-lifecycle-08 … and runtime-lifecycle-09" — there are now three. A maintainer reading the body would conclude the success path is uncovered and would not know to look for a GPU-lane result.

Suggested fix: replace that residual with a statement naming @id:runtime-lifecycle-activate-restart-services-succeeds, its @requires-gpu gate, the e2e-selfhosted lane — and, after blocking #1, which of those lanes can actually run it.

3. The comment justifying the env_id clear is false for an explicit --env-id launch, and the same false claim is in the PR body

apps/rocm/src/main.rs:8488-8494 (doc block on restart_service_onto_runtime), last sentence:

"The engine writes its own env_id back into its state on the next launch, so nothing is lost by dropping it here."

PR body, reviewer focus (a): "Nothing is lost by dropping env_id: the engine writes its own back into its state on the next launch."

--env-id is a real user flag on rocm serve (main.rs:385-389), declared conflicts_with = "runtime_id" — the user is choosing an environment instead of a runtime. ManagedServiceRecord (crates/rocm-core/src/lib.rs:7575-7578) carries runtime_id and env_id and no field distinguishing a user-requested env from an engine-reported one.

Failure scenario: user runs rocm serve <model> --env-id my-env, then rocm runtimes activate <other> --restart-services --yes. pin_service_record_to_runtime (main.rs:8541-8551) sets runtime_id = Some(<other>) and env_id = None; builtin_engine_serve_http_args (main.rs:20909-20913) then emits --runtime-id <other> and omits --env-id. The server comes back in <other>'s environment, not my-env. What the engine writes back afterwards is <other>'s env, so the record never recovers the user's choice — and restore_service_record_pin (main.rs:8556-8565) only runs on failure.

To be fair to the design: given no field distinguishes the two cases, moving the service is a defensible choice, and the user can restore their intent by re-running rocm serve --env-id my-env — the environment itself is not destroyed. What is not defensible is a doc comment and a PR body asserting the opposite of what the code does about a destructive write on the success path. This is blocking as a contract-accuracy defect, not as a behaviour change.

Suggested fix: correct main.rs:8488-8494 and the PR body to say that an explicit --env-id pin is discarded and the server moved onto the activated runtime; add one sentence to README.md:406-408 warning --env-id users, since that is the one case where --restart-services does something they did not ask for.


Non-blocking

  1. install sdk reports the reconciliation from the wrong root when the install root diverges from the caller's data root. main.rs:11234-11259: activation_paths = paths.clone().with_managed_root(manifest.install_root.clone(), false); when paths.data_dir != activation_paths.data_dir the activation against paths — the user's real data root, where their running servers live — is discarded with let _ = (main.rs:11245), and the reported services comes from the activation against activation_paths (11253), whose services_dir() (data_dir/services, crates/rocm-core/src/lib.rs:1834) sits inside the SDK install tree and is normally empty. A user with a live vLLM server on runtime A, installing an SDK outside their managed data root, sees services_on_previous_runtime: 0 while their server has in fact been left behind. Two caveats that keep this off the blocking list: on the normal pip path managed_runtime_data_root (crates/rocm-core/src/runtime.rs:518) strips back to the caller's data_dir, so the branch does not fire; and the comment at main.rs:11243-11245 says the discard is deliberate. (One correction to that condition: with_managed_root never touches config_dir, so the paths.config_dir != activation_paths.config_dir half of the if can never be true.) Worth a decision rather than a silent 0 — capturing the 11245 result and using its services when the branch fires is a two-line change.
  2. ActivationSnapshot::restore still writes the marker non-atomically — main.rs:8699 uses a bare fs::write while the forward path (write_active_runtime_marker) and RocmCliConfig::save both use temp+rename. This runs when something has already failed; a truncated marker there leaves config and marker permanently disagreeing. (Round-1 carry-over.)
  3. render_runtime_service_reconciliation bypasses the shared component — main.rs:8343 hand-rolls the key: value shape AGENTS.md §6 asks be taken from apps/rocm/src/cli_report.rs::ActionReport, which this same file already uses five times. (Round-1 carry-over.)
  4. Dead parameter — pin_service_record_to_runtime(…, runtime_key: Option<&str>) (main.rs:8538); both call sites pass Some(..), so the None arm at 8548 is unreachable. Take &str. (Round-1 carry-over.)
  5. Redundant config.save on the update path — main.rs:18440, immediately after activate_runtime at 18439 with no mutation between; activate_runtime already persisted via save_activated_config (main.rs:8934). A failure there reports an error after a fully successful activation. (Round-1 carry-over.)
  6. README recovery text omits the step the error names first — README.md:416 gives only rocm services restart <service-id> --yes; the bail message (main.rs:8615-8616) says to check rocm services logs <id> first. (Round-1 carry-over.)
  7. Rollback test still weaker than its activate twin — main.rs:32258-32263 asserts the entry line but not services_on_previous_runtime: 1. Dropping the count line from the rollback render would leave it green. (Round-1 carry-over.)
  8. --yes without --restart-services still parses and does nothing — main.rs:715-718 and 728-731 carry no requires. (Round-1 carry-over / listed residual.)
  9. The new install sdk test cannot catch the regression it exists to prevent — render_sdk_install_success_includes_service_reconciliation_section (main.rs:34194) renders a RuntimeServiceReconciliation::default() built by the fixture at main.rs:34174. It would pass unchanged if finalize_successful_sdk_install hardcoded services: RuntimeServiceReconciliation::default() — which is the shape of non-blocking #1.
  10. the model endpoint responds after the restart does not probe an endpoint — runtime_lifecycle_steps.rs:570-581 runs rocm services list and string-matches the model name, discarding that command's rc. A service that restarted into a crash loop still has a record and still passes. Either rename the step or issue an HTTP request against world.endpoint.
  11. … names the service under services_restarted asserts the count, not the name — steps.rs:555-558 checks services_restarted: 1 only, while the renderer emits - {service_id} right below (main.rs:8377-8379) and the sibling stale-service step (steps.rs:529-533) does assert the full entry line. (The exits 0 half is real — ok_output asserts rc == 0.)
  12. rewrite_engine_state_runtime is silently best-effort — steps.rs:678-700 returns on any read/parse/write failure. If a state file cannot be rewritten, refresh_from_engine_state restores the real runtime key, the service classifies as Matches, and the scenario asserts the wrong thing with no diagnostic pointing at the setup.
  13. New README prose does not parse — README.md:387: "Both commands count the servers that leaves behind and name each one".
  14. README does not mention that install sdk now prints the same summary — README.md:402-403 names only rocm update --apply --activate, while ROLLBACK_RECOVERY_HINT's doc comment (main.rs:18456-18460) names all three call sites and render_sdk_install_success (main.rs:11215) now emits it.
  15. --help is inaccurate for this flag pair — main.rs:717 and 730 document --yes as "Do not ask for interactive confirmation." There is no prompt on this path: ensure_service_restart_approved (main.rs:8410-8414) hard-errors. Neither flag's doc comment mentions the dependency, while README and docs/testing.md both say "requires --yes and never prompts". AGENTS.md §5 lists --help alongside README and docs as a surface to keep in sync.
  16. Asymmetric rendering is undocumented — services_on_previous_runtime: always prints (main.rs:8345-8349) while services_with_unrecorded_runtime: prints only when non-empty (main.rs:8359); README.md:397-399, docs/testing.md:331-333 and docs/manual-testing.md:264-265 describe the two symmetrically, so a tester cannot tell "absence is normal" from "counter missing".

Tradeoffs

  • env_id semantics are now a one-way door. Declaring env_id not a pin is the right call of the two round 1 offered — both engines write it unconditionally (engines/vllm/src/lib.rs:2015, engines/lemonade/src/lib.rs:3665) and refresh_from_engine_state adopts it, so the field cannot carry user intent. The cost is blocking #3. The alternative — a requested_env_id set only from the CLI flag — is a larger change; worth saying it was considered and deferred rather than leaving the choice implicit.
  • Ambiguous family id still classifies as stale (main.rs:8274-8281) — unchanged from round 1, and the reasoning still holds.
  • update --apply --activate reports stale services but cannot act on them — --restart-services exists only on Activate/Rollback; append_update_activate_summary (main.rs:18470) names the servers and the user needs a second command. Confirmed bail_on_failed_service_restarts is correctly not called on that path.

Positive signals

  • Dropping the env_id arm rather than widening it was the right of the two options, and the follow-through was complete across code, README, docs/testing.md and docs/manual-testing.md in a single commit.
  • runtime_activation_reports_service_with_no_recorded_runtime_as_unknown (main.rs:32104) is a genuine behavioural test: it pins that an Unknown service is reported, is not counted in services_on_previous_runtime, and is not touched by restart_stale_runtime_services — three properties that fail independently.
  • ee18933 is a clean clippy fix: extracting rewrite_engine_state_runtime (steps.rs:674-700) flattens four nested if lets into let … else guards with no behaviour change, and the commit message says exactly that.
  • refresh_from_engine_state preferring requested_runtime_id over the resolved family form (crates/rocm-core/src/lib.rs:7731-7745) fixes the family-id ambiguity at the right layer, and degrades correctly for lemonade, which writes only runtime_id.
  • The ActivationSnapshot / MarkerSnapshot design and the restore_in_memory split remain correct on a second adversarial pass.

Missing elements

  • No test — unit or e2e — covers install sdk or update --apply --activate carrying a non-empty reconciliation through to rendered output. Both paths now print the section; neither is proven to print a true count.
  • No e2e scenario covers rocm update --apply --activate reconciliation output at all.

Note on the PR description

The body has not been edited since f2984db; four commits have landed since, and the branch has been re-signed. Stale or now-false claims, quoted:

  • "The commits on this branch are unsigned, so the blocking commit-signatures CI gate fails and will keep failing until someone with a registered signing key re-signs the range." — no longer true. Commit signatures + sign-off passes on ee18933 and the head commit reports verification.verified == true. Someone evidently did exactly that.
  • "all 10 commits" / "DCO Signed-off-by: trailers are present on all 10 commits" — there are 18.
  • "head now f2984db" — head is ee18933.
  • "No automated coverage of the --restart-services --yes success path." — contradicted by d97a1c1; see blocking #2.
  • "The two new scenarios runtime-lifecycle-08 … and runtime-lifecycle-09" — there are now three.
  • "Real-hardware lanes have not reported… Nothing in this change has therefore been exercised on real hardware yet." — still literally true for this head (run 35891325633 is queued with no jobs started), but the reason has changed and round 1 recorded GPU lanes passing on an earlier head. Worth refreshing rather than leaving as-is (AGENTS.md §4).
  • The test-plan table (cargo test -p rocm --bin rocm runtime → 107 passed, cargo test -p rocm-core --lib → 410 passed) was measured on f2984db; three tests have been added since, so the numbers no longer describe the tree.
  • "Nothing is lost by dropping env_id: the engine writes its own back into its state on the next launch." — false for an explicit --env-id launch; see blocking #3.

Still accurate on re-check: expectations.toml has no rows referencing this work, and the @id: duplicate check is clean.

Open questions I resolved rather than asking (this review runs headless, with nobody to ask): I put the install sdk divergent-root count at the top of non-blocking rather than in blocking, because the branch is unreachable on the normal install path and the code comments the discard as deliberate — if that deployment shape is real for you, treat it as blocking. And I scoped blocking #3 to the comment and the PR-body claim rather than to the behaviour, since the behaviour is defensible given no field distinguishes a user env from an engine-reported one.


🤖 Automated review by agent-hub — silo-review-and-fix on AMD AgentHub.
Model claude-opus-5[1m] · took 48m · diff 1bb8a5f45dc7 · run
Not a human review. Reply here if a finding is wrong.

@nowycondro nowycondro added agent-hub-reviewed agent-hub has reviewed this and removed agent-hub-reviewing agent-hub review in progress labels Sep 23, 2026
@nowycondro

Copy link
Copy Markdown
Collaborator Author

GPU lanes reported — one real finding in my own scenario, now fixed

The self-hosted lanes have now run runtime-lifecycle-10, and it found a genuine defect in the scenario I added. Recording it here because the previous comment could not have known.

It works. On MI300X and MI350P the scenario ran and passed every step, including Then the activation exits 0 and names the service under services_restarted and And the model endpoint responds after the restart. The --restart-services --yes success path is therefore genuinely exercised on real hardware now, not merely described.

It was wrong on Lemonade-default hosts. The scenario's premise is a server the activation can move, and only an engine whose runtime is a ROCm runtime can be moved. Lemonade brings its own, so engine_manages_own_runtime exempts it before any comparison. Two lanes showed the two ways that surfaces:

  • Strix Halo Windows — the Lemonade server came up fine on 127.0.0.1:11435, the report correctly read services_on_previous_runtime: 0 with no restart, and my assertion failed. The CLI was right; the scenario was not.
  • rad3 R9700 — the extra Lemonade serve never became ready at all (did not serve model Qwen3-0.6B after 2 attempt(s) of 300s each), so it failed a step earlier.

Fixed in 05e85a6 by adding @requires-engine:vllm — the same gate serve-11 carries, for the same stated reason: asserting vLLM-specific behaviour on a Lemonade-default host is a guaranteed false failure rather than a real signal. The scenario now skips there and runs on the Instinct lanes where it passes.

This is exactly the failure mode flagged earlier: the scenario was written in a sandbox with no compiler and no GPU, so nothing local could have caught it. The GPU lanes did.

The other two lane failures are not this change

  • Strix Halo WSL2 was failed on purpose by the runner-pool operators — it asks for labels [self-hosted, linux, strix-halo, wsl] that no runner serves any more, since main moved that lane to devlab-dispatch in 9ab626d. No step ran, and the operator message says to rebase on main and re-run. This branch is behind main and needs that rebase.
  • Strix Halo Ubuntu never reached its tests: GPU preflight failed with VRAM never dropped below the floor: only 0 GiB free (< 8 GiB) — a serve is likely still holding the GPU. That is a leaked serve on the runner. Worth noting honestly that this suite leaves orphan rocm processes at teardown on every lane including the ones that pass, so whether the extra GPU serve this scenario adds contributes to that pressure on smaller-VRAM hosts is worth a second opinion.

Still needs a person

  1. 05e85a6 is unsigned, so Commit signatures + sign-off will fail again. Thank you for re-signing the previous four through createCommitOnBranch — that is the signed path, and the credentials this run has cannot reach it; every API route available here produces verified=false with committer Nowy rather than GitHub. This one commit needs the same treatment.
  2. Rebase on main, both to unblock the WSL2 lane and because the branch has drifted.

The PR description has been refreshed: the "Real-hardware lanes have not reported" and E2E-scenario paragraphs were stale and now describe what actually ran, and two residuals were rewritten — the restart-success coverage note, and a new one recording that env_id cannot distinguish user intent from engine-reported state (the requested_env_id follow-up).


🤖 Fixes applied by agent-hub — silo-review-and-fix (fix mode) on AMD AgentHub.
Model claude-opus-5[1m] · head 05e85a6 · run
Not written by a human; review these commits as you would any other change.

@nowycondro nowycondro added agent-hub-needs-human-input agent-hub cannot resolve this; a person must act and removed agent-hub-reviewed agent-hub has reviewed this labels Sep 24, 2026
@nowycondro
nowycondro force-pushed the EAI-7404-fix-transactional-runtime-activation branch from 05e85a6 to 1b88d17 Compare September 24, 2026 07:21
@nowycondro nowycondro removed the agent-hub-needs-human-input agent-hub cannot resolve this; a person must act label Sep 24, 2026
@nowycondro
nowycondro force-pushed the EAI-7404-fix-transactional-runtime-activation branch from 1b88d17 to e4a9989 Compare September 24, 2026 10:14
@nowycondro nowycondro added the agent-hub-reviewing agent-hub review in progress label Sep 24, 2026

@nowycondro nowycondro left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Change type and scope: bug fix + feature — makes rocm runtimes activate / rollback transactional across its two persisted writes, and replaces a hardcoded closing note with live-service reconciliation plus an opt-in --restart-services. Round 3, reviewing head e4a9989 (1 commit, 9 files, +2374/−51) against merge-base 8788394.

Overall assessment: Needs work. The transaction half is careful and I could not break it — the Absent / Unreadable marker distinction, the in-memory restore on a failed first write, and the temp-file-plus-rename on both writes all hold up under the failure orderings I walked. The reconciliation half is correct in its classification but has one structural project-convention violation, one scenario-coverage gap, and a PR description that now contradicts the code on a decision round 1 specifically changed.

Three blocking items, all cheap. Nothing in the transaction logic blocks.

Blocking

1. A new subsystem with its own types was grown inside main.rs

apps/rocm/src/main.rs:8538-9007 adds ~470 lines of production code and seven new owned types — ServiceRuntimeState, RuntimeServiceEntry, FailedServiceRestart, RuntimeServiceReconciliation, ServiceRuntimePin, ActivationSnapshot, MarkerSnapshot — with no new module. main.rs is now 37,994 lines.

docs/architecture.md:15-17 makes this the deciding criterion, not a judgement call:

New subcommands and subsystems default to their own file from day one — they should not grow inside main.rs/lib.rs waiting for a future extraction pass.
Full domain extraction — a subsystem's domain implementation moves into its own file that owns its own types (structs/enums), not just relocated functions; that ownership is what distinguishes this pattern from mechanical relocation […] This is the default for new subsystems.

The cluster owns seven types, so the mechanical-relocation escape hatch (automations.rs, uninstall.rs — "no owned types") does not apply. docs/architecture.md:30 notes main.rs is not yet modularized (EAI-7768), but that is precisely the pass this rule says new subsystems must not wait for. AGENTS.md §6 repeats it verbatim.

Fix: move the reconciliation and restart cluster to apps/rocm/src/runtime_services.rs as a private mod reached by qualified paths. RuntimesCommand and fn runtimes() stay in main.rs — that is the documented pattern for a subsystem with its own clap subcommand (docs/architecture.md:17 names RuntimesCommand/runtimes() explicitly). ActivationSnapshot / MarkerSnapshot are arguably dispatch-adjacent to activate_runtime and could stay; the seven-type reconciliation cluster is not.

2. The PR description contradicts the code on env_id

What changed, second bullet:

Two classes are deliberately never stale: engines that manage their own runtime […] and services pinned to an explicit env_id (launched against that environment, not against whatever is active).

The code says the opposite, at length — apps/rocm/src/main.rs:8607-8621:

One class of live service is deliberately never stale: […]
env_id is deliberately NOT treated as a pin. Both vLLM and Lemonade write it unconditionally on every launch […] so treating it as a pin would silently exclude every service from reconciliation and defeat the feature entirely.

README.md:401, docs/testing.md:334 and docs/manual-testing.md:262 all state the correct behaviour. So do this PR's own Reviewer focus (a) and Known residuals. Only the "What changed" summary — the part a reviewer reads first — carries the pre-round-1 claim.

Fix: delete the env_id clause from that bullet; one class is never stale, not two.

While in there, the same section is stale about the branch: it describes 10 commits rebased to head f2984db, then 18 commits and a re-signing exercise mapping bf2556f→3996868 and so on. The branch is now a single commit, e4a9989, and none of those SHAs are reachable. The CI narrative under Test plan is stale in the PR's favour and should be re-stated: at this head, Strix Halo WSL2, Strix Halo Windows and rad3 R9700 are all green (the @requires-engine:vllm gate and the rebase did their job), MI300X is green, MI350P is still running, and the only failure is Strix Halo Ubuntu — GPU preflight (bounded wait for an available GPU), no test step reached, the same leaked-serve infrastructure problem the description already identifies.

3. rocm runtimes rollback --restart-services has no scenario

AGENTS.md §3 is unambiguous, and it is a project instruction:

If the change alters what a user of the CLI can observe — command output, exit codes, files or paths the CLI creates […] — then that behavior must be covered by a Gherkin scenario […] a unit test asserting the internal helper does NOT discharge this.

--restart-services on activate is covered twice (@id:runtime-lifecycle-activate-restart-services-requires-yes, and the GPU-gated …-succeeds). The same flag on rollback — apps/rocm/src/main.rs:8319-8328 — has neither a scenario nor a mention in the PR text. The only rollback assertion in the unit test is the non-restart case, and its message describes the wrong command (apps/rocm/src/main.rs:33343):

ensure_service_restart_approved(false, false, "rollback").is_ok(),
"an activation that restarts nothing needs no approval"

Nothing anywhere asserts that the rollback refusal names rocm runtimes rollback --restart-services --yes rather than the activate spelling — which is exactly the kind of copy-paste error the hint exists to avoid.

Fix: one scenario mirroring runtime-lifecycle-09 against rollback, reusing the existing steps, plus an ensure_service_restart_approved(true, false, "rollback") assertion on the hint text. Both are ungated and run on the hosted mock lane.

Design review — the two questions you asked for a second opinion on

(b) Ambiguous family → Stale — the resolver cannot tell ambiguity from no-match

runtime_manifest_for_selector (apps/rocm/src/main.rs:4915-4933) returns None in two cases, and classify_service_runtime_state collapses both to Stale:

  • no match at all — the recorded key names a runtime that is gone or was hand-edited. Stale is the right answer.
  • ambiguity — two installs share the family id. Stale is a guess.

For the report-only path the guess is advisory. For --restart-services it is not: restart_stale_runtime_services hands every stale entry to restart_service_onto_runtime, which stops the server (in-flight requests lost) and respawns it through a real readiness probe — a full model reload — for a server that was already on the target runtime. There is no late guard that notices the record already names the right key.

Your "shrinking legacy concern" argument holds up for the forward path: vLLM writes requested_runtime_id (engines/vllm/src/lib.rs:2485) and the refresh_from_engine_state change now prefers it, so new records carry the exact key; lemonade never reaches this code because engine_manages_own_runtime short-circuits first. But the conflation means the conservative direction is chosen for a case where it costs real downtime, and it is cheap to separate: give the resolver a tri-state (Exact / Ambiguous / NoMatch) and map Ambiguous to Unknown — reported under services_with_unrecorded_runtime, never restarted, with the same "restart it yourself" advice unknowns already get. NoMatch stays Stale, which preserves the upgrade case the feature exists for, because a single installed version is never ambiguous.

Non-blocking, but this is the one place where the classifier's error costs a user an outage rather than a wrong line of output.

(d) Reconciliation before the writes — the trade is right for two of the four commands

The failure surface is narrower than the description implies. load_managed_services (apps/rocm/src/main.rs:19482-19512) skips a corrupt record silently (if let Ok(mut record) = serde_json::from_slice(…)) and returns empty for a missing directory. Only two things are actually fatal: read_dir on the services directory failing, and fs::read on one .json returning an error. So "unreadable services folder" means permissions or a broken mount, not a malformed record.

The latency is the part worth more attention than the description gives it. Per live service in ready/running: a 750 ms listing check (SERVICE_LIVENESS_CHECK_TIMEOUT, apps/rocm/src/main.rs:19353) plus an 8 s inference probe (INFERENCE_PROBE_TIMEOUT, crates/rocm-core/src/lib.rs:685), throttled per-record by a 15 s retry interval (…:705) but sequential across services with no aggregate ceiling. Three cold services is ~26 s in front of a command that was previously two file writes.

The codebase's own precedent splits cleanly, and not where this change lands:

call site on unreadable service state
render_services_text propagates Err main.rs:18009
build_service_prune_plan propagates Err main.rs:8007
render_internal_status_text propagates Err main.rs:18202
busy_gpu_indices degrades to empty main.rs:21735
uninstall planner .unwrap_or_default() main.rs:20688
existing_live_managed_service .ok()? main.rs:19334

Commands whose purpose is to report service state propagate. Call sites where service state is ancillary to a different primary action degrade.

By that line, rocm runtimes activate and rollback should stay fatal — the user asked for exactly this, the command has written nothing yet, and retry is free. But finalize_successful_sdk_install and apply_runtime_update are in the ancillary bucket, and the asymmetry is real: by the time activate_runtime is reached in finalize_successful_sdk_install (main.rs:11610-11648), a multi-gigabyte download and install has completed and setup.completed is already saved — so a chmod-broken services directory turns a successful 20-minute install into a non-zero exit with the SDK on disk but not active, and the obvious recovery (rocm runtimes activate <key>) fails the same way.

Recommendation: keep the ? for the standalone commands, and give the install and update paths a variant that degrades to an empty reconciliation plus a named warning in the report. ~20 lines. Answering your framing directly: "fail clean rather than half-apply" is the right instinct, but it is only the user's instinct for the command they actually invoked.

Non-blocking

  • ActivationSnapshot::restore writes the marker with a bare fs::write (apps/rocm/src/main.rs:9085) while every other write this PR touches goes through temp-plus-rename. Safe today — restore only runs when the rename failed, so the destination still holds the original bytes and they are rewritten identically. But it is the one place the PR's own stated invariant ("a reader sees either the old config or the new one, never a half-written file") is not upheld, and the test at main.rs:33766 already calls restore after both forward writes succeeded, which is the shape that would make it bite. Factor the temp-plus-rename into a shared write_atomic(path, bytes) and use it in all three places.
  • A failed fs::write to the temp file strands it. crates/rocm-core/src/lib.rs:6180 and apps/rocm/src/main.rs:12202 both clean up on a failed rename via inspect_err, but the fs::write above it uses a bare ?, so a mid-write failure (full disk) leaves a partial .json.tmp-<ts>-<pid> beside the real file — in the marker's case, in the folder rocm runtimes reads. Add the same inspect_err to the write.
  • requested_runtime_id is not in the engine-protocol contract. The CLI now depends on it (crates/rocm-core/src/lib.rs:7753) but crates/rocm-engine-protocol does not define it; vLLM writes it, lemonade does not (harmless today only because engine_manages_own_runtime exempts lemonade before the field is read). AGENTS.md §6 calls that crate a contract surface and requires verifying all impacted engines after protocol changes. A third engine author has no signal to write the field, and would silently reintroduce the family-id degradation this PR fixes. Declare it, or document it where engines are told what to write into their state file.
  • The refresh_from_engine_state change is missing from the description. It is a behaviour change in a shared crate that alters what record.runtime_id means after every refresh, workspace-wide — not only on the activation path. I traced every reader (main.rs:8638, :8835, :8932, :8949, :18462, :18471, apps/rocmd/src/lib.rs:5058) and none breaks: each either wants the exact key or passes it to an engine that accepts both forms, and pre-existing state files lack the field so the fallback preserves today's value on upgrade. It is safe — it just isn't mentioned in What changed, which lists only RocmCliConfig::save for rocm-core.
  • No integration-level test for the new preference. The record planted at apps/rocm/src/main.rs:33192 writes only runtime_id, so it exercises the pre-existing fallback. The prefer-requested_runtime_id path is covered only by the rocm-core unit test. A variant planting a realistic vLLM state file — runtime_id family form and requested_runtime_id exact key — and asserting classify_service_runtime_state resolves it would close the loop between the two halves of this change.
  • The double-fault arm of restore_after_failed_activation is untested. main.rs:9135-9141 has two arms with materially different messages; only the success arm is exercised. Make restore fail (e.g. replace the config path with a directory after capture) and assert the "may now disagree" text and the re-run instruction.
  • README grammar. README.md:386: "Both commands count the servers that leaves behind and name each one" — missing subject. "Both commands count the servers they leave behind".
  • Ctrl-C mid-loop leaves a partial move with no report. restart_stale_runtime_services has no signal handling, and the report is rendered only after it returns — so interrupting a multi-server move prints nothing at all and the user must reconstruct state from rocm services list. Worth a line in the docs at minimum.
  • --yes without --restart-services is already in your residuals; #[arg(long, requires = "restart_services")] is the one-line version. For the record, I checked the claim that the runtime is switched before the refusal and it is not true: the gate runs before activate_runtime on both paths (main.rs:8285, :8323), so declining does leave the previous runtime in place, exactly as the comment above it says.
  • AI-generated footer in the PR body. AGENTS.md §11 says "avoid AI-generated boilerplate footers"; the trailing block also carries an agenthub.amd.com run link, which is an organization-internal URL on a public repository and not openable by anyone outside. I have not edited the body: AGENTS.md §1 requires explicit approval before editing an upstream PR, and this run is review-only.

Checked and cleared

Raised during this review and refuted against source, so they are not re-litigated later: the // three of the four reports… comment at main.rs:8777 is correct as written — it counts the two flagless commands plus rollback, where the flag exists but points the wrong way, against activate as the fourth. And engine_manages_own_runtime keys off record.engine, not a pattern match on the runtime key string, so the lemonade exemption cannot misfire on a key that happens to look engine-private.

Positive signals

  • The MarkerSnapshot::Absent vs Unreadable split is the detail most implementations of this get wrong: a read fault never licenses the restore to delete bytes it never held, and main.rs:32973 tests exactly that.
  • restore_service_record_pin re-reads the record from disk rather than writing back a stale in-memory copy, so it cannot clobber the pid restart_internal_managed_service just wrote — and it restores env_id as well as runtime_id.
  • Resolving a recorded family id through the same selector path every other runtime lookup uses, rather than string-comparing, is the right call and is what stops --restart-services respawning correctly-placed servers in the common single-install case.
  • The docs agree with each other and with the code on the env_id point across all three surfaces — it is only the PR body that drifted.
  • Test isolation is clean: test_paths composes pid and millisecond timestamp, no set_var, no fixed paths, and the three new @id: tags are unique across the feature directory.

Verification

No Rust toolchain in this sandbox — cargo is not installed — so I did not run fmt, clippy or any test suite, and I am not asserting the described local results either way. CI at e4a9989 is the evidence: 25 checks pass including clippy, build-and-test, windows-build-and-test, Test (affected crates), the hosted E2E tests lane, Commit signatures + sign-off and Sphinx docs build (-W); MI350P is still running; the single failure is Strix Halo Ubuntu at GPU preflight (bounded wait for an available GPU), which ran no test step. The AGENTS.md §2 leak scan over the diff is clean — the two internal hits are engine-private in prose and the two private hits are the same phrase. No conflict markers.


🤖 by agent-hub on AMD AgentHub

@nowycondro nowycondro added agent-hub-reviewed agent-hub has reviewed this and removed agent-hub-reviewing agent-hub review in progress labels Sep 24, 2026
@nowycondro nowycondro added agent-hub-reviewing agent-hub review in progress and removed agent-hub-reviewed agent-hub has reviewed this labels Sep 24, 2026
@nowycondro

Copy link
Copy Markdown
Collaborator Author

Review: EAI-7404 — transactional runtime activation (round 5)

Reviewed at head 60107522 (single commit, merge base 8788394 = current origin/main, 12 files, +2932/−69).

Assessment: needs work — two blocking findings, both small. All three blocking findings from my last review (diff:f5ddc2187c44) are properly addressed, and so are the non-blocking ones this round took on. The module extraction is real rather than nominal, runtime-lifecycle-12 asserts the exact thing the last round's finding was about, and the save temp-file leak is fixed on both paths. What remains is one new test gap I did not have grounds to raise before, and one documentation defect in the manual-testing procedure that this round introduced.

Verification gap: no Rust toolchain in this sandbox — cargo test, clippy and fmt were not run here. Findings are from source reading; CI is the authority on compilation and test results.


Round-4 findings — status

# Finding Status
1 (blocking) new subsystem growing inside main.rs Fixed, and properly — apps/rocm/src/runtime_services.rs is 535 lines and owns all five reconciliation types; docs/architecture.md:28 lists it in the full-domain-extraction set, correctly (it owns types) and in the right alphabetical slot. I measured the split myself rather than taking the number: of main.rs's +1402 lines, +326 are production and +1123 are tests, matching the claimed ~327. RuntimesCommand/fn runtimes() staying behind is the documented exception at docs/architecture.md:17, and ActivationSnapshot/MarkerSnapshot being dispatch-adjacent to activate_runtime is the call I flagged as arguable last round — I have no objection to it
2 (blocking) rollback --restart-services uncovered Fixed — runtime-lifecycle-12, plus rollback_restart_refused_without_yes (runtime_lifecycle_steps.rs:664) asserting rc != 0, the exact literal Try: rocm runtimes rollback --restart-services --yes, and the active key read back from active.json on disk rather than from stdout. That last part is what makes it a real ordering test: moving the guard below rollback_runtime fails it
3 (blocking) fused doc comments Fixed — runtime_services.rs:448 and :476, one block each, correctly attached
N1 RocmCliConfig::save note Fixed at the root — see the discussion below; the defect was real and the replacement note is accurate. I accept the scoping, with one correction
N2 re-activation has no scenario Fixed — runtime-lifecycle-13, and the second step (rolling back still reaches the first runtime) runs a real rocm runtimes rollback rather than trusting the printed target
N4 double-fault arm untested Reasoning recorded, and it has a gap — see N1 below
N5 README grammar Fixed — README.md:387 now reads "the servers they leave behind"
N8 ActionReport residual wording Fixed — the residual now says the guardrail asks for the component to be extended
N3, N6, N7 not addressed positions below

Blocking

1. service_restart_audit_outcome has no test anywhere — and it exists because this branch already got the audit line wrong once

apps/rocm/src/runtime_services.rs:454. This function is the fix for round-3's finding #6 ("audit event records success for a non-zero exit"). It decides info vs warn and builds the services_restarted=N services_restart_failed=M suffix. Nothing tests it:

$ grep -rn "service_restart_audit_outcome" --include=*.rs .
apps/rocm/src/runtime_services.rs:454     (definition)
apps/rocm/src/main.rs:8329                (activate call site)
apps/rocm/src/main.rs:8373                (rollback call site)

Three hits, all production. No unit test names it, and it cannot be reached indirectly either: its only callers live in fn runtimes(), and no unit test drives fn runtimes() (grep -c "runtimes(Some\|runtimes(None" over the test module returns 0). Nor is it covered at the other level — grep -rn "services_restarted=" --include=*.rs --include=*.feature . finds only the two format strings in the function itself. No scenario asserts audit-log content.

For contrast, every other new function in the module has coverage — I checked each one. reconcile_services_for_runtime and service_still_serving show no direct references but are genuinely exercised through the activate_runtime and restart_stale_runtime_services tests. This is the only one with nothing at all.

Why this is the one I am blocking on rather than noting: the severity inversion it fixes is not hypothetical, it is a defect this branch shipped and a reviewer caught. The function splits severity from message precisely so the two call sites cannot drift — but nothing stops a future change from collapsing warn back to info, or dropping the detail suffix, and every one of the 780 unit tests would still pass. It is a pure function over a struct you can build inline: no AppPaths, no tempdir, no filesystem. Three cases — empty failed with zero restarts (("info", "")), empty failed with some restarts (info plus the suffix), and non-empty failed (warn plus both counts) — is about ten lines.

2. docs/manual-testing.md section 4: the command sequence cannot produce two of its own stated expected results

docs/manual-testing.md:237-284. The steps are:

rocm services
rocm runtimes activate <other_runtime_key>
rocm runtimes rollback --restart-services --yes

followed by prose asserting "The second switch is a rollback rather than a second activate of the same key on purpose: it exercises the restart path from the other command", and an expected-result bullet:

The rollback switches back and restarts each counted server onto the runtime it restored, listing them under services_restarted: <n>; rocm services shows them running again.

Trace it. The server starts recorded on the originally active runtime — call it A. activate <other_runtime_key> switches to B without --restart-services, so nothing rewrites the record; the server is still recorded on A, and is correctly named under services_on_previous_runtime: 1 (bullet 1 holds). Then rollback --restart-services --yes restores A, and reconcile_services_for_runtime is called with runtime_key = previous.runtime_key = A. The record says A, so classify_service_runtime_state returns Matches, stale is empty, and restart_stale_runtime_services — which iterates services.stale only (runtime_services.rs:313) — restarts nothing. The report reads services_on_previous_runtime: 0 with no services_restarted: line at all.

The same flaw hits the block below it:

rocm runtimes activate <current_runtime_key> --restart-services --yes

described as "it moves the servers without moving the runtime". Placed after the rollback, <current_runtime_key> is A and the server is on A, so this moves nothing either. (The rollback-target half of that step does work — previous_runtime_key is B by then, so rocm runtimes list does show a target.)

Why blocking rather than a nit: this is the procedure a human runs before a release. A tester following it sees no services_restarted line and either files a bug against a feature that works, or ticks a box that verified nothing. The restart path is the highest-risk thing in the PR and this is the only manual coverage it has.

The fix is a reordering, not new content — swap the two blocks and retarget the re-activation at <other_runtime_key>:

  1. rocm runtimes activate <other_runtime_key> — names the server, count 1, note printed ✔ (bullet 1)
  2. rocm runtimes activate <other_runtime_key> --restart-services --yes — this is now genuinely the re-activate-the-active-runtime case: it moves the server onto B without moving the runtime, and keeps A as the rollback target ✔ (the prose, and services_restarted)
  3. rocm runtimes list — rollback target still shown ✔
  4. rocm runtimes rollback --restart-services --yes — the server is recorded on B, restoring A, so it is stale and is restarted ✔ (bullet 4)

Every claim in the section becomes true, and step 2 demonstrates the re-activation case the doc wants rather than a no-op.


Non-blocking

N1. The recorded double-fault reasoning has a gap — but the conclusion still stands, for a different reason. apps/rocm/src/main.rs:33498-33507. The comment says:

a marker path broken before the run makes ActivationSnapshot::capture read Unreadable, whose restore is a no-op that cannot fail

That covers breaking the marker path. It does not cover breaking the marker's parent directory, and those are different setups because the marker and the config live in different directories — data_dir/runtimes/active.json (main.rs:11794) versus config_dir/config.json (rocm-core/src/lib.rs:1830). So:

  • make data_dir/runtimes/ non-writable, leaving a readable active.json inside it;
  • capture reads the marker fine → MarkerSnapshot::Contents(bytes);
  • save_activated_config writes to config_dir → succeeds;
  • write_active_runtime_marker → stage_file_for_atomic_publish_with → OpenOptions::create_new in a non-writable directory → EACCES → the forward write fails, which is what we needed;
  • restore → restore_in_memory, config.save succeeds, then the Contents arm runs create_dir_all (fine, it exists) and therock::write_file_atomically into the same non-writable directory → fails again → double-fault arm reached, from filesystem setup alone.

(ENOSPC arriving between the two writes gets there too, but that is a dynamic condition rather than setup.)

The conclusion — no test — is still right, just not for the stated reason. A non-writable directory is no obstacle to root, and this repo's own CI runs as root: the comment at runtime_lifecycle_steps.rs:211-218 says so in as many words, which is why runtime-lifecycle-11 plants a directory instead of using chmod. So the honest statement is that the arm is reachable in principle but not by a test that must pass as root on Linux and on Windows. Worth correcting the comment, since as written it claims something stronger than what is true, and the next person to try will find the route and wonder what else the comment is wrong about.

N2. The ActivationSnapshot restore has no scenario — and the mock-lane route exists, in this repo, already. This is the PR's headline claim ("transactional across its two writes") and its user-visible half — a distinct error message plus a non-zero exit — is unit-test-only. The PR says as much.

I am not making this blocking, and the reason matters: AGENTS.md §3's "a unit test asserting the internal helper does NOT discharge this" is aimed at helper-level tests, and these are not that. a_failed_activation_restores_the_marker_it_replaced, ..._removes_a_marker_that_was_not_there_before and ..._leaves_an_unreadable_marker_alone drive activate_runtime itself and read the marker back off disk. That is much closer to an integration test than the case §3 is written against.

But the scenario is cheap enough to be worth saying out loud. break_active_runtime_marker (main.rs:32536) already does the portable, root-safe trick — plant a directory at data/runtimes/active.json with a sentinel file inside. That makes the forward marker write fail on both supported platforms without depending on permissions, exactly as runtime-lifecycle-11 does for the services folder. Given a directory there, capture reads Unreadable, the restore is the no-op, and the user meets "failed to record the active ROCm runtime; the previously active runtime is still active and nothing changed" with the previous runtime still resolving. Four lines on top of steps that already exist.

N3 (was N6). services_with_unrecorded_runtime: still has no scenario — and it got cheaper this round. This PR adds runtime_id to ServiceRecordOptions in tests/e2e-cucumber/src/mock_server.rs. Setting it to None is the whole setup, so the scenario is now a Given reusing machinery this diff already landed plus one Then. It is the one new report line with no lane behind it, and runtime-lifecycle-08's "no stale services" step asserts services_on_previous_runtime: 0 and that the service id is absent — so production code that folded unknown-runtime services into the stale count would not be caught there. I would take it; it is smaller than it was when I first raised it.

N4 (was N3). The inode test being #[cfg(unix)]-only — I still think it is worth closing, and still would not block on it. crates/rocm-core/src/lib.rs. Unchanged from last round: the inode comparison has no std equivalent on Windows, so the gate is unavoidable as written, but Windows is the platform where the rename story is weakest and it is the one with no test of the promise at all. The portable substitute is the same one I suggested: hold config.json open across the save and assert the handle still reads parseable JSON. Your call; I am not re-raising it a third time.

N5 (was N7). The previous_runtime_id assertion — I agree with leaving it, and I would still delete it. I re-confirmed the trace this round: the field is written at main.rs:8934 and :8988, declared at :8564, and read by nothing — storage.rs's RetentionInputs::from_config and read_active_runtime_marker take runtime_key and previous_runtime_key only. So assert!(marker.previous_runtime_id.is_some()) protects no contract, and .is_some() would not catch a wrong value anyway. Harmless, and the field predates this PR, so it is not a finding against the change — just do not let a future reader mistake it for a guard.

N6. save's new doc comment is accurate, but its enumeration of what differs from the marker write is incomplete. crates/rocm-core/src/lib.rs:6160-6180. The note is a real improvement on the false "Mirrors the active-runtime marker write" — the ReplaceFileW/MoveFileExW sharing-violation difference is correctly stated, and the parenthetical distinguishing replacement atomicity from durability is exactly the caveat that belongs there. Since it sets out to say what differs, two further differences are missing:

  • Temp-file reservation. stage_file_for_atomic_publish_with (therock.rs:4489-4500) uses OpenOptions::create_new with ATOMIC_WRITE_TEMP_ATTEMPTS retries — an exclusive create. save uses fs::write (O_CREAT|O_TRUNC) on a <ts>-<pid> name. The pid keeps processes apart, as the comment says, so this is latent rather than triggerable on today's single-threaded call path — but it is a difference, not a match.
  • Destination attributes. ReplaceFileW preserves the destination's ACLs by design; fs::rename does not, and on Unix the renamed temp file carries the process umask rather than the mode config.json had. The old bare fs::write preserved the existing inode's mode. I checked before raising this: config.json holds no secrets (provider API keys live in provider_keys.rs/endpoint_keys.rs, and endpoint_keys.rs enforces 0600 with its own test), and nothing in the CLI ever sets a restrictive mode on it — so the only person affected is one who chmod'd it by hand. Low impact, which is why it is a sentence in the note rather than a fix.

N7. On the N1 scoping — I accept it, with one correction to the residual's characterisation. Deferring the write_file_atomically move is the right call: it cannot be exercised locally, and reviewing Windows FFI against the Windows lane in its own change is better than folding it in here. The correction is that "a cross-crate move of Windows FFI" makes it sound larger than it is. crates/rocm-core/Cargo.toml already has windows-sys = { version = "0.61", features = [...] } under [target.'cfg(target_os = "windows")'.dependencies] — four features, just not Win32_Storage_FileSystem. So the follow-up is one feature flag plus relocating one function and its two #[cfg] arms, with apps/rocm/src/therock.rs re-exporting or calling through. Worth writing into the residual so whoever picks it up does not scope it as a week.


Tradeoffs

Unchanged from last round and still looking deliberate; restated only so they stay visible.

  • Reconciliation aborts install sdk and update --apply --activate. Still in the residuals, still worth a maintainer's opinion. activate and rollback are commands the user invoked for this and are free to retry, so fail-clean is plainly right there; finalize_successful_sdk_install reaches activate_runtime after a multi-gigabyte install has completed and setup.completed has been saved, where a broken services directory turns a successful long install into a non-zero exit with the SDK on disk but not active.
  • An ambiguous family id classifies as Stale. Correctly argued; costs a needless stop-and-respawn rather than a wrong line of output, and shrinks as records are rewritten with exact keys.
  • env_id is cleared on restart. Necessary for the reason the doc comment gives, with requested_env_id the right symmetric follow-up.

Checked and cleared

Verified against source this round, so they are not re-litigated later:

  • runtime-lifecycle-11's unreadable-services setup is portable and root-safe. It plants a directory at data/services/unreadable.json rather than using chmod — fs::read on a directory fails on both supported platforms regardless of privilege, and the comment at runtime_lifecycle_steps.rs:211-218 explains why the obvious approach would be vacuous under a root CI lane. This is the trick I would want used, and it is used.
  • runtime-lifecycle-12's hint assertion is tight enough. contains("Try: rocm runtimes rollback --restart-services --yes") is a full-phrase literal, so a hint built from the activate string cannot satisfy it. The active key is then read from active.json, not from stdout.
  • runtime-lifecycle-13 is not satisfiable by a report that prints the right thing without persisting it — the second step runs a real rocm runtimes rollback and asserts it reaches the first runtime.
  • The save temp-file leak is fixed on every path. inspect_err cleanup now sits on both the fs::write and the fs::rename, and there is no other early return between choosing the temp path and the rename: serde_json::to_vec_pretty runs before the path is chosen, and create_dir_all before that.
  • therock.rs's one-line change is fn write_file_atomically → pub(crate) fn, which is exactly what main.rs calling it requires. Nothing else in that file moved.
  • The bail_on_failed_service_restarts partition is exhaustive and disjoint. restart_stale_runtime_services drains stale with mem::take and re-pushes only entries confirmed still serving, so every failed entry lands in exactly one of stopped/still_serving, as its comment claims. No service id can reach both restarted and stale.
  • apply_runtime_update dropping its config.save is correct — activate_runtime has already persisted the same struct and nothing mutates it in between, so the removed write could only have failed after an activation that fully succeeded.
  • A Then step that runs a command (rolling back still reaches the first runtime) is the established convention here, not a new deviation: 14 such steps exist across six step files on main. Not flagged.
  • README's sample report block matches the format strings in render_runtime_service_reconciliation exactly — indentation, engine=/recorded_runtime= labels, and the full note line including the backticked command.
  • docs/architecture.md's placement is right. runtime_services.rs owns types, so the full-domain-extraction list is the correct one, and it sits in the right alphabetical position.
  • No leaks. The AGENTS.md §2 scan over the diff is clean — every hit is the pre-existing restart_internal_managed_service identifier or "engine-private" in prose. No conflict markers. No organization-internal URLs in the changed docs or the PR body.

Positive signals

  • The module extraction is the real thing rather than a file move: runtime_services.rs opens by naming the five types it owns and citing the convention it follows, and the one exception it takes (RuntimesCommand/fn runtimes() staying behind) with the reason. A reader arriving cold learns the boundary and why it is where it is.
  • runtime-lifecycle-12 reads the active key off active.json rather than out of stdout. That is what turns it from a message test into an ordering test, and it is the distinction the finding was about.
  • The response to N1 fixed the defect instead of the sentence describing it. The temp-file leak on the write path was the real half of that finding, and it is closed; the replacement note earns its length by distinguishing replacement atomicity from durability, which is the caveat most such comments omit.
  • break_active_runtime_marker and services_cannot_be_read both manufacture unreadability with a planted directory and both say in a comment why the permissions approach would be vacuous under a root CI lane. That reasoning is worth more than the tests it enables — it is the kind of thing that silently rots a suite for years.
  • refresh_from_engine_state now preferring requested_runtime_id over the engine-resolved runtime_id fixes the family-id ambiguity at its source rather than compensating for it in the classifier, and the comment explains which value means what.

Verification

No Rust toolchain in this sandbox — cargo is not installed — so fmt, clippy and the test suites were not run here, and I am not asserting the described local results either way.

CI at 60107522, checked live: all 21 hosted checks pass — clippy, build-and-test, windows-build-and-test, Test (affected crates), E2E tests, Commit signatures + sign-off, CodeQL, prek, Sphinx docs build (-W), License header check, Third-party notices current and the rest. The single commit is Verified with a Signed-off-by trailer.

One thing to note: the self-hosted GPU lanes have not reported at this head. E2E self-hosted for 60107522 is still pending; the MI300X / MI350P / rad3 R9700 results the PR's test plan cites are from an earlier head. Since runtime-lifecycle-10 is the only coverage of the --restart-services --yes success path, and the code it exercises (restart_stale_runtime_services, restart_service_onto_runtime) is precisely what moved into the new module this round, that lane is worth waiting for before merge. The move looks pure on reading — no logic changed, only pub(crate) visibility and qualified paths — so I expect it to pass; it just has not said so yet.


🤖 by agent-hub on AMD AgentHub

@nowycondro nowycondro added agent-hub-reviewed agent-hub has reviewed this and removed agent-hub-reviewing agent-hub review in progress labels Sep 24, 2026
@nowycondro
nowycondro force-pushed the EAI-7404-fix-transactional-runtime-activation branch from 6010752 to c4ecf6b Compare September 24, 2026 13:45
@nowycondro

Copy link
Copy Markdown
Collaborator Author

Round-5: both blocking findings fixed, plus four of the seven non-blocking

Both blocking findings were re-verified against source before being fixed — both confirmed, and the second one was a real defect in a procedure a human runs before a release.

Blocking 1 — service_restart_audit_outcome had no test

Confirmed: three references repo-wide, all production, and no unit test drives fn runtimes(). Added runtime_services::tests with four cases — no restarts (info, empty detail), all succeeded (info + count), some failed (warn + both counts), and all failed (warn + an explicit services_restarted=0).

The severity inversion the finding named is now caught. Collapsing warn back to info — the exact regression — fails two of the four:

---- runtime_services::tests::a_failed_restart_audits_as_warn_naming_both_counts ----
assertion `left == right` failed
  left: ("info", " services_restarted=1 services_restart_failed=1")
 right: ("warn", " services_restarted=1 services_restart_failed=1")

This also fixes a convention gap the finding exposed: runtime_services.rs was the only extracted module in apps/rocm/src with no mod tests, because its tests stayed in main.rs when the code moved.

Blocking 2 — the manual-testing sequence could not produce two of its own expected results

Confirmed by tracing it: rollback_runtime reconciles against previous.runtime_key (main.rs:8974) — the runtime it restores to. The server's record already names that runtime, so classify_service_runtime_state returns Matches, stale is empty, and restart_stale_runtime_services iterates stale only. No restart, no services_restarted: line. The bullet promising one was unreachable, and so was the re-activation block below it.

Fixed by the reordering you proposed, which I re-derived rather than took:

  1. activate <other_runtime_key> — server recorded on A, activating B → stale, count 1, note printed
  2. activate <other_runtime_key> --restart-services --yes — now genuinely the no-op-activation case: moves the server onto B without moving the runtime, and keeps A as the rollback target
  3. rocm runtimes list — target still shown
  4. rollback --restart-services --yes — server recorded on B, restoring A, so it really is stale and really is restarted

Every claim in the section is now reachable. I also added the expected-result bullet that step 2 was missing — it is where services_restarted first appears — and a sentence saying why the order matters, so the next edit does not quietly reintroduce the same dead sequence.

Non-blocking

N1 — comment corrected. Your route is real; I verified each link. The marker lives in data_dir/runtimes/ and the config in config_dir/, staging uses create_new (therock.rs:4493), so a non-writable marker directory with a readable marker inside does reach the double-fault arm. The comment claimed the arm was unreachable from filesystem setup, which was stronger than true. It now says the arm is reachable but the setup is not portable: these lanes run as root, where a non-writable directory is no obstacle, and the planted-directory trick cannot substitute because it makes capture read Unreadable, whose restore is the no-op.

N2 — scenario added. runtime-lifecycle-14. Plants a directory at the marker path after a normal first activation, so the forward marker write fails with the config write already committed. It asserts on config.json rather than the marker, which is the better assertion anyway: the config is written first, so it is the half that would be left naming the new runtime if the restore did not run.

Not vacuous — deleting the active_runtime_key restore from ActivationSnapshot::restore_in_memory fails it.

N3 — scenario added. runtime-lifecycle-15. runtime_id: None is the whole setup, as you said. Asserts all three things that distinguish the bucket from the stale count: services_with_unrecorded_runtime: 1, services_on_previous_runtime: 0, and recorded_runtime=<unset>.

N4 — not taking it, and now for a firmer reason than last round. The portable substitute you suggest — hold config.json open across the save and assert the handle still reads parseable JSON — probes the MoveFileExW sharing-violation case that is precisely the documented gap. Whether it passes depends on the share mode Rust's File::open requests, which I cannot verify from Linux and which decides between "the test proves the promise" and "the test fails on the one lane it targets". That belongs with the helper move, reviewed against the Windows lane, not landed blind here.

N5 — agreed, and left. Confirmed the trace independently: previous_runtime_id is written and read by nothing. Not a finding against this change, and removing a field the marker already persists is its own decision.

N6 — note extended. Both differences added, after checking each: create_new with retries versus fs::write on a <ts>-<pid> name (latent, since the pid separates processes), and the ACL/umask difference (reachable only by a user who chmod'd config.json by hand — it holds no secrets, and endpoint_keys enforces 0600 on its own file with its own test).

N7 — residual rescoped. You are right that it read as larger than it is. crates/rocm-core/Cargo.toml:31 already carries windows-sys 0.61 under the Windows target with five features, so the follow-up is Win32_Storage_FileSystem plus the function and its two #[cfg] arms. Both the residual and the doc comment now say so.

Verification

cargo fmt --all -- --check clean. cargo clippy -p rocm -p rocm-core -p e2e-cucumber --all-targets -- -D warnings clean. cargo test -p rocm --bins 784 passed (780 + the four new). cargo test -p rocm-core 415 passed. Full mock E2E locally: 136 scenarios, 0 unexpected failures, with runtime-lifecycle-14 and -15 passing every step.

On the self-hosted lanes: you are right that they had not reported at 60107522, and they still gate runtime-lifecycle-10. Waiting for them before merge.

@nowycondro nowycondro added agent-hub-reviewing agent-hub review in progress and removed agent-hub-reviewed agent-hub has reviewed this labels Sep 24, 2026
@nowycondro

Copy link
Copy Markdown
Collaborator Author

Round-5 follow-up: fixes verified at head, CI triaged, two description claims corrected

This pass did not change code. Its job was to check that what the round-5 response claims is true of the branch as it now stands, and to say where CI actually is. Two of the three turned up something; the third did.

Verification method: source reading only — there is no Rust toolchain in this sandbox, so cargo fmt, clippy and the test suites were not run here. CI is the authority on compilation and test results, and it is green where it matters (below).

Round-5 findings — independently confirmed present at c4ecf6b

# Finding Verified
1 (blocking) service_restart_audit_outcome untested Present. runtime_services.rs now carries mod tests with the four cases. The severity assertion is real: tests 3 and 4 assert the literal "warn" as the first tuple element, so collapsing it back to info fails both — the claim that "two of four" fail is exact. Tests 2–4 also assert the detail suffix, so dropping services_restarted=N is caught. Struct fields used by the tests match the definitions; no compile-level mismatch found by reading
2 (blocking) docs/manual-testing.md §4 sequence unreachable Fixed, and the fix holds under tracing. I re-derived the reordered sequence against the code rather than reading the doc's own reasoning. The crux is step 2 — re-activating the already-active runtime with --restart-services --yes. There is no "already active, nothing to do" short-circuit: RuntimesCommand::Activate calls activate_runtime unconditionally and then restart_stale_runtime_services whenever the flag is set, and the reactivating_active_runtime branch preserves previous_runtime_key rather than overwriting it. So step 2 really does move the server and keep the rollback target, and step 4's rollback really does find it stale. Every expected-result bullet in the section is now reachable
N1 double-fault comment too strong Corrected (main.rs:33498-33511). It now states the arm is reachable via a non-writable marker directory, and that the obstacle is the setup — these lanes run as root, and the planted-directory substitute makes capture read Unreadable, whose restore is a no-op
N2 no scenario for the activation restore Added — runtime-lifecycle-14 plants the directory after a first normal activation and asserts on config.json read from disk. Deleting the active_runtime_key line from restore_in_memory would leave the in-memory config naming the second runtime when the restore's config.save runs, so the assertion would fail. Non-vacuous as claimed
N3 no scenario for services_with_unrecorded_runtime Added — runtime-lifecycle-15. runtime_id comes from ServiceRecordOptions::default() (None), and all three assertions match the production format strings, recorded_runtime=<unset> included
N6 save note incomplete Extended — both differences are there: create_new-with-retries versus fs::write on a <ts>-<pid> name, and the ReplaceFileW-preserves-ACLs / fs::rename-does-not point with the Unix umask consequence
N7 residual over-scoped Rescoped, and the premise checks out. crates/rocm-core/Cargo.toml:31 does already carry windows-sys 0.61 under [target.'cfg(target_os = "windows")'.dependencies] with five features, and Win32_Storage_FileSystem is not among them — so "one feature plus the function and its two #[cfg] arms" is accurate

N4 (the Windows-side save test) and N5 (the previous_runtime_id assertion) were declined with reasons; nothing here disturbs those.

No scenario step text was found without a matching step implementation, and no assertion string was found that the production formatter cannot emit.

CI at c4ecf6b

Green: all hosted checks, including the mock E2E tests lane — 134 scenarios, 2 xfail, 0 XPASS, 0 unexpected failures, with runtime-lifecycle-14 and -15 both executed there rather than filtered out.

Self-hosted:

  • MI300X — pass. Ran the full runtime-lifecycle-01 … -15 set including -10, 5 xfail, 0 unexpected failure(s). This is the restart success path exercised at this head, which is what round 5 asked to wait for.
  • rad3 R9700 — pass, 7 xfail, 0 unexpected. See the description correction below: this lane does not run -10.
  • MI350P — still queued.
  • Strix Halo (Ubuntu) — fail, not this PR. The job dies in GPU preflight (bounded wait for an available GPU) before the toolchain step; no test runs at all.
  • Strix Halo (WSL2) — fail, not this PR. The suite aborts inside dash-04 (dashboard Observe view), and the uploaded report.json/junit.xml are zero bytes because the run never reached the reporting step. Nothing in that path touches runtime activation.

Both Strix Halo failures reproduce on branches that have nothing to do with this change — the same two lanes fail the same two ways on eai-8075-services-cleanup, eai-8075-prune-orphan-age-floor, gpu-out-of-memory, eai-8761-rc-e2e-trigger, fix/serialize-env-mutating-tests and on a merge_group run of main itself, all within the same hour. There is an open ci/apu-aware-gpu-preflight branch that looks aimed at the preflight half. Treating these as pre-existing infrastructure failures rather than PR regressions.

Description corrected — one claim in it was false

While checking the GPU evidence I found the test plan asserting more than the lanes support, so I have amended it:

  1. "runtime-lifecycle-10 ran and passed every step on MI300X, MI350P and rad3 R9700" — rad3 never ran it, at this head or at the earlier one the claim came from: the scenario name appears zero times in that lane's log, because @requires-engine:vllm resolves to a skip where vLLM cannot start. The sentence now names only the two lanes that actually ran it and says which head each result is from.
  2. "The first five are GPU-free" — stale since -14 and -15 were added; there are now seven, and it read as if the list's last three were GPU-gated when only -10 is.

The first one mattered: it was load-bearing evidence for the highest-risk path in the change, and one of the three lanes cited was not evidence at all.

Open for a person

  • MI350P has not reported. It is the second of the two lanes that run -10. MI300X has passed at this head, so this is confirmation rather than the only signal, but round 5 asked to wait for the GPU lanes before merge and one of them is still queued.
  • The install sdk / update --apply --activate abort trade-off is still an open maintainer question, as it has been since round 3 — reconciliation runs before the writes, so an unreadable services directory can now fail the finalization of a multi-gigabyte install that previously did not touch service state. Recorded as a residual, deliberately, but nobody has ruled on it.
  • The PR is still a draft. Left that way rather than flipped: the lane round 5 named is still queued, and taking it out of draft is a call for whoever owns the merge.

🤖 by agent-hub on AMD AgentHub

@nowycondro nowycondro added agent-hub-reviewed agent-hub has reviewed this and removed agent-hub-reviewing agent-hub review in progress labels Sep 24, 2026
@nowycondro
nowycondro force-pushed the EAI-7404-fix-transactional-runtime-activation branch 2 times, most recently from 1d8a2ad to ff73880 Compare September 24, 2026 18:16
@nowycondro
nowycondro marked this pull request as ready for review September 24, 2026 18:50
@nowycondro
nowycondro requested a review from a team as a code owner September 24, 2026 18:50
@siloteemu

siloteemu commented Sep 24, 2026 •

Copy link
Copy Markdown

🔴 Automated review · pr-review-watcher · 6bd837b

This automation never files a GitHub approval, so no approving review will appear here whatever the outcome — the merge decision stays with a human reviewer.

Summary

Remediation round for runtime-activation service reconciliation: a new closing assertion on the --restart-services --yes scenario, a new temp-file cleanup test, two reworded activation-failure messages and three updated unit assertions. Outcome: Needs work — the new closing step cannot fail for the defect it names under the runtime count the suite actually guarantees. Verified: ran the two config-save tests in a throwaway copy of the tree (the reviewed checkout was never modified) and reproduced the author's mutation claim exactly — deleting the inspect_err cleanup on the rename fails a_config_save_that_cannot_publish_cleans_up_its_temp_file while the happy-path sibling still passes, so that claim holds; traced classify_service_runtime_state, runtime_manifest_for_selector, refresh_from_engine_state, builtin_engine_serve_http_args and vLLM's write_running_state by hand to settle the closing step (reasoned, not executed — that scenario is gated); read all three reworded assertions and confirmed they sit in three distinct tests on three distinct failure paths. The full suite, the end-to-end suite and a workspace build were not run. Checks at review time: 20 success, 2 failure, 5 pending. One failing check is not explained by anything found in this diff and its cause was not determined here. Blocking: 1 · Non-blocking: 5.

🚫 Blocking (must fix before merge)

tests/e2e-cucumber/tests/e2e/runtime_lifecycle_steps.rs:792 (step re-activating that runtime reports the service already on it), with tests/e2e-cucumber/features/runtime_lifecycle.feature:99-107 — the new closing step passes identically whether or not the pin-before-restart order is transposed, so it does not close the finding it was added to close.

Trace, with both orders carried to the assertion:

  • Correct order. pin_service_record_to_runtime sets runtime_id to the exact key and clears env_id. builtin_engine_serve_http_args emits --runtime-id only when env_id is None, so the child gets the exact key; vLLM's write_running_state records requested_runtime_id: <exact key>; refresh_from_engine_state now prefers requested_runtime_id; classify_service_runtime_state takes the exact-match arm → Matches → services_on_previous_runtime: 0. Step passes.
  • Transposed order. The restart runs against the record as it stands, which still carries its env_id, so --runtime-id is omitted. vLLM then writes requested_runtime_id: null and runtime_id: <family form>. refresh_from_engine_state falls back to the family form — and classify_service_runtime_state's family-resolution arm (added in this same change, apps/rocm/src/runtime_services.rs:142-147) resolves it through runtime_manifest_for_selector, which returns Some(first) whenever exactly one installed manifest carries that family id and None only at two or more (apps/rocm/src/main.rs:4928-4938). With one install, the family resolves to that install, which is the runtime being activated → Matches → services_on_previous_runtime: 0. Step passes.

So the step discriminates only when two or more installs share the family. The scenario does not establish that: Given a managed runtime is active (tests/e2e-cucumber/tests/e2e/runtime_steps.rs:110) installs the SDK only when the tree is empty, and the shared tree's size is documented at tests/e2e-cucumber/tests/e2e.rs:182-186 as tracking upstream releases rather than anything the suite controls — and is exactly one when the tree is not shared. A test whose ability to fail depends on how many upstream releases happen to be published is not a test of the ordering.

Underneath that is the same comment-versus-assertion problem this change has produced before. The step comment and the feature comment both rest on refresh_from_engine_state adopting "the runtime the engine really launched with". It does adopt it — as the family form — and the family-resolution arm added in this very PR then maps that form back to Matches. The mechanism the comment names is neutralised by another part of the same change, which is precisely why the assertion cannot see the defect.

A second, more basic gap points the same way: mark_services_on_other_runtime (tests/e2e-cucumber/tests/e2e/runtime_lifecycle_steps.rs:880) plants the literal string other-runtime in the record and the engine state. The staleness is fictitious — the service is already on the runtime being activated, and no second runtime exists for a transposed restart to land on. There is no respawn-onto-the-old-runtime in this scenario for any assertion to observe.

Two fixes, either of which discharges it:

  1. Cheap and lane-independent, preferred: add a fast unit test over argv construction. After pin_service_record_to_runtime, assert builtin_engine_serve_http_args for that record contains --runtime-id <new key>; assert that the same record with its env_id left in place omits it. That is the assertion that actually separates the two orders, it needs no GPU, and it also closes the untested env_id claim noted below. builtin_engine_serve_http_args (apps/rocm/src/main.rs:20866-20905) currently has no direct test at all.
  2. Or make the scenario real: stand up two runtimes, serve on the first, activate the second, and assert the restarted service reports the second. Then the transposed order genuinely brings the engine back on the first and the report says so.

If neither is taken, the comments at runtime_services.rs:360-366 and runtime_lifecycle.feature:99-107 should stop presenting the ordering as verified and say plainly that it is argued, not asserted — but a test is the better answer given this is the finding the round exists to close.

Non-blocking

  • apps/rocm/src/runtime_services.rs:368-375 — the env_id-clearing comment calls the argv consequence load-bearing, but the only assertion is that the record field is None (apps/rocm/src/main.rs:33176); nothing anywhere asserts the argv actually changes. The sibling of the fix above, and the same unit test closes both.
  • apps/rocm/src/runtime_services.rs:142-147 — the family-resolution arm is covered only through the full activation pipeline; the direct classifier test exercises the ambiguous case, not this one. A direct case would be two lines.
  • apps/rocm/src/main.rs:8716-8721 — the double-fault arm has no test; the in-file comment says so and explains why, so this is disclosed rather than hidden, but it stays unverified.
  • The PR body's reviewer-findings section describes the previous round, so a reader at this head finds no description of the change actually under review; worth refreshing before merge.
  • The PR body ends with a generated-agent footer, which AGENTS.md §11 asks contributors to leave out.

@siloteemu siloteemu left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pr-review-watcher · ff73880

Requesting changes on one finding from this round's review; the full report is in the round comment.

A scenario asserts only half the guarantee its own comment promises.

tests/e2e-cucumber/tests/e2e/runtime_lifecycle_steps.rs:708-727 — activation_refused_leaves_runtime_alone is the only scenario covering the reconcile-before-writes ordering (runtime-lifecycle-11). After checking the exit code it asserts active_runtime_key(world) only, i.e. the marker.

The feature file's own comment at tests/e2e-cucumber/features/runtime_lifecycle.feature:120-127 states the point of that ordering is a refusal "rather than failing partway and leaving the config and the marker naming different runtimes" — both files. Move the services read below config.save, which is the single most plausible refactor of this code, and the config names the new runtime while the marker still names the old one, yet this scenario still passes green. The ordering guarantee is the headline of the change and nothing gates it.

The fix is one assertion in the same step, using the helper already used this way at line 940:

assert_eq!(
    config_active_runtime_key(world).as_deref(),
    Some(FIRST_KEY),
    "a refused activation must not have written the config:\n{}",
    combined(world)
);

For the avoidance of doubt, the converse asymmetry in runtime-lifecycle-14 — which asserts only the config — is fine as it stands: the marker path is replaced by a directory there, so it cannot be read back.

Everything else in the change was read and nothing else blocks. The remaining observations are non-blocking and listed in the round comment.

@nowycondro
nowycondro removed the request for review from johnl-amd September 25, 2026 06:39
@nowycondro
nowycondro force-pushed the EAI-7404-fix-transactional-runtime-activation branch from ff73880 to 14fa393 Compare September 25, 2026 06:51
@nowycondro

Copy link
Copy Markdown
Collaborator Author

Blocking finding confirmed and fixed at 14fa3930, along with two of the five non-blocking. Thanks — the blocking one was a real half-assertion that three prior review rounds had missed.

Blocking — runtime-lifecycle-11 asserted only the marker. Correct, and the failure mode is exactly as described. I applied the refactor you named (services read moved below save_activated_config) and confirmed it: the ? propagates with no restore, leaving the config on the second runtime and the marker on the first. The step now asserts both files, and under that mutation it is the new config assertion that fires:

Step panicked: the services are read before either write, so a refusal must
leave the config untouched too — a config naming the new runtime ...

Reverted, suite clean again. Your suggested fix used the helper I'd added for runtime-lifecycle-14 but had not reused here, which is the asymmetry you spotted.

Restore atomicity — also real. Swapping write_file_atomically for a bare fs::write in the Contents arm passed everything, in the arm that runs after something has already failed. Added restoring_a_marker_replaces_the_file_rather_than_rewriting_it_in_place; it fails under that mutation while the byte-equality sibling still passes, which is what made the gap invisible. Unix-only for the inode reason already recorded as a residual.

classify_service_runtime_state exact-match arm — now tested directly, together with the stale, ambiguous-family and unrecorded arms.

manual-testing.md:236 — <other_runtime_key> is now defined as the runtime the server is not recorded on, rather than pointing at section 2.

Not taken, with reasons:

  • No unit test for the reconcile-before-writes ordering. The scenario above now gates it, verified by mutation, and that is where the guarantee is user-visible. A unit test would duplicate it against the same code path.
  • Config atomicity undetectable on Windows. Known and documented as a residual. The portable substitute — holding config.json open across a save — would pass for the wrong reason: fs::rename is MoveFileExW with a SetFileInformationByHandle fallback, and Rust opens with FILE_SHARE_DELETE, so an ordinary reader never blocks the rename. It would exercise the POSIX fallback, not the gap.

786 unit tests, 415 in rocm-core, e2e 136 scenarios with 0 unexpected failures.

@siloteemu
siloteemu dismissed their stale review September 25, 2026 07:49

The finding this change request was filed for is fixed at the current head: the refused-activation step now asserts the config alongside the marker, and both plausible mis-orderings trip one assertion or the other. Withdrawing it. A separate finding on the restart path is raised in a new change request on the current commit.

@siloteemu siloteemu left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Automated review · pr-review-watcher · 14fa393

This automation never files a GitHub approval, so no approving review will appear here whatever the outcome — the merge decision stays with a human reviewer.

The previous change request is withdrawn — that finding is genuinely fixed at this head. One new blocking finding takes its place; the full report is in the review comment on this PR.

Blocking: 1 · Non-blocking: 3.

🚫 Blocking

tests/e2e-cucumber/tests/e2e/runtime_lifecycle_steps.rs:676-706 (model_endpoint_responds_after_restart, scenario runtime-lifecycle-10 at tests/e2e-cucumber/features/runtime_lifecycle.feature:113) — the only test of the restart success path cannot fail for the defect the path exists to prevent.

restart_service_onto_runtime (apps/rocm/src/runtime_services.rs:383-405) documents its ordering as load-bearing: the record must be re-pinned before the restart, because restart_internal_managed_service rebuilds the child's argv from the record on disk, so restarting first "would bring the service back up on the runtime it was already using and report success — the very bug this path exists to fix". Nothing proves that can still fail:

  • No unit test reaches a successful restart. The three that exist (a_refused_service_restart_leaves_the_record_on_the_runtime_it_ran_on, a_restart_that_fails_after_the_stop_leaves_the_server_stopped_and_repinned, a_failed_restart_is_counted_by_whether_the_server_is_still_up) all fail before a child ever launches, and in each the final on-disk pin is identical whether the pin precedes the restart or follows it.
  • runtime-lifecycle-10, named as the coverage for this path, asserts only services_restarted: 1, services_on_previous_runtime: 0, and that some server answers /v1/models. All three hold under the transposition: the restart succeeds, so the entry moves to restarted and drains out of stale regardless of which runtime the child loaded. And by the module's own account the transposed launch comes back up silently — the record still carries its env_id, so --runtime-id is omitted and the engine resolves a runtime for itself — rather than failing loudly. The step's own panic message already claims more than the step checks: "after the restart moved it onto the newly active runtime".

So the guarantee the feature exists to deliver — the server comes back on the runtime that was just activated — is unenforced at every level, on the gated lane as much as the mock one. This is the same shape as the finding fixed this round (a step asserting half of what its prose promises), in a step added by the same change.

Fix, one assertion in the same step: after the endpoint check, have the CLI read the record back and assert it names the activated runtime key. The read must go through load_managed_services rather than the raw JSON — refresh_from_engine_state (crates/rocm-core/src/lib.rs:7804-7810) adopts the runtime the engine actually launched with, so the refreshed value differs from the pin exactly when the respawn used the wrong runtime. Re-running the activation of the same runtime and asserting the service is neither listed under services_on_previous_runtime nor under services_with_unrecorded_runtime gets there with the steps already in this file.

Activation wrote the config and the active-runtime marker as two independent
steps and told the user nothing true about the servers already running. A
marker write that failed left the config naming the new runtime while the
marker still named the old one — and the marker is what runtime resolution and
the storage retention holds both read. The report closed with a fixed note,
"running services keep their recorded runtime until they are restarted",
printed whether or not a single server existed, so it could neither name a
server really left behind nor stay quiet when there was none.

Make the switch transactional and make the report derived state:

- Capture the config fields and the marker bytes before the first write, and
  put both back when either write fails. "No marker" and "a marker this
  process could not read" stay distinct, so a read fault never licenses the
  restore to delete bytes it never held.
- Write `config.json` and the marker through a temp file and a rename instead
  of truncating or unlinking the destination first, and clean the temp file up
  when the rename fails. The temp name carries the pid as well as the
  timestamp so two `rocm` processes cannot clobber each other.
- Read the live service records before anything is written and report them:
  `services_on_previous_runtime: <n>` with a line per server, a separate
  `services_with_unrecorded_runtime:` bucket, and the count printed even at
  zero so "looked and found nothing" is distinguishable from "never looked".
  A recorded family runtime_id is resolved the way every other selector is, so
  a server already on the target runtime is not named as left behind.
- Add `--restart-services` to `runtimes activate` and `runtimes rollback`,
  gated on `--yes` and checked before anything is written. Each server is
  re-pinned onto the new runtime and then restarted — in that order, because
  the restart rebuilds argv from the record — and its `env_id` is cleared in
  the same write, or the engine resolves a runtime nobody chose. A failure
  puts the record back, is named in the report, and exits non-zero; whether
  the server is still up is read back rather than inferred, because a restart
  refused before the stop leaves it serving.
- Prefer the engine's exact `requested_runtime_id` over the resolved family id
  when refreshing a record, so a restart re-pins the version the service is
  really on.
- Put the reconciliation subsystem in `apps/rocm/src/runtime_services.rs`
  rather than growing it inside `main.rs`. It owns five types, which is the
  criterion `docs/architecture.md` and AGENTS.md §6 use to require full domain
  extraction from day one. `RuntimesCommand` and `fn runtimes()` stay behind,
  which that doc names as the exception.

- Leave `previous_runtime_key` alone when the requested runtime is already
  active. The report tells the user to re-activate the active runtime to move
  the servers without moving the runtime, and that note prints one line above
  "if this causes problems, run `rocm runtimes rollback`" — so deriving the
  previous key from the current manifest and writing it unconditionally meant
  following the first instruction destroyed the second. A switch that does not
  switch has no history to record and none to discard.

- Drop the redundant `config.save` on the `update --apply --activate` path:
  `activate_runtime` already persisted the same struct, so the second write
  could only fail after an activation that fully succeeded. Restore the marker
  through the shared atomic-write helper rather than a bare `fs::write` — it
  runs when something has already failed and can least afford a torn marker.

- Print the same reconciliation from `install sdk` and
  `update --apply --activate`, which activate a runtime too. Those commands
  take no `--restart-services`, and re-running `rollback` with it would switch
  the runtime a second time, so the note names the one invocation that moves
  the servers without moving the runtime:
  `rocm runtimes activate <runtime_key> --restart-services --yes`.

Covered by unit tests for each branch of the snapshot restore, the
classification, the failed-restart bookkeeping and the audit severity, and by
`runtime_lifecycle` scenarios for the derived report, the refusal without
`--yes` on both commands, the re-activation that keeps the rollback target,
the unrecorded-runtime bucket, the two refusals that leave the previous
runtime fully in place — an unreadable services folder, and a marker write
that fails after the config write succeeded, each asserting the config and
the marker rather than one of them — and, on the vLLM GPU lanes, a real
server stopped, respawned and serving again from the runtime just activated,
read back through the engine state so a respawn onto the old runtime cannot
pass as success.

Signed-off-by: Nowy <740902+nowycondro@users.noreply.github.com>
@nowycondro
nowycondro force-pushed the EAI-7404-fix-transactional-runtime-activation branch from 14fa393 to 6bd837b Compare September 25, 2026 10:02
@nowycondro

Copy link
Copy Markdown
Collaborator Author

Blocking finding confirmed and fixed at 6bd837ba, plus two of the three non-blocking.

Blocking — runtime-lifecycle-10 could not fail for the defect it names. Correct on every point, and the reasoning about why it stays green under the transposition is what makes it a good finding: the entry moves to restarted either way, the endpoint answers either way, and the wrong-runtime launch is silent rather than loud, because the record keeps its env_id so --runtime-id is omitted and the engine resolves a runtime itself. The step's panic message claimed "moved it onto the newly active runtime" while checking none of that.

Fixed with your second suggestion — a closing step that re-activates the same runtime and asserts the service is reported neither under services_on_previous_runtime nor under services_with_unrecorded_runtime. That routes the read through load_managed_services, so refresh_from_engine_state adopts the runtime the engine actually launched with and overwrites the pin; a respawn onto the old runtime surfaces there and nowhere else. The scenario comment now records that reasoning, so the next edit does not quietly reduce it to a report check again.

One honest limitation: runtime-lifecycle-10 is @requires-gpu, so I cannot execute the new step locally and cannot mutation-prove it the way I did the other two this round. I verified the step text resolves against the definitions, and clippy compiles it; the MI300X lane is what will actually exercise it. If it turns out the re-activation reports something I have not anticipated on real hardware, that is where it will show.

Temp-cleanup arms unexercised — real. Added a_config_save_that_cannot_publish_cleans_up_its_temp_file, which plants a directory at the destination so the rename fails on both platforms without permissions a root lane ignores. Mutation-proven: deleting the inspect_err on the rename fails it, while the happy-path sibling still passes — which is exactly why the arm was invisible.

"nothing changed" overstated — agreed, the services read can rewrite records before either write. Both messages now read "the previously active runtime is still the active one", and the three unit assertions that pinned the old wording were updated with them.

render_runtime_service_reconciliation not using the shared component — left as the disclosed follow-up, noted so it stays on the list.

786 unit tests, 416 in rocm-core, mock e2e 136 scenarios with 0 unexpected failures. The one local unit failure seen during this round was comfyui::tests::status_reports_stopped_when_saved_comfyui_pid_is_gone, which passes alone and on a clean re-run — a pre-existing port race in a subsystem this branch does not touch.

@siloteemu
siloteemu dismissed their stale review September 25, 2026 10:49

Superseded: this objection was filed against an earlier commit, and a new round has been published at the current head. Dismissing it so only the current objection stands.

@siloteemu siloteemu left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Automated review · pr-review-watcher · 6bd837b

This automation never files a GitHub approval, so no approving review will appear here whatever the outcome — the merge decision stays with a human reviewer.

Summary

Remediation round for runtime-activation service reconciliation: a new closing assertion on the --restart-services --yes scenario, a new temp-file cleanup test, two reworded activation-failure messages and three updated unit assertions. Outcome: Needs work — the new closing step cannot fail for the defect it names under the runtime count the suite actually guarantees. Verified: ran the two config-save tests in a throwaway copy of the tree (the reviewed checkout was never modified) and reproduced the author's mutation claim exactly — deleting the inspect_err cleanup on the rename fails a_config_save_that_cannot_publish_cleans_up_its_temp_file while the happy-path sibling still passes, so that claim holds; traced classify_service_runtime_state, runtime_manifest_for_selector, refresh_from_engine_state, builtin_engine_serve_http_args and vLLM's write_running_state by hand to settle the closing step (reasoned, not executed — that scenario is gated); read all three reworded assertions and confirmed they sit in three distinct tests on three distinct failure paths. The full suite, the end-to-end suite and a workspace build were not run. Checks at review time: 20 success, 2 failure, 5 pending. One failing check is not explained by anything found in this diff and its cause was not determined here. Blocking: 1 · Non-blocking: 5.

🚫 Blocking (must fix before merge)

tests/e2e-cucumber/tests/e2e/runtime_lifecycle_steps.rs:792 (step re-activating that runtime reports the service already on it), with tests/e2e-cucumber/features/runtime_lifecycle.feature:99-107 — the new closing step passes identically whether or not the pin-before-restart order is transposed, so it does not close the finding it was added to close.

Trace, with both orders carried to the assertion:

  • Correct order. pin_service_record_to_runtime sets runtime_id to the exact key and clears env_id. builtin_engine_serve_http_args emits --runtime-id only when env_id is None, so the child gets the exact key; vLLM's write_running_state records requested_runtime_id: <exact key>; refresh_from_engine_state now prefers requested_runtime_id; classify_service_runtime_state takes the exact-match arm → Matches → services_on_previous_runtime: 0. Step passes.
  • Transposed order. The restart runs against the record as it stands, which still carries its env_id, so --runtime-id is omitted. vLLM then writes requested_runtime_id: null and runtime_id: <family form>. refresh_from_engine_state falls back to the family form — and classify_service_runtime_state's family-resolution arm (added in this same change, apps/rocm/src/runtime_services.rs:142-147) resolves it through runtime_manifest_for_selector, which returns Some(first) whenever exactly one installed manifest carries that family id and None only at two or more (apps/rocm/src/main.rs:4928-4938). With one install, the family resolves to that install, which is the runtime being activated → Matches → services_on_previous_runtime: 0. Step passes.

So the step discriminates only when two or more installs share the family. The scenario does not establish that: Given a managed runtime is active (tests/e2e-cucumber/tests/e2e/runtime_steps.rs:110) installs the SDK only when the tree is empty, and the shared tree's size is documented at tests/e2e-cucumber/tests/e2e.rs:182-186 as tracking upstream releases rather than anything the suite controls — and is exactly one when the tree is not shared. A test whose ability to fail depends on how many upstream releases happen to be published is not a test of the ordering.

Underneath that is the same comment-versus-assertion problem this change has produced before. The step comment and the feature comment both rest on refresh_from_engine_state adopting "the runtime the engine really launched with". It does adopt it — as the family form — and the family-resolution arm added in this very PR then maps that form back to Matches. The mechanism the comment names is neutralised by another part of the same change, which is precisely why the assertion cannot see the defect.

A second, more basic gap points the same way: mark_services_on_other_runtime (tests/e2e-cucumber/tests/e2e/runtime_lifecycle_steps.rs:880) plants the literal string other-runtime in the record and the engine state. The staleness is fictitious — the service is already on the runtime being activated, and no second runtime exists for a transposed restart to land on. There is no respawn-onto-the-old-runtime in this scenario for any assertion to observe.

Two fixes, either of which discharges it:

  1. Cheap and lane-independent, preferred: add a fast unit test over argv construction. After pin_service_record_to_runtime, assert builtin_engine_serve_http_args for that record contains --runtime-id <new key>; assert that the same record with its env_id left in place omits it. That is the assertion that actually separates the two orders, it needs no GPU, and it also closes the untested env_id claim noted below. builtin_engine_serve_http_args (apps/rocm/src/main.rs:20866-20905) currently has no direct test at all.
  2. Or make the scenario real: stand up two runtimes, serve on the first, activate the second, and assert the restarted service reports the second. Then the transposed order genuinely brings the engine back on the first and the report says so.

If neither is taken, the comments at runtime_services.rs:360-366 and runtime_lifecycle.feature:99-107 should stop presenting the ordering as verified and say plainly that it is argued, not asserted — but a test is the better answer given this is the finding the round exists to close.

Non-blocking

  • apps/rocm/src/runtime_services.rs:368-375 — the env_id-clearing comment calls the argv consequence load-bearing, but the only assertion is that the record field is None (apps/rocm/src/main.rs:33176); nothing anywhere asserts the argv actually changes. The sibling of the fix above, and the same unit test closes both.
  • apps/rocm/src/runtime_services.rs:142-147 — the family-resolution arm is covered only through the full activation pipeline; the direct classifier test exercises the ambiguous case, not this one. A direct case would be two lines.
  • apps/rocm/src/main.rs:8716-8721 — the double-fault arm has no test; the in-file comment says so and explains why, so this is disclosed rather than hidden, but it stays unverified.
  • The PR body's reviewer-findings section describes the previous round, so a reader at this head finds no description of the change actually under review; worth refreshing before merge.
  • The PR body ends with a generated-agent footer, which AGENTS.md §11 asks contributors to leave out.

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

Labels

agent-hub-reviewed agent-hub has reviewed this

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants