Repository navigation
fix(dash-daemon): keep managed services when the registry cannot be read, and retire the old Lemonade instance when its host or port changes - #927
Conversation
… registry changes Drive the real run_loop through the managed-service registry and pin what the broadcast stream and the snapshots show when a service's record is removed, when the registry directory is gone, and when the directory exists but cannot be listed (a regular file in its place, and on unix a mode-000 directory). The first two are the intended behaviour: an empty or absent registry means no services, so the known one is announced gone and leaves the snapshots. The last two document a defect, not a design: a failed read is indistinguishable from an empty registry, so the loop retires every managed instance on a transient glitch. They pass on today's code; the follow-up change flips them. Signed-off-by: Jussi Elo <jussi.elo@amd.com>
The daemon's discovery loop read the managed-service registry through a best-effort loader that turned an unreadable directory, or a record it could not read, into zero services, and then reconciled against that empty set. A transient read error therefore announced every managed instance gone and dropped it from the snapshots, the same storm Docker discovery already avoids by skipping the reconcile when its listing errors. read_service_records now returns a Result that tells a registry that is confirmed empty (no directory, or no records) from one that could not be read (the directory cannot be listed, or a record file cannot be read). On a failed read the loop reconciles nothing for that tick: the known services stay known, no gone or repeat discovered event is sent, and the instances keep the data they hold and keep being scraped. The header carries a warning for as long as the registry cannot be read, and the log carries one warning for the first failed pass of a streak and one line when reads succeed again. A record that reads but is not valid JSON is skipped and reported once, so it no longer hides the other services, and an unchanged set of corrupt records is not reported again. load_service_records keeps its best-effort contract for the one-shot readers in the dashboard. The two commit-one tests that pinned the old retirement now assert the new behaviour, with recovery, recovery into an empty registry, malformed-record and once-per-streak-logging tests beside them, and scenario dash-42 covers the on-screen behaviour. Signed-off-by: Jussi Elo <jussi.elo@amd.com>
…d-failure origin/main added scenario dash-42 (instance detail recent throughput) while this branch added one under the same number. Keep main's dash-42 and renumber the registry-read scenario to dash-43; nothing else conflicted. Signed-off-by: Jussi Elo <jussi.elo@amd.com>
There was a problem hiding this comment.
🟡 Changes recommended
The compatibility loader can hide readable services when one record fails, and the end-to-end test has a registry-replacement race that can fail even with a correct implementation.
2 open issues
What changed in this PR
This PR distinguishes an unreadable managed-service registry from an empty one, allowing the dashboard daemon to retain known services during read failures.
Changes:
- Adds explicit registry read results and skips reconciliation when reads fail.
- Adds persistent header warnings and logs failure/recovery transitions.
- Adds reader, daemon, logging, and end-to-end regression coverage, plus architecture documentation.
Verification was limited to the supplied diffs; the repository snapshot was unavailable, and tests were not run.
| File | Description |
|---|---|
tests/e2e-cucumber/tests/e2e/dash_steps.rs |
Adds registry-failure injection and screen assertions. |
tests/e2e-cucumber/features/dash.feature |
Adds scenario dash-43 for retaining managed models during read failures. |
docs/architecture.md |
Documents registry failure handling. |
crates/rocm-dash-daemon/tests/registry_read_logging.rs |
Checks failure and recovery logging across outages. |
crates/rocm-dash-daemon/tests/discovery_reconcile.rs |
Covers retention, retirement, recovery, and malformed records. |
crates/rocm-dash-daemon/src/runner.rs |
Preserves known services on read errors and tracks registry health. |
crates/rocm-dash-daemon/src/registry.rs |
Adds fallible registry reads and classification tests. |
Review effort: Balanced
Add one test per lifecycle row of the Lemonade source: never answered, first answer, the same endpoint again, a host change, a port change, the endpoint going quiet, coming back elsewhere, the same answer twice in one tick, and moving away and back. Each row asserts the announcements, the instances a snapshot would carry, the per-id telemetry and the (id, port) pairs the vLLM scrape would poll. The vLLM scrape target selection moves out of run_loop into vllm_scrape_targets, unchanged, so the tests can ask which endpoints would be polled. The host-change, port-change and move-away-and-back rows pin what the code does today: the superseded instance is neither announced gone nor removed and is still a scrape target. A following change flips them. Signed-off-by: Jussi Elo <jussi.elo@amd.com>
…ort changes The Lemonade id encodes host and port, so moving the endpoint yields a new id. The old id's telemetry was purged and the new id announced, but the old instance was never retired: it stayed in the state and in every snapshot, no InstanceGone was sent for it, and the vLLM scrape kept polling its port, because the scrape filter only skips the current Lemonade id. On an id change the old id is now retired first (InstanceGone, removed from the state, telemetry purged) and then the new id is upserted and announced, so a snapshot carries exactly one Lemonade instance and nothing polls the endpoint it left. The rows of the Lemonade state matrix that pinned the stale instance, and the pin in the lifecycle test, now assert the retired behaviour. Signed-off-by: Jussi Elo <jussi.elo@amd.com>
…registry loader The compatibility loader behind the dashboard's launcher, exit prompt and services overlay was a thin wrapper over the strict reader, so a single record file that could not be read (a permission or I/O error) made it discard every readable service. The previous best-effort loader skipped only the failing record. Both readers now share one scan that differs only in what it does with a record file that cannot be read: the strict reader the daemon's discovery loop uses still fails the pass, the best-effort loader skips that file and keeps the rest. The record reader is injected into the scan, so the two behaviours are tested on every platform with a record that the reader refuses, alongside the Unix mode-000 test. A registry with one corrupt record and two readable ones is covered for both readers. Signed-off-by: Jussi Elo <jussi.elo@amd.com>
… an empty registry The step made the registry unreadable by deleting its directory and then writing a regular file in its place. The dashboard is a separate process that polls the registry on its own schedule, so a poll between the two calls could legitimately read an absent registry (confirmed empty), retire the model, and leave the later failed reads preserving an empty known set: the scenario could fail against a correct daemon. The step now adds one record that cannot be opened, a symbolic link to itself, in a single atomic step that leaves the model's own record and the directory in place. It fails with a loop error rather than not-found, so the strict reader fails the pass, and it works when the runner is privileged. The scenario stays Linux-only; the non-Unix build keeps a stub that panics with that reason. The in-process daemon tests keep the delete-then-write swap, which is safe there because the runner shares their current-thread runtime and cannot run between the two calls; the helper now says so. Signed-off-by: Jussi Elo <jussi.elo@amd.com>
Signed-off-by: Jussi Elo <jussi.elo@amd.com>
juhovainio
left a comment
There was a problem hiding this comment.
Approving. I read through the diff (no local build or test run) and found nothing blocking; CI is green. Both fixes do what the tickets ask: a failed registry read is now distinct from an empty registry and no longer wipes managed services, and the old Lemonade instance is retired when its host or port changes. The tests assert both the announcements and the resulting snapshot, which is what I wanted to see.
Three non-blocking points, two of them as inline comments:
- A single permanently unreadable record fails every pass, so discovery would stay frozen. I understand this is a deliberate, documented trade-off; worth confirming it is the one you want.
- The Lemonade retirement is user-visible but only covered by unit tests. Could the PR text say which lane covers it (or add a scenario), and confirm the Serving/Instances views and the Lemonade wording in docs/architecture.md need no change?
- docs/testing.md and docs/manual-testing.md are not touched; please check whether the new scenario dash-43 needs an entry there.
| Ok(bytes) => bytes, | ||
| Err(e) if e.kind() == ErrorKind::NotFound => continue, | ||
| Err(_) if on_unreadable == UnreadableRecord::Skip => continue, | ||
| Err(source) => return Err(RegistryReadError { path, source }), |
There was a problem hiding this comment.
One permanently unreadable record (a root-owned file, a symlink loop) now fails every pass, so discovery freezes: new services never appear and gone ones never retire. Is that the intended trade-off? Consider still adding newly readable records, or bounding how long the freeze can last.
There was a problem hiding this comment.
It was a deliberate trade-off, but the freeze was wider than it needed to be, so it is narrower now (913a53e). A pass that cannot open one record still retires nothing, which is the rule this PR exists for, but the scan now keeps reading past the bad record and the error carries the records that did read. The run loop adds and refreshes those services and announces new ones once, so a new rocm serve appears on the next discovery tick even beside a stale root-owned record. What stays frozen is only retirement: a service that stopped, or whose record was removed, keeps its row and its last live fields until the registry reads in full, and the header warning names the unreadable path the whole time. I did not bound the freeze with a deadline, because retiring on a timer would bring back the wrongly vanishing row; the module docs of registry.rs and the PR body state this. Each row of the state matrix asserts the announcements and the snapshot together (new record beside an unreadable one, removed record then fixed, the known service's own record becoming unreadable then readable), each shown red by a targeted mutation, and scenario dash-44 (ea75b80) covers the new model appearing on screen.
| let is_new_id = current.as_deref() != Some(svc.container_id.as_str()); | ||
| if is_new_id && let Some(old_id) = current.as_deref() { | ||
| self.telemetry.purge(old_id); | ||
| if is_new_id && let Some(old_id) = current.take() { |
There was a problem hiding this comment.
Retiring the old id here is user-visible (the old row disappears) but only unit-tested. Please say in the PR text which lane covers it, or add a scenario, and confirm the Serving/Instances views and docs/architecture.md need no change.
There was a problem hiding this comment.
The lane is cargo test -p rocm-dash-daemon in the ordinary Rust test job: the host-change, port-change, move-away-and-back, quiet and lifecycle rows are unit and integration tests there. I did not add a scenario because none can reach it: the shipped dashboard sets enable_lemonade to false and fixes the Lemonade host and port in runner_options in apps/rocm/src/dash.rs, so no end-to-end scenario can start a Lemonade endpoint and move it (the integration test cannot move one mid-session either). The row is user-visible only where Lemonade discovery is enabled, which today is a daemon embedder and the Lemonade serving-discovery work in #631. I also checked the views: the Instances and Serving views render whatever the snapshot lists, and the Lemonade wording in crates/rocm-dash-tui is about the engine picker and install labels, so nothing there changes. The Lemonade wording in docs/architecture.md is unaffected too, since the rocm-dash-daemon entry does not describe Lemonade discovery. The PR body now says all of this and no longer claims the shipped dashboard shows the change.
…ecord cannot be opened A record file the daemon cannot open (a root-owned file left by a privileged serve, a symlink loop) failed every discovery pass, so the managed registry stopped following new services for as long as the file stayed. The rule that a failed read never retires anything is unchanged, but the failure now carries the records that did read, and the loop upserts and announces those services while retiring none. read_service_records keeps scanning past an unreadable record and returns the first failure with the partial read. The best-effort loader no longer needs its own mode: it uses the partial read of a failed scan. In the run loop, a failed pass upserts the services it saw and adds them to the known set, so a later complete read can retire them; the services it did not see stay known and keep their non-vLLM marks. A service that stopped, or whose record was removed, keeps its row until the registry reads in full, and the header warning names the unreadable path throughout; the module docs state this trade-off. State matrix, each row asserting the announcements and the snapshot together: one record unreadable with a new valid record added, with a known record removed (then fixed, which retires it and clears the warning), and with the known service's own record becoming unreadable (then readable again, announcing nothing). Shown red by removing the upsert on a failed pass, by retiring on a failed pass, by not adding the seen services to the known set, by not refreshing the non-vLLM marks and by dropping the partial read. Signed-off-by: Jussi Elo <jussi.elo@amd.com>
…e registry record With the registry made unreadable by one record that cannot be opened, a second model whose record can be read is written into the registry, and the scenario waits for it to appear on screen while the first model stays. The second record points at a disposable process, because the harness teardown runs the real stop command for every record but the first model's, and a record naming the harness itself would signal the harness. Shown red by removing the daemon's upsert on a failed pass: the new model never appears. dash-43 is unchanged; the rest of the matrix (a removed record is not retired until the registry reads in full, quiet recovery) is asserted by the daemon's own tests. Signed-off-by: Jussi Elo <jussi.elo@amd.com>
|
Answers to the three points of the approving review, for the push at ea75b80.
|

Closes ROCMAI-883 and ROCMAI-866.
Summary
Two defects in how the dashboard daemon keeps its list of instances in step with what is really running, fixed together because they sit in the same discovery pass of
crates/rocm-dash-daemon/src/runner.rs. The first (ticket in the first section below) is that a managed-service registry that cannot be read no longer makes every served model vanish; the second is that when the Lemonade endpoint moves to a new host or port, the daemon no longer keeps the endpoint it left. Neither change alters what a healthy, unchanged system looks like.ROCMAI-883: keep managed services when the registry cannot be read
The daemon follows the managed-service registry (the records
rocm servewrites under the services directory) on every discovery tick. When that directory could not be read, or a record file could not be read, the loader quietly returned zero services and the run loop reconciled against that empty set, so a single transient read error announced every managed instance gone and dropped it from the snapshots. A dashboard user would see every served model vanish for a tick (or until the next successful read) with nothing to explain why. Docker discovery already avoids this: when its listing errors, the reconcile step is skipped and the known set is left alone. The registry had no way to say "I could not read" as opposed to "there is nothing here".The fix makes that distinction in the registry reader and honours it in the loop.
read_service_recordsreturns aResult: a registry that reads in full (including one with no directory, or no records) is confirmed, and one that cannot be read in full is an error. A failed pass retires nothing, exactly as a Docker error does: the known services stay known, no gone or repeat discovered event is sent, the instances keep the data they hold and keep being scraped, and the header warns until a read succeeds. A confirmed-empty registry still retires the services it lacks. One difference from Docker, whose listing is all or nothing: a registry can fail on a single record while the others read, so the error carries the records that did read, and the loop still adds and refreshes those services (announced once, as ever). Retirement is the only thing a failed pass withholds.registry::read_service_records(new)Result<RegistryRead, RegistryReadError>.RegistryReadholds the well-formed records (newest first) and the paths of records that read but are not valid JSON. The scan keeps going past a record it cannot open and returns the first failure together with what it did read.registry::RegistryReadError(new)partial: theRegistryReadof everything the pass could read (empty when the directory itself could not be listed).registry::load_service_recordsapps/rocm/src/dash.rs(launcher, exit prompt, services overlay), which have nothing to retire.runner::run_loopmanaged blockread_service_records. OnOkit reconciles and refreshes the non-vLLM set. OnErrit upserts and announces the services the partial read saw, retires nothing, and sets a header warning.runner::Reconcile::upsert_set(new)reconcile_set, which now calls it and then retires.runner::DiscoveryState::keep_through_partial_registry(new)runner::RegistryHealth(new)How each case is classified: a directory that does not exist is confirmed empty (it is created the first time something is served). A directory that exists but cannot be listed, or a regular file where it should be, is a read failure with nothing partial to offer. A record file that vanishes between the listing and the read was removed on purpose and is skipped. Any other failure to read a record (a permission or I/O error, a symlink loop) is a read failure for the pass, because that record's service cannot be told apart from one that is gone. A record that reads but does not parse is malformed: it is skipped and reported, and it no longer hides the other services. That last choice is safe because the rocm-cli writer replaces a record atomically (
write_atomicincrates/rocm-core/src/service_record.rs), so a corrupt file is corrupt for good rather than half-written.What a permanently unreadable record costs, since a review asked whether that is the intended trade-off: while one record stays unreadable (for example a root-owned file left by a privileged serve) the registry never reads in full, so retirement stays frozen for as long as it lasts, with no timeout. A service that has stopped, or whose record was removed, keeps its row and its last live fields until the file is fixed or removed, and the header warning names the unreadable path throughout. New services still appear on the next discovery tick, and a service whose record reads is refreshed. The freeze is deliberately not bounded by a deadline: retiring a service because a file stayed unreadable for long enough would bring back the wrongly vanishing row this change exists to prevent, so the cost is made visible (the warning and the log line name the file) rather than guessed away. The module docs of
registry.rsstate this.ROCMAI-866: retire the old Lemonade instance when its host or port changes
The Lemonade instance id encodes host and port, so a moved endpoint is a new id. The daemon purged the old id's telemetry and announced the new id, but never retired the old instance: it stayed in the daemon state and in every snapshot afterwards, no gone event was sent for it, and the vLLM Prometheus scrape kept polling its port as if it were a vLLM server, because the scrape filter only skips the current Lemonade id. The result was two Lemonade rows (one dead) and a scrape warning on every instance cadence for as long as the daemon ran. If the endpoint later went quiet, only the current id was retired, so the dead one was never cleaned up.
Now, when the probe reports a different id,
reconcile_lemonaderetires the old one first (gone event, removed from the state, telemetry purged) through the same retire step an endpoint that goes quiet already used, and then upserts and announces the new one. A snapshot carries exactly one Lemonade instance, and the old endpoint is no longer in the set the vLLM scrape polls. An in-place refresh of the same id is unchanged and still carries live fields over. The vLLM scrape target selection moves out ofrun_loopinto a small function,vllm_scrape_targets, with the filter unchanged, so the tests can ask which endpoints would be polled.Verification
Run on the combined result, including the review-fix commits:
cargo fmt --all --check;cargo clippy --locked -p rocm-dash-daemon --all-targets -- -D warnings;cargo clippy --locked -p e2e-cucumber --test e2e -- -D warnings;cargo test -p rocm-dash-daemon --no-fail-fast(80 tests on main: 56 unit, 4 + 9 + 11 integration; 117 after: 82 unit, 14 + 9 + 1 + 11 integration, none lost); thefeature_namingtest;cargo xtask e2e -- -n "dash-4[34]"(both scenarios pass);cargo xtask manifest --check;cargo xtask check-crate-edges; lychee in offline mode ondocs/architecture.md; the Windows cross-checkcargo clippy -p rocm-dash-daemon -p e2e-cucumber --all-targets --target x86_64-pc-windows-msvc -- -D warningswith stub C compilers (clean); and the leak scan over the lines this branch adds relative to main (no hits). On the merged tree I also broke the Lemonade retire step by hand (purge only, the old behaviour) and the host-change, port-change, move-away-and-back and lifecycle tests went red, then restored it. The per-change mutation checks described below were done on each branch before the two were combined.Not run: the Windows tests themselves (only the cross-check compiles for that target), so CI's Windows job is the check; the full workspace test gate, coverage and the smoke script, which CI carries. The mode-000 tests, the symbolic-link tests and the scenarios' link steps are
cfg(unix); the portable file-in-place-of-directory test and the injected-reader tests are the ones that run on the Windows job. The Lemonade change is not platform-gated.Registry read tests. State matrix for the new health state, one test per row: first failure, repeated failure (quiet), recovery (once), a new streak (reported again), empty and populated reads (quiet), malformed set new, unchanged, grown, cleared, and malformed set unchanged across a read failure; these are the
registry_health_*unit tests inrunner.rs. The loop-level behaviour is driven through the realrun_loopincrates/rocm-dash-daemon/tests/discovery_reconcile.rs, asserting the announcements and the snapshots together (and the header warning): confirmed-empty registry retires, absent directory retires, an unlistable directory (a file in its place; portable) keeps the instance, an unreadable directory (mode 000, Unix only and skipped when running as root) keeps it, recovery to the same service announces nothing and clears the warning, recovery into an empty registry retires it, and a malformed record beside a valid one hides nothing while a new valid record is still discovered.crates/rocm-dash-daemon/tests/registry_read_logging.rscaptures the daemon's log across two outages and a recovery and asserts one warning per streak and one recovery line; it is its own test binary becausetracingcaches whether a callsite is enabled the first time any thread reaches it.registry.rshas unit tests for the classification and for both readers, including the unreadable-record behaviour and the partial read on every platform through the injected reader. Shown to fail when the code they cover is broken, by editing the branch and confirming red: reading an unlistable directory as empty; reconciling against the empty set on a failed read (four loop tests and scenario dash-43 fail); logging on every failed tick; treating a malformed record as a read failure; treating an unreadable record file as skipped; treating an absent directory as a failure; never clearing the header warning on recovery; and making the best-effort loader and the strict reader behave alike on an unreadable record.Unreadable-record tests (the partial read). State matrix for a registry where some record cannot be opened, one test per row in
discovery_reconcile.rs, each asserting the announcements, the ids a snapshot lists and the header warning together: one record unreadable and a new valid record added (the new service is announced once, the known one is never missing, the warning names the file); one record unreadable and a known record removed (nothing is announced and the removed service keeps its row, then the unreadable record is removed, the registry reads in full, the service is retired once and the warning clears); the known service's own record replaced by an unreadable one (it keeps its row with its live fields and nothing is announced, then it reads again and nothing is announced and the warning clears).runner.rshas two unit tests for the discovery state after a partial read (what joins the known set, which non-vLLM marks follow the record, what stays). Shown red by editing the branch: dropping the upsert on a failed pass fails the new-service test and scenario dash-44; retiring on a failed pass (reconciling the partial read as if it were complete) fails the removed-record test, the own-record test and three of the older outage tests (unlistable directory, unreadable directory, recovery into an empty registry); not adding the seen services to the known set fails the new-service test and the discovery-state test; not refreshing the non-vLLM marks fails the discovery-state test; and returning an empty partial read from the scan fails the four registry unit tests that read past an unreadable record.Lemonade tests. State matrix for the Lemonade source, one test per row, each asserting the announcements, the instances a snapshot would carry, the per-id telemetry and the pairs the vLLM scrape would poll together:
The existing lifecycle test had pinned the stale instance as a conscious pin; that assertion is now inverted. Shown to fail when the code it covers is broken: skipping the retire on an id change fails the host-change, port-change, move-away-and-back and lifecycle tests; retiring without purging telemetry fails five rows plus the lifecycle test; retiring the new id instead of the old one fails four; and dropping the Lemonade exclusion from the scrape target filter fails seven.
User-visible behaviour
Scenario
dash-43(@id:dash-managed-model-survives-unreadable-registry, tagged@requires-os:linux, so it runs on the Linux e2e lane and is not exercised on Windows) drives the real dashboard under a pseudo-terminal: with a managed model on screen, it adds one record to the registry that cannot be opened (a symbolic link to itself, which fails with a loop error and not with not-found), waits for the header warnings badge to grow (evidence that a frame assembled after the failed read is on screen), and then asserts the model is still on screen, sampling for a few seconds. The step is one atomic operation that leaves the model's own record and the directory in place, so the dashboard can never poll an empty or absent registry in between, and it works when the runner is privileged. It fails if the loop retires the model on a failed read. Scenariodash-44(@id:dash-new-model-appears-beside-unreadable-record, also@requires-os:linux, on the same ordinary hermetic Linux lane) starts from the same unreadable record and then writes a second model's record into the registry: it asserts the second model appears on screen while the first stays. It fails if a failed pass adds nothing. The contrast (a confirmed-empty registry retires), the removed-record and recovery rows and the once-per-streak log line are asserted by the daemon's own tests, where the loop runs without a terminal. dash-07 (managed model visible) is unchanged and still applies.docs/testing.mdanddocs/manual-testing.mdlist no dash scenario individually (they name a scenario only where a special lane needs it), so neither gets an entry for dash-43 or dash-44.On a registry read error, and only then (a readable registry, empty or populated, behaves as before):
managed registry: cannot read <path>: <error>; keeping the last known services, for as long as reads keep failing. It clears on the first successful read.After the Lemonade host or port changes, wherever Lemonade discovery is enabled (the daemon's
enable_lemonadeoption; the shippedrocm dashsets it off and fixes the host and port inrunner_optionsinapps/rocm/src/dash.rs, so today this is reached by a daemon embedder, and by the Lemonade serving-discovery work that turns it on):No Gherkin scenario is added for the Lemonade change, and none can reach it: the shipped dashboard never enables Lemonade discovery and its Lemonade host and port are fixed for the life of a run, so no end-to-end scenario can start a Lemonade endpoint and move it, and not even the integration test in
crates/rocm-dash-daemon/tests/discovery_reconcile.rscan move one mid-session. The lane that covers it iscargo test -p rocm-dash-daemonin the ordinary Rust test job: the reconcile step and the scrape-target selection are covered at the unit level, and the existing Lemonade lifecycle integration test still passes.Notes for reviewers
The two changes do not interact. Lemonade is a single probed endpoint with its own id and its own retire path, while the registry governs the managed-service source; a registry read failure touches
known_servicesandmanaged_non_vllmonly, andvllm_scrape_targetsreads the latter, so a kept managed service is still scraped and a kept non-vLLM one is still skipped. The two sources treat silence differently on purpose: an unanswered Lemonade probe is a definite "not there" and retires the instance, whereas an unreadable registry is "unknown" and retires nothing. The branch merged cleanly, with no textual conflicts, because the Lemonade change edits the Lemonade reconcile step, the scrape-target selection and a test block placed before the lifecycle test, and the registry change edits the managed-service block, the discovery state and tests at the end of the module.Read the registry-read commits first, in order: the first pins current behaviour on an unreadable directory, the second changes it and flips those pins, and a merge of
origin/mainonly renumbers the new scenario (main added adash-42while this branch added one under the same number; main's keepsdash-42, this one isdash-43). The commit message of the second commit still calls the scenariodash-42. Two review-fix commits follow: the first makes the best-effort loader skip only an unreadable record (it had been discarding every readable service on one bad file), and the second changes how dash-43 makes the registry unreadable. Then the Lemonade commits: the first adds the Lemonade state matrix tests together with the behaviour-neutralvllm_scrape_targetsextraction and pins the old behaviour, and the second changes the reconcile step and flips exactly those assertions. A merge commit joins the two. Two commits answer the approving review and come last: the first makes a failed pass keep adding and refreshing the services that did read (the partial read, with its state matrix and the registry module's trade-off note), and the second adds scenario dash-44 for it.docs/architecture.mdhas one changed line, therocm-dash-daemoncrate description, which now covers the registry read distinction, including that a pass that cannot read in full retires nothing but still adds what it read. Open pull requests touching the same files: #631 changesregistry.rs,runner.rs,telemetry_contract.rs,dash.feature,dash_steps.rsanddocs/architecture.md(Lemonade serving discovery), so expect conflicts in the runner and registry modules there. #914, #856 and #913 append to or editdash.featureanddash_steps.rs, so the scenario file tail and the step file may conflict textually.Risk: a registry record that stays unreadable (for example a file with permissions removed) now keeps the dashboard from retiring any managed service until it is fixed, instead of silently dropping that service. That is deliberate (the alternative is guessing which service the file belonged to) and is visible through the header warning and the log line, which name the path. New and readable services are not held back by it.