fix(presence): dedupe instances via stable instanceId
parent
cd84c5ad08
commit
6a05d60f41
|
|
@ -1,13 +1,24 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum InstanceIdentity {
|
enum InstanceIdentity {
|
||||||
|
private static let suiteName = "com.steipete.clawdis.shared"
|
||||||
|
private static let instanceIdKey = "instanceId"
|
||||||
|
|
||||||
|
private static let defaults: UserDefaults = {
|
||||||
|
UserDefaults(suiteName: suiteName) ?? .standard
|
||||||
|
}()
|
||||||
|
|
||||||
static let instanceId: String = {
|
static let instanceId: String = {
|
||||||
if let name = Host.current().localizedName?.trimmingCharacters(in: .whitespacesAndNewlines),
|
if let existing = defaults.string(forKey: instanceIdKey)?
|
||||||
!name.isEmpty
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!existing.isEmpty
|
||||||
{
|
{
|
||||||
return name
|
return existing
|
||||||
}
|
}
|
||||||
return UUID().uuidString
|
|
||||||
|
let id = UUID().uuidString.lowercased()
|
||||||
|
defaults.set(id, forKey: instanceIdKey)
|
||||||
|
return id
|
||||||
}()
|
}()
|
||||||
|
|
||||||
static let displayName: String = {
|
static let displayName: String = {
|
||||||
|
|
@ -19,4 +30,3 @@ enum InstanceIdentity {
|
||||||
return "clawdis-mac"
|
return "clawdis-mac"
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,8 @@ pnpm clawdis gateway --force
|
||||||
- `send` — send a message via the active provider(s).
|
- `send` — send a message via the active provider(s).
|
||||||
- `agent` — run an agent turn (streams events back on same connection).
|
- `agent` — run an agent turn (streams events back on same connection).
|
||||||
|
|
||||||
|
See also: `docs/presence.md` for how presence is produced/deduped and why `instanceId` matters.
|
||||||
|
|
||||||
## Events
|
## Events
|
||||||
- `agent` — streamed tool/output events from the agent run (seq-tagged).
|
- `agent` — streamed tool/output events from the agent run (seq-tagged).
|
||||||
- `presence` — presence updates (deltas with stateVersion) pushed to all connected clients.
|
- `presence` — presence updates (deltas with stateVersion) pushed to all connected clients.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
---
|
||||||
|
summary: "How Clawdis presence entries are produced, merged, and displayed"
|
||||||
|
read_when:
|
||||||
|
- Debugging the Instances tab
|
||||||
|
- Investigating duplicate or stale instance rows
|
||||||
|
- Changing gateway WS hello or system-event beacons
|
||||||
|
---
|
||||||
|
# Presence
|
||||||
|
|
||||||
|
Clawdis “presence” is a lightweight, best-effort view of:
|
||||||
|
- The **Gateway** itself (one per host), and
|
||||||
|
- The **clients connected to the Gateway** (mac app, WebChat, CLI, etc.).
|
||||||
|
|
||||||
|
Presence is used primarily to render the mac app’s **Instances** tab and to provide quick operator visibility.
|
||||||
|
|
||||||
|
## The data model
|
||||||
|
|
||||||
|
Presence entries are structured objects with (some) fields:
|
||||||
|
- `instanceId` (optional but strongly recommended): stable client identity used for dedupe
|
||||||
|
- `host`: a human-readable name (often the machine name)
|
||||||
|
- `ip`: best-effort IP address (may be missing or stale)
|
||||||
|
- `version`: client version string
|
||||||
|
- `mode`: e.g. `gateway`, `app`, `webchat`, `cli`
|
||||||
|
- `lastInputSeconds` (optional): “seconds since last user input” for that client machine
|
||||||
|
- `reason`: a short marker like `self`, `connect`, `periodic`, `instances-refresh`
|
||||||
|
- `text`: legacy/debug summary string (kept for backwards compatibility and UI display)
|
||||||
|
- `ts`: last update timestamp (ms since epoch)
|
||||||
|
|
||||||
|
## Producers (where presence comes from)
|
||||||
|
|
||||||
|
Presence entries are produced by multiple sources and then **merged**.
|
||||||
|
|
||||||
|
### 1) Gateway self entry
|
||||||
|
|
||||||
|
The Gateway seeds a “self” entry at startup so UIs always show at least the current gateway host.
|
||||||
|
|
||||||
|
Implementation: `src/infra/system-presence.ts` (`initSelfPresence()`).
|
||||||
|
|
||||||
|
### 2) WebSocket hello (connection-derived presence)
|
||||||
|
|
||||||
|
Every WS client must begin with a `hello` frame. On successful handshake, the Gateway upserts a presence entry for that connection.
|
||||||
|
|
||||||
|
This is meant to answer: “Which clients are currently connected?”
|
||||||
|
|
||||||
|
Implementation: `src/gateway/server.ts` (WS `hello` handling uses `hello.client.instanceId` when provided; otherwise falls back to `connId`).
|
||||||
|
|
||||||
|
### 3) `system-event` beacons (client-reported presence)
|
||||||
|
|
||||||
|
Clients can publish richer periodic beacons via the `system-event` method. The mac app uses this to report:
|
||||||
|
- a human-friendly host name
|
||||||
|
- its best-known IP address
|
||||||
|
- `lastInputSeconds`
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
- Gateway: `src/gateway/server.ts` handles method `system-event` by calling `updateSystemPresence(...)`.
|
||||||
|
- mac app beaconing: `apps/macos/Sources/Clawdis/PresenceReporter.swift`.
|
||||||
|
|
||||||
|
## Merge + dedupe rules (why `instanceId` matters)
|
||||||
|
|
||||||
|
All producers write into a single in-memory presence map.
|
||||||
|
|
||||||
|
Key points:
|
||||||
|
- Entries are **keyed** by a “presence key”. If two producers use the same key, they update the same entry.
|
||||||
|
- The best key is a stable, opaque `instanceId` that does not change across restarts.
|
||||||
|
- Keys are treated case-insensitively.
|
||||||
|
|
||||||
|
Implementation: `src/infra/system-presence.ts` (`normalizePresenceKey()`).
|
||||||
|
|
||||||
|
### mac app identity (stable UUID)
|
||||||
|
|
||||||
|
The mac app uses a persisted UUID as `instanceId` so:
|
||||||
|
- restarts/reconnects do not create duplicates
|
||||||
|
- renaming the Mac does not create a new “instance”
|
||||||
|
- debug/release builds can share the same identity
|
||||||
|
|
||||||
|
Implementation: `apps/macos/Sources/Clawdis/InstanceIdentity.swift`.
|
||||||
|
|
||||||
|
`displayName` (machine name) is used for UI, while `instanceId` is used for dedupe.
|
||||||
|
|
||||||
|
## TTL and bounded size (why stale rows disappear)
|
||||||
|
|
||||||
|
Presence entries are not permanent:
|
||||||
|
- TTL: entries older than 5 minutes are pruned
|
||||||
|
- Max: map is capped at 200 entries (LRU by `ts`)
|
||||||
|
|
||||||
|
Implementation: `src/infra/system-presence.ts` (`TTL_MS`, `MAX_ENTRIES`, pruning in `listSystemPresence()`).
|
||||||
|
|
||||||
|
## Remote/tunnel caveat (loopback IPs)
|
||||||
|
|
||||||
|
When a client connects over an SSH tunnel / local port forward, the Gateway may see the remote address as loopback (`127.0.0.1`).
|
||||||
|
|
||||||
|
To avoid degrading an otherwise-correct client beacon IP, the Gateway avoids writing loopback remote addresses into presence entries.
|
||||||
|
|
||||||
|
Implementation: `src/gateway/server.ts` (`isLoopbackAddress()`).
|
||||||
|
|
||||||
|
## Consumers (who reads presence)
|
||||||
|
|
||||||
|
### macOS Instances tab
|
||||||
|
|
||||||
|
The mac app’s Instances tab renders the result of `system-presence`.
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
- View: `apps/macos/Sources/Clawdis/InstancesSettings.swift`
|
||||||
|
- Store: `apps/macos/Sources/Clawdis/InstancesStore.swift`
|
||||||
|
|
||||||
|
The store refreshes periodically and also applies `presence` WS events.
|
||||||
|
|
||||||
|
## Debugging tips
|
||||||
|
|
||||||
|
- To see the raw list, call `system-presence` against the gateway.
|
||||||
|
- If you see duplicates:
|
||||||
|
- confirm clients send a stable `instanceId` in `hello`
|
||||||
|
- confirm beaconing uses the same `instanceId`
|
||||||
|
- check whether the connection-derived entry is missing `instanceId` (then it will be keyed by `connId` and duplicates are expected on reconnect)
|
||||||
|
|
||||||
Loading…
Reference in New Issue