Skip to content

feat(server): read a Session family in one request - #60925

Open
simonklee wants to merge 12 commits into
devfrom
oc-subsessions
Open

simonklee wants to merge 12 commits into
devfrom
oc-subsessions

Conversation

@simonklee

@simonklee simonklee commented Oct 31, 2026 •

Copy link
Copy Markdown
Member

Opening a Session with subagents previously rebuilt its family across N+1 reads: session.get, paged session.list?parentID=, and per-member permission and form reads, plus a session.get re-read after every terminal event. On a root with 97 subagents, opening the Session in the TUI sent 199 requests, booted idle Locations just to return [], and logged 404 bursts for children whose git worktrees had been deleted. Because reads merged into cached state and overwrote pending-request lists, children deleted while disconnected stayed listed until restart and a permission.asked racing an in-flight list read could be erased.

GET /api/experimental/session/:sessionID/family (client.experimental.sessionFamily.get) returns the complete Session tree for any member ID in one recursive query, paired with pending permissions and forms read only from Locations that have finished starting (LocationServiceMap.ready / Instance.live). session.permission.list uses the same readiness check instead of starting idle Locations, and session.list only returns a directional cursor when another page exists.

Session.Info gains optional status ("idle" | "running", while storage and Session transfer keep the status-less Session.Persisted shape), and /session/active entries gain optional rootID so reconnect hydration can sync one family per running root without walking ancestors.

The shared Solid data layer now loads families from the new endpoint, journals events that arrive while a Session, family, or pending-request read is in flight to replay them over the snapshot, and folds time.busy, time.idle, outcome, time.viewed, and time.updated directly from events without re-reading the Session. Against an older server that lacks the family route or omits status / rootID, the client falls back per connection to paged child and per-member reads. The TUI session route and open dialog now load a family in one read (plus /session/active once per connection).

Design notes, compatibility details, and remaining client migrations (mini, web app request sweeps, opencode-ios) are recorded in specs/v2/session-family-api.md.
svg


Added by the opencode bot (AI-generated).

Fixes #57139

Related: #56907

Public API: adds GET /api/experimental/session/:sessionID/family, optional Session.Info.status and /session/active rootID; session.list cursors only when another page exists (backwards compatible)

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified on source builds of the base and this PR. The new family route returns the whole tree, root first, for any member ID (tried with a grandchild), and 404 for an unknown ID. status is present on Session reads, and session.list drops next on the last forward page. Client, server, core and TUI tests nearest the change pass, and the changed packages type-check.

Public API impact (all backwards compatible for existing callers):

  • Added: GET /api/experimental/session/:sessionID/family (experimental.sessionFamily.get, response schema Session.Family), and the client.experimental.sessionFamily.get client method.
  • Modified: Session.Info gains optional status: "idle" | "running".
  • Added: schema Session.Persisted. SessionTransfer.Data.info changes from Session.Info to Session.Persisted: exports no longer carry status, and imports are unaffected.
  • Modified: SessionActive (/api/session/active entries) gains optional rootID.
  • Behaviour: session.list omits next on the last forward page and previous on the first backward page. Callers that page until a cursor is missing stop one request earlier.
  • Behaviour: session.permission.list returns [] for a Location that is not running, instead of starting it.
  • SDK: InstanceOptions.key / Options.key take Session.Persisted (.Encoded) instead of Session.Info. This is the same shape without the optional status.

No blocking concerns.

Fixes #57139
Related: #56907

@romainfd-arrakis

romainfd-arrakis commented Nov 3, 2026 •

Copy link
Copy Markdown

Thanks for this. Besides the request count, it also fixes the session loss after a reconnect in #59542: 0/15 → 15/15 TUIs kept in our repro (200 children, 5 TUIs, simulated sleep). Details and a rerun recipe are in this comment.

Pending permissions and forms live in memory inside a started Location,
so a Location that is not running cannot hold one. session.permission.list
still entered the Session's Location: it booted idle Locations (MCP
processes, watchers) just to return [], and failed with
LocationNotFoundError for Sessions whose worktree was deleted. Clients
read it for every member of a Session family, so one root with deleted
worktree children produced a burst of 404s on every open.

Instance.live, added for form listing, used RcMap.has, which is also true
while a Location is still starting, so a form list still waited on the
start and failed with it. Track readiness instead: LocationServiceMap
gains ready(ref), true only between a successful start and invalidation
or failure, read from the builds registry without waiting. The SDK's
embedded instance map tracks the same state for its own live.

session.permission.list now returns [] when the Location is not running,
like session.form.list.
Session.list returned cursor.next for every non-empty page, including the
last one, so a list whose size is a multiple of the limit, or a short
final page, made clients request one more empty page. Read one extra row
to learn whether another page exists, and return the cursor in the
direction of travel only then. A backward page arrives reversed, so its
extra row is dropped from the front.
Every view that shows a Session today joins it against the
/session/active snapshot and the execution event stream to learn whether
it is running. Put the answer on the Session itself: Session.Info gains
status, "running" while SessionExecution in this process owns a busy
period for the Session and "idle" otherwise.

Core's Session facade attaches status wherever it returns a Session, so
HTTP responses and plugins both see it. It snapshots the running set
before reading rows, so a read never pairs "idle" with a row from before
the terminal commit.

