From 6a05d60f412cd06f36a6ca0a89c4bfc461de20b4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 12 Dec 2025 16:56:46 +0000 Subject: [PATCH] fix(presence): dedupe instances via stable instanceId --- .../Sources/Clawdis/InstanceIdentity.swift | 20 ++- docs/gateway.md | 2 + docs/presence.md | 115 ++++++++++++++++++ 3 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 docs/presence.md diff --git a/apps/macos/Sources/Clawdis/InstanceIdentity.swift b/apps/macos/Sources/Clawdis/InstanceIdentity.swift index f8762af82..c48b798e1 100644 --- a/apps/macos/Sources/Clawdis/InstanceIdentity.swift +++ b/apps/macos/Sources/Clawdis/InstanceIdentity.swift @@ -1,13 +1,24 @@ import Foundation 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 = { - if let name = Host.current().localizedName?.trimmingCharacters(in: .whitespacesAndNewlines), - !name.isEmpty + if let existing = defaults.string(forKey: instanceIdKey)? + .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 = { @@ -19,4 +30,3 @@ enum InstanceIdentity { return "clawdis-mac" }() } - diff --git a/docs/gateway.md b/docs/gateway.md index 005f359d2..add12c3e2 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -51,6 +51,8 @@ pnpm clawdis gateway --force - `send` — send a message via the active provider(s). - `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 - `agent` — streamed tool/output events from the agent run (seq-tagged). - `presence` — presence updates (deltas with stateVersion) pushed to all connected clients. diff --git a/docs/presence.md b/docs/presence.md new file mode 100644 index 000000000..cb03a3e5a --- /dev/null +++ b/docs/presence.md @@ -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) +