🌐 US-Proxy
class="logged-out env-production page-responsive" style="word-wrap: break-word;" >
Skip to content

feat: implement the chat state machine - #25908

Merged
hugodutka merged 5 commits into
hugodutka/chatd-refactorfrom
chatd-pr1-state-machine
Jun 11, 2026
Merged

feat: implement the chat state machine#25908
hugodutka merged 5 commits into
hugodutka/chatd-refactorfrom
chatd-pr1-state-machine

Conversation

@hugodutka

Copy link
Copy Markdown
Contributor

PR 1 of the chatd refactor. It serves 2 purposes:

  1. it implements the chat state machine; and
  2. it contains all the database changes in the chatd refactor PR stack.

I kept the database changes in a single PR to make rebasing easier. However, because of it, this PR doesn't compile and it doesn't pass tests - that's intentional. Only the tip of the stack will pass all tests.

@ibetitsmike

Copy link
Copy Markdown
Collaborator

@hugodutka I think it would be beneficial to use grill-with-docs to create a CONTEXT.md style file to describe all the concepts

@hugodutka

Copy link
Copy Markdown
Contributor Author

@ibetitsmike I'm planning to include that file in the final PR - it'll be a lightly edited version of the RFC we have in Notion.

@hugodutka
hugodutka force-pushed the chatd-pr1-state-machine branch from 5d6ca0a to 723e393 Compare June 3, 2026 12:45
Comment on lines +108 to +110
_ = f
_ = seeded
_ = result

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.

drive-by nit: underscore the func params instead

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done

Comment on lines +82 to +92
// Store exposes the active transaction store so callers can perform
// validation reads (for example loading the messages affected by an
// EditMessage transition) and metadata writes (for example updating
// title or labels) that must be atomic with the transition.
//
// Callers MUST NOT use Store to mutate execution-state tables
// (chats.status, chat_messages, chat_queued_messages, chat_heartbeats,
// or the version fields on chats). Those mutations belong to the
// transition methods and are validated against the state machine
// matrix.
func (tx *Tx) Store() database.Store { return tx.store }

@johnstcn johnstcn Jun 2, 2026

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.

concern, non-blocking: My biggest architectural suggestion: could we instead expose only the set of read-only 'safe' methods that cannot mutate state here? This could be a follow-up meta-refactor.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We talked about it on Slack: we can't expose only read-only methods, since metadata updates, like the workspace binding, are still handled by direct DB calls rather than transitions. We can however expose only a subset of Store to make direct DB calls inside machine.Update more intentional.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'm going to do this in a follow up PR after we merge the refactor. Then we'll be sure what subset is needed.

Comment on lines 1649 to 1658

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.

review: this is replaced by SetFamilyArchived. We need chatd to handle the state transition so we can't just go around mutating it with a bulk query.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

^ refers to AutoArchiveInactiveChats

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'm going to remove AutoArchiveInactiveChats in a follow up PR. I had to keep it here because it was causing problems during rebase.

@hugodutka
hugodutka force-pushed the chatd-pr1-state-machine branch 2 times, most recently from 779754a to f2c32c2 Compare June 4, 2026 18:51
@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown

Docs preview

📖 View docs preview for docs/admin/security/audit-logs.md

@mafredri mafredri left a comment

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.

Submitting part 1 of my review, looking at chatstate next.

Comment thread coderd/database/dbauthz/dbauthz.go Outdated
@@ -1999,6 +2032,13 @@ func (q *querier) DeleteChatDebugDataByChatID(ctx context.Context, arg database.
return q.db.DeleteChatDebugDataByChatID(ctx, arg)
}

