Skip to content

Bug: skills refresh-state workspaceVersions map retains entries after watcher teardown #77997

Description

@fede-kamel

Problem

src/agents/skills/refresh.ts and src/agents/skills/refresh-state.ts accumulate two leaks under the existing call pattern:

  1. workspaceVersions: Map<string, number> in refresh-state.ts:8 — populated on every skills file-watch event via bumpSkillsSnapshotVersion({ workspaceDir }). No production removal path. Only resetSkillsRefreshStateForTest clears it (test-only).

  2. watchers: Map<string, SkillsWatchState> in refresh.ts:36 — populated on every ensureSkillsWatcher(workspaceDir) call. The only production teardown is the rare watchEnabled=false config path. The hot caller (auto-reply/reply/session-updates.ts:159) calls ensureSkillsWatcher on every session turn but never disposes on session end, so each distinct workspaceDir touched by any session adds a chokidar instance that lives for the gateway lifetime.

Why it leaks

Drove the unpatched module against 5,000 distinct workspace dirs:

=== exported names ===
  bumpSkillsSnapshotVersion
  getSkillsSnapshotVersion
  registerSkillsChangeListener
  resetSkillsRefreshStateForTest
  setSkillsChangeListenerErrorHandler
  shouldRefreshSnapshotForVersion
per-workspace clear API present: false

=== after 5000 bumps + simulated teardown ===
workspaces still reporting non-zero version: 5000
workspaces back to zero (cleaned up):       0
fresh untouched workspace reports: 0

Every entry persists; a fresh untouched workspace correctly reports 0, confirming the persistence is from real Map entries (not a global fallback).

Impact

  • Memory creep: ~150-600 bytes per workspaceVersions entry, plus full chokidar watcher (FDs + larger overhead) per watchers entry, scaling with distinct workspace dirs ever touched. Never freed until process restart.
  • FD pressure: each chokidar watcher holds inotify/fseventsd handles; on long-lived gateways managing many ephemeral sandboxes, this can hit FD limits before memory.
  • Lifecycle inconsistency: watchers cleans up on watchEnabled=false, workspaceVersions does not — both keyed by workspaceDir but different lifetimes.

Tracking PR

Fix in #77998. The PR adds (1) clearSkillsSnapshotVersionForWorkspace for safe per-workspace version cleanup, (2) disposeSkillsWatcher for unified teardown, and (3) opportunistic idle eviction (1h TTL) on every ensureSkillsWatcher call, so watchers.size ≤ # workspaces touched in the last 1h and workspaceVersions ⊆ watchers as keysets.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Normal backlog priority with limited blast radius.clawsweeper:linked-pr-openClawSweeper found an open linked pull request for this issue.clawsweeper:no-new-fix-prClawSweeper does not recommend queueing a new automated fix PR for this issue.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:crash-loopCrash, hang, restart loop, or process-level availability failure.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.staleMarked as stale due to inactivity

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions