feat(client): keep sessions, handoffs and lineage working while a host is down - #156
Merged
Conversation
…s down A remote host that was already unreachable when the app started came back empty: platform registrations are rebuilt from a live descriptor fetch, so a host that did not answer was never installed, and its cached shell and thread snapshots were never rendered. Worse, a host that dropped mid-session fell out of the reported set and `removePlatformEnvironment` cleared its caches, turning a temporary outage into permanent local data loss. The last registration a host resolved is now persisted, so a disconnected start installs the environment immediately and lets the supervisor retry in the background. Cached data is only discarded when the user removes a connection by hand. On desktop, a locked OS keyring no longer reports "no saved connections" for the whole run; the catalog waits a bounded moment for it to open. Handing a session off now works while its host is unreachable, which is when it matters most. The digest renderer moved to `@t3tools/shared/sessionDigest` so the client can render the same digest from its cached copy of the thread; the fallback is chosen from the connection phase, because an environment call parks rather than fails while a connection is retrying. The digest says where it came from and that git state and any un-synced messages are missing. Model: Claude Opus 5 (1M context), harness: T3 Code.
A cached thread only held the window that was loaded to render it — about ten user turns — which is thin as the basis of a handoff once its host goes away. The open thread now walks the existing pagination backwards while its host is reachable, so the copy on this device grows toward the whole conversation before anyone needs it. It reuses the request the "load earlier turns" control already makes, and is driven by page state rather than a timer, so nothing polls; pages are spaced so a backfill never starves live traffic. Caching whole conversations is unbounded by nature, so this also adds the part that says stop. A startup sweep evicts least-recently-opened threads until the cache fits (roughly 200 MB total, 10 MB for any one thread), and never evicts a thread opened in the last week, one pinned because its history seeded a handoff, or one that is the parent of a thread still cached. Evicting costs history, never correctness: the next open re-fetches from the host. Bookkeeping lives in its own IndexedDB store rather than on the snapshot, so a client that does not know about it loses nothing, and the mobile cache is untouched. Model: Claude Opus 5 (1M context), harness: T3 Code.
Lineage was a bare thread id, which is only unambiguous inside one server, so the sidebar filed a child under a parent in its own environment or nowhere. That ruled out the case people actually have: a child started on a machine that is up, continuing work whose own host is down or busy. A thread now records the environment its parent id belongs to. Null means "the same environment", which is what every existing row already means, so nothing is backfilled and a server that knows nothing about this keeps working. The sidebar resolves parents by (environment, thread), the move-under picker offers sessions on other machines while still refusing descendants across them, and `t3_link_session` takes an optional parent environment. The cycle guard is skipped for a parent named on another environment: that id belongs to a graph this server has never seen, so walking it locally could only match by coincidence. The client tree keeps its own depth and seen-set guards for the lineages it stitches together. A draft seeded from another session's context no longer drops its parent when it moves to a different project — it records which environment that parent is on instead. Moving a seeded draft somewhere the work can actually run, while its parent's host is unreachable, is the whole point. Model: Claude Opus 5 (1M context), harness: T3 Code.
Thread transfer impact✅ Thread transfer remains within every enforced ceiling.
Baseline: unavailable · PR result: Scenario and decoded snapshot size10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.
Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed. |
Four gaps that all showed up as "the app looks broken" rather than "that machine is down": The thread header claimed "Syncing messages…" for a cached thread whose host was unreachable, and spun on "Loading messages…" forever when it had no detail yet. Nothing was syncing and nothing was going to load. A cached thread on an unreachable host now reads as what it is — a cached copy — so the state is legible instead of looking like a hang. Search only ever asked the host, so work from a machine that went down became unfindable. Searches now also cover the threads this device has cached, merged behind the live results so a reachable host always leads. This is the honest subset, not a replacement for the host's index: it cannot see a thread that was never opened here. A handoff could go to the clipboard or a new thread, but not out of the app. "Save full context as file" writes the same digest to markdown — what survives being sent to a colleague or kept while a machine is rebuilt. Model: Claude Opus 5 (1M context), harness: T3 Code.
An earlier commit on this branch swept up a pre-existing local change to the vendored alchemy gitlink. `.repos/` is read-only reference material and has nothing to do with this work, so restore the pointer the base branch carries. Model: Claude Opus 5 (1M context), harness: T3 Code.
Model: Claude Opus 5 (1M context), harness: T3 Code.
Making lineage cross environments changed `collectDescendantThreadIds` to return (environment, thread) pairs, and the mobile drag validator was still asking it about a bare thread id. The lookup never matched, so nesting a row under its own descendant was allowed — the one thing that guard exists to stop. `measureSubtreeDepth` had the same bare-id assumption: it bucketed children by parent id alone, which can now associate rows across environments and misjudge the depth a drop would produce. Both now key on the same scoped pair the rest of the lineage code uses, exported from client-runtime so there is one definition rather than three spellings. Model: Claude Opus 5 (1M context), harness: T3 Code.
…mpared Three places still spoke the old bare-id dialect after lineage gained an environment, each wrong in a way that only shows up once a parent actually lives on another machine. The client thread reducer dropped `parentEnvironmentId` from a re-parent event, so a lineage moved across machines rendered correctly only after a refetch. Mobile's drag re-parent omitted the field entirely — and omitted means "unchanged", so a thread that used to hang off another machine would keep pointing at that machine while carrying a local parent id. Its "already parented here" check compared bare ids, so a same-id session elsewhere read as a no-op and refused a real move. The composer draft store compared drafts by parent id alone, which would treat a draft re-targeted at another environment as unchanged and keep the stale parent environment — the exact state the seeded-draft handoff depends on being right. Model: Claude Opus 5 (1M context), harness: T3 Code.
The sweep keeps a thread that is the parent of something still cached, so evicting never orphans a lineage the sidebar is drawing. It built that parent's key from the child's environment, which is only right when both sit on the same machine — for a parent on another machine it protected a key nothing is stored under, and left the real parent evictable. The parent's environment is now read alongside its id, so the key names the machine the parent is actually cached under. Model: Claude Opus 5 (1M context), harness: T3 Code.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
When a remote host goes down, the desktop app degrades far worse than it needs to:
removePlatformEnvironmentcleared its caches — turning a temporary outage into permanent local data loss.What this does
Keeps a down host's sessions. The last registration a host resolved is persisted, so a disconnected start installs the environment immediately and lets the supervisor retry in the background. Cached data is now discarded only when the user removes a connection by hand. On desktop, a locked OS keyring no longer reports "no saved connections" for the whole run.
Makes handoff work offline. The digest renderer moved to
@t3tools/shared/sessionDigestso the client renders the same digest from its cached copy. The fallback is chosen from the connection phase, not by trying the RPC first — an environment call parks rather than fails while a connection is retrying, which would hang the menu on exactly the hosts that need it. The digest states where it came from and that git state and un-synced messages are missing.Deepens the cache, inside a budget. The open thread walks the existing pagination backwards while its host is reachable (event-driven off page state, no polling, spaced so it never starves live traffic). A startup sweep evicts least-recently-opened threads until the cache fits (~200 MB total, 10 MB per thread) and never evicts a thread opened in the last week, one pinned by a handoff, or the parent of a thread still cached.
Lets lineage cross machines. A thread records the environment its parent id belongs to (
NULL= same environment, so nothing is backfilled). The sidebar resolves parents by (environment, thread), the move-under picker offers sessions on other machines while still refusing descendants across them, andt3_link_sessiontakes an optional parent environment. The local cycle guard is skipped for a foreign parent, since that id belongs to a graph this server has never seen. A seeded draft no longer drops its parent when it moves to another project — it records which environment the parent is on, which is what makes "hand this off to a machine that is up" work.Tells the truth while offline. A cached thread on an unreachable host reads as a cached copy instead of a spinner that never resolves. Search now also covers the threads this device has cached, merged behind live results so a reachable host always leads. "Save full context as file" writes the same digest to markdown, for a handoff that leaves the app entirely.
Notes
scripts/check-fork-markers.tspasses locally). Additive schema only; fork migration 1024.VITE_T3_OFFLINE_HISTORY_SYNC(on by default; setfalseto isolate).Verification
Scoped tests only, per repo policy — CI owns the full suite. New fork-owned tests cover the persisted registration cache, the offline digest (including a parity test asserting the client and host renderers produce byte-identical markdown), the history-sync policy, the eviction policy, cached search, the offline sync phase, cross-environment tree nesting, and the cross-environment decider rules. Existing sidebar/projector/snapshot/MCP expectations were updated where behavior intentionally changed.
Two failures in
apps/web(terminal/ghostty/runtimeAbi.test.ts,preview/PreviewView.test.tsx) reproduce on the base branch with these changes stashed and are unrelated to this work.Model: Claude Opus 5 (1M context), harness: T3 Code.
🤖 Generated with Claude Code