func (q *querier) DeleteChatHeartbeats(ctx context.Context, arg database.DeleteChatHeartbeatsParams) (int64, error) {

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.

This seems to be a batch method, although the naming suggests its for a single chat, we should make the name reflect what it does (applies to multiple methods).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Renamed DeleteChatHeartbeats to BatchDeleteChatHeartbeats and UpsertChatHeartbeats to BatchUpsertChatHeartbeats. I didn't find any other queries that needed renaming.

@@ -0,0 +1,346 @@
-- Foundation for the chatd core state machine refactor (PR 1).

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.

I'd prefer to remove self-referential references like "PR 1" from these comments as the comments will live on without that context.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done.


-- 2. Add new versioning, ownership, retry, and pending-action fields to chats.
ALTER TABLE chats
ADD COLUMN snapshot_version bigint NOT NULL DEFAULT 1,

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.

Readers will wonder why snapshots begin at 1 while other versions at 0. I suggest encoding reasoning from the RFC as COMMENT ON on tables, columns, etc so that the knowledge doesn't just live hidden away in the migration file and RFC.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added COMMENT ON entries to explain it.


-- 11. Index for queue-order reads and head selection.
CREATE INDEX IF NOT EXISTS idx_chat_queued_messages_chat_position_id
ON chat_queued_messages(chat_id, position, id);

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.

This index requires a bit more explanation about its purpose and what it's targeting. Is the chat_id, position, and id known when querying? Is it for joining efficiency? Index-only lookups? Depending on table size it could even slow things down so worth going through the EXPLAIN (ANALYZE, COSTS, VERBOSE, BUFFERS, FORMAT JSON) of it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed the index altogether. I don't think it's needed at this point.


-- 14. chat_heartbeats: unlogged table for ownership leases. Keyed by
-- (chat_id, runner_id) so a single chat can briefly have entries from
-- multiple runners during failover.

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.

When adding a comment on, worth mentioning why it's safe that it's unlogged as well.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated the comment.

Comment thread coderd/database/queries/chats.sql Outdated
-- current runner heartbeat is stale. The runner_id IS NULL predicate is
-- a robustness extension for inconsistent rows where a worker_id exists
-- without a runner_id; normal missing ownership is worker_id IS NULL or
-- a missing or stale heartbeat row.

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.

I wonder if we should encode more about the inclusion criteria in the doc comment. Just seeing this comment doesn't really leave enough clues to verify the implementation and thus trusting that its correct. Making changes here becomes slightly dangerous. I'm guessing test coverage will prove it behaves the right way but that may also change as time goes on.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated the comment to describe the inclusion criteria.

Comment thread coderd/database/queries/chats.sql Outdated
-- are intentionally kept side-by-side with the legacy chatd queries
-- above so the existing runtime keeps working while the state machine
-- lands behind it.
-- =====================================================================

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.

I think these belong in their own file rather than having a custom form header comment with === separation. Just put this doc at the top. Also, referring to previous comment about query comments having enough high-level detail that a reader can verify the query implementation, I think it's worth going through the queries below through that lens as well.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think chats.sql is the right place for these queries. State machine queries aren't special in any way - they should be in the same place as all the other queries that modify and read from chat-related tables.

I think it's worth going through the queries below through that lens as well

Could you point out queries that should have clearer comments? I think they are pretty clear already.

Comment thread coderd/database/models.go
HistoryVersion int64 `db:"history_version" json:"history_version"`
QueueVersion int64 `db:"queue_version" json:"queue_version"`
GenerationAttempt int64 `db:"generation_attempt" json:"generation_attempt"`
RetryState pqtype.NullRawMessage `db:"retry_state" json:"retry_state"`

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.

What data type is this, curious why it's a raw message rather than e.g. json if the content is json?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The content is jsonb. sqlc uses pqtype.NullRawMessage to represent it.

@mafredri mafredri left a comment

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.

Since there was so much to go through, I've mainly focused on trying to review the production code with spot checks that the allowable states matches what the code claims. I have not done in-depth analysis of the tests and what they prove. From what I've gathered so far, the state machine seems sound.

// stillBlocked returns true if c has NOT received a value yet. The
// caller must already have established a happens-before ordering via
// some other channel so this check is meaningful.
func stillBlocked(c <-chan struct{}) bool {

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.

"Received a value" can be expressed as len(c) == 0 which is simpler. Does this also check if c is closed? Docs could be updated and len would remove disambiguity.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It also checks if c is closed. Updated the comment.

lockWG.Go(func() {
lockErr = m.Lock(ctx, func(_ database.Store) error {
close(lockEntered)
<-releaseLock

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.

We should try to avoid naked selects that block forever. If release doesn't happen we'll leave this goroutine running forever, lockWG.Wait blocks forever, etc. Channels won't et closed when require short-circuits the test. These types of setups have typically lead to flakes or hard-to-diagnose issues.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed by also selecting on ctx.Done.

// Force a sync round-trip through the DB. This serves the
// same role as a Sleep but is deterministic: by the time
// this read completes, the scheduler has had a chance to
// run goroutine B if it could make progress.

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.

This is not necessarily true on a highly congested low-resource system. Ideally we could test this with true determinism. Can't we hook into calls done by m.Update to know for sure?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed by hooking into LockChatAndBumpSnapshotVersion. It's still not a deterministic solution, but it should be a bit more reliable.

Comment thread coderd/x/chatd/chatstate/doc.go Outdated
// state classification helpers live in `state.go` and `transition.go`
// alongside unit-testable classifiers; the SQL is in
// `coderd/database/queries/chats.sql` (e.g. `LockChatAndBumpSnapshotVersion`,
// `UpdateChatExecutionState`).

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.

Referencing functio names and files is ripe to go stale. I'd like this doc to stand the test of time (barring a refactor).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done.

Comment thread coderd/x/chatd/chatstate/errors.go Outdated

// ErrInvalidState is returned when the chat row, queue, and
// archive flag together produce a combination outside the 13
// valid execution states described in the RFC.

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.

"The RFC" will not stand the test of time. These comments need to be updated and terminology established and kept consistent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed mentions of the RFC from the comments.

return PromoteQueuedMessageResult{}, xerrors.Errorf("set running: %w", err)
}
cancellations := inserted[:len(inserted)-1]
insertedUserMsg := inserted[len(inserted)-1]

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.

This looks safe today, but we never check len(inserted) as a guard, should we?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added a guard.

Comment thread coderd/x/chatd/chatstate/transitions.go Outdated
Comment on lines +846 to +847
}
return InterruptResult{}, newTransitionError(TransitionInterrupt, from, "unhandled state in Interrupt")

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.

Suggested change
}
return InterruptResult{}, newTransitionError(TransitionInterrupt, from, "unhandled state in Interrupt")
default:
return InterruptResult{}, newTransitionError(TransitionInterrupt, from, "unhandled state in Interrupt")
}

Suggestion: This structure protects against unintended logic changes in earlier cases where the return wouldn't happen. Also makes it more explicit that the earlier cases are the only allowed states.

Comment thread coderd/x/chatd/chatstate/transitions.go Outdated
if err != nil {
return EnterRequiresActionResult{}, xerrors.Errorf("get db now: %w", err)
}
deadline := sql.NullTime{Time: now.Add(5 * time.Minute), Valid: true}

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.

Nit: Move 5 * time.Minute to a constant with accompanying documentation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done.

"no pending dynamic tool calls",
)
}
now, err := tx.store.GetDatabaseNow(tx.ctx)

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.

