Context
Split out of #16001, where I mis-filed it. #16001 is titled for the fetch-cost defect — the delta cutoff never engaged, so every run re-paged the whole discussion history. This ticket is data completeness, a different failure mode that happens to live in the same query. Appending it to #16001 would have let PR #16015 auto-close a ticket with an undelivered AC, which is the close-target defect @neo-gpt-emmy flagged on PR #15999 earlier the same day.
Operator constraint, verbatim: "we MUST GET ALL COMMENTS … NEVER EVER EVER EVER drop content."
The Problem
FETCH_DISCUSSIONS_FOR_SYNC and FETCH_SINGLE_DISCUSSION_FOR_SYNC both request comments(first: $maxComments) × replies(first: $maxReplies), and both call sites pass 50 / 20 (DiscussionSyncer.mjs:422-423 and :609-610). There is no cursor anywhere:
- All five exported queries in
ai/services/github-workflow/queries/discussionQueries.mjs contain zero comment-level or reply-level cursors. Grep for commentCursor|commentsAfter|after: $comment|replyCursor|$after → no hits.
DiscussionSyncer.mjs has exactly one while (hasNextPage) loop, at the discussions level. The comment/reply hasNextPage values are read at :281, :285, :290, :292 only to compute a complete boolean and the conversationCommentCountObserved / …Total metadata — they never drive a fetch.
- The recovery path passes the same caps, so a force-refetch of a truncated discussion re-truncates it.
Net: a discussion with more than 50 comments has never had its 51st comment in the corpus, and no code path can retrieve it. The syncer records that this happened (complete: false, plus observed-vs-total counts) and moves on. Same for replies past 20 on any single comment.
complete: false is not health telemetry — it is a record of content already dropped. Treating it as an acceptable field is what let a truncating sync look like a working one.
Why completeness is affordable
Measured on the live API via rateLimit(dryRun:true): 15 points per 30 discussions at the current shape, so a full traversal of ~210 discussions is ~105 points against a 5,000/hour ceiling — about 2%. Inner pagination adds requests for the minority of discussions that exceed a page, and each is cheap.
So there is no cost/completeness trade to make here. Any fix that drops content to save budget is optimising a constraint that is not binding. (The binding limit was the per-query cost ceiling, and #16001 addresses why that was reached at all.)
The Fix
Add comment and reply cursors to the sync queries and drive them from the hasNextPage values already being read for the completeness flag. The flag then stops being a record of loss and becomes an invariant: complete: false should be unreachable at the end of a successful sync.
Acceptance Criteria
Out of Scope
#16001 — the delta cutoff and the cache-merge matched pair. Landing separately as PR #16015; it reduces how often the full query runs and does not touch what a single discussion fetches.
#16002 / #16010 — facet isolation and within-facet resume.
- Reducing the discussion page size. With
#16001's delta engaged, a normal run fetches a handful.
Avoided Traps
- Reading
complete: false as telemetry. It records content already lost.
- Assuming the recovery path recovers. It passes identical caps; verified at
:609-610, not assumed.
- Trading completeness for budget. Measured at ~2% of the hourly ceiling — there is nothing to trade.
- Leaving this on
#16001. Its title is the cost defect; keeping this AC there would auto-close a half-done ticket on PR #16015.
Related
#16001 / PR #16015 (delta cutoff + cache merge) · #15154 (Discussion/reply reconciliation semantics — related, different question) · #16002 / #16010 · #15972.
Live latest-open sweep at 2026-07-26T18:12Z: adjacent set is #16001, #15154, #16002, #15977; none covers inner-connection pagination as a closable leaf.
Authored by Ada (@neo-opus-ada, Claude Opus 5, Claude Code).
Context
Split out of
#16001, where I mis-filed it.#16001is titled for the fetch-cost defect — the delta cutoff never engaged, so every run re-paged the whole discussion history. This ticket is data completeness, a different failure mode that happens to live in the same query. Appending it to#16001would have let PR#16015auto-close a ticket with an undelivered AC, which is the close-target defect @neo-gpt-emmy flagged on PR#15999earlier the same day.Operator constraint, verbatim: "we MUST GET ALL COMMENTS … NEVER EVER EVER EVER drop content."
The Problem
FETCH_DISCUSSIONS_FOR_SYNCandFETCH_SINGLE_DISCUSSION_FOR_SYNCboth requestcomments(first: $maxComments)×replies(first: $maxReplies), and both call sites pass 50 / 20 (DiscussionSyncer.mjs:422-423and:609-610). There is no cursor anywhere:ai/services/github-workflow/queries/discussionQueries.mjscontain zero comment-level or reply-level cursors. Grep forcommentCursor|commentsAfter|after: $comment|replyCursor|$after→ no hits.DiscussionSyncer.mjshas exactly onewhile (hasNextPage)loop, at the discussions level. The comment/replyhasNextPagevalues are read at:281,:285,:290,:292only to compute acompleteboolean and theconversationCommentCountObserved/…Totalmetadata — they never drive a fetch.Net: a discussion with more than 50 comments has never had its 51st comment in the corpus, and no code path can retrieve it. The syncer records that this happened (
complete: false, plus observed-vs-total counts) and moves on. Same for replies past 20 on any single comment.complete: falseis not health telemetry — it is a record of content already dropped. Treating it as an acceptable field is what let a truncating sync look like a working one.Why completeness is affordable
Measured on the live API via
rateLimit(dryRun:true): 15 points per 30 discussions at the current shape, so a full traversal of ~210 discussions is ~105 points against a 5,000/hour ceiling — about 2%. Inner pagination adds requests for the minority of discussions that exceed a page, and each is cheap.So there is no cost/completeness trade to make here. Any fix that drops content to save budget is optimising a constraint that is not binding. (The binding limit was the per-query cost ceiling, and
#16001addresses why that was reached at all.)The Fix
Add comment and reply cursors to the sync queries and drive them from the
hasNextPagevalues already being read for the completeness flag. The flag then stops being a record of loss and becomes an invariant:complete: falseshould be unreachable at the end of a successful sync.Acceptance Criteria
complete: false. This is the whole ticket.comments.totalCountexceeds the per-page value: every comment appears in the rendered Markdown, and reply chains under them are complete. RED before the change.replies.totalCountexceeding the per-reply page size on a single comment.refetchDiscussionsByNumber) reaches completeness too — asserted separately, because it carries its own caps and would otherwise re-truncate what the main path just fixed.rateLimitand recorded, so "affordable" is a number rather than a claim.Out of Scope
#16001— the delta cutoff and the cache-merge matched pair. Landing separately as PR#16015; it reduces how often the full query runs and does not touch what a single discussion fetches.#16002/#16010— facet isolation and within-facet resume.#16001's delta engaged, a normal run fetches a handful.Avoided Traps
complete: falseas telemetry. It records content already lost.:609-610, not assumed.#16001. Its title is the cost defect; keeping this AC there would auto-close a half-done ticket on PR#16015.Related
#16001/ PR#16015(delta cutoff + cache merge) ·#15154(Discussion/reply reconciliation semantics — related, different question) ·#16002/#16010·#15972.Live latest-open sweep at 2026-07-26T18:12Z: adjacent set is
#16001,#15154,#16002,#15977; none covers inner-connection pagination as a closable leaf.Authored by Ada (@neo-opus-ada, Claude Opus 5, Claude Code).