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

fix(agent): gate workspace context collection until the agent is ready - #26715

Merged
kylecarbs merged 6 commits into
mainfrom
kylecarbs/agentcontext-ready-gate
Jun 25, 2026
Merged

fix(agent): gate workspace context collection until the agent is ready#26715
kylecarbs merged 6 commits into
mainfrom
kylecarbs/agentcontext-ready-gate

Conversation

@kylecarbs

@kylecarbs kylecarbs commented Jun 25, 2026

Copy link
Copy Markdown
Member

Problem

Workspace context surfaced in chat (Coder Agents) is incomplete and racy on a fresh boot:

  • The context panel is missing personal skills (only repo-level skills under .claude/skills show up).
  • The MCP section lists .mcp.json files but no MCP servers are registered.
  • The Issues panel reports instruction files as unreadable, e.g. CLAUDE.md (file: unreadable) and .cursorrules (file: unreadable) with symlink resolve: lstat .../AGENTS.md: no such file or directory.

Root cause

agentcontext.Manager collected and pushed context too eagerly:

  • NewManager ran an eager resolve at agent init().
  • RunPush starts as a normal connection routine (startAgentAPI210) with no lifecycle gating, so the first snapshot was pushed (Initial=true) as soon as the agent API connected.

Both happened before startup scripts finish and before the lifecycle reaches ready. At that point:

  • CLAUDE.md / .cursorrules symlinks to AGENTS.md don't resolve yet, so EvalSymlinks fails and the resolver emits StatusUnreadable "symlink resolve" issues.
  • Personal skills haven't synced yet, so they're missing.
  • MCP servers connect via mcpManager.Reload(...) only after ready, so only .mcp.json configs appear, with no servers.

That partial, error-laden snapshot is persisted by coderd and can hydrate a chat.

Fix

Gate agentcontext.Manager until the agent is ready, unconditionally:

  • The Manager always starts gated. NewManager leaves the zero-value (version 0) snapshot in place and never walks the filesystem; RunPush withholds version-0 snapshots, so nothing reaches coderd.
  • The agent calls Manager.SetReady() from the lifecycle transition in handleManifest, right after startup scripts finish (ready, or terminal start_error / start_timeout so a failed startup still surfaces whatever context exists).
  • On SetReady, the Manager performs the first real resolve (version 1) and broadcasts it; RunPush ships it with Initial=true. Later changes (MCP connect, skill edits) re-resolve and push as before.

Eager resolution before ready was the bug, not a mode worth preserving, so the gate is always on rather than an opt-in option. This aligns the agent-side push with chatd, which already waits for agent readiness before loading context. No proto/coderd/DB changes: coderd simply never receives a pre-ready snapshot.

Design notes & decisions
  • Unconditional, not opt-in. An earlier iteration added the gate as an opt-in ManagerOptions.GateUntilReady. Since the eager resolve-on-construct was the defect, the option, the eager first resolve, and the now-dead resolveLocked helper were all removed; the Manager is always gated until SetReady.
  • Version 0 is the pre-ready sentinel. The gated placeholder is just the zero-value snapshot (version 0); the first real resolve is version 1, so the push loop withholds anything at version 0. An earlier revision carried a dedicated Snapshot.Initializing bool plus an HTTP /resync field, but the push loop was the only consumer and nothing read the HTTP field, so both were dropped.
  • Defer, don't retry symlinks. Transient "unreadable" symlinks are an artifact of collecting before checkout. Deferring until ready fixes all three symptom classes at once and avoids masking genuine post-ready errors (a broken symlink at ready is still reported).
  • Release on terminal startup states too (start_error, start_timeout), so a failed startup still surfaces whatever context exists instead of gating forever. On reconnect the Manager instance is reused and stays ready.

Tests

  • agentcontext.TestManager_WithholdsCollectionUntilReady simulates collection running before startup finishes (broken CLAUDE.md / .cursorrules -> AGENTS.md symlinks): asserts the gated snapshot is the empty version-0 placeholder with no resources and no unreadable issues, and that after SetReady (target now present) the inventory resolves cleanly to a single instruction file with no spurious issues.
  • agentcontext.TestRunPush_WaitsForReady asserts the push loop ships nothing while gated even when content exists, then ships the full inventory with Initial=true after SetReady.
  • agentcontext.TestManager_SetReadyIsIdempotent covers the version-0 placeholder before ready, the single resolve to version 1 on SetReady, and idempotency across repeated calls.
  • Updated agent.TestAgent_ContextStatePushed: the first push now already contains AGENTS.md with Initial=true and no UNREADABLE resources (no pre-startup empty/partial push).

