Skip to content

feat(client): keep sessions, handoffs and lineage working while a host is down - #156

Merged
tusharbhardwaj-bk merged 9 commits into
expbkmainfrom
t3code/lleida
Sep 1, 2026
Merged

feat(client): keep sessions, handoffs and lineage working while a host is down#156
tusharbhardwaj-bk merged 9 commits into
expbkmainfrom
t3code/lleida

Conversation

@tusharbhardwaj-bk

@tusharbhardwaj-bk tusharbhardwaj-bk commented Sep 1, 2026

Copy link
Copy Markdown

Problem

When a remote host goes down, the desktop app degrades far worse than it needs to:

  • Sessions vanish. A host that was already unreachable at startup was never installed (platform registrations require a live descriptor fetch), so 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.
  • Handoff dies exactly when it matters. "New child thread with this context…" called an RPC on the parent's host; offline it failed with a toast and created nothing.
  • A child could not be parented to a session on another machine, so the natural move — continue the work where something is actually up — lost the lineage.
  • Cached history was ~10 user turns deep with no size management, so even where the cache worked it was a weak basis for a handoff.
  • The UI lied about it. A cached thread on a dead host still said "Syncing messages…", or span on "Loading messages…" forever.

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/sessionDigest so 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, and t3_link_session takes 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

  • Fork-owned code throughout; upstream files touched only at marked seams (scripts/check-fork-markers.ts passes locally). Additive schema only; fork migration 1024.
  • Background history sync is behind VITE_T3_OFFLINE_HISTORY_SYNC (on by default; set false to isolate).
  • The workstreams share a foundation (the shared renderer, the installed-environment fix everything else depends on), so they ship as one PR rather than five.
  • Offline search is the honest subset — it cannot see a thread that was never opened on this device, and says so rather than pretending to replace the host's index.

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

…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.
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL labels Sep 1, 2026
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ℹ️ No successful main baseline artifact is available yet. This run establishes the initial measurement.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire 13.5 KiB 15.1 KiB
Codex Thread snapshot wire 7.2 KiB 7.3 KiB
Codex Live turn WebSocket wire 6.4 KiB 7.8 KiB
Codex Live turn WebSocket decoded 55.6 KiB 66.4 KiB
Codex Live turn messages 10 21
Claude Total thread wire 13.6 KiB 15.1 KiB
Claude Thread snapshot wire 7.2 KiB 7.3 KiB
Claude Live turn WebSocket wire 6.4 KiB 7.8 KiB
Claude Live turn WebSocket decoded 56.5 KiB 66.4 KiB
Claude Live turn messages 11 21

Baseline: unavailable · PR result: 0b751f0 · Source CI: success

Scenario and decoded snapshot size

10 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.

  • Codex decoded thread snapshot: 110.5 KiB
  • Claude decoded thread snapshot: 111.2 KiB

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.
@tusharbhardwaj-bk
tusharbhardwaj-bk merged commit 7c7ae28 into expbkmain Sep 1, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant