---
id: 10090
title: '[bug] IssueSyncer silently drops content past timelineItems cap — handoff comment on #10030 lost'
state: CLOSED
labels:
  - bug
  - ai
  - regression
  - architecture
assignees:
  - tobiu
createdAt: '2026-04-19T11:09:49Z'
updatedAt: '2026-05-15T12:42:19Z'
githubUrl: 'https://github.com/neomjs/neo/issues/10090'
author: tobiu
commentsCount: 0
parentIssue: null
subIssues: []
subIssuesCompleted: 0
subIssuesTotal: 0
blockedBy: []
blocking: []
closedAt: '2026-04-19T11:41:29Z'
---
# [bug] IssueSyncer silently drops content past timelineItems cap — handoff comment on #10030 lost

## The Problem

`IssueSyncer` silently truncates issue content once the `timelineItems` GraphQL connection grows past `maxTimelineItemsPerIssue` (50). Concrete reproducer: comment 4275716860 on Epic #10030 — a session handoff — was authored 2026-04-19T10:33:35Z. The local file `resources/content/issues/issue-10030.md` received correct `updatedAt` and `commentsCount: 2` metadata, but the comment BODY never landed. The sync engine reported success.

Root cause: since the unified-timeline refactor (#8527 / #8528, Jan 2026), `IssueSyncer.#formatTimelineEvent` renders `IssueComment` events inline through the `issue.timelineItems.nodes` channel. The GraphQL query asks for `timelineItems(first: $maxTimelineItems)`; GitHub orders this connection oldest-first (no `orderBy` available). Once total events exceed 50, the tail — including newly-authored comments — is dropped. No guard checks `pageInfo.hasNextPage` or compares fetched comments to `issue.comments.totalCount`.

## Architectural Reality

- Failure is silent because the frontmatter channel (`issue.updatedAt`, `issue.comments.totalCount`) is a separate GraphQL scalar path, unaffected by the timeline cap. Metadata tracking and content rendering have diverged.
- PR-based workflow is accelerating the trigger: each PR cross-references the Epic, consuming timeline slots. Epics will hit 50 faster now than when the unified-timeline design landed.
- REST `/issues/10030/timeline` reports 55 total events; the local file renders exactly 50, confirming the cap is the cutoff.
- Files touched by the fix live inside the MCP server tree (`ai/mcp/server/github-workflow/services/sync/IssueSyncer.mjs`, `ai/mcp/server/github-workflow/services/queries/issueQueries.mjs`). Detector + recovery glue live OUTSIDE that tree at `ai/scripts/detectTruncatedTimelines.mjs` and consume the `ai/services.mjs` SDK facade (`GH_SyncService`, `GH_IssueService`). This respects the ongoing migration of services out of `ai/mcp/server/`; when `IssueSyncer` eventually moves to `ai/services/github/`, only the SDK re-exports shift — consumer scripts stay stable.

## Fix Approach (option B — pagination)

1. Add `pageInfo { hasNextPage endCursor }` on `timelineItems` in `FETCH_ISSUES_FOR_SYNC` and `FETCH_SINGLE_ISSUE`; introduce a `$timelineCursor` variable.
2. In `IssueSyncer.pullFromGitHub` + the related-issue force-refetch loop, continue-paginate the connection when `hasNextPage` is true; concat nodes before handing to `#formatIssueMarkdown`.
3. Keep `maxTimelineItemsPerIssue: 50` as the page size.
4. Emit `logger.warn` when pagination continues (issue number + cumulative count). Silent failure is the root issue; observability is non-negotiable.
5. Extract the existing single-issue force-fetch loop (`IssueSyncer.mjs:456-506`) into `refetchIssuesByNumber(numbers[])`; expose via `GH_SyncService` so the recovery script consumes it as a first-class SDK method, not a re-implementation.
6. Playwright spec under `test/playwright/unit/ai/mcp/server/github-workflow/` mocks an issue with 75 timeline events and asserts every comment body renders.

## Avoided Traps

- **Bump cap to 250 and move on.** GraphQL max is 250 for this connection; PR-accelerated growth will reach it too. Silent truncation stays possible.
- **Separate `comments` pagination + merge at render.** Cleaner in isolation but duplicates render logic and adds a merge pass. Paginating the single unified `timelineItems` connection hits the same correctness with one code path.
- **Make the detector an MCP tool.** Admin-initiated + rare; MCP tool budget is 77/100. A standalone script under `ai/scripts/` matching the `analyzeNlTelemetry.mjs` precedent is right-sized.
- **Full `sync_all` for recovery.** Delta sync gates on per-issue `updatedAt`, which did NOT change when truncation occurred. Blanket sync won't heal affected files. Recovery script calls `refetchIssuesByNumber()` with just the detector's output list — surgical, no churn.

## Acceptance Criteria

- [ ] `ai/scripts/detectTruncatedTimelines.mjs` scans `resources/content/issues/*.md` only (archives skipped); primary signal `commentsCount > rendered-comment-block count`; secondary heuristic `rendered-timeline-entries === 50`; emits JSON affected-set
- [ ] Baseline detector run attached in PR narrative
- [ ] `issueQueries.mjs` queries expose `pageInfo` on `timelineItems` + accept `$timelineCursor`
- [ ] `IssueSyncer` paginates until `hasNextPage === false`, warn-logs on continuation
- [ ] `refetchIssuesByNumber(numbers[])` extracted + exposed via `GH_SyncService`
- [ ] Playwright spec covers 75-event mocked issue
- [ ] Post-fix detector run reports zero affected issues after force-refetch
- [ ] `issue-10030.md` body contains the 2026-04-19 handoff comment — regression proof

## Origin Session ID
`d9eb5e76-5430-45f7-b3ea-8600664d28f9`

## Timeline

- 2026-04-19T11:09:50Z @tobiu added the `bug` label
- 2026-04-19T11:09:50Z @tobiu added the `ai` label
- 2026-04-19T11:09:50Z @tobiu added the `regression` label
- 2026-04-19T11:09:50Z @tobiu added the `architecture` label
- 2026-04-19T11:10:05Z @tobiu assigned to @tobiu
- 2026-04-19T11:34:22Z @tobiu referenced in commit `a32c0af` - "fix(github-workflow): paginate timelineItems to prevent silent content drop (#10090)

The IssueSyncer rendered comment bodies through the unified timelineItems
GraphQL channel, which was page-capped at maxTimelineItemsPerIssue (50) with
no continuation logic. Once an issue's timeline grew past the cap, tail
events including newly-authored comments were silently dropped from the
local markdown while scalar frontmatter metadata (commentsCount, updatedAt)
stayed correct — a divergence between metadata tracking and content
rendering that gave a false appearance of successful sync.

Changes:
- issueQueries: add pageInfo on timelineItems in both FETCH queries and
  introduce FETCH_ISSUE_TIMELINE_PAGE for continuation fetches.
- IssueSyncer: add #exhaustTimelineItems pagination primitive with a warn
  log on continuation; extract the related-issues force-update loop into
  a reusable refetchIssuesByNumber(numbers, metadata) method that both
  pullFromGitHub and external tooling share.
- SyncService: expose refetchIssuesByNumber({numbers}) as the SDK entry
  for surgical recovery bypassing delta-sync updatedAt gating.
- ai/scripts/detectTruncatedTimelines.mjs: diagnostic that flags files
  whose rendered comment blocks fall short of frontmatter commentsCount
  or whose timeline sits exactly at the cap.
- ai/scripts/refetchTruncatedIssues.mjs: thin recovery wrapper that
  consumes the detector output (list or --stdin JSON) and delegates to
  the SyncService endpoint.
- IssueSyncer.spec: Playwright regression covering a 75-event mocked
  issue that forces one continuation page and asserts every comment and
  structural event lands in the rendered markdown.

Recovery artifacts in this commit: issues #10030, #9486, #9999, #9535 —
the four issues flagged as drifted by the detector baseline run — were
healed via the new refetch endpoint and now reflect live GitHub state."
- 2026-04-19T11:35:15Z @tobiu cross-referenced by PR #10091
- 2026-04-19T11:41:29Z @tobiu closed this issue
- 2026-04-19T11:41:29Z @tobiu referenced in commit `3ec8167` - "fix(github-workflow): paginate timelineItems to prevent silent content drop (#10090) (#10091)

The IssueSyncer rendered comment bodies through the unified timelineItems
GraphQL channel, which was page-capped at maxTimelineItemsPerIssue (50) with
no continuation logic. Once an issue's timeline grew past the cap, tail
events including newly-authored comments were silently dropped from the
local markdown while scalar frontmatter metadata (commentsCount, updatedAt)
stayed correct — a divergence between metadata tracking and content
rendering that gave a false appearance of successful sync.

Changes:
- issueQueries: add pageInfo on timelineItems in both FETCH queries and
  introduce FETCH_ISSUE_TIMELINE_PAGE for continuation fetches.
- IssueSyncer: add #exhaustTimelineItems pagination primitive with a warn
  log on continuation; extract the related-issues force-update loop into
  a reusable refetchIssuesByNumber(numbers, metadata) method that both
  pullFromGitHub and external tooling share.
- SyncService: expose refetchIssuesByNumber({numbers}) as the SDK entry
  for surgical recovery bypassing delta-sync updatedAt gating.
- ai/scripts/detectTruncatedTimelines.mjs: diagnostic that flags files
  whose rendered comment blocks fall short of frontmatter commentsCount
  or whose timeline sits exactly at the cap.
- ai/scripts/refetchTruncatedIssues.mjs: thin recovery wrapper that
  consumes the detector output (list or --stdin JSON) and delegates to
  the SyncService endpoint.
- IssueSyncer.spec: Playwright regression covering a 75-event mocked
  issue that forces one continuation page and asserts every comment and
  structural event lands in the rendered markdown.

Recovery artifacts in this commit: issues #10030, #9486, #9999, #9535 —
the four issues flagged as drifted by the detector baseline run — were
healed via the new refetch endpoint and now reflect live GitHub state."
- 2026-04-19T11:44:09Z @tobiu cross-referenced by #10092
- 2026-04-19T11:56:56Z @tobiu referenced in commit `386073d` - "fix(github-workflow): sentinel sweep for comment-deletion drift (#10092)

Delta-sync uses issue.updatedAt as its invalidation signal, and GitHub does
NOT bump updatedAt when a comment is deleted. Affected local files keep stale
commentsCount + stale comment bodies indefinitely — the mode that kept #9535
frozen for weeks during the #10090 investigation. GraphQL exposes no
ISSUE_COMMENT_DELETED_EVENT, so the timeline-scan pattern from #7948 (for
sub-issue/blocking relationship drift) does not transfer here.

