EAI-7404: make runtime activation and rollback transactional - #424
nowycondro wants to merge 1 commit into
Conversation
847a013 to
f2984db
Compare
d7da211 to
40666ee
Compare
nowycondro
left a comment
There was a problem hiding this comment.
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:
resolve_engine_selection(main.rs:21388) setsruntime_id: None, env_id: Some(..)for a--env-idlaunch, so the record starts out matching the arm.- vLLM's
write_running_state(engines/vllm/src/lib.rs:2015) writes"runtime_id": runtime.runtime_idinto the engine state file.VllmRuntime.runtime_idis a plainString, not anOption(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. refresh_from_engine_state(crates/rocm-core/src/lib.rs:7740) then doesself.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.saveruns twice on the update path.main.rs:18417-18418:activate_runtimealready persisted viasave_activated_config, thenconfig.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 beforebail_on_failed_service_restartsever 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 barefs::write, while the forward path inwrite_active_runtime_markeruses 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-rollswriteln!in thekey: valueshape thatapps/rocm/src/cli_report.rs::ActionReportexists 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) takesruntime_key: Option<&str>and has aNonearm that setsrecord.runtime_id = None. Every call site passesSome(..). Take&str. -
README omits a step the error text includes.
README.md:415givesrocm services restart <service-id> --yesas the recovery; the actual bail message (main.rs:8603) says to checkrocm 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 notservices_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_selectorreturningNonemeans 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 isrefresh_from_engine_statenow preferringrequested_runtime_id— which shrinks the window only for records written after this change. -
update --apply --activatereports stale services but cannot act on them.--restart-servicesexists only onActivateandRollback, 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. -
--yeswithout--restart-servicesparses and does nothing. Already in your known residuals. A claprequires = "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
ActivationSnapshotcaptures exactly the three fieldsactivate_runtimeandrollback_runtimemutate —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.MarkerSnapshotdistinguishingAbsent/Unreadableis the right shape: restoringAbsentremoves the file it wrote, andUnreadablecorrectly does nothing rather than overwriting bytes it never read.- Temp files are created as siblings of their targets in both
RocmCliConfig::saveandwrite_active_runtime_marker, so the rename cannot cross a filesystem, and both clean up the temp on a failed rename. restore_in_memoryon a failedconfig.saveis a subtle one to have found — without it the caller's nextconfig.savewould re-persist the activation that was just refused.- The
--restart-servicesapproval 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-signaturesCI gate fails and will keep failing."Commit signatures + sign-offpasses.
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.
Round-2 fixes: all three blocking findings addressedEach 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.
1.
|
5eb9e80 to
ee18933
Compare
nowycondro
left a comment
There was a problem hiding this comment.
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
ee18933passes, includingCommit 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-hostedforee18933(run35891325633) 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 --yessuccess path. … The success path is covered only by the manual test indocs/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_idback 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
install sdkreports 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); whenpaths.data_dir != activation_paths.data_dirthe activation againstpaths— the user's real data root, where their running servers live — is discarded withlet _ =(main.rs:11245), and the reportedservicescomes from the activation againstactivation_paths(11253), whoseservices_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, seesservices_on_previous_runtime: 0while their server has in fact been left behind. Two caveats that keep this off the blocking list: on the normal pip pathmanaged_runtime_data_root(crates/rocm-core/src/runtime.rs:518) strips back to the caller'sdata_dir, so the branch does not fire; and the comment atmain.rs:11243-11245says the discard is deliberate. (One correction to that condition:with_managed_rootnever touchesconfig_dir, so thepaths.config_dir != activation_paths.config_dirhalf of theifcan never be true.) Worth a decision rather than a silent 0 — capturing the11245result and using itsserviceswhen the branch fires is a two-line change.ActivationSnapshot::restorestill writes the marker non-atomically —main.rs:8699uses a barefs::writewhile the forward path (write_active_runtime_marker) andRocmCliConfig::saveboth use temp+rename. This runs when something has already failed; a truncated marker there leaves config and marker permanently disagreeing. (Round-1 carry-over.)render_runtime_service_reconciliationbypasses the shared component —main.rs:8343hand-rolls thekey: valueshape AGENTS.md §6 asks be taken fromapps/rocm/src/cli_report.rs::ActionReport, which this same file already uses five times. (Round-1 carry-over.)- Dead parameter —
pin_service_record_to_runtime(…, runtime_key: Option<&str>)(main.rs:8538); both call sites passSome(..), so theNonearm at8548is unreachable. Take&str. (Round-1 carry-over.) - Redundant
config.saveon the update path —main.rs:18440, immediately afteractivate_runtimeat18439with no mutation between;activate_runtimealready persisted viasave_activated_config(main.rs:8934). A failure there reports an error after a fully successful activation. (Round-1 carry-over.) - README recovery text omits the step the error names first —
README.md:416gives onlyrocm services restart <service-id> --yes; the bail message (main.rs:8615-8616) says to checkrocm services logs <id>first. (Round-1 carry-over.) - Rollback test still weaker than its activate twin —
main.rs:32258-32263asserts the entry line but notservices_on_previous_runtime: 1. Dropping the count line from the rollback render would leave it green. (Round-1 carry-over.) --yeswithout--restart-servicesstill parses and does nothing —main.rs:715-718and728-731carry norequires. (Round-1 carry-over / listed residual.)- The new
install sdktest cannot catch the regression it exists to prevent —render_sdk_install_success_includes_service_reconciliation_section(main.rs:34194) renders aRuntimeServiceReconciliation::default()built by the fixture atmain.rs:34174. It would pass unchanged iffinalize_successful_sdk_installhardcodedservices: RuntimeServiceReconciliation::default()— which is the shape of non-blocking #1. the model endpoint responds after the restartdoes not probe an endpoint —runtime_lifecycle_steps.rs:570-581runsrocm services listand 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 againstworld.endpoint.… names the service under services_restartedasserts the count, not the name —steps.rs:555-558checksservices_restarted: 1only, 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. (Theexits 0half is real —ok_outputassertsrc == 0.)rewrite_engine_state_runtimeis silently best-effort —steps.rs:678-700returns on any read/parse/write failure. If a state file cannot be rewritten,refresh_from_engine_staterestores the real runtime key, the service classifies asMatches, and the scenario asserts the wrong thing with no diagnostic pointing at the setup.- New README prose does not parse —
README.md:387: "Both commands count the servers that leaves behind and name each one". - README does not mention that
install sdknow prints the same summary —README.md:402-403names onlyrocm update --apply --activate, whileROLLBACK_RECOVERY_HINT's doc comment (main.rs:18456-18460) names all three call sites andrender_sdk_install_success(main.rs:11215) now emits it. --helpis inaccurate for this flag pair —main.rs:717and730document--yesas "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 anddocs/testing.mdboth say "requires--yesand never prompts". AGENTS.md §5 lists--helpalongside README and docs as a surface to keep in sync.- Asymmetric rendering is undocumented —
services_on_previous_runtime:always prints (main.rs:8345-8349) whileservices_with_unrecorded_runtime:prints only when non-empty (main.rs:8359);README.md:397-399,docs/testing.md:331-333anddocs/manual-testing.md:264-265describe the two symmetrically, so a tester cannot tell "absence is normal" from "counter missing".
Tradeoffs
env_idsemantics are now a one-way door. Declaringenv_idnot 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) andrefresh_from_engine_stateadopts it, so the field cannot carry user intent. The cost is blocking #3. The alternative — arequested_env_idset 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 --activatereports stale services but cannot act on them —--restart-servicesexists only onActivate/Rollback;append_update_activate_summary(main.rs:18470) names the servers and the user needs a second command. Confirmedbail_on_failed_service_restartsis correctly not called on that path.
Positive signals
- Dropping the
env_idarm rather than widening it was the right of the two options, and the follow-through was complete across code, README,docs/testing.mdanddocs/manual-testing.mdin a single commit. runtime_activation_reports_service_with_no_recorded_runtime_as_unknown(main.rs:32104) is a genuine behavioural test: it pins that anUnknownservice is reported, is not counted inservices_on_previous_runtime, and is not touched byrestart_stale_runtime_services— three properties that fail independently.ee18933is a clean clippy fix: extractingrewrite_engine_state_runtime(steps.rs:674-700) flattens four nestedif lets intolet … elseguards with no behaviour change, and the commit message says exactly that.refresh_from_engine_statepreferringrequested_runtime_idover 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 onlyruntime_id.- The
ActivationSnapshot/MarkerSnapshotdesign and therestore_in_memorysplit remain correct on a second adversarial pass.
Missing elements
- No test — unit or e2e — covers
install sdkorupdate --apply --activatecarrying 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 --activatereconciliation 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-signaturesCI gate fails and will keep failing until someone with a registered signing key re-signs the range." — no longer true.Commit signatures + sign-offpasses onee18933and the head commit reportsverification.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 isee18933. - "No automated coverage of the
--restart-services --yessuccess path." — contradicted byd97a1c1; see blocking #2. - "The two new scenarios
runtime-lifecycle-08… andruntime-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
35891325633is 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 onf2984db; 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-idlaunch; 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.
GPU lanes reported — one real finding in my own scenario, now fixedThe self-hosted lanes have now run It works. On MI300X and MI350P the scenario ran and passed every step, including 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
Fixed in 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
Still needs a person
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 🤖 Fixes applied by agent-hub — |
05e85a6 to
1b88d17
Compare
1b88d17 to
e4a9989
Compare
nowycondro
left a comment
There was a problem hiding this comment.
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.rswaiting 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_idis 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.
Staleis the right answer. - ambiguity — two installs share the family id.
Staleis 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::restorewrites the marker with a barefs::write(apps/rocm/src/main.rs:9085) while every other write this PR touches goes through temp-plus-rename. Safe today —restoreonly 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 atmain.rs:33766already callsrestoreafter both forward writes succeeded, which is the shape that would make it bite. Factor the temp-plus-rename into a sharedwrite_atomic(path, bytes)and use it in all three places.- A failed
fs::writeto the temp file strands it.crates/rocm-core/src/lib.rs:6180andapps/rocm/src/main.rs:12202both clean up on a failedrenameviainspect_err, but thefs::writeabove 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 folderrocm runtimesreads. Add the sameinspect_errto the write. requested_runtime_idis not in the engine-protocol contract. The CLI now depends on it (crates/rocm-core/src/lib.rs:7753) butcrates/rocm-engine-protocoldoes not define it; vLLM writes it, lemonade does not (harmless today only becauseengine_manages_own_runtimeexempts 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_statechange is missing from the description. It is a behaviour change in a shared crate that alters whatrecord.runtime_idmeans 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 onlyRocmCliConfig::saveforrocm-core. - No integration-level test for the new preference. The record planted at
apps/rocm/src/main.rs:33192writes onlyruntime_id, so it exercises the pre-existing fallback. The prefer-requested_runtime_idpath is covered only by therocm-coreunit test. A variant planting a realistic vLLM state file —runtime_idfamily form andrequested_runtime_idexact key — and assertingclassify_service_runtime_stateresolves it would close the loop between the two halves of this change. - The double-fault arm of
restore_after_failed_activationis untested.main.rs:9135-9141has two arms with materially different messages; only the success arm is exercised. Makerestorefail (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_serviceshas 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 fromrocm services list. Worth a line in the docs at minimum. --yeswithout--restart-servicesis 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 beforeactivate_runtimeon 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.comrun 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::AbsentvsUnreadablesplit is the detail most implementations of this get wrong: a read fault never licenses the restore to delete bytes it never held, andmain.rs:32973tests exactly that. restore_service_record_pinre-reads the record from disk rather than writing back a stale in-memory copy, so it cannot clobber the pidrestart_internal_managed_servicejust wrote — and it restoresenv_idas well asruntime_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-servicesrespawning correctly-placed servers in the common single-install case. - The docs agree with each other and with the code on the
env_idpoint across all three surfaces — it is only the PR body that drifted. - Test isolation is clean:
test_pathscomposes pid and millisecond timestamp, noset_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
Review: EAI-7404 — transactional runtime activation (round 5)Reviewed at head Assessment: needs work — two blocking findings, both small. All three blocking findings from my last review ( Verification gap: no Rust toolchain in this sandbox — Round-4 findings — status
Blocking1.
|
6010752 to
c4ecf6b
Compare
Round-5: both blocking findings fixed, plus four of the seven non-blockingBoth 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 —
|
Round-5 follow-up: fixes verified at head, CI triaged, two description claims correctedThis 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 Round-5 findings — independently confirmed present at
|
| # | 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…-15set 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 uploadedreport.json/junit.xmlare 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:
- "
runtime-lifecycle-10ran 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:vllmresolves 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. - "The first five are GPU-free" — stale since
-14and-15were added; there are now seven, and it read as if the list's last three were GPU-gated when only-10is.
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 --activateabort 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
1d8a2ad to
ff73880
Compare
|
🔴 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. SummaryRemediation round for runtime-activation service reconciliation: a new closing assertion on the 🚫 Blocking (must fix before merge)
Trace, with both orders carried to the assertion:
So the step discriminates only when two or more installs share the family. The scenario does not establish that: Underneath that is the same comment-versus-assertion problem this change has produced before. The step comment and the feature comment both rest on A second, more basic gap points the same way: Two fixes, either of which discharges it:
If neither is taken, the comments at Non-blocking
|
siloteemu
left a comment
There was a problem hiding this comment.
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.
ff73880 to
14fa393
Compare
|
Blocking finding confirmed and fixed at Blocking — Reverted, suite clean again. Your suggested fix used the helper I'd added for Restore atomicity — also real. Swapping
Not taken, with reasons:
786 unit tests, 415 in |
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
left a comment
There was a problem hiding this comment.
🔴 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 onlyservices_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 torestartedand drains out ofstaleregardless of which runtime the child loaded. And by the module's own account the transposed launch comes back up silently — the record still carries itsenv_id, so--runtime-idis 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>
14fa393 to
6bd837b
Compare
|
Blocking finding confirmed and fixed at Blocking — Fixed with your second suggestion — a closing step that re-activates the same runtime and asserts the service is reported neither under One honest limitation: Temp-cleanup arms unexercised — real. Added "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.
786 unit tests, 416 in |
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
left a comment
There was a problem hiding this comment.
🔴 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_runtimesetsruntime_idto the exact key and clearsenv_id.builtin_engine_serve_http_argsemits--runtime-idonly whenenv_idisNone, so the child gets the exact key; vLLM'swrite_running_staterecordsrequested_runtime_id: <exact key>;refresh_from_engine_statenow prefersrequested_runtime_id;classify_service_runtime_statetakes 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-idis omitted. vLLM then writesrequested_runtime_id: nullandruntime_id: <family form>.refresh_from_engine_statefalls back to the family form — andclassify_service_runtime_state's family-resolution arm (added in this same change,apps/rocm/src/runtime_services.rs:142-147) resolves it throughruntime_manifest_for_selector, which returnsSome(first)whenever exactly one installed manifest carries that family id andNoneonly 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:
- Cheap and lane-independent, preferred: add a fast unit test over argv construction. After
pin_service_record_to_runtime, assertbuiltin_engine_serve_http_argsfor that record contains--runtime-id <new key>; assert that the same record with itsenv_idleft in place omits it. That is the assertion that actually separates the two orders, it needs no GPU, and it also closes the untestedenv_idclaim noted below.builtin_engine_serve_http_args(apps/rocm/src/main.rs:20866-20905) currently has no direct test at all. - 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— theenv_id-clearing comment calls the argv consequence load-bearing, but the only assertion is that the record field isNone(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.
Summary
rocm runtimes activate(androllback, and the--activatetail ofrocm 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 thenwrite_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 dropprevious_runtime_key, which is the only state that makes a retry possible.RocmCliConfig::savecompounded it: a barefs::writetruncates the liveconfig.jsonbefore writing, andloadhard-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_runtimereads the live managed-service records and classifies each against the runtime being activated. The report now prints a real count — includingservices_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 underservices_with_unrecorded_runtime, since what they loaded cannot be read back.lemonade-embeddable-<version>, which is not a ROCm runtime key, so comparing would mark every lemonade server permanently stale). Anenv_idon a record is not a pin and does not exempt a server — see reviewer focus (a).--restart-servicesonactivate/rollbackmoves the stale servers onto the newly active runtime. It requires--yesand 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.ActivationSnapshotcaptures 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 nextconfig.savewould 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::savenow 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.docs/testing.mdanddocs/manual-testing.mddescribe 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-servicesstops and respawns live servers.Reviewer focus
(a) The record is rewritten before the restart, on purpose — and
env_idis cleared in the same write.restart_service_onto_runtimewritesrecord.runtime_id = <new key>and only then callsrestart_internal_managed_service. The order is load-bearing:restart_internal_managed_servicerebuilds the child's argv from the record on disk and passesrecord.runtime_idverbatim, 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 (includinggpu_required) is validated exactly as for a freshrocm 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_idon a service record is not a user pin.engines/vllmwrites it unconditionally on every launch andrefresh_from_engine_stateadopts whatever the engine reports, so a record acquires anenv_idwhether or not the user ever asked for one. Andbuiltin_engine_serve_http_argsomits--runtime-idfrom the child's argv wheneverenv_idis set (if env_id.is_none() { ... }), after whichenv_root_for_servicehands 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_runtimenow clears both and returns aServiceRuntimePincapturing both, and the failure path restores both. Nothing is lost by droppingenv_id: the engine writes its own back into its state on the next launch.(b) A recorded
runtime_idmay be a family id, not an exact versioned key. Every launch path records an exact key, butrefresh_from_engine_stateafterwards adopts whatever the engine reports, and an engine may report the manifest'sruntime_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-serviceswould stop and respawn it for nothing.classify_service_runtime_statenow resolves the recorded value throughruntime_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 returnNone, 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_servicestops 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 namesrocm services restart <id> --yesas the way back; the docs and the manual test's observable check match.(d) New failure mode before the writes.
reconcile_services_for_runtimeruns beforeconfig.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 failrocm runtimes activate,rocm runtimes rollback, the finalization step ofrocm install sdk, androcm 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 thatload_managed_servicesrefreshes 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 (unlikebuild_service_prune_plan, whose age gate does).Test plan
Run locally at the current head (
rust-toolchain.tomlpins 1.96.0). The branch is one squashed commit on top oforigin/main@8788394, zero commits behind.cargo fmt --all -- --checkcargo clippy -p rocm -p rocm-core -p e2e-cucumber --all-targets -- -D warningscargo test -p rocm --binscargo test -p e2e-cucumber --test feature_namingcargo test -p rocm-corecomfyui::tests::status_reports_stopped_when_saved_comfyui_pid_is_gonefails 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-coretestsaving_the_config_replaces_the_file_rather_than_rewriting_it_in_placewas checked by mutation: replacingsave's temp-file-and-rename with a plainfs::writemakes 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'swarnback toinfofails two of its four unit tests — the severity inversion they exist to catch. Deleting theactive_runtime_keyrestore fromActivationSnapshot::restore_in_memoryfailsruntime-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
capturereadUnreadable, so theMarkerSnapshot::Contentsbyte-restore arm is never entered. Afterwards the marker names nothing at all rather than naming the wrong runtime.Second-reviewer findings (
pr-review-watcher, atff738803). One blocking, now fixed, plus two of its five non-blocking:runtime-lifecycle-11asserted 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 belowconfig.savewould 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.write_file_atomicallyfor a barefs::writein theContentsarm passed every test — the arm that runs after something has already failed.restoring_a_marker_replaces_the_file_rather_than_rewriting_it_in_placenow 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 indocs/architecture.md— it ownsServiceRuntimeState,RuntimeServiceEntry,FailedServiceRestart,RuntimeServiceReconciliationandServiceRuntimePin.RuntimesCommandandfn runtimes()stay inmain.rs, which that doc names as the exception for a subsystem with its own clap subcommand.ActivationSnapshot/MarkerSnapshotstay besideactivate_runtimefor the same reason.main.rsgrows by ~327 production lines rather than ~793, and the module map indocs/architecture.mdlists 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-servicesis 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-servicesis refused without--yes, the refusal names therollbackform rather than theactivateone, 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 underservices_with_unrecorded_runtime:rather than folded into the stale count.runtime-lifecycle-10— the--restart-services --yessuccess 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 testslane on every PR.runtime-lifecycle-10ran and passed every step on MI300X at this head (c4ecf6b, the lane reporting0 unexpected failure(s)), includingservices_restartedand 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:vllmtag 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_namingpasses (ids unique, indexes sequential, every scenario id feature-qualified).expectations.tomlhas 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> --yesstill replays the oldruntime_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 bareservices restartshould 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 namesrocm runtimes activate <key> --restart-services --yes: that is currently the only command that moves a running server onto the active runtime.rocm install sdkandrocm 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.--restart-services --yessuccess path is covered only on the GPU lanes.restart_internal_managed_servicestops 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_idcannot distinguish user intent from engine-reported state. Both engines writeenv_idon every launch andrefresh_from_engine_stateadopts it, so an explicit--env-idpin is indistinguishable from an engine default and is not honoured across a runtime switch —--restart-servicesmoves such a server and clears itsenv_id. The symmetric fix is for engines to writerequested_env_idthe way they already writerequested_runtime_id. That changes a contract surface across both engines, so it is a follow-up rather than folded in here.render_runtime_service_reconciliationdoes not go throughcli_report.rs::ActionReport.ActionReportmodels flatkey: valuepairs; 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. ExtendingActionReportis its own change.RocmCliConfig::savestill has its own atomic write.write_active_runtime_markernow goes throughtherock::write_file_atomically, which on Windows usesReplaceFileWas 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 blockfs::renameat all, because Rust opens withFILE_SHARE_READ | WRITE | DELETEandMoveFileExWreplaces a destination whose open handles all share delete. It is refused when another process holds the destination withoutFILE_SHARE_DELETE— the antivirus/indexer case — which raises a sharing violation.fs::rename'sSetFileInformationByHandlefallback does not rescue that: it is gated onACCESS_DENIEDand exists to get past a readonly attribute, not an open handle.ReplaceFileWsurvives by renaming the destination aside first.ReplaceFileWalso preserves the destination's ACLs, which a rename does not.rocm-corecannot depend onapps/rocm, sosavekeeps its own copy and the doc comment now says so instead of claiming parity. Closing it means moving the helper down intorocm-core.crates/rocm-core/Cargo.tomlalready carrieswindows-sysunder[target.'cfg(target_os = "windows")'.dependencies]andWin32_Storage_FileSystemis the only missing feature — and sinceReplaceFileWis the solewindows_sys::reference in all ofapps/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 — andapps/rocmdcarries a second, already-drifted copy that the same follow-up should fold in. Its own change, reviewed against the Windows lane.restore_after_failed_activationhas 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 failssave_activated_configfirst and never reaches the restore, and a marker path broken before the run makesActivationSnapshot::capturereadUnreadable, 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 aSigned-off-bytrailer. 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