Status is process-local and must not be persisted. The previous shape is
now Session.Persisted: the store, durable events, and Session export and
import keep using it, so exports written before this change still import
and new exports, including the app and desktop transcript exports, carry
no status. Session.Status has no schema identifier because SessionStatus
already names the session.status event union.
To draw one Session family, clients list children member by member and
read pending permissions and forms per member. Opening a root with about
a hundred subagents took about two hundred requests, and every client
rebuilt the tree its own way with its own cap on members.

GET /api/session/:sessionID/family returns the root and every descendant
(root first, then by creation time and ID) from one recursive query,
with status taken from one snapshot of the running set. Any member's ID
returns the same family. The response is complete; it is not paginated.

It also carries the family's pending permission requests and forms.
Those live in memory inside running Locations, so the read collects them
only from members whose Location has finished starting. A member whose
Location is not running contributes nothing and is neither started nor
waited on.
Execution events carry only a Session ID, so a client that sees a child
start before it has loaded that child cannot tell which root to mark
busy. The iOS app walks parents one GET at a time to find out, and the
TUI loads the child to resolve its root.

Each /session/active entry now carries rootID, the root of the family
that contains the running Session, resolved for all running Sessions in
one query that shares the family read's root rule. It stays out of the
durable execution events, which record only irreducible facts;
/session/active is a read model and may carry derived state.
…st observation

The shared data layer built a family by paging direct children, read
pending permissions and forms per member, re-read a Session after every
terminal and viewed event, and merged every read into what it already
held. A child deleted while disconnected stayed listed until restart,
and a permission.asked that arrived before a permission list response
which missed it was erased by that response.

session.sync(id, { children: true }) now reads /session/:id/family. It
replaces the family's membership, forgets members the server no longer
lists, and takes each member's status and pending requests from the same
response. It also marks every member's Session, family, and pending
request reads complete, so opening a child costs nothing more.

Reads and events travel on different connections, so a journal records
the events that arrive while a read is in flight and replays them on top
of its response. Session field patches, status, membership, and pending
requests all go through it, and every fold is idempotent because replay
reapplies events the response may already include. Terminal and viewed
events fold time.idle, outcome, and time.viewed, and time.updated
follows the events the projector stamps, so the per-event Session
re-reads are gone. A family read invalidated in flight applies nothing,
because a newer read supersedes it.

Reconnect hydration reads one family per running root from the rootID
on /session/active instead of one Session per running child.
With the data layer's family read carrying every member's status,
permissions, and forms, the TUI's own per-member reads are redundant.
Started alongside the family read, they still went out as separate
requests.

The session route now awaits only the family read and no longer syncs
pending requests for each descendant. Tab prefetch drops its permission
and form reads. The open dialog reads one family per distinct root of
running Sessions and of Sessions with pending requests, instead of two
pending-request reads per running Session and a single read per root.
Opening a root with subagents now costs /session/active once per
connection and one family read.
New endpoints start experimental. The family read moves to
/api/experimental/session/:sessionID/family in the server.experimental
group, beside schedule and media.get, so the generated SDK exposes it
only as client.experimental.sessionFamily.get. In its own session group
the codegen would also emit a deprecated client.session.family alias for
a method that never shipped. The identifier experimental.session.family
collides with the session group's experimental methods in the codegen,
so it follows the persistentPty precedent for routes under
/api/experimental/session.

The spec records the open question of whether status on Session.Info and
rootID on /session/active should start experimental too.
…wire

A client from this branch talking to an older server receives Sessions
without status and /session/active entries without rootID. The Promise
client never checks, but the Effect client decodes responses, so it
rejected every Session from such a server.

Both fields are now optional on the wire. Servers with this change
always send them; a missing value means the server predates them, not
that the Session is idle. The client data layer then keeps the status it
derives from /session/active and execution events instead of inventing
one.
…amily route

A client from this branch talking to an older server got a 404 for every
family read, so the TUI closed the session it was opening.

The data layer now learns once per connection whether the server has the
family route. An older server answers it with a 404 that has no typed
error body; a missing Session still returns SessionNotFoundError and
still rejects. On the first untyped 404 the client falls back for the
rest of the connection and tries the route again after every reconnect.

The fallback restores the reads that preceded the family route: the
Session and its paged direct children, merged into the membership
already known, then pending permissions and forms for every known family
member, waiting only for the requested Session's. Status comes only from
/session/active and execution events, since older servers send none. A
stale read neither falls back nor marks the route missing. Delete the
fallback once the oldest supported server has the family route.
Learn the family route's absence per connection where the data layer
already invalidates on disconnect. server.connected is delivered after
the connection reports connected, so route effects started family reads
before the old reset and kept using the fallback after reconnecting to
an upgraded server.

Key family membership by the client's own root. The server roots a
family whose top names a missing parent at the top, while resolveRoot
walks to the missing ID, so such a family read showed no members and
dropped its journaled events.

A fallback read invalidated in flight no longer starts member reads that
could complete with pre-reconnect data. Metadata updates and permission
replies now go through the journal, so an in-flight read cannot undo
them, and metadata updates raise time.updated like the projector does.

Test fixtures drop status values that were only needed while status was
required, and the TUI fixture's family route now roots orphan families
and forwards failed sub-reads as the real server does. The spec states
what is implemented and what is still to do.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new concerns. The new commits rebase the same change onto current dev. The only differences are conflict resolutions and readyWhenCached in two test files that are new on dev, to fit LocationServiceMap.ready. On the new head, core, server, client and TUI type-check, and the nearest tests pass. A source build serves the family route and the last-page cursor as before. The public API impact is unchanged from my previous review.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

tui: a session move gets reverted by a session fetch that was already in flight

2 participants