Fix (option B from the ticket): add a totals-only sentinel pass that runs
after the delta pull inside runFullSync. For every tracked issue it compares
live `comments.totalCount` against the stored `commentsTotal` sentinel.
Mismatches are force-refetched via the recovery primitive introduced in
#10090. No new API surface, no polling, no `since:` filter — one batched
GraphQL request per ~100 issues at ~1 rate-limit unit per batch.

Changes:
- issueQueries: add `buildIssueTotalsBatchQuery(numbers)` — function
  export (not a static template) that composes aliased GraphQL fields
  (issue42: issue(number:42) { comments { totalCount } }) for N issues in
  one request.
- IssueSyncer: add `detectStaleCommentsCounts(metadata)` primitive. Lazily
  seeds missing `commentsTotal` entries in place without triggering a
  refetch so pre-migration metadata graduates cleanly; otherwise records
  stale entries for downstream recovery. Metadata writes in pullFromGitHub
  and refetchIssuesByNumber now capture `commentsTotal` alongside
  `contentHash`.
- MetadataManager: persist `commentsTotal` in the pruned issue metadata.
- SyncService: wire the sweep in runFullSync after the delta pull, pipe
  mismatches into refetchIssuesByNumber, surface checked/stale/seeded/errors
  counts in final log + return-stats.
