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)
- Add
pageInfo { hasNextPage endCursor } on timelineItems in FETCH_ISSUES_FOR_SYNC and FETCH_SINGLE_ISSUE; introduce a $timelineCursor variable.
- In
IssueSyncer.pullFromGitHub + the related-issue force-refetch loop, continue-paginate the connection when hasNextPage is true; concat nodes before handing to #formatIssueMarkdown.
- Keep
maxTimelineItemsPerIssue: 50 as the page size.
- Emit
logger.warn when pagination continues (issue number + cumulative count). Silent failure is the root issue; observability is non-negotiable.
- 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.
- 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
Origin Session ID
d9eb5e76-5430-45f7-b3ea-8600664d28f9
The Problem
IssueSyncersilently truncates issue content once thetimelineItemsGraphQL connection grows pastmaxTimelineItemsPerIssue(50). Concrete reproducer: comment 4275716860 on Epic #10030 — a session handoff — was authored 2026-04-19T10:33:35Z. The local fileresources/content/issues/issue-10030.mdreceived correctupdatedAtandcommentsCount: 2metadata, but the comment BODY never landed. The sync engine reported success.Root cause: since the unified-timeline refactor (#8527 / #8528, Jan 2026),
IssueSyncer.#formatTimelineEventrendersIssueCommentevents inline through theissue.timelineItems.nodeschannel. The GraphQL query asks fortimelineItems(first: $maxTimelineItems); GitHub orders this connection oldest-first (noorderByavailable). Once total events exceed 50, the tail — including newly-authored comments — is dropped. No guard checkspageInfo.hasNextPageor compares fetched comments toissue.comments.totalCount.Architectural Reality
issue.updatedAt,issue.comments.totalCount) is a separate GraphQL scalar path, unaffected by the timeline cap. Metadata tracking and content rendering have diverged./issues/10030/timelinereports 55 total events; the local file renders exactly 50, confirming the cap is the cutoff.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 atai/scripts/detectTruncatedTimelines.mjsand consume theai/services.mjsSDK facade (GH_SyncService,GH_IssueService). This respects the ongoing migration of services out ofai/mcp/server/; whenIssueSyncereventually moves toai/services/github/, only the SDK re-exports shift — consumer scripts stay stable.Fix Approach (option B — pagination)
pageInfo { hasNextPage endCursor }ontimelineItemsinFETCH_ISSUES_FOR_SYNCandFETCH_SINGLE_ISSUE; introduce a$timelineCursorvariable.IssueSyncer.pullFromGitHub+ the related-issue force-refetch loop, continue-paginate the connection whenhasNextPageis true; concat nodes before handing to#formatIssueMarkdown.maxTimelineItemsPerIssue: 50as the page size.logger.warnwhen pagination continues (issue number + cumulative count). Silent failure is the root issue; observability is non-negotiable.IssueSyncer.mjs:456-506) intorefetchIssuesByNumber(numbers[]); expose viaGH_SyncServiceso the recovery script consumes it as a first-class SDK method, not a re-implementation.test/playwright/unit/ai/mcp/server/github-workflow/mocks an issue with 75 timeline events and asserts every comment body renders.Avoided Traps
commentspagination + merge at render. Cleaner in isolation but duplicates render logic and adds a merge pass. Paginating the single unifiedtimelineItemsconnection hits the same correctness with one code path.ai/scripts/matching theanalyzeNlTelemetry.mjsprecedent is right-sized.sync_allfor recovery. Delta sync gates on per-issueupdatedAt, which did NOT change when truncation occurred. Blanket sync won't heal affected files. Recovery script callsrefetchIssuesByNumber()with just the detector's output list — surgical, no churn.Acceptance Criteria
ai/scripts/detectTruncatedTimelines.mjsscansresources/content/issues/*.mdonly (archives skipped); primary signalcommentsCount > rendered-comment-block count; secondary heuristicrendered-timeline-entries === 50; emits JSON affected-setissueQueries.mjsqueries exposepageInfoontimelineItems+ accept$timelineCursorIssueSyncerpaginates untilhasNextPage === false, warn-logs on continuationrefetchIssuesByNumber(numbers[])extracted + exposed viaGH_SyncServiceissue-10030.mdbody contains the 2026-04-19 handoff comment — regression proofOrigin Session ID
d9eb5e76-5430-45f7-b3ea-8600664d28f9