Validated on the changed packages: go test -race ./agent/agentcontext/..., go test ./agent/ -run TestAgent_ContextStatePushed, golangci-lint run, go vet, gofmt (all clean).


🤖 Generated by Coder Agents on behalf of @kylecarbs.

The agentcontext.Manager resolved and pushed workspace context at agent
init, before startup scripts finished. The boot-time snapshot captured
unresolved instruction-file symlinks (CLAUDE.md / .cursorrules ->
AGENTS.md) as "unreadable" issues, missed skills that had not synced
yet, and listed .mcp.json configs with no connected MCP servers. That
partial, error-laden snapshot was pushed to coderd and could hydrate a
chat.

Add an opt-in readiness gate to the Manager (GateUntilReady + SetReady).
While gated it publishes only an Initializing snapshot and the push loop
ships nothing, so pre-startup partial state never reaches coderd or
hydrates a chat. The agent enables the gate and calls SetReady from the
lifecycle transition in handleManifest once startup scripts finish
(ready, start_error, or start_timeout). chatd already waits for agent
readiness before loading context, so this aligns the agent-side push
with that contract.
The readiness gate first landed as an opt-in option (GateUntilReady)
layered on top of the original eager resolve-on-construct behavior. But
eager resolution before the agent reaches lifecycle ready is the bug, not
a mode worth keeping: it captured unresolved instruction-file symlinks
(CLAUDE.md / .cursorrules -> AGENTS.md) as "unreadable", missed skills
that had not synced, and listed .mcp.json configs with no connected MCP
servers.

Make the gate unconditional. The Manager always starts gated: NewManager
publishes only an Initializing placeholder (version 0) and never walks the
filesystem until SetReady runs the first real resolve (version 1). This
removes the GateUntilReady option, drops the now-dead resolveLocked
helper, and deletes the eager first resolve from NewManager.

The agent already calls SetReady from the lifecycle transition in
handleManifest once startup scripts finish (ready, start_error, or
start_timeout), and chatd waits for agent readiness before loading
context, so the push side now matches that contract by construction.
Tighten the readiness-gate comments added with the gate. The behavior is
unchanged; the comments just restate it more concisely (and drop the
play-by-play in the gate checks, SetReady, the push loop, and the gate
tests).
…version 0

The push loop is the only consumer that needs to know a snapshot is the
pre-ready placeholder, and Version already encodes that: the gated
placeholder is the zero value (version 0) and the first real resolve is
version 1. The HTTP /resync SnapshotResponse.Initializing field had no
consumer (frontend, CLI, and coderd all ignore it).

Remove Snapshot.Initializing and the HTTP field, and gate RunPush on
snap.Version == 0. NewManager now leaves the zero-value snapshot in
place instead of stamping a placeholder. Behavior is unchanged.
@kylecarbs
kylecarbs requested a review from mafredri June 25, 2026 19:30

@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.

Why do LLM comments have to be so frustratingly bad. 😭

Considering I haven't reviewed the full context refactor, this review was pretty shallow, but didn't find any immediate issues with the logic.

Comment thread agent/agent.go
// the context manager's gate so it collects and pushes the
// now-complete inventory instead of pre-startup partial
// state.
a.contextManager.SetReady()

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.

Should this wait until after MCP manager reload? Or are they different code paths and one does not affect the other?

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.

Different code paths, and the ordering is intentional. SetReady() only releases the gate for instruction files and skills, which are complete once startup scripts finish. MCP servers connect asynchronously in mcpManager.Reload(...) (which can block while connecting, so it's deliberately placed after the lifecycle transition to avoid delaying ready). When the MCP engine's catalog changes it calls the Manager's Trigger (wired via SetOnReload(a.contextManager.Trigger)), driving a re-resolve and a follow-up push that includes the servers. Calling SetReady() after Reload(...) would only delay the instruction-file/skill push behind MCP connection for no benefit. Clarified the inline comment in e5e861c and left this thread open in case you want to weigh in on the ordering.

— 🤖 via Coder Agents, on behalf of @kylecarbs

Comment thread agent/agentcontext/manager.go Outdated
Comment thread agent/agentcontext/manager.go Outdated
Comment thread agent/agentcontext/resolve.go
@kylecarbs
kylecarbs merged commit 1742003 into main Jun 25, 2026
29 of 30 checks passed
@kylecarbs
kylecarbs deleted the kylecarbs/agentcontext-ready-gate branch June 25, 2026 20:17
@github-actions github-actions Bot locked and limited conversation to collaborators Jun 25, 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