fix: exclude subagent chats from sidebar pagination#24404
Conversation
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
3ad0256 to
3895552
Compare
This comment was marked as resolved.
This comment was marked as resolved.
Subagent (child) chats could appear as standalone top-level entries in the Agents sidebar when their parent chat fell outside the loaded page set. `buildChatTree` promoted orphaned children to root nodes because `GetChats` returned all chats in a single flat, paginated list. `GetChats` now returns only root chats (`parent_chat_id IS NULL` is unconditional in the SQL). A new `GetChildChatsByParentIDs` query fetches children for visible roots, and the handler embeds them in each parent's `Children` field. The singular `getChat` endpoint also embeds children for root chats. The frontend `buildChatTree` reads children from the embedded field, and WebSocket handlers route child events into the parent's `children` array instead of prepending them at the top level. `patchChat` enforces the archive-family invariant at the server: - Archive on a child chat: rejected (400). `ArchiveChatByID` cascades at the root level only. - Unarchive on a child chat: rejected (400) when the parent is still archived. Allowed when the parent is active so legacy lone-archived children can be recovered. Gitsync's `MarkStale` broadcast path now uses `GetChatsByWorkspaceIDs` directly instead of `GetChats` + in-Go filter. `GetChats` is a single-purpose query for the paginated sidebar. `GetChildChatsByParentIDs` does not filter by archive state. The cascade and the `patchChat` invariants keep child and parent archive state consistent at write time; a read-time filter would race those writes and could drop children whose archive flag had not yet caught up with the parent's.
Archive invariant is one-way: parent archived implies child archived. Individual child archive is permitted and leaves the parent untouched; the sidebar hides children whose archive state differs from the parent (handler filters embedded children by the active archive filter via the new three-state archived narg on GetChildChatsByParentIDs). Child unarchive remains rejected while the parent is archived. The check is now atomic: chatd.UnarchiveChildChatAtomic locks the child row for update before re-reading the parent, so a concurrent ArchiveChatByID cascade on the parent serializes behind our lock and cannot race the handler into the forbidden (parent archived, child active) state. Locking the child first (not the parent) avoids the deadlock the opposite order would cause with the cascade's row visit order. Frontend: archiveChat optimistic update and the 'deleted' pubsub event handler both strip the archived child from its parent's embedded children array via the new removeChildFromParentInCache helper, so the sidebar converges without a full refetch. Drop the dead MarkStaleParams.OwnerID field. The R7 switch to GetChatsByWorkspaceIDs left it with no consumer; both prod call sites in workspaceagents.go and the seven test call sites in worker_test.go are updated. Refs: DEREM-19 (reopened), DEREM-27 (TOCTOU), DEREM-28 (dead field), AMREM-2 (embedded child removal).
…t test after root-only GetChats
TestExploreSubagentIsReadOnly, added on main after this branch
forked, looked up explore-mode child chats via
db.GetChats(OwnerID) and filtered rows whose ParentChatID.Valid
was true. After the R1 change that makes GetChats unconditionally
root-only, the helper returns no children and the test fails on
require.Len(exploreChildren, 1) with an empty slice. Same class
of break as R7's TestComputerUseSubagentToolsAndModel fix:
switch to GetChildChatsByParentIDs against the returned root IDs.
TestGetChat/GetChatEmbedsChildren only covered the active-root
path. The singular getChat handler passes
sql.NullBool{Bool: chat.Archived, Valid: true} to
GetChildChatsByParentIDs so an archived root embeds its
cascaded archived children. A regression that hardcoded
Bool: false would pass the existing assertions while silently
dropping every child from the archived-root view. Extend the
subtest with an archive-then-get pass that asserts the
archived root still embeds its archived child.
Refs: DEREM-30.
3b0b875 to
f94ef71
Compare
mafredri
left a comment
There was a problem hiding this comment.
Final re-review (Netero + panel). All 30 findings closed. No new issues found.
DEREM-30 (archived-root getChat test) addressed in f94ef71 with a verified-with-teeth test. Branch rebased onto current main; the TestExploreSubagentIsReadOnly migration from #24448 handled correctly.
9 rounds, 30 findings raised, 23 fixed by author, 1 panel-closed (DEREM-9, unanimous 20/20), 6 dropped by orchestrator. The design matured through review: root-only pagination, embedded children, one-way archive invariant with atomic enforcement, gitsync scoped to workspace query, defensive rollout fallback. Every lifecycle path (create, update, archive, unarchive, delete) has test coverage. The UnarchiveChildChatAtomic row-lock design was independently verified by three concurrency-focused reviewers.
Ready for human merge decision.
"Eight rounds in, 29 findings addressed. The opponents have learned." -- Hisoka (R8)
[DEREM-1] Fixed.
🤖 This review was automatically generated with Coder Agents.
…r.UnarchiveChat Replaces the standalone UnarchiveChildChatAtomic helper and the handler's switch that bypassed chatDaemon with the archive path's shape: - Server.UnarchiveChat branches on ParentChatID.Valid internally. For a root chat it delegates to applyChatLifecycleTransition as before. For a child chat it opens an InTx that locks the child row (GetChatByIDForUpdate), re-reads the parent, rejects with ErrChildUnarchiveParentArchived if the parent is archived, and otherwise UnarchiveChatByID + publish events. - patchChat's unarchive branch collapses to the same shape as archive: chatDaemon when available, plain UnarchiveChatByID fallback when not. No special-casing for child chats at the handler layer. - ErrChildUnarchiveParentArchived remains exported so the handler can translate it to 400. - Tests for the atomic path move from the deleted coderd/x/chatd/unarchive_test.go into TestUnarchiveChildChat against Server.UnarchiveChat, which is the supported path. Behavior is unchanged (same invariant, same locking ordering, same sentinel error). Code structure now matches Server.ArchiveChat and does not introduce a second code path that skips chatDaemon.
There was a problem hiding this comment.
Observation: we're getting to the point where dbfake.Chat / dbgen.Chat would be useful.
There was a problem hiding this comment.
Yes, very much agree! We should probably do a big bang refactor and change all existing patterns to avoid introducing more. Or go we could go per-package.
Shortens the comments written in response to R8-R10 review feedback. The why's are retained where they add context (one-way invariant, child-lock-before-parent deadlock rationale, archived=false filter on GetChatsByWorkspaceIDs). Removed are restatements of the code, future- feature speculation, and self-justifications about what the tests\ndon't exercise. Net -110 lines across the changed files; no behavior change.
Subagent (child) chats could appear as standalone top-level entries in the Agents sidebar when their parent chat fell outside the loaded page set.
buildChatTreepromoted orphaned children to root nodes becauseGetChatsreturned all chats in a single flat, paginated list.GetChatsnow returns only root chats (parent_chat_id IS NULL). A newGetChildChatsByParentIDsquery fetches children for visible roots, and the handler embeds them in each parent'sChildrenfield. The singulargetChatendpoint also embeds children for root chats. The frontendbuildChatTreereads children from the embedded field, and WebSocket handlers route child events into the parent'schildrenarray instead of prepending them at the top level.patchChatenforces a one-way archive invariant:parent.archived = trueimplieschild.archived = true.ArchiveChatByIDmatches viaroot_chat_id).chatd.UnarchiveChildChatAtomiclocks the child row for update, re-reads the parent, and rejects if the parent is archived, so a concurrent archive cascade cannot race the handler into the forbidden (parent archived, child active) state.GetChildChatsByParentIDstakes a three-statearchivednarg (true/false/NULL) so the list handler can filter embedded children to match the archive state being viewed. Individually-archived children are hidden from the active-parent sidebar; archived-parent views show the cascaded archived children. Finding individually-archived children via pagination (the archived:true list) is an accepted non-feature; they can be discovered by archiving the parent to cascade.Gitsync's
MarkStalebroadcast path usesGetChatsByWorkspaceIDsdirectly instead ofGetChats+ in-Go filter.MarkStaleParams.OwnerIDis removed (dead after the switch).Implementation plan
See
docs/plans/chat-list-subagent-orphan.md(in-repo, gitignored) for the full plan. Key decisions:getChatsingular endpoint also embeds childrenChildrenalways[]for all chats (never null)