GeezerVison is showing github.com — feat: add chat tree root, named children, and tree API by jscottmiller · Pull Request #29615 · coder/coder
Conversation
Behind the new chat-tree experiment, each user gets one lazily created tree root per organization and can create named child chats under it, up to depth 5 including the root. Stacked on #29614 (scott/chat-tree/1-chat-kind).
GET /organizations/{organization}/chats/tree creates the caller's root on first read (title Root, kind = root, standard initial system messages, status waiting), adopts the caller's parentless chats under it (changing only parent_chat_id), and returns root and chat kind rows with depth and child_chat_count; archived=true returns archived rows plus the root. POST /chats accepts parent_chat_id (validated and locked in the creating transaction: same owner and organization, not a subagent, not archived, depth below the limit) and defaults to the root when omitted. GET /chats/{chat} reports depth and child_chat_count to the owner. Roots cannot be archived, pinned, or shared. GET /chats still lists kind = chat rows only. With the experiment off the tree route is 404, parent_chat_id is 400, no root is created, and chats stay parentless.
Semantics: archiving cascades over the recursive subtree through parent_chat_id (named children and subagents) atomically, and re-reads the subtree after locking members so a child created concurrently under a descendant is included; unarchiving covers the chat and its direct subagents only and is rejected while the parent is archived. Auto-archive candidates are kind = chat rows whose whole subtree is inactive (walked per candidate through a new chat_subtree function), archived as a subtree, and recorded once per tick. Migration 000599 switches the parent and root foreign keys to ON DELETE CASCADE; DeleteOldChats skips archived rows with an unarchived descendant, so a subtree is purged by its parent's retention age. root_chat_id keeps its existing meaning (the spawning chat of a subagent) and is never the tree root; downstream code locates the tree root by (owner_id, organization_id, kind = 'root').
Internals: chatstate.CreateIdleChat (N -> W), Server.EnsureChatTreeRoot (per owner/org advisory lock, ReadCommitted transaction, model config resolved before the lock), validateChatTreeParent, ChatTreeParent; dbauthz wrappers scoped to the owner's chats in the organization; codersdk.Client.ChatTree, ChatTreeResponse, ChatTreeMaxDepth, CreateChatRequest.ParentChatID. ARCHITECTURE.md carries TODO markers where behavior changed; the prose is left for the maintainers.
Notes for reviewers:
- Adoption is not audited (no handler-level resource); root creation is audited from both the tree route and
POST /chats.
- Lazy versus eager materialization: a set-based eager migration was measured at about 10 s for 500k chats and 20k owners but rejected because it does not remove the adoption path (experiment-off creates stay parentless) and exposes roots and parents while the experiment is off; details in the plan's "Decisions and constraints" item 6.
- Deferred to the UI project: the stale comment in
chatHelpers.ts describing the removed COALESCE(root_chat_id, parent_chat_id) precedence.
Implementation plan (chat-tree-model)
Plan: chat-tree-model
Project chat-tree-model (implement-medium). Repository coder/coder, base origin/main (a698b026fe).
Branch stack: scott/chat-tree/1-chat-kind and scott/chat-tree/2-chat-tree, two draft PRs.
Revision 2: incorporates the plan panel review (REVIEW-plan.md).
Scope
First layer of the chat tree: data model, semantics, internal functions, and HTTP API. No UI, no
parent/child messaging tool. Everything user-visible is behind the new chat-tree experiment.
Out of scope: sidebar tree (project chat-tree-ui), send_chat_message tool (project
chat-tree-messaging), move/reparent API, drag and drop. One exception, accepted from review:
four existing frontend sites that infer "subagent" from parent_chat_id are switched to kind
so the legacy sidebar does not misrender named children when the experiment is on.
What the code does today (verified)
chats.parent_chat_id and chats.root_chat_id are set only on subagent chats
(coderd/x/chatd/subagent.go:1208-1380, both set at :1277-1278, :1351-1352). Subagents
cannot spawn subagents (subagent_catalog.go:264-272), so root_chat_id == parent_chat_id on
every existing child.
- "
parent_chat_id IS NULL" is the de facto discriminator for "top-level user chat" in:
- SQL:
GetChats (chats.sql:747), GetChatWorkerAcquisitionCandidates (:2477),
GetAutoArchiveInactiveChatCandidates (:2544), AutoArchiveInactiveChats (:2929),
CountChatCapacityActiveByPool / CountChatCapacityQueuedByPool / GetChatQueuedForCapacity
(:2967, :2979, :3000), GetChatsUpdatedAfter telemetry has_parent (:2393).
- Schema:
chat_acl_only_on_root_chats, chats_pin_order_parent_check,
idx_chats_auto_archive_candidates, idx_chats_worker_acquisition_candidates
(dump.sql:2212, :2216, :4804, :4822).
- View
chats_expanded coalesces a child's ACL from COALESCE(root_chat_id, parent_chat_id)
(dump.sql:2327-2337); the same LEFT JOIN and explicit column list is copied into 17 query
CTEs in chats.sql. sqlc.yaml:204 maps the view to database.Chat.
- Go (complete sweep list in PR 1 step 4 below).
- Family operations key on
root_chat_id: GetChatFamilyIDsByRootID (chats.sql:362, used by
chatstate.SetFamilyArchived, family.go:67-115), auto-archive activity and cascade
(:2924, :2946), aibridge GetAIBridgeChatCost (aibridge.sql:797). ArchiveChatByID /
UnarchiveChatByID (chats.sql:1-130) have no production callers (verify during PR 1).
- FKs
parent_chat_id and root_chat_id are ON DELETE SET NULL. The only deletion path is
DeleteOldChats (dbpurge of archived chats by updated_at, chats.sql:2369); there is no
DELETE endpoint.
chatstate.CreateChat requires at least one initial message and lands in running
(chatstate/transitions.go:98-121). CreateChat publishes a sidebar created watch event
(chatd.go:1464).
httpmw.RequireExperimentWithDevBypass skips the check in dev builds and RequireExperiment
returns 403 (httpmw/experiments.go:36-58); exp_chats.go:4742-4745 shows the
api.Experiments.Enabled + httpapi.ResourceNotFound pattern.
- Audit:
enterprise/audit/table.go:443-491; parent_chat_id/root_chat_id are ActionIgnore.
- Chat RBAC is owner-scoped; ACL sharing is read-only (
codersdk/chats.go:1944,
rbac/roles.go:456, :1206). SetFamilyArchived runs each member through ChatMachine.Update
on the dbauthz store, so cascades are authorised per row.
- Frontend sites that infer "subagent" from
parent_chat_id:
site/src/pages/AgentsPage/AgentsPageLayout.tsx:134, :575, :614-621;
site/src/components/ChatConversation/chatHelpers.ts:94-108 (cost key, resolved via
root_chat_id ?? parent_chat_id).
Design decisions
D1. Reuse parent_chat_id; keep root_chat_id as the subagent-family root (flag on decision 1)
Decision 1 (reuse parent_chat_id / root_chat_id) is viable for parent_chat_id but needs a
semantic migration because "has a parent" currently means "is a subagent". Giving root_chat_id
the meaning "tree root" would additionally break every family operation listed above (archive
cascade, auto-archive activity, cost aggregation, ACL inheritance would all span the user's whole
tree), and reparenting a shared chat would violate chat_acl_only_on_root_chats and then have its
own ACL overridden by the root's empty ACL in chats_expanded.
Chosen variant:
parent_chat_id: immediate parent of any kind. Set on named children (parent is the root or
another named chat) and on subagents (parent is the spawning chat). Unchanged for existing rows.
root_chat_id: unchanged meaning, the nearest non-subagent ancestor of a subagent chat. NULL on
the root and on named chats. Never points at the tree root unless the tree root itself spawned
the subagent. It is not the tree root: downstream projects locate the tree root by
(owner_id, organization_id, kind = 'root') or by walking parent_chat_id.
kind chat_kind NOT NULL DEFAULT 'chat' discriminator, values root, chat, subagent.
- The tree root of any non-subagent chat is the unique
kind = 'root' row for
(owner_id, organization_id), so no per-row tree-root pointer is required. Depth is computed
with a bounded recursive CTE over parent_chat_id.
- Cross-owner or cross-organisation parents are enforced in Go (
validateChatTreeParent), not by
a composite FK. Every tree query filters by owner and organisation, so a bad pointer can only
truncate a tree, never leak a chat.
Alternatives considered: (a) root_chat_id = tree root, with all family operations rewritten to
target "chat plus its subagents" via parent_chat_id (more churn, every ACL CTE changes meaning);
(b) a separate tree_parent_chat_id column (smallest blast radius, but contradicts decision 1 and
gives the UI two parent pointers to merge). This variant is raised at the step 3 checkpoint.
D2. Every "is subagent" gate keys on kind
PR 1 replaces each parent_chat_id IS NULL / ParentChatID.Valid gate with kind. With the kind
backfilled from parent_chat_id, PR 1 is behaviour-neutral (the FK change moved to PR 2 for this
reason). Completion criterion: rg 'ParentChatID.Valid' coderd/ enterprise/ returns only
wire-format copies (debug run context, hook ChatRef, db2sdk pointer copy, header logging).
D3. Root creation is a new idle-create transition
chatstate.CreateIdleChat: inserts the chat with the same initial system messages CreateChat
uses (deployment prompt when configured, detached workspace awareness) and no user message,
status = waiting, no ownership hint, no watch event. The state machine gains N -> W: CreateIdle.
A W row is never selected by GetChatWorkerAcquisitionCandidates; the first SendMessage
follows the normal W -> R0 path. (Revised at step 6, REVIEW-diff #6: the original "no messages"
wording would have let root turns run without the deployment system prompt.) TODO items are left in ARCHITECTURE.md (not edited beyond TODO markers).
D4. Lazy tree materialisation, owner context, advisory lock
chatd.Server.EnsureChatTreeRoot(ctx, ownerID, orgID) (root database.Chat, created bool, err error):
- Fast path: one query
GetChatTreeRootStateByOwnerAndOrganization returning the root row (if
any) and the count of adoptable chats (kind = 'chat' AND parent_chat_id IS NULL). If the root
exists and the count is 0, return.
- Slow path:
InTx(ReadCommitted); pg_advisory_xact_lock(hashtext('chat_tree:' || owner || org));
re-read; create the root with CreateIdleChat if absent (title constant Root, model config
resolved exactly like postChats without an explicit model, no workspace);
AdoptParentlessChatsIntoTreeRoot reparents every kind = 'chat' AND parent_chat_id IS NULL
chat of that owner in that org, archived included. The UPDATE changes only parent_chat_id:
no updated_at change (retention and sidebar order), no snapshot_version bump, no pubsub or
watch event. Runner snapshots keep a stale ParentChatID until their next transition, which is
safe once D2 is complete.
- Partial unique index
chats_one_tree_root_per_owner_org ON chats(owner_id, organization_id) WHERE kind = 'root' is the invariant; the advisory lock serialises creators, so a unique
violation is returned as an error, not retried inside the aborted transaction.
All queries run as the acting user. InsertChat is authorised with WithOwner(owner);
AdoptParentlessChatsIntoTreeRoot, GetChatTreeRootStateByOwnerAndOrganization, and
GetChatTreeByOwnerAndOrganization are authorised in dbauthz against
ResourceChat.WithOwner(owner).InOrg(org) for ActionUpdate / ActionRead. No
AsSystemRestricted is needed. Reparenting a pinned or shared chat needs no data changes because
the pin and ACL constraints are rewritten to kind (D2).
If the organisation has no usable model config, EnsureChatTreeRoot returns
ErrChatTreeRootUnavailable; GET .../chats/tree then responds 200 with root_chat_id: null and
the owner's parentless chats; POST /chats fails on model resolution as it does today, with or
without parent_chat_id, so no chain can be created that is not anchored at a root.
Audit: the tree handler records AuditActionCreate for the root when created is true.
Adoption is not audited (no handler-level resource; documented in the PR).
D5. Cascades walk parent_chat_id recursively; parent archived implies subtree archived
GetChatFamilyIDsByRootID is renamed GetChatSubtreeIDs and becomes WITH RECURSIVE over
parent_chat_id (depth bound 6: 5 tree levels plus one subagent level). For a chat without named
children this yields exactly the current family. SetFamilyArchived keeps its all-or-nothing rule
(a running descendant fails the request) and its ErrChatNotRoot guard changes from
ParentChatID.Valid to Kind == subagent. ArchiveChatByID/UnarchiveChatByID are left
untouched if confirmed unused (comment updated), otherwise rewritten the same way.
The invariant "an archived chat has no unarchived descendant" is maintained by:
- archive: cascade over the recursive subtree (above);
- create:
validateChatTreeParent reads the parent with GetChatByIDForUpdate inside the create
transaction (parent-first lock order, matching SetFamilyArchived, which locks the subtree root
first) and rejects archived = true (400);
- unarchive: reads the parent (plain read, before locking the target) and fails while the parent
is archived; the existing rule for subagents extended to named children;
- unarchive cascade: applies to the chat and its subagents only. Named descendants that were
archived stay archived (no resurrection of independently archived chats);
- auto-archive: a
kind = 'chat' candidate is inactive only when its whole recursive subtree is
inactive, and the cascade archives the recursive subtree. The root is never a candidate.
Delete cascade (PR 2): chats_parent_chat_id_fkey and chats_root_chat_id_fkey become
ON DELETE CASCADE (a subagent's parent and root are the same row). DeleteOldChats additionally
skips rows with any unarchived row in their recursive subtree and its comment is updated. This
also fixes the latent orphaning of subagents when a purged parent's children became apparent
roots. Recorded consequence: a subtree is purged by the parent's retention age.
D6. Experiment chat-tree
Checked with api.Experiments.Enabled(codersdk.ExperimentChatTree) (no dev bypass), returning
httpapi.ResourceNotFound (404) for the tree route and 400 for parent_chat_id in POST /chats.
- Off:
POST /chats rejects parent_chat_id (400), never touches the tree; tree route 404;
no root is created; existing chats stay parentless.
- On:
POST /chats calls EnsureChatTreeRoot and defaults parent_chat_id to the root;
tree route active.
D7. Two PRs
- PR 1
feat(coderd): add chat kind discriminator on scott/chat-tree/1-chat-kind.
- PR 2
feat(coderd): add chat tree root, named children, and tree API on
scott/chat-tree/2-chat-tree (base PR 1).
Invariants (contract for chat-tree-messaging and chat-tree-ui)
Kind:
kind = 'root': the per (owner_id, organization_id) tree root. Real chat, own transcript and
agent, no workspace binding at creation. parent_chat_id IS NULL, root_chat_id IS NULL.
Exactly zero or one per owner per organisation (partial unique index). Created lazily, never
eagerly. Cannot be archived (PATCH archived -> 400 Chat tree root cannot be archived),
auto-archive never selects it, therefore never purged. Cannot be pinned (400). Cannot be shared:
PATCH /chats/{chat}/acl on a root is rejected and its ACL stays empty (a shared root would be
invisible to the sharee, since GET /chats excludes roots and the tree is owner-only). Created
with title Root and the standard initial system messages; title generation never runs for it
(it runs only from POST /chats); PATCH title works. It can spawn subagents and receive messages
like any chat.
kind = 'chat': a user chat. With the experiment on, parent_chat_id is the root or another
kind = 'chat' chat of the same owner and organisation. With the experiment off, or before the
owner's tree has been materialised, parent_chat_id IS NULL (legacy top-level). root_chat_id IS NULL. Pinnable, shareable, auto-archive candidate individually (inactivity measured over its
recursive subtree). Title generation unchanged. May spawn subagents; Children on the API is
therefore no longer always empty for chats with a parent.
kind = 'subagent': spawned by a root or chat chat. parent_chat_id = spawning chat,
root_chat_id = spawning chat (unchanged today). Not pinnable, no own ACL (inherits the
spawning chat's ACL through chats_expanded), excluded from the tree and from GET /chats.
Cannot have named children (400 Subagent chats cannot have child chats) and cannot spawn
subagents (unchanged). Schema: CHECK ((kind = 'subagent') = (root_chat_id IS NOT NULL)),
CHECK (kind <> 'subagent' OR parent_chat_id IS NOT NULL),
CHECK (kind <> 'root' OR (parent_chat_id IS NULL AND root_chat_id IS NULL)).
Tree shape:
- Depth: root = 1; a named chat's depth = parent depth + 1. Only
root and chat rows count.
Maximum depth 5, so a chat at depth 5 cannot get named children (400
Chat tree depth limit exceeded, codersdk.ChatTreeMaxDepth = 5). Depth and child count are
reported only for chats whose ancestor chain ends at a root row (tree response and single GET),
only to the owner (a reader granted access through an ACL does not receive them), and omitted for
legacy parentless chats and for subagents.
- Named children are created only under a
root or chat parent owned by the same user in the
same organisation, and only while the parent is not archived. Cross-owner, cross-organisation,
unreadable, or non-existent parents all return the same 400 (no existence oracle). Shared (ACL)
access to a parent does not permit creating children under it. An owner's parent whose ancestor
chain does not end at a root returns a distinct 400 (Parent chat is not attached to a chat tree).
- Cycles are impossible by construction (no reparent API); a future move API must re-check depth
of the moved subtree and ancestor loops, and must lock parent before child.
- An archived chat never has an unarchived descendant (named or subagent). Archiving a chat
applies SetArchived(true) to the whole subtree; if any member is running, interrupting, or
requires_action the request fails and nothing changes. Unarchiving a chat whose parent is
archived fails (400). Unarchiving a chat unarchives it and its subagents only.
- Deleting a chat row (dbpurge only) deletes its subtree through the FK cascade; dbpurge never
deletes a row whose subtree contains an unarchived chat.
- Lazy materialisation adopts only
kind = 'chat' AND parent_chat_id IS NULL rows, including
archived ones, changing nothing but parent_chat_id. It never touches subagents and never
changes root_chat_id. Historical subagents orphaned by an earlier purge (parent NULL) are
kind = 'chat' after the backfill and are adopted like any other top-level chat (they already
appear in GET /chats today).
Capacity and worker pools: root and chat chats run in the top-level pool, subagent in the
subagent pool. Turn-status labels, push, summaries, plan-mode paths, and tool filtering treat
root and chat identically. AI Gateway attribution: HeaderCoderChatID is root_chat_id when
set, else the chat's own ID; HeaderCoderSubchatID is set only for subagents.
Subagent tools: wait_agent, message_agent, interrupt_agent accept only kind = 'subagent'
targets that are direct children of the calling chat (the ancestor walk stops at the first
non-subagent row); list_agents lists only kind = 'subagent' children. The lifecycle hook
ChatRef gains kind so consumers stop inferring "subagent" from parent_chat_id.
API sketch
Types (codersdk/chats.go):
type ChatKind string
const (
ChatKindRoot ChatKind = "root"
ChatKindChat ChatKind = "chat"
ChatKindSubagent ChatKind = "subagent"
)
const ChatTreeMaxDepth = 5
// Added to codersdk.Chat (existing fields kept: ID, Title, Status, Archived, PinOrder, Labels,
// UpdatedAt, ParentChatID, RootChatID, Children, HasUnread, ...).
Kind ChatKind `json:"kind"`
// Depth and ChildChatCount are populated by GET /chats/{chat} and the tree response only, and
// only for root/chat rows anchored at a root. Omitted elsewhere.
Depth *int `json:"depth,omitempty"`
ChildChatCount *int `json:"child_chat_count,omitempty"` // direct kind=chat children, any archived state
// CreateChatRequest gains:
ParentChatID *uuid.UUID `json:"parent_chat_id,omitempty" format:"uuid"`
type ChatTreeResponse struct {
// Null when the root could not be created (no usable model config); Chats is then the
// owner's parentless chats.
RootChatID *uuid.UUID `json:"root_chat_id" format:"uuid"`
// root and chat kind rows owned by the caller in the organisation, sorted by depth then
// updated_at desc. Subagents excluded. No Children embedding. Not paginated; bounded by the
// caller's unarchived chats unless archived=true.
Chats []Chat `json:"chats"`
}
codersdk.Chat.RootChatID becomes root_chat_id from the database, else the chat's own ID
(the parent fallback is dropped; for existing subagents the value is unchanged). Children
keeps its current meaning: direct subagent children only (every GetChildChatsByParentIDs
call site passes kinds = [subagent]: exp_chats.go:516, :1533, subagent.go:1068).
Endpoints (registered in coderd/chat_routes.go, both /api/experimental and /api/v2
mounts, Swagger annotated once with @Router /api/v2/organizations/{organization}/chats/tree):
| Method |
Path |
Behaviour |
GET |
/organizations/{organization}/chats/tree?archived=false |
Experiment chat-tree (404 otherwise). Calls EnsureChatTreeRoot for the caller, returns ChatTreeResponse. archived defaults to false; archived=true returns only archived rows plus the root. Owner-only; shared chats are not included. codersdk.Client.ChatTree. |
POST |
/chats |
Optional parent_chat_id. Experiment off: any value -> 400. Experiment on: omitted -> tree root (after EnsureChatTreeRoot); given -> validated (owner, org, kind not subagent, not archived, depth). |
GET |
/chats/{chat} |
Adds kind, depth, child_chat_count. Children = subagents. |
GET |
/chats |
Unchanged filters and pagination; kind = 'chat' only (excludes root and subagents). Named children appear flat. |
GET |
/chats/watch |
Events carry kind; a root creation emits no created event. |
PATCH |
/chats/{chat} |
archived allowed on chat (cascade to subtree; unarchive guarded by parent), 400 on root and subagent. pin_order 400 on root and subagent. |
PATCH |
/chats/{chat}/acl |
400 on root and subagent. |
GET |
/chats/{chat}/cost |
Aggregates the chat plus its subagents (root resolved via root_chat_id only). |
Internal functions (coderd/x/chatd):
func (p *Server) EnsureChatTreeRoot(ctx, ownerID, orgID uuid.UUID) (database.Chat, bool, error)
CreateOptions.ParentChatID validated in CreateChat via
validateChatTreeParent(ctx, tx, ownerID, orgID, parentID) (depth int, err error); returns
ErrChatTreeDepthExceeded, ErrChatTreeParentIsSubagent, ErrChatTreeParentArchived,
ErrChatTreeParentMismatch (also used for unreadable and missing parents). The messaging
project's tool and the HTTP handler both call CreateChat with ParentChatID.
func (p *Server) ChatTreeParent(ctx, chat) (database.Chat, bool, error);
GetChildChatsByParentIDs gains a kinds filter param.
New / changed queries (coderd/database/queries/chats.sql):
GetChatTreeRootStateByOwnerAndOrganization, GetChatTreeByOwnerAndOrganization (recursive
from the root, with depth and child_chat_count, archived filter, UNION of parentless
kind = 'chat' rows for the root-unavailable case), AdoptParentlessChatsIntoTreeRoot :execrows,
GetChatTreeDepthByID (recursive, bounded; 0 when the chain does not end at a root),
InsertChat gains kind, GetChildChatsByParentIDs gains kinds.
- Recursive rewrite of
GetChatFamilyIDsByRootID (renamed GetChatSubtreeIDs), auto-archive
candidate and cascade queries, DeleteOldChats guard.
kind replaces parent_chat_id IS NULL in the queries listed under "What the code does today";
index expressions and query expressions use the identical form (kind = 'subagent').
Sequencing
PR 1 scott/chat-tree/1-chat-kind (behaviour-neutral):
- Migration
000598_chat_kind:
CREATE TYPE chat_kind AS ENUM ('root','chat','subagent');
ALTER TABLE chats ADD COLUMN kind chat_kind NOT NULL DEFAULT 'chat';
UPDATE chats SET kind = 'subagent' WHERE parent_chat_id IS NOT NULL;
UPDATE chats SET root_chat_id = parent_chat_id WHERE parent_chat_id IS NOT NULL AND root_chat_id IS NULL;
UPDATE chats SET root_chat_id = NULL WHERE parent_chat_id IS NULL AND root_chat_id IS NOT NULL;
DROP VIEW chats_expanded; replace chat_acl_only_on_root_chats with
kind <> 'subagent' OR (acl empty); replace chats_pin_order_parent_check with
pin_order = 0 OR kind = 'chat'; add the three kind CHECKs; recreate
idx_chats_auto_archive_candidates with kind = 'chat' and
idx_chats_worker_acquisition_candidates with (kind = 'subagent'); recreate chats_expanded
with c.kind in the column list and LEFT JOIN chats root ON root.id = c.root_chat_id;
partial unique index chats_one_tree_root_per_owner_org.
Down: UPDATE chats SET parent_chat_id = NULL WHERE kind = 'chat';
DELETE FROM chats WHERE kind = 'root'; drop view, indexes, checks; drop column; recreate view;
restore the old checks and indexes; drop type.
- Queries:
kind in InsertChat (explicit param); the 17 CTEs get the kind column and the
join root.id = x.root_chat_id; kind gates in the eight queries above (acquisition row
returns kind); telemetry has_parent -> kind = 'subagent'.
make gen; audit table: kind ActionTrack, parent_chat_id ActionTrack (mutable from
PR 2 on).
- Go sweep (complete list):
database.Chat.IsSubChat() -> Kind == ChatKindSubagent (modelmethods.go:197);
chatstate.CreateChatInput.Kind (transitions.go:29) and dbgen.Chat defaulting Kind
from ParentChatID.Valid (dbgen.go:101);
subagent.go:1226-1229 spawn sets Kind = subagent; subagent_catalog.go:264-272
validateSubagentSpawnParent; subagent.go:1065 list_agents guard; subagent.go:1685-1718
isSubagentDescendant stops at the first non-subagent row;
chatd.go:1456 root fallback; chatd.go:2061-2096 archive guards; chatd.go:3447-3630
filterExternalMCPConfigsForTurn, toolAllowedForTurn, filterToolsForTurn,
activeToolNamesForTurn, stopAfterPlanTools, stopAfterBehaviorTools take isSubagent bool
(and mcp_tool_search.go:35,46,63); chatd.go:4454, :4707 label/summary skips;
generation_preparer.go:172, :245, :418, :847; agentadmission.go:70; worker.go:211;
chatstate/family.go:77; chatstate/errors.go:39 doc; chatprovider.go:644-660
CoderHeaders (chat id = root_chat_id else self; subchat only for subagents);
auto_archive.go:203; chathooks/trigger.go:83 and codersdk/x/agenthooks/types.go:60
ChatRef.Kind; exp_chats.go postChats (:1394, remove the :1435-1441 guard),
getChat:1531, getChatCost:1703-1706, patchChat:2317, :2390; exp_chats_acl.go:47,
:108; dbauthz.go:7453; db2sdk.go:1895-1914 (Kind, RootChatID fallback),
ChatRowsWithChildren. Completion criterion in D2.
codersdk.Chat.Kind; ChatKind type; Children doc comment; make gen for
typesGenerated.ts. Frontend compatibility edits: AgentsPageLayout.tsx:134, :575,
:614-621 use kind === "subagent"; chatTree.ts:40-43 comment.
- Tests: migration test with a fixture (parentless chat, subagent, shared parentless chat,
pinned chat) for backfill, CHECKs, and the down migration; dbauthz method tests for changed
queries; testutil.Fake chat fixtures set Kind; sweep of the ~59 test sites that insert
chats with RootChatID so they set Kind (or rely on the dbgen default); CoderHeaders
test; existing chatd/coderd tests pass.
PR 2 scott/chat-tree/2-chat-tree:
- Experiment
ExperimentChatTree = "chat-tree" with display name and ExperimentsKnown.
- Migration
000599_chat_tree_cascade: FKs ON DELETE CASCADE; index
(owner_id, organization_id) WHERE kind = 'chat' AND parent_chat_id IS NULL for the adoption
fast path. Down restores SET NULL and drops the index.
chatstate.CreateIdleChat and TODO markers in ARCHITECTURE.md: "Transitions used by the HTTP
endpoints" (CreateIdle), "Execution state transition diagram" (N --> W: CreateIdle),
PATCH /chats/{chat} (root rejects archive, recursive cascade over named descendants,
unarchive guard for named children, unarchive does not cascade to named descendants),
"Concurrent agent limiter" (top-level = kind IN (root, chat)), "Auto-archive loop" (root
never selected, subtree inactivity), tool list near line 885 (subagent tools restricted to
kind = subagent direct children).
- Queries listed above,
make gen, dbauthz entries and method tests.
EnsureChatTreeRoot, validateChatTreeParent (parent locked FOR UPDATE, archived
rejected), CreateChat parent handling, cascade guards (root archive/pin/ACL rejection,
parent-archived unarchive guard, unarchive limited to subagents), auto-archive subtree
inactivity, DeleteOldChats guard, message_agent/wait_agent/interrupt_agent direct
subagent child requirement.
- HTTP: tree route (
api.Experiments.Enabled, audit root creation), postChats parent handling,
patchChat changes, getChat/db2sdk depth/child_chat_count, codersdk.Client.ChatTree,
Swagger, make gen.
- Tests (all
t.Parallel, unique owners/orgs, no sleeps):
EnsureChatTreeRoot: idempotent; N goroutines racing produce one root; adopts archived,
pinned, and shared parentless chats without changing updated_at; leaves subagents and other
owners/orgs untouched; no model config -> ErrChatTreeRootUnavailable; no watch event.
CreateChat with parent: depth 5 accepted, depth 6 rejected; subagent parent rejected;
archived parent rejected; other owner / other org / missing parent return the same 400;
experiment off rejects parent_chat_id.
- Archive cascade over named descendants and subagents; running descendant fails atomically;
root archive 400; root pin 400; root ACL patch 400; unarchive child under archived parent
400; unarchive parent leaves independently archived named child archived.
- Auto-archive: root never selected; parent with an active named child not selected; inactive
subtree archived as a whole. dbpurge: archived parent with unarchived child (constructed
directly) is skipped; archived subtree purged together.
- Named child can spawn a subagent; subagent's
root_chat_id is the named child; capacity
pool classification by kind (existing capacity tests extended); message_agent from the root
to a grandchild subagent rejected.
- ACL on a named child is independent of the root (share child, root unshared, and vice versa).
GET /chats excludes root and subagents and Children holds subagents only; GET tree
returns depth, counts, and honours archived; experiment off -> 404 and no root row.
Checks before handoff: make gen, make fmt, make lint, targeted go test for
coderd/database/... (migrations, dbauthz), coderd/x/chatd/..., coderd chat tests,
site typecheck for the compatibility edits.
Testing approach
Real Postgres via dbtestutil.NewDB. chatd tests through chattest helpers and coderdtest for
HTTP. Race test (make test-race) on the EnsureChatTreeRoot concurrency test.
What the later projects must do
chat-tree-messaging:
- Add
send_chat_message on top of Server.SendMessage with SendMessageBusyBehavior
(queue | interrupt), resolving targets via ChatTreeParent and
GetChildChatsByParentIDs(kinds = [chat]). Restrict to the immediate parent and direct named
children unless the orchestrator decides otherwise.
- Decide whether a message from a child to a parent that is
kind = root is allowed (the root is
a real chat and can run turns; this plan allows it).
- Do not reuse
message_agent/wait_agent/interrupt_agent for named children; this layer
restricts them to direct subagent children.
- Audit, hooks (
user_prompt_submit admission), and spend attribution use the receiving chat's
owner, same as today.
chat-tree-ui:
- Build the tree from
GET /organizations/{organization}/chats/tree using parent_chat_id,
kind, depth, child_chat_count; treat root_chat_id: null as a flat list. Archived
subtrees come from the same endpoint with archived=true; children of very large nodes may
eventually need a paginated parent:<uuid> search filter, which this layer does not add.
- Stop inferring "subagent" from
parent_chat_id anywhere; use kind. Watch events carry kind.
- Shared-with-me chats are not in the tree response; keep them in a separate section from
GET /chats?source=shared_with_me.
- Subagents remain in
Chat.Children of their parent (one level), not in the tree response.
- Root cannot be archived, pinned, or shared; hide those actions. Root has a stable title
Root
until the user renames it.
- Move/reparent needs a new endpoint (not in this layer); the invariants above define what it
must re-check.
Decisions and constraints (orchestrator, step 3)
- D1 variant accepted.
parent_chat_id carries the tree edge; root_chat_id keeps its existing
meaning (nearest non-subagent ancestor of a subagent, NULL on root and named chats); the tree
root is located by (owner_id, organization_id, kind = 'root'). Reasoning recorded in the
Invariants section: root_chat_id drives archive, auto-archive, cost, and ACL family
operations, which must stay scoped to "a chat plus its subagents"; the messaging and UI
projects must not treat root_chat_id as the tree root.
- FK
ON DELETE CASCADE purging the subtree with the parent's retention age is accepted, given
the dbpurge guard that skips rows with unarchived descendants.
- PR 1 makes the three minimal
kind edits in AgentsPageLayout.tsx (discriminator swaps only);
the UI project owns everything else.
- Defaults accepted: root title
Root (UI may relabel), EnsureChatTreeRoot from the tree GET
and POST /chats only, unarchive does not resurrect independently archived descendants.
- All
REVIEW-plan.md dispositions stand as recorded.
- Amendment to decision 3 (human, via orchestrator): an eager migration is acceptable if it is fast on
large deployments. Assessed and rejected; lazy materialisation stays the primary path.
- Candidate migration (set-based):
INSERT INTO chats (owner_id, organization_id, kind, title, status, last_model_config_id, client_type) SELECT DISTINCT ON (owner_id, organization_id) ... FROM chats WHERE kind = 'chat' AND parent_chat_id IS NULL AND NOT EXISTS (root) picking the
newest chat's last_model_config_id, then UPDATE chats c SET parent_chat_id = r.id FROM chats r WHERE r.kind = 'root' AND same owner/org AND c.kind = 'chat' AND c.parent_chat_id IS NULL.
Indexes needed: chats_one_tree_root_per_owner_org (already added) for the anti-join and the
UPDATE ... FROM join; no new index.
- Measured on the current schema (14 indexes on
chats) with 500k parentless chats, 50k subagents,
20k owners in one organisation: INSERT 0.66 s (20k roots), UPDATE 9.5 s (500k rows), 10.2 s total
in one transaction; roughly linear (1M chats about 20 s). Lock scope: row locks on every
reparented chat and the unique index for the transaction duration; in a rolling upgrade, old
replicas' chat status and heartbeat writes block for that window. parent_chat_id is indexed, so
the UPDATE rewrites every row (no HOT updates) and roughly doubles the chats heap until vacuum.
The only trigger on chats fires on retry-state columns and is not involved.
- Fast enough, but it does not simplify the code and it changes experiment-off behaviour:
(a) with the experiment off, POST /chats still creates parentless chats (attaching them to the
root would move parent assignment outside the experiment), so adoption on read is still needed
when the experiment is later turned on; the eager step only front-loads one batch; (b) every
existing chat gains a visible parent_chat_id and a kind = 'root' row exists for every user
while the experiment is off, which decision 7 excludes and which forces the remaining legacy
frontend inferences (REVIEW-diff Package v2 UI into coderd with go embed #22) to be fixed in PR 1; (c) a migration cannot seed the
deployment system prompt (site config resolved in Go), so eager roots would need a lazy
first-message seeding path that lazy CreateIdleChat avoids; (d) the migration must guess a
model config (newest chat's, possibly disabled or deleted), whereas the lazy path resolves the
org default like POST /chats.
- Choice: stay lazy.
EnsureChatTreeRoot remains the single path for root creation and adoption;
the measured migration is recorded here in case a later project wants to pre-materialise trees
once the experiment is on by default.
Open questions (resolved at step 3, kept for the record)
- D1 variant (
root_chat_id keeps the subagent-family meaning) versus the literal decision 1.
ON DELETE CASCADE also applies to subagents of purged chats and purges a subtree by the
parent's retention age. Acceptable?
GET /chats stays flat (kind = 'chat', named children included) so the legacy sidebar keeps
listing every user chat; the four frontend compatibility edits are the minimum for it not to
misrender. Alternatively accept degradation and make no frontend edits.
- Root title constant
Root versus leaving it to the UI (title still stored).
EnsureChatTreeRoot is triggered from the tree route and POST /chats only, not from
GET /chats.
- Unarchive of a parent does not resurrect independently archived named descendants (chosen);
confirm.
Diff review dispositions
| # |
Reviewer |
Severity |
Location |
Finding |
Disposition |
Action |
| 1 |
Ilse |
blocker |
000598_chat_kind.down.sql:6 |
DELETE FROM chats WHERE kind='root' runs while the kind CHECKs still exist and (after 599 down) FKs are SET NULL; a root that spawned a subagent makes the RI action produce a subagent with NULL pointers and the CHECK fires. Reproduced on PG. |
accept |
Move the DROP CONSTRAINT block above the deletes and delete root subtrees explicitly (root_chat_id IN roots, then roots). Add a root-with-subagent case to the down test. |
| 2 |
Ilse |
blocker |
PR 1 DeleteOldChats + 000598 CHECKs with SET NULL FKs |
With only PR 1 deployed, purging an archived parent whose archived subagents fall outside the same LIMIT batch triggers SET NULL on the subagent and violates chats_kind_subagent_parent_check; dbpurge fails. PR 1 is not deployable alone. |
accept |
In PR 1, make DeleteOldChats include the subagent family in deletable (OR root_chat_id IN (SELECT id FROM deletable)), so parent and subagents delete in one statement. PR 2 then replaces it with the cascade version. Add a dbpurge test for the split-batch shape. |
| 3 |
Ilse, Tomas |
major |
chats.sql GetAutoArchiveInactiveChatCandidates |
Top-level recursive CTE is seeded by every old unarchived chat and scanned per outer row: O(candidates x subtree). Measured 55 s vs 0.19 s on 30k chats. |
accept |
sqlc rejects a nested WITH RECURSIVE in any form, so migration 000599 adds a plpgsql set-returning function chat_subtree(uuid) and the LATERAL calls it per outer row. Measured on 550k chats with recent activity everywhere (steady state): 5.8 s versus 3.4 s for the pre-change query and 55 s for the top-level CTE. |
| 4 |
Tomas |
major |
family.go archive cascade vs chattree.go create |
Archive reads the subtree once before locking descendants; a concurrent create locks only its direct parent P (descendant of the archive top), sees P unarchived, inserts X, commits; archive then archives P without X. Leaves an archived P with an unarchived child. |
accept |
After the machine.Update loop, re-read GetChatSubtreeIDs and process new IDs until the set is stable (all members locked means no in-flight create holds a parent lock). Add an interleaving test with two transactions. |
| 5 |
Tomas |
major |
auto_archive.go:93-114 |
Parent and named child can both be candidates in one batch; the second cascade returns already-archived members, so archived has duplicates (metrics, audit rows, digest). |
accept |
Track seen IDs in archiveOnce; skip candidates already archived in this tick and drop duplicate family members. Assert audit/digest counts in TestWorker_AutoArchiveTree. |
| 6 |
Tomas |
major |
CreateIdleChat / EnsureChatTreeRoot |
The root has no system messages, so its turns run without the deployment system prompt and workspace-awareness message that every other chat gets. Deviation from D3 ("no messages"). |
accept |
Let CreateIdleChatInput take initial system messages; EnsureChatTreeRoot passes the same deployment prompt and detached-awareness messages CreateChat uses. Status stays waiting; acquisition ignores waiting, so the root is still never acquired. Record the D3 deviation in the PR. |
| 7 |
Priya |
major |
exp_chats.go postChats root creation |
Root created through POST /chats is only logged, not audited; plan says root creation is audited. |
accept |
Emit audit.BackgroundAudit[database.Chat] (AuditActionCreate, New: root) when created; add audit-count assertions to TestChatTree_LazyRootAndAdoption (exactly one create row after the first read, none after the second). |
| 8 |
Priya |
major |
exp_chats_tree_test.go |
No test asserts the audit contract (create audited once, adoption not audited). |
accept |
Covered by the test added for #7. |
| 9 |
Ilse |
minor |
dbauthz.go AdoptParentlessChatsIntoTreeRoot |
RootChatID is not checked to be a kind='root' chat of the same owner/org; dbauthz is the only layer that can stop a mismatched root pointer. |
accept |
Fetch the root in the wrapper and reject unless kind root and owner/org match; update the dbauthz test to mock GetChatByID. |
| 10 |
Ilse |
minor |
000598_chat_kind.up.sql rolling upgrade |
Old replicas insert subagents with default kind='chat' and root_chat_id set, violating the new CHECK until restarted. |
accept |
Note the mixed-version window in the PR 1 description. No schema change. |
| 11 |
Ilse |
minor |
dbgen.go defaultChatKind |
A seed with only ParentChatID is classified subagent and fails the root CHECK; tests must set Kind explicitly. |
accept |
Derive from RootChatID.Valid only, matching CreateChatInput.kind(). |
| 12 |
Ilse |
nit |
chats.sql AutoArchiveInactiveChats |
Unused query whose cascade ignores named children. |
accept |
Mark unused in its comment (deletion is out of scope). |
| 13 |
Ilse |
nit |
DeleteOldChats :execrows |
Cascaded descendants are not counted in the returned row count. |
accept |
Note in the query comment. |
| 14 |
Tomas |
minor |
chattree.go:108 |
ResolveModelConfigID runs inside the transaction while holding the advisory lock and a pooled connection; the resolver uses a second connection. |
accept |
Resolve before opening the transaction when the pre-check shows no root; pass the resolved ID in. |
| 15 |
Tomas |
minor |
ARCHITECTURE.md |
The mermaid diagram edge was edited directly rather than marked TODO; missing markers for the parent-lock variant of Create and the Gateway attribution rule. |
accept |
Replace the direct edge edit with a TODO comment in the diagram block; add the two markers. No prose. |
| 16 |
Tomas |
minor |
chattree_test.go |
Missing: adoption on a second call after a chat is created post-root (fast path falls through), no created watch event / ownership hint on root creation, interleaving test for #4. |
accept |
Add the three cases. |
| 17 |
Tomas |
nit |
chattree.go:171 |
A sharee with read ACL takes a FOR UPDATE lock on another owner's chat before the owner check rejects. Momentary; released on rollback. |
accept |
Comment only. |
| 18 |
Priya |
minor |
exp_chats_tree.go resolver |
Any resolver error (including DB 500s) is folded into ErrChatTreeRootUnavailable, so a transient failure yields 200 with root_chat_id: null. |
accept |
Map only 4xx resolver responses to unavailable; return other errors so the handler 500s. |
| 19 |
Priya |
minor |
exp_chats.go postChats |
Experiment-off parent_chat_id rejection happens after several DB reads. |
accept |
Move the static 400s directly after request decode. |
| 20 |
Priya |
minor |
getChat position |
A sharee sees depth and child_chat_count of the owner's chat, including archived and unshared siblings. |
accept |
Gate attachChatTreePosition on chat.OwnerID == apiKey.UserID; note in Invariants. |
| 21 |
Priya |
minor |
chattree.go depth 0 |
An owner's legacy parentless parent yields Parent chat not found, which is misleading once the owner check passed. |
accept |
Add ErrChatTreeParentUnanchored -> 400 Parent chat is not attached to a chat tree.; keep mismatch for missing/unauthorized/other owner. |
| 22 |
Priya |
minor |
Frontend compat scope |
Three further parent_chat_id-as-subagent inferences remain (ChatTreeNode.tsx:76, ChatSummaryPanel.tsx:36, chatTree.ts:53-62) and will misfire for adopted chats with the experiment on. |
accept |
Three discriminator swaps (ChatTreeNode.tsx, ChatSummaryPanel.tsx, chatTree.ts) in PR 1. |
| 23 |
Priya |
nit |
exp_chats_tree_test.go:225 |
One-entry map loop. |
accept |
Collapse to a direct call. |
| 24 |
Priya |
nit |
exp_chats_tree_test.go |
Missing negatives: non-member tree GET (404), parent_chat_id nil UUID with experiment on (400). |
accept |
Add both. |
| 25 |
Priya |
nit |
exp_chats.go writeChatArchiveError |
case writeChatInvalidState(...) is a side-effecting case expression. |
accept |
Restructure with an explicit if before the switch. |
| 26 |
Priya |
nit |
chatHelpers.ts:97 |
Comment still describes the removed COALESCE(root, parent) precedence. |
defer |
UI project scope; behavior unaffected. |
Generated by Coder Agents on behalf of @jscottmiller.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Docs preview
Check off each page once it's been reviewed. If a page changes in a later push, its checkbox clears automatically so it gets a fresh look. Pages not yet wired into the docs navigation aren't listed here.
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Behind the chat-tree experiment, each owner gets one lazily created root
chat per organization. GET /organizations/{organization}/chats/tree creates
the root on first read, adopts the owner's parentless chats under it, and
returns root and chat kind rows with depth and direct child counts.
POST /chats accepts parent_chat_id (validated and locked in the creating
transaction, depth capped at 5 including the root) and defaults to the
root when omitted.
Archive cascades over the recursive subtree through parent_chat_id;
unarchive covers the chat and its subagents and is blocked under an
archived parent; tree roots reject archive, pin, and sharing.
Auto-archive treats a chat as inactive only when its whole subtree is,
and dbpurge skips archived chats with unarchived descendants now that the
parent and root foreign keys cascade on delete.
Adds chatstate.CreateIdleChat (N -> W) and TODO markers in
ARCHITECTURE.md for the human-authored updates.
Address the diff review: the archive cascade re-reads the subtree after
locking members so a child committed under a descendant during the
cascade is archived too; auto-archive records nested candidates once and
walks each candidate's subtree through a chat_subtree function inside the
LATERAL; tree roots start with the standard system messages and resolve
their model config before the locked transaction; root creation through
POST /chats is audited; adoption verifies the root belongs to the owner;
tree position is returned to the owner only; resolver failures other than
missing models surface as errors; an owner's unanchored parent gets a
distinct error.
linear-code
Bot
commented
Sep 22, 2026
•
edited
Loading
Uh oh!
There was an error while loading. Please reload this page.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Labels
experimental
Changes that might not necessarily be merged, until its approved to proceed with.
Milestone
No milestone
Development
Successfully merging this pull request may close these issues.
Uh oh!
There was an error while loading. Please reload this page.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
|