- config.template: add `staleCommentsBatchSize: 100` knob.
- IssueSyncer.spec: three new Playwright cases — lazy seeding on fresh
  metadata, no-drift happy path, drift detection of the 11→10 pattern from
  #9535.

Live GraphQL probe against real repo confirms the aliased batch query
works end-to-end at cost=1 per 5-issue batch.

Follow-ups (captured in review, not in scope here):
- Per-number error isolation: GraphQL `issue(number:N)` errors hard if N
  is a PR instead of an Issue, which currently fails the entire batch.
  metadata.issues excludes PRs by contract so this is a theoretical edge,
  but per-alias error tolerance would be more defensive."
- 2026-04-19T12:00:04Z @tobiu cross-referenced by PR #10093
- 2026-04-19T12:03:48Z @tobiu referenced in commit `88307bf` - "fix(github-workflow): sentinel sweep for comment-deletion drift (#10092) (#10093)

Delta-sync uses issue.updatedAt as its invalidation signal, and GitHub does
NOT bump updatedAt when a comment is deleted. Affected local files keep stale
commentsCount + stale comment bodies indefinitely — the mode that kept #9535
frozen for weeks during the #10090 investigation. GraphQL exposes no
ISSUE_COMMENT_DELETED_EVENT, so the timeline-scan pattern from #7948 (for
sub-issue/blocking relationship drift) does not transfer here.

