Repository navigation
feat(device): connected-device registry with duplicate-across-transport detection - #381
Conversation
…rt detection (closes #380) Core created devices and forgot them: `DaqifiDeviceFactory.Connect*` returned per-device objects and nothing tracked the live set or correlated device identity, so every consumer managing more than one device built its own registry — the in-repo MCP server hand-rolled two `ConcurrentDictionary`s plus stale-handle eviction, and the desktop app's `ConnectionManager` additionally implemented the piece Core lacked entirely: recognizing the same physical unit reached over both USB and WiFi. Adds `DaqifiDeviceRegistry`, a thread-safe live set that owns the lifetime of what it holds: - `DeviceIdentity` fingerprints a physical unit as serial -> MAC -> USB `LocationKey`. Matching is decided by the first discriminator both sides report (serial compares case-insensitively and decisively, MAC ignores separators), so blank-identity devices never falsely match each other. - The duplicate check runs pre-connect from `IDeviceInfo` and again post-connect from `DeviceMetadata`, since a serial-port device's serial number is often only known once it has answered its first status message. - `DuplicatePolicy` exposes desktop's `KeepExisting | SwitchToNew | Cancel` semantics; with no callback the existing connection wins. Unlike desktop's reference implementation the policy is consulted on the post-connect check too, and is always invoked without the registry lock held so a policy that blocks on a user prompt cannot block other threads. - Keys (lookup handles, defaulting to the identity key) are kept distinct from identities (duplicate fingerprints), so a consumer with its own device ids can supply them. The MCP server's `_connected` dictionary, stale-handle eviction, and disposal paths collapse onto the registry; its `_gate` stays pending #342. `connect_device` now reports the id of the connection that won, so an agent reconnecting a unit already live on another transport is handed that connection instead of opening a redundant second one. Includes ports of desktop's `DuplicateDeviceDetectionTests` cases plus identity fallback, phase, policy-action, event, pruning, and concurrency coverage, and a multi-device section in `docs/DEVICE_INTERFACES.md`. Bench-tested against a Nyquist 1 (fw 3.7.2) on /dev/cu.usbmodem1101: real discovery -> connect registers under `sn:9090539562006014104` with the MAC merged in from post-connect metadata; reconnecting the same discovery result is rejected without reopening the port; a blank-serial `IDeviceInfo` still resolves its identity post-connect, and the same unit presented as a WiFi device is rejected before any TCP attempt while a different serial is not; removal and disposal release the serial port. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PR Summary by QodoAdd connected-device registry with cross-transport duplicate detection
AI Description
Diagram
High-Level Assessment
Files changed (11)
|
Code Review by Qodo
1.
|
…actually detect Bench-testing a physical USB unplug showed the device keeps reporting IsConnected: SerialStreamTransport.IsConnected is the OS handle's IsOpen, which stays true after the cable is pulled, and neither the consumer's read failures nor the producer's write failures feed back into transport status. So stale pruning cannot fire for an unplugged serial device, and the docs claiming a unit that was unplugged "never blocks a later reconnect" overstated it. Behavior is unchanged either way — the MCP server's previous hand-rolled tracking gated on the same IsConnected flag — but a reader should not have to discover the limit with a cable in their hand. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ment is open The pre-connect duplicate check dropped the existing registration before opening the replacement, so a SwitchToNew whose new transport failed to connect left the caller with neither: the exception propagated out of ConnectAsync and the registry was empty. Caught on the bench with the real cross-transport case this feature exists for. A Nyquist 1 was connected over USB and also answering discovery at 192.168.1.30; asking to switch to WiFi disposed the healthy USB session, then the TCP connect to the advertised data port timed out — the device broadcasts on UDP 30303 but is not listening on 9760 (ProbeTcpDataPortAsync finds no port answering even with no USB session in flight, so that part is environmental, not a Core bug). Dropping first was chosen to match desktop's ConnectionManager and because the existing connection may hold the resource the new one needs. But USB -> WiFi is exactly the case where it does not, and it is the case the policy exists for. Now the pre-connect check records the approved replacement and defers: the registration step drops it once the new connection is live, and skips a second policy call so a consumer prompting its user still sees one dialog. When the replacement cannot connect, the original survives untouched. Same-resource switches (replacing a live connection on the same serial port) now fail rather than freeing the port first. That case is degenerate — a dead handle is pruned before the check, and forcing a fresh connection to a port you already hold is spelled Remove() then ConnectAsync() — and it is a far better trade than losing a working connection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…de-off Verified end to end on a Nyquist 1 reachable over USB and WiFi at once: the replacement TCP session is established before the USB registration is dropped, the freed serial port reopens, and the reverse direction (USB attempted while WiFi is live) is rejected as a duplicate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Hardware validation: full cross-transport caseCompleted against a Nyquist 1 (fw 3.7.2) reachable over USB and WiFi simultaneously —
Also verified earlier in the same session: a blank-serial Two findings this testing produced1. 2. Core never notices a physical USB unplug — filed as #382, pre-existing and out of scope here. |
|
Downstream follow-up: daqifi-desktop#752 tracks moving desktop's Note desktop consumes Core as a NuGet package, so it needs a release containing this, not just the merge. |
|
/agentic_review |
| var overUsb = ConnectedDevice("usb", serialNumber); | ||
| var overWifi = ConnectedDevice("wifi", serialNumber); | ||
| var policyCalls = new List<DuplicateCheckPhase>(); | ||
| var connectedWhileOldStillRegistered = false; | ||
| var connects = new List<int>(); | ||
| using var registry = RegistryReturning(connects, overUsb, overWifi); | ||
|
|
||
| await registry.ConnectAsync(UsbInfo(serialNumber)); | ||
|
|
||
| registry.DuplicatePolicy = check => | ||
| { | ||
| policyCalls.Add(check.Phase); | ||
| return DuplicateDeviceAction.SwitchToNew; | ||
| }; | ||
| registry.DeviceRemoved += (_, _) => | ||
| { | ||
| // At the moment the old registration is dropped, the replacement must already be live. | ||
| connectedWhileOldStillRegistered = overWifi.IsConnected; | ||
| }; | ||
|
|
||
| var result = await registry.ConnectAsync(WifiInfo(serialNumber)); | ||
|
|
||
| Assert.Equal(DeviceRegistrationOutcome.ReplacedExisting, result.Outcome); | ||
| Assert.True(connectedWhileOldStillRegistered); | ||
| Assert.False(overUsb.IsConnected); |
There was a problem hiding this comment.
1. Ordering test is tautological 🐞 Bug ⚙ Maintainability
ConnectAsync_SwitchToNew_DropsTheOldConnectionOnlyAfterTheNewOneIsOpen records overWifi.IsConnected when DeviceRemoved fires, but overWifi is created via ConnectedDevice(...) which calls Connect() immediately, so the assertion will pass regardless of whether the registry actually waits to drop the old connection until the replacement is opened. This weakens regression coverage for the critical guarantee that a failed replacement connect must not cost the existing session.
Agent Prompt
## Issue description
The test `ConnectAsync_SwitchToNew_DropsTheOldConnectionOnlyAfterTheNewOneIsOpen` does not actually verify ordering because the replacement device (`overWifi`) is already connected before the second `ConnectAsync` runs.
## Issue Context
`ConnectedDevice(...)` calls `device.Connect()` as part of test setup, so `overWifi.IsConnected` is always true when `DeviceRemoved` fires.
## Fix Focus Areas
- src/Daqifi.Core.Tests/Device/DaqifiDeviceRegistryTests.cs[19-33]
- src/Daqifi.Core.Tests/Device/DaqifiDeviceRegistryTests.cs[318-349]
## Suggested fix approach
Update the test to ensure the replacement becomes connected *only when the connector is invoked* (or later), and assert that the old registration is not removed until after that point.
One option:
1. Create `overWifi` as a disconnected `new DaqifiDevice("wifi")`.
2. In the registry connector delegate, set metadata, then call `overWifi.Connect()` immediately before returning it.
3. Use a `TaskCompletionSource`/barrier inside the connector to pause before connecting/returning, and while paused assert:
- the old device is still registered/connected
- no `DeviceRemoved` event has fired
4. Release the barrier and then assert the removal happens only after the connector proceeds.
This makes the ordering guarantee observable and prevents false-positive passes.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
There was a problem hiding this comment.
Accepted and fixed in 6bcdd1f. Correct call — the assertion was vacuous.
ConnectedDevice(...) calls Connect() during setup, so overWifi.IsConnected was true before the second ConnectAsync ever ran and the check could not fail. The replacement is now built disconnected and opens inside the connector delegate, so IsConnected at DeviceRemoved time genuinely reflects whether the registry waited.
I took the simpler of your two suggested shapes — connect inside the connector rather than a TaskCompletionSource barrier — because it already makes the ordering observable, and I would rather not add cross-thread synchronization to a test that does not need it.
Rather than trust the reasoning, I verified the test is a real guard by reintroducing the drop-first ordering (RemoveRegistration before the connector runs) and re-running:
ConnectAsync_SwitchToNew_DropsTheOldConnectionOnlyAfterTheNewOneIsOpen [FAIL]
ConnectAsync_SwitchToNew_ReplacesTheExistingConnection [FAIL]
ConnectAsync_SwitchToNew_WhenTheNewConnectionFails_KeepsTheExistingOne [FAIL]
All three fail with the regression reintroduced, where this one previously passed. Then reverted; full suite green on net9.0 and net10.0 (1821 Core + 23 MCP).
Worth noting this is the second instance of the same blind spot in this PR: my stubs were connected before the code under test ran, so the tests could not observe ordering. The other SwitchToNew test happens to have been a genuine guard already — it fails when the connector throws — but that was luck rather than design, and it is why the guarantee this test covers was worth pinning properly.
|
Code review by qodo was updated up to the latest commit 4390b72 |
Two findings from Qodo's review, both accepted. 1. The registry captured the existing registration under the lock, released the lock to run DuplicatePolicy, then returned that captured reference on DuplicateRejected without re-checking it. The policy is documented as free to block on a user prompt, so the window is long and a concurrent Remove/Clear/prune — or the policy itself — could leave the caller holding a disposed handle while the contract promises the live connection. Both the pre-connect and post-connect paths now revalidate that the captured registration is still the entry filed under its key before handing it back. When it has gone, the check is re-resolved against the current set instead: pre-connect that means connecting after all, post-connect it means keeping the device we just opened rather than disposing it in favour of an entry that no longer exists. 2. ConnectAsync_SwitchToNew_DropsTheOldConnectionOnlyAfterTheNewOneIsOpen was tautological. It asserted the replacement was connected when DeviceRemoved fired, but the test helper connects the device during setup, so it was true regardless of ordering. The replacement now opens inside the connector and nowhere earlier. Verified by reintroducing the drop-first ordering: all three SwitchToNew tests fail, where previously this one passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
/agentic_review |
| lock (_lock) | ||
| { | ||
| ThrowIfDisposed(); | ||
| stale = PruneLocked(); | ||
| existing = FindDuplicateLocked(key, discoveryIdentity); | ||
| } | ||
|
|
||
| CompleteRemoval(stale, DeviceRemovalReason.Disconnected); | ||
|
|
||
| if (existing == null) | ||
| { | ||
| break; | ||
| } |
There was a problem hiding this comment.
1. Connect after disposal race 🐞 Bug ☼ Reliability
DaqifiDeviceRegistry.ConnectAsync can still invoke _connector and open a new connection after another thread has disposed the registry, because it breaks out of the pre-connect loop and proceeds to connect without a final lock-synchronized ThrowIfDisposed(). This can cause unnecessary/unsafe transport activity during shutdown even though RegisterCore will later throw ObjectDisposedException and dispose the newly-opened device.
Agent Prompt
### Issue description
`DaqifiDeviceRegistry.ConnectAsync` can begin a connection after the registry has been disposed because the last `ThrowIfDisposed()` check happens inside the loop’s locked section, but once the code hits `if (existing == null) { break; }` it proceeds to call `_connector(...)` without another lock-synchronized disposed check. A concurrent `Dispose()` can set `_disposed = true` under `_lock` in that gap.
### Issue Context
- `Dispose()` sets `_disposed = true` under `_lock`, so a disposed check taken under the same lock immediately before `_connector` is sufficient to ensure we never start a new connection *after* disposal has already begun/completed.
- `RegisterCore` already checks `ThrowIfDisposed()` and will dispose the device on exception, but that still allows transient hardware/network activity.
### Fix Focus Areas
- src/Daqifi.Core/Device/DaqifiDeviceRegistry.cs[204-264]
### Suggested change
1. Right before invoking `_connector(...)`, take `_lock` and call `ThrowIfDisposed()` (or otherwise read `_disposed` under `_lock`).
2. Keep the lock hold extremely short (check only) so you do not hold `_lock` across the awaited connection.
Example shape:
```csharp
lock (_lock)
{
ThrowIfDisposed();
}
var device = await _connector(...);
```
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
There was a problem hiding this comment.
Accepted and fixed in 4eab983 — with one correction to the framing.
The race is real, and worse than the description suggests in one respect: on the SwitchToNew path the unguarded gap spans the entire duplicate-policy call, which is documented as free to block on a user prompt. So this is not a narrow instruction-width window — an app disposing the registry while a duplicate dialog is up would reliably go on to open a connection.
Verified rather than assumed. With the check removed, the regression test observes connectorRuns == 2 on an already-disposed registry: the connection genuinely opens, and RegisterCore throws afterwards. Test added as ConnectAsync_RegistryDisposedWhileThePolicyDecides_NeverReachesTheConnector.
Where I would push back: the suggested fix narrows the window, it does not close it, and I would rather the code not claim otherwise. A Dispose() landing between the new check and _connector, or during the connect itself, still opens a connection that the registration step then throws away. Closing it properly needs in-flight tracking — a drain or a lifetime token cancelled on Dispose — which changes what Dispose means (does it block? cancel? for how long?) and belongs with the session/lifecycle work in #342 rather than being smuggled into this PR.
So the fix is in, and the comment above it plus Dispose's remarks now say plainly what is and is not guaranteed: nothing leaks either way, but transport activity started before disposal does complete. Callers needing connect attempts to stop promptly should cancel the token they passed in — which already works today.
Full suite green on net9.0 and net10.0 (1822 Core + 23 MCP).
|
Code review by qodo was updated up to the latest commit 6bcdd1f |
…posed The pre-connect loop's disposed check ran under the lock, but the path from there to the connector was unguarded — and on the SwitchToNew path that gap spans the entire duplicate-policy call, which is documented as free to block on a user prompt. An app disposing the registry while that prompt was up would still go on to open a connection, which the registration step then threw away. Re-checks under the lock immediately before connecting. This narrows the window rather than closing it: a Dispose landing between that check and the connector, or during the connect itself, still lets the connection open and be disposed by the registration step. Guaranteeing no transport activity outlives the registry needs in-flight tracking on Dispose, which belongs with the session/lifecycle work in #342; Dispose's docs now state what it does and does not wait for. Verified the regression test is a real guard by removing the check: the connector runs a second time (connectorRuns 2 rather than 1) with the registry already disposed. Reported by Qodo. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
/agentic_review |
| // Re-check immediately before opening anything. The policy above runs without the lock and | ||
| // may block indefinitely on a user prompt, so a registry disposed during that window would | ||
| // otherwise still reach the connector and start fresh transport activity on the way down. | ||
| // This narrows that window to a few instructions rather than closing it: a Dispose landing | ||
| // between here and the connector, or during the connect itself, still lets the connection | ||
| // open, and RegisterCore then throws and disposes it. Guaranteeing no transport activity | ||
| // outlives the registry needs in-flight tracking on Dispose, which is out of scope here. | ||
| lock (_lock) | ||
| { | ||
| ThrowIfDisposed(); | ||
| } | ||
|
|
There was a problem hiding this comment.
1. Missing cancellation recheck 🐞 Bug ☼ Reliability
DaqifiDeviceRegistry.ConnectAsync only checks cancellationToken at method entry, so if DuplicatePolicy blocks and the token is canceled later, ConnectAsync can still invoke _connector and begin/attempt connection work before cancellation is observed. This can delay cancellation and may start transport activity for connectors that don’t immediately honor the token.
Agent Prompt
### Issue description
`DaqifiDeviceRegistry.ConnectAsync` checks `cancellationToken` only once at the start of the method. Because `DuplicatePolicy` is explicitly allowed to block (e.g., user prompt), cancellation can be requested during that window, but the method will still proceed to invoke `_connector`.
### Issue Context
The PR added a second disposed-state check immediately before `_connector` to prevent transport activity during shutdown. The same risk exists for cancellation: after a potentially long/blocking policy callback, the method should re-check cancellation as close as possible to invoking `_connector`.
### Fix Focus Areas
- src/Daqifi.Core/Device/DaqifiDeviceRegistry.cs[261-274]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
There was a problem hiding this comment.
Accepted and fixed in 154499e, with one clarification on severity.
The gap is real and is the exact analogue of the disposal check — the token was read only at method entry, so a cancellation requested while DuplicatePolicy blocks was not observed before _connector.
But no transport activity actually started on the production path. DaqifiDeviceFactory.ConnectFromDeviceInfoAsync calls cancellationToken.ThrowIfCancellationRequested() at its own entry, so the default connector already threw before opening anything. Your description hedges this correctly ("connectors that don't immediately honor the token"), and it is worth stating plainly so the severity is not overread: this was a latent contract gap, not a live leak.
Fixed anyway, for two reasons that outweigh the one-line cost:
ConnectAsyncdocuments its own cancellation behaviour, so it should not depend on the callee to honour it — the connector is a seam, and a custom one need not check.- It was asymmetric with the disposed guard added immediately above it. A reader would reasonably wonder why one shutdown signal is checked there and the other is not.
Regression test ConnectAsync_CancelledWhileThePolicyDecides_NeverReachesTheConnector cancels inside the policy; the stub connector deliberately ignores the token, so it proves the registry does the checking. Verified as a real guard by removing the line — no OperationCanceledException surfaces and the test fails.
Full suite green on net9.0 and net10.0 (1823 Core + 23 MCP).
|
Code review by qodo was updated up to the latest commit 4eab983 |
…connecting ConnectAsync checked the token only at method entry, so a cancellation requested while DuplicatePolicy blocked on a user prompt was not observed until the connector itself looked at it. The default connector does check (DaqifiDeviceFactory.ConnectFromDeviceInfoAsync throws at entry), so no transport activity actually started on the production path — but relying on the callee for a contract this method states is the wrong way round, and it left the cancellation guard asymmetric with the disposed guard added alongside it. Verified the regression test is a real guard by removing the check: the stub connector ignores the token, so no OperationCanceledException surfaces and the test fails. Reported by Qodo. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 154499e |
Review rounds complete — final stateFour rounds of
Each fix was verified by reintroducing the defect and confirming the new test fails — not by assuming. The disposal one, for example, shows Two places I deliberately did not go as far as suggested, both documented in the code rather than left implicit:
Re-validated on hardware after the review fixes
One run in three failed at the switch with |
Closes #380.
The problem, plainly
A DAQiFi box can be reached two ways at the same time — plugged in over USB and sitting on the WiFi network. To discovery those look like two completely different devices: one has a COM port, the other has an IP address. Nothing in Core knew they were the same physical unit.
So an app would happily connect to both, and the user would end up with one box showing up twice in their device list, two connections competing for the same hardware, and duplicated data. The desktop app hit this in the field and solved it in its own UI layer. Every other consumer had to solve it again — the in-repo MCP server hand-rolled two
ConcurrentDictionarys plus its own stale-handle eviction, and still had no cross-transport detection at all.This PR moves that whole layer into Core.
What a user experiences now
"I plugged it in, and it's also on WiFi." The second connection is recognized as the same box before anything is opened. The app hands back the connection it already has instead of making a second one. By default the existing connection quietly wins; if the app wants to ask the user ("It's connected over USB — switch to WiFi?"), it can, and the answer can be keep, switch, or cancel.
"Switch this to WiFi." The new connection is opened first, and only then is the old one dropped. If the WiFi address turns out not to answer, the user keeps the USB session they had instead of ending up with nothing. (This is a bug this PR originally shipped with — see Findings below.)
"I'm done with this device." Removing it disconnects and disposes it properly, and the serial port is actually released — verified by reconnecting to the same port immediately afterward.
Talking to the MCP agent. The one place with visible behavior change today. Before, asking the agent to connect to a device already connected under a different id opened a redundant second connection. Now it returns the live one and tells the agent which id to use going forward.
For the desktop app nothing changes yet — it still has its own
ConnectionManager. This is the Core layer it migrates onto, which is the point: that logic stops being desktop-only and stops being reimplemented per consumer.What a developer writes
Before, you tracked devices yourself:
Now:
The registry owns the devices — it disconnects and disposes anything it removes or rejects, so you never double-dispose or leak a port.
How it works
DaqifiDeviceRegistryConnectAsync/Register/Remove/Clear, stale pruningDeviceIdentityLocationKeyDuplicateDeviceCheck/DuplicateDeviceActionKeepExisting/SwitchToNew/CancelanswerDeviceRegistration/DeviceRegistrationResultTwo concepts kept deliberately separate. A key is the handle you look a device up by; it defaults to the identity key, but a consumer that already mints its own device ids (the MCP server, a UI list) can supply one. An identity is only ever used for duplicate detection.
Identity matching is decided by the first discriminator both sides report. Serial numbers compare case-insensitively and decisively — different serials are different boxes, whatever a weaker discriminator says. MAC comparison ignores separators. The USB location key is the last resort for identical units reporting no serial. A device reporting none of the three never matches another, so two unidentifiable devices stay two devices — the worst possible failure here would be silently merging two real boxes into one.
The check runs twice: pre-connect from
IDeviceInfo(free to reject — nothing is open yet) and again post-connect fromDeviceMetadata, because a serial-port device's serial number is often only known once it has answered its first status message. That mirrors desktop'sConnectionManager.cs:95/:131pair.Deltas from the desktop reference implementation
SwitchToNewworks when the duplicate is only discoverable after connecting.SwitchToNewopens the replacement before dropping the original (desktop drops first).MCP server
_connected, the stale-handle eviction, and the disposal paths collapse onto the registry._discoveredand_gatestay (the latter pending #342).connect_devicenow returns the id of the connection that won, so an agent reconnecting a unit already live on another transport is handed that connection instead of opening a redundant second one — the tool description says so.Testing
Unit: ports of desktop's
DuplicateDeviceDetectionTestscases, plus identity fallbacks, both check phases, every policy action,SwitchToNewordering, events, key minting, pruning, disposal ownership, and concurrency. Full suite green on net9.0 and net10.0 (1819 Core + 23 MCP).Hardware, against a Nyquist 1 (fw 3.7.2) reachable over USB and WiFi at once — full detail in the validation comment:
Registered, MAC merged in from post-connect metadata, 32 channelsDuplicateRejected, same handle, no TCP connection openedSwitchToNewReplacedExisting— TCP opened first, then USB dropped; new transport liveDuplicateRejected(reverse direction)Findings the hardware testing produced
1.
SwitchToNewcould lose a working connection — fixed in 0a59df3. The pre-connect check dropped the existing registration before opening the replacement, so when a WiFi TCP connect timed out the registry was left empty and the exception escaped: a user clicking "switch to WiFi" lost their healthy USB session. Now the approved replacement is recorded and the drop deferred until the new connection is live. The unit tests were structurally blind to this — every one used a stub connector that always succeeded. Trade-off: switching to a connection needing the same resource the old one holds now fails rather than freeing it first; that case is degenerate (a dead handle is pruned before the check, and forcing a fresh connection to a port you already hold isRemove()thenConnectAsync()), and it beats losing a live connection.2. Core never notices a physical USB unplug — filed as #382, pre-existing and out of scope here.
SerialStreamTransport.IsConnectedis the OS handle'sIsOpen, which stays true after the cable is pulled; consumer read failures and producer write failures never feed back into transport status. SoConnectionStatus.Lostis unreachable over serial, and this registry's stale pruning cannot fire for an unplug. Behavior is unchanged from the MCP server's previous tracking, which gated on the same flag — but the docs now state the limit plainly instead of implying pruning covers it. It is a hard blocker for #379.Scope
MVP per the issue: registry + duplicate policy. Automatic reconnect stays with #379, per-device operation serialization with #342, and the registry surfaces whatever type the factory settles on in #333. Aggregate status strings and UI concerns stay in consumers.
🤖 Generated with Claude Code