Thanks to visit codestin.com
Credit goes to github.com

Skip to content

fix: exclude subagent chats from sidebar pagination#24404

Merged
mafredri merged 5 commits into
mainfrom
fix/chat-list-subagent-orphan
Apr 20, 2026
Merged

fix: exclude subagent chats from sidebar pagination#24404
mafredri merged 5 commits into
mainfrom
fix/chat-list-subagent-orphan

Conversation

@mafredri
Copy link
Copy Markdown
Member

@mafredri mafredri commented Apr 15, 2026

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). 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 a one-way archive invariant: parent.archived = true implies child.archived = true.

  • Archiving the parent cascades to all children (ArchiveChatByID matches via root_chat_id).
  • Unarchiving the parent cascades to all children symmetrically.
  • Archiving a child individually is allowed and leaves the parent untouched.
  • Unarchiving a child while its parent is archived is rejected (400). The check is atomic: chatd.UnarchiveChildChatAtomic locks 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.

GetChildChatsByParentIDs takes a three-state archived narg (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 MarkStale broadcast path uses GetChatsByWorkspaceIDs directly instead of GetChats + in-Go filter. MarkStaleParams.OwnerID is removed (dead after the switch).

Implementation plan

See docs/plans/chat-list-subagent-orphan.md (in-repo, gitignored) for the full plan. Key decisions:

  • D1: Exclude children from pagination, embed in parent response (human decision)
  • D2: Single HTTP response, two DB queries in the same handler (human + agent)
  • D5: getChat singular endpoint also embeds children
  • D6: Children always [] for all chats (never null)
  • D7 (R8): Archive invariant is one-way. Individual child archive allowed; child-unarchive requires active parent, enforced atomically via row lock on the child.

🤖 This PR was created with the help of Coder Agents, and will be reviewed by a human. 🏂🏻

mafredri

This comment was marked as resolved.

This comment was marked as resolved.

This comment was marked as resolved.

mafredri

This comment was marked as resolved.

This comment was marked as resolved.

mafredri

This comment was marked as resolved.

This comment was marked as resolved.

mafredri

This comment was marked as resolved.

This comment was marked as resolved.

mafredri

This comment was marked as resolved.

mafredri

This comment was marked as resolved.

Comment thread coderd/database/queries/chats.sql Outdated
Comment thread coderd/database/queries/chats.sql
@mafredri mafredri force-pushed the fix/chat-list-subagent-orphan branch from 3ad0256 to 3895552 Compare April 17, 2026 08:53
mafredri

This comment was marked as resolved.

This comment was marked as resolved.

mafredri

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.
@mafredri mafredri force-pushed the fix/chat-list-subagent-orphan branch from 3b0b875 to f94ef71 Compare April 17, 2026 17:25
Copy link
Copy Markdown
Member Author

@mafredri mafredri left a comment

Choose a reason for hiding this comment

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

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.
@mafredri mafredri marked this pull request as ready for review April 17, 2026 17:50
Comment thread coderd/database/db2sdk/db2sdk.go
Comment thread coderd/database/dbauthz/dbauthz.go
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Observation: we're getting to the point where dbfake.Chat / dbgen.Chat would be useful.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Comment thread coderd/exp_chats.go
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.
@mafredri mafredri merged commit fc24937 into main Apr 20, 2026
29 checks passed
@mafredri mafredri deleted the fix/chat-list-subagent-orphan branch April 20, 2026 10:20
@github-actions github-actions Bot locked and limited conversation to collaborators Apr 20, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants