The Problem
Startup-sync latency for the gh-workflow MCP server regressed severely in PR #10093 (ticket #10092). The detectStaleCommentsCounts sentinel sweep — added to catch comment-deletion drift that slips past the updatedAt gate — runs 37 serial GraphQL round trips per runFullSync (3,669 tracked issues / 100 batch size), adding ~11–18 seconds of wall-clock per sync on every invocation. This pushes the MCP stdio handshake over Claude Code's registration budget, causing intermittent UI invisibility of the server and, when the client kills the process mid-sync, truncating the subsequent git pull --rebase --autostash and wedging the main checkout (observed 2026-04-19 at 22:42PM — separate recovery required).
The sweep's raison d'être was narrow to begin with: GitHub does not bump issue.updatedAt when a comment is deleted, so comment deletion on an issue with no subsequent activity stays locally rendered indefinitely. In practice this matters only for closed/quiet issues — an archival-accuracy concern, not an operational one.
The Architectural Reality (the actual oversight)
The gh-workflow sync currently stores commentsTotal in metadata via issue.comments?.totalCount ?? 0 at ai/mcp/server/github-workflow/services/sync/IssueSyncer.mjs:450 and :593 — a scalar sourced from GitHub's dedicated comments { totalCount } GraphQL field.
Historical context: the first versions of this MCP server didn't fetch timelines — comments came from a dedicated endpoint. When unified timelines were added later (primarily for the portal app's ticket rendering) the sync converged on timelineItems as the authoritative per-issue event source, with #10090 adding pagination-exhaust so timelineItems.nodes is the complete set. Sourcing commentsTotal from a separate scalar is a pre-timeline-era leftover — every comment already lives in timelineItems.nodes as an ISSUE_COMMENT typed node. Counting them is a JS loop over data already in memory. The #10092 sentinel is secondary infrastructure built on top of that oversight.
Fix
- Remove the sweep and its supporting infrastructure:
SyncService.runFullSync — delete lines 107-115 (detectStaleCommentsCounts call + stale-count refetch wiring)
IssueSyncer.detectStaleCommentsCounts — delete method
issueQueries.buildIssueTotalsBatchQuery — delete function
config.template.staleCommentsBatchSize — delete knob
- Derive
commentsTotal from timelineItems at IssueSyncer.mjs:450 and :593 — post-#exhaustTimelineItems so authoritative. One source of truth: the metadata field cannot disagree with what's rendered in the markdown.
- Drop
comments { totalCount } sub-selection from FETCH_ISSUES_FOR_SYNC and FETCH_SINGLE_ISSUE — no longer consumed, minor GraphQL complexity saving.
- Keep
refetchIssuesByNumber — still the healing primitive for #10090 pagination recovery and on-demand fixup.
- Update
IssueSyncer.spec.mjs — remove the three stale-count cases (lazy seed / no-drift / drift detection); add assertion that commentsTotal derived at write time matches timeline's ISSUE_COMMENT node count.
Accepted Blind Spot
After this change, a deleted comment on an issue that subsequently receives zero activity (no new comments, no label/milestone changes, no reactions) will remain locally rendered until something eventually bumps the issue's updatedAt. This is explicitly accepted: the cost of the sentinel sweep (~15s/sync, every sync) is not justified by the narrow case it catches. Agents needing audit-grade accuracy can invoke the existing refetchIssuesByNumber SDK endpoint on demand.
Avoided Traps
- Extending the detector pattern (#10094). With
CommentTotalsDetector removed, only #7948's relationship-event scan remains, and that's intrinsic to the fetch path — not a sweep. The detector abstraction has zero sweep instances left to unify. #10094 should be closed as superseded when this PR lands.
- Scheduled / periodic sweep. Still pays the cost, just on a timer. Duct tape on a marginal design choice.
- Transport-decoupling of startup sync. Previously discussed and rejected — consistency with memory-core and knowledge-base (both block transport on sync completion) is a deliberate ecosystem contract.
Acceptance Criteria
Related
- Reverses approach of: PR #10093 (ticket #10092)
- Supersedes: #10094 (detector interface — close on merge)
- Leaves intact: #10090 (pagination stays; intrinsic to fetch path), #7948 (relationship-event scan stays; intrinsic to pullFromGitHub)
Origin Session ID
07f601dc-353a-44d2-a373-18da2a0d305a
The Problem
Startup-sync latency for the gh-workflow MCP server regressed severely in PR #10093 (ticket #10092). The
detectStaleCommentsCountssentinel sweep — added to catch comment-deletion drift that slips past theupdatedAtgate — runs 37 serial GraphQL round trips perrunFullSync(3,669 tracked issues / 100 batch size), adding ~11–18 seconds of wall-clock per sync on every invocation. This pushes the MCP stdio handshake over Claude Code's registration budget, causing intermittent UI invisibility of the server and, when the client kills the process mid-sync, truncating the subsequentgit pull --rebase --autostashand wedging the main checkout (observed 2026-04-19 at 22:42PM — separate recovery required).The sweep's raison d'être was narrow to begin with: GitHub does not bump
issue.updatedAtwhen a comment is deleted, so comment deletion on an issue with no subsequent activity stays locally rendered indefinitely. In practice this matters only for closed/quiet issues — an archival-accuracy concern, not an operational one.The Architectural Reality (the actual oversight)
The gh-workflow sync currently stores
commentsTotalin metadata viaissue.comments?.totalCount ?? 0atai/mcp/server/github-workflow/services/sync/IssueSyncer.mjs:450and:593— a scalar sourced from GitHub's dedicatedcomments { totalCount }GraphQL field.Historical context: the first versions of this MCP server didn't fetch timelines — comments came from a dedicated endpoint. When unified timelines were added later (primarily for the portal app's ticket rendering) the sync converged on
timelineItemsas the authoritative per-issue event source, with #10090 adding pagination-exhaust sotimelineItems.nodesis the complete set. SourcingcommentsTotalfrom a separate scalar is a pre-timeline-era leftover — every comment already lives intimelineItems.nodesas anISSUE_COMMENTtyped node. Counting them is a JS loop over data already in memory. The #10092 sentinel is secondary infrastructure built on top of that oversight.Fix
SyncService.runFullSync— delete lines 107-115 (detectStaleCommentsCountscall + stale-count refetch wiring)IssueSyncer.detectStaleCommentsCounts— delete methodissueQueries.buildIssueTotalsBatchQuery— delete functionconfig.template.staleCommentsBatchSize— delete knobcommentsTotalfrom timelineItems at IssueSyncer.mjs:450 and :593 — post-#exhaustTimelineItemsso authoritative. One source of truth: the metadata field cannot disagree with what's rendered in the markdown.comments { totalCount }sub-selection fromFETCH_ISSUES_FOR_SYNCandFETCH_SINGLE_ISSUE— no longer consumed, minor GraphQL complexity saving.refetchIssuesByNumber— still the healing primitive for #10090 pagination recovery and on-demand fixup.IssueSyncer.spec.mjs— remove the three stale-count cases (lazy seed / no-drift / drift detection); add assertion thatcommentsTotalderived at write time matches timeline'sISSUE_COMMENTnode count.Accepted Blind Spot
After this change, a deleted comment on an issue that subsequently receives zero activity (no new comments, no label/milestone changes, no reactions) will remain locally rendered until something eventually bumps the issue's
updatedAt. This is explicitly accepted: the cost of the sentinel sweep (~15s/sync, every sync) is not justified by the narrow case it catches. Agents needing audit-grade accuracy can invoke the existingrefetchIssuesByNumberSDK endpoint on demand.Avoided Traps
CommentTotalsDetectorremoved, only #7948's relationship-event scan remains, and that's intrinsic to the fetch path — not a sweep. The detector abstraction has zero sweep instances left to unify. #10094 should be closed as superseded when this PR lands.Acceptance Criteria
runFullSyncno longer invokesdetectStaleCommentsCounts; method +buildIssueTotalsBatchQuery+staleCommentsBatchSizeconfig removedcommentsTotalderived fromtimelineItems.nodes.filter(n => n.__typename === 'IssueComment').lengthat both write sites (IssueSyncer.mjs:450and:593)comments { totalCount }sub-selection removed fromFETCH_ISSUES_FOR_SYNCandFETCH_SINGLE_ISSUEIssueSyncer.spec.mjsstale-count cases removed; new assertion covers timeline-derived count correctnessrunFullSynclogs no longer referencestaleCountsRelated
Origin Session ID
07f601dc-353a-44d2-a373-18da2a0d305a