Summary
The adhoc.json session metadata file (stored at .herdctl/session-metadata/adhoc.json) grows unboundedly because there is no pruning, TTL, or cleanup mechanism for stale metadata entries. On production (devbox.valfenda.net), there are 110 adhoc session metadata entries that are never cleaned up.
This is separate from #164 (attribution race condition for new sessions) and distinct from #167 (cross-agent metadata contamination). Issue #167 addresses metadata written to the wrong agent's file; this issue addresses the fact that the adhoc.json file itself grows indefinitely with entries for sessions that may no longer exist on disk.
Root Cause
How adhoc metadata entries are created
When SessionDiscoveryService.getAllSessions() scans ~/.claude/projects/, directories that don't match any known fleet agent use metadataKey = "adhoc" (line 552 of session-discovery.ts). Every session in these unattributed directories gets an autoName and preview extracted from its JSONL file and cached into .herdctl/session-metadata/adhoc.json via batchSetAutoNames("adhoc", ...) and batchSetPreviews("adhoc", ...).
The critical amplification path: getAllSessionGroups() in web-chat-manager.ts calls getAllSessions() without a limit parameter, so Phase 2's top-N filtering is skipped and every single non-sidechain session in every non-temp directory is enriched and has metadata written.
Why entries are never removed
The SessionMetadataStore class has no cleanup, pruning, TTL, or expiry logic whatsoever. The only "delete" operation is removeCustomName(), which removes a user-set custom name but doesn't address the autoName/preview cache entries. There is:
- No TTL or expiry check on metadata entries
- No periodic pruning job
- No max-entries limit
- No reconciliation against existing JSONL files
- No staleness detection
Growth pattern
Every unique Claude Code CLI session file discovered in any unattributed directory gets an entry added to adhoc.json. These entries persist even after:
- The underlying JSONL session file is deleted by Claude Code
- The working directory no longer exists
- The session hasn't been accessed in months
On a development machine with active Claude Code usage, new sessions are created frequently, and old ones are never cleaned up, leading to monotonic growth.
Key files
| File |
Line |
Role |
packages/core/src/state/session-metadata.ts |
Entire file |
SessionMetadataStore — no cleanup logic exists |
packages/core/src/state/session-discovery.ts |
552 |
metadataKey = agentName ?? "adhoc" — adhoc key assignment |
packages/core/src/state/session-discovery.ts |
660-666 |
Batch writes metadata for all enriched sessions |
packages/web/src/server/chat/web-chat-manager.ts |
211 |
getAllSessions(agentList) called without limit — enriches ALL sessions |
Impact
Severity: Medium — Not a crash or data loss bug, but causes degradation over time:
-
Growing file size: adhoc.json grows with every newly discovered unattributed session. At 110 entries with autoName + autoNameMtime + preview + previewMtime per entry, the file is still manageable (~50-100KB), but on machines with heavy CLI usage it will grow to thousands of entries over months.
-
Increasing I/O per request: Every call to getAllSessions() (triggered by /api/chat/all and /api/chat/recent) reads the entire adhoc.json into memory, parses it, and potentially writes it back with new entries. As the file grows, this read/parse/write cycle gets slower.
-
Memory overhead: The in-memory cache in SessionMetadataStore holds the full parsed adhoc.json object, which grows proportionally.
-
Wasted JSONL parsing: The autoName/preview cache entries reference sessions whose JSONL files may no longer exist. When resolveAutoName() checks the cache and finds a valid entry, it skips the JSONL parse — but the entry itself is dead weight that was created by parsing a file that no longer matters.
Proposed Fix
Add a prune(agentName, validSessionIds: Set<string>) method to SessionMetadataStore that removes entries whose sessionId is not in the provided set. Call it at the end of getAllSessions() for each directory group, passing the set of sessionIds that were actually found during the scan.
// In SessionMetadataStore
async prune(agentName: string, validSessionIds: Set<string>): Promise<number> {
const metadata = await this.loadMetadata(agentName);
if (!metadata) return 0;
let pruned = 0;
for (const sessionId of Object.keys(metadata.sessions)) {
if (!validSessionIds.has(sessionId)) {
delete metadata.sessions[sessionId];
pruned++;
}
}
if (pruned > 0) {
await this.saveMetadata(agentName, metadata);
}
return pruned;
}
Additionally, consider:
- A configurable max-entries limit (e.g., keep most recent 500 entries by mtime)
- Only running the prune on every Nth call or at startup to avoid prune-on-every-request overhead
Relationship to other issues
Summary
The
adhoc.jsonsession metadata file (stored at.herdctl/session-metadata/adhoc.json) grows unboundedly because there is no pruning, TTL, or cleanup mechanism for stale metadata entries. On production (devbox.valfenda.net), there are 110 adhoc session metadata entries that are never cleaned up.This is separate from #164 (attribution race condition for new sessions) and distinct from #167 (cross-agent metadata contamination). Issue #167 addresses metadata written to the wrong agent's file; this issue addresses the fact that the
adhoc.jsonfile itself grows indefinitely with entries for sessions that may no longer exist on disk.Root Cause
How adhoc metadata entries are created
When
SessionDiscoveryService.getAllSessions()scans~/.claude/projects/, directories that don't match any known fleet agent usemetadataKey = "adhoc"(line 552 ofsession-discovery.ts). Every session in these unattributed directories gets an autoName and preview extracted from its JSONL file and cached into.herdctl/session-metadata/adhoc.jsonviabatchSetAutoNames("adhoc", ...)andbatchSetPreviews("adhoc", ...).The critical amplification path:
getAllSessionGroups()inweb-chat-manager.tscallsgetAllSessions()without a limit parameter, so Phase 2's top-N filtering is skipped and every single non-sidechain session in every non-temp directory is enriched and has metadata written.Why entries are never removed
The
SessionMetadataStoreclass has no cleanup, pruning, TTL, or expiry logic whatsoever. The only "delete" operation isremoveCustomName(), which removes a user-set custom name but doesn't address the autoName/preview cache entries. There is:Growth pattern
Every unique Claude Code CLI session file discovered in any unattributed directory gets an entry added to
adhoc.json. These entries persist even after:On a development machine with active Claude Code usage, new sessions are created frequently, and old ones are never cleaned up, leading to monotonic growth.
Key files
packages/core/src/state/session-metadata.tsSessionMetadataStore— no cleanup logic existspackages/core/src/state/session-discovery.tsmetadataKey = agentName ?? "adhoc"— adhoc key assignmentpackages/core/src/state/session-discovery.tspackages/web/src/server/chat/web-chat-manager.tsgetAllSessions(agentList)called without limit — enriches ALL sessionsImpact
Severity: Medium — Not a crash or data loss bug, but causes degradation over time:
Growing file size:
adhoc.jsongrows with every newly discovered unattributed session. At 110 entries with autoName + autoNameMtime + preview + previewMtime per entry, the file is still manageable (~50-100KB), but on machines with heavy CLI usage it will grow to thousands of entries over months.Increasing I/O per request: Every call to
getAllSessions()(triggered by/api/chat/alland/api/chat/recent) reads the entireadhoc.jsoninto memory, parses it, and potentially writes it back with new entries. As the file grows, this read/parse/write cycle gets slower.Memory overhead: The in-memory cache in
SessionMetadataStoreholds the full parsedadhoc.jsonobject, which grows proportionally.Wasted JSONL parsing: The autoName/preview cache entries reference sessions whose JSONL files may no longer exist. When
resolveAutoName()checks the cache and finds a valid entry, it skips the JSONL parse — but the entry itself is dead weight that was created by parsing a file that no longer matters.Proposed Fix
Add a
prune(agentName, validSessionIds: Set<string>)method toSessionMetadataStorethat removes entries whose sessionId is not in the provided set. Call it at the end ofgetAllSessions()for each directory group, passing the set of sessionIds that were actually found during the scan.Additionally, consider:
Relationship to other issues