Fix (option B from the ticket): add a totals-only sentinel pass that runs
after the delta pull inside runFullSync. For every tracked issue it compares
live `comments.totalCount` against the stored `commentsTotal` sentinel.
Mismatches are force-refetched via the recovery primitive introduced in
#10090. No new API surface, no polling, no `since:` filter — one batched
GraphQL request per ~100 issues at ~1 rate-limit unit per batch.

Changes:
- issueQueries: add `buildIssueTotalsBatchQuery(numbers)` — function
  export (not a static template) that composes aliased GraphQL fields
  (issue42: issue(number:42) { comments { totalCount } }) for N issues in
  one request.
- IssueSyncer: add `detectStaleCommentsCounts(metadata)` primitive. Lazily
  seeds missing `commentsTotal` entries in place without triggering a
  refetch so pre-migration metadata graduates cleanly; otherwise records
  stale entries for downstream recovery. Metadata writes in pullFromGitHub
  and refetchIssuesByNumber now capture `commentsTotal` alongside
  `contentHash`.
- MetadataManager: persist `commentsTotal` in the pruned issue metadata.
- SyncService: wire the sweep in runFullSync after the delta pull, pipe
  mismatches into refetchIssuesByNumber, surface checked/stale/seeded/errors
  counts in final log + return-stats.
- config.template: add `staleCommentsBatchSize: 100` knob.
- IssueSyncer.spec: three new Playwright cases — lazy seeding on fresh
  metadata, no-drift happy path, drift detection of the 11→10 pattern from
  #9535.

Live GraphQL probe against real repo confirms the aliased batch query
works end-to-end at cost=1 per 5-issue batch.

Follow-ups (captured in review, not in scope here):
- Per-number error isolation: GraphQL `issue(number:N)` errors hard if N
  is a PR instead of an Issue, which currently fails the entire batch.
  metadata.issues excludes PRs by contract so this is a theoretical edge,
  but per-alias error tolerance would be more defensive."
- 2026-04-19T12:20:06Z @tobiu cross-referenced by #10094
- 2026-04-19T12:20:26Z @tobiu cross-referenced by #10095
- 2026-04-19T13:10:27Z @tobiu cross-referenced by PR #10099
- 2026-04-19T21:40:56Z @tobiu cross-referenced by #10110
- 2026-04-19T21:53:35Z @tobiu cross-referenced by PR #10111