Inside a transaction, this always returns the same value. Do we really need to do this round-trip? This seems only used once, should we bake it into the update query and just pass in the offset (5 min)?.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We could, but I think it's clearer this way. I'd like to avoid premature optimization: if this ever proves to be a bottleneck, let's optimize it.

Comment thread coderd/x/chatd/chatstate/transitions.go Outdated
}); err != nil {
return FinishInterruptionResult{}, xerrors.Errorf("set running: %w", err)
}
all := append([]database.ChatMessage{}, insertedPartial...)

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.

Small inconsistency, above we return insertedPartial directly (no copy), here we make a copy for all. If we don't need a copy because we're guarding the underlying array, we can just all = append(insertedPartial, insertedHead...).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed.

@hugodutka
hugodutka force-pushed the chatd-pr1-state-machine branch 8 times, most recently from 93e4119 to bb090c5 Compare June 11, 2026 17:03
@hugodutka
hugodutka force-pushed the chatd-pr1-state-machine branch from bb090c5 to 0bc334f Compare June 11, 2026 17:04
@hugodutka hugodutka mentioned this pull request Jun 11, 2026
@hugodutka
hugodutka changed the base branch from main to hugodutka/chatd-refactor June 11, 2026 17:07
@hugodutka
hugodutka merged commit 079e31e into hugodutka/chatd-refactor Jun 11, 2026
17 of 29 checks passed
@hugodutka
hugodutka deleted the chatd-pr1-state-machine branch June 11, 2026 17:08
@github-actions github-actions Bot locked and limited conversation to collaborators Jun 11, 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.

4 participants