Repository navigation
Conversation
There was a problem hiding this comment.
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 schemaSession.Family), and theclient.experimental.sessionFamily.getclient method. - Modified:
Session.Infogains optionalstatus: "idle" | "running". - Added: schema
Session.Persisted.SessionTransfer.Data.infochanges fromSession.InfotoSession.Persisted: exports no longer carrystatus, and imports are unaffected. - Modified:
SessionActive(/api/session/activeentries) gains optionalrootID. - Behaviour:
session.listomitsnexton the last forward page andpreviouson the first backward page. Callers that page until a cursor is missing stop one request earlier. - Behaviour:
session.permission.listreturns[]for a Location that is not running, instead of starting it. - SDK:
InstanceOptions.key/Options.keytakeSession.Persisted(.Encoded) instead ofSession.Info. This is the same shape without the optionalstatus.
No blocking concerns.
|
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.
931ed78 to
dccc014
Compare
There was a problem hiding this comment.
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.
Opening a Session with subagents previously rebuilt its family across N+1 reads:
session.get, pagedsession.list?parentID=, and per-memberpermissionandformreads, plus asession.getre-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 apermission.askedracing 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.listuses the same readiness check instead of starting idle Locations, andsession.listonly returns a directional cursor when another page exists.Session.Infogains optionalstatus("idle" | "running", while storage and Session transfer keep the status-lessSession.Persistedshape), and/session/activeentries gain optionalrootIDso 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, andtime.updateddirectly from events without re-reading the Session. Against an older server that lacks the family route or omitsstatus/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/activeonce per connection).Design notes, compatibility details, and remaining client migrations (

mini, web app request sweeps,opencode-ios) are recorded inspecs/v2/session-family-api.md.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)