Frontmatter
| title | fix(github-workflow): sentinel sweep for comment-deletion drift (#10092) |
| author | tobiu |
| state | Merged |
| createdAt | Apr 19, 2026, 2:00 PM |
| updatedAt | Apr 19, 2026, 2:03 PM |
| closedAt | Apr 19, 2026, 2:03 PM |
| mergedAt | Apr 19, 2026, 2:03 PM |
| branches | dev ← agent/10092-comment-deletion-detection |
| url | https://github.com/neomjs/neo/pull/10093 |

Input from Claude Opus 4.7 (Claude Code):
❋ # PR Review Summary
Status: Comment (ready for human merge; non-blocking follow-ups noted)
Self-Review Opening: Self-review of #10092. I chose option B (totals-only sentinel sweep via aliased GraphQL batch query) over option A (drop
since:on a timer) and option C (per-repo events API polling). B was the only choice that closes the drift window at sub-linear cost without introducing a second API surface. ReusingrefetchIssuesByNumberfrom #10091 as the recovery primitive was the architectural pivot: the sweep only has to detect, not heal — healing is already a solved primitive on dev. Gaps noted below, hunting blind spots harder than I did in the PR body.
📊 Evaluation Metrics
[ARCH_ALIGNMENT]: 93 — The sentinel adds one query-builder, one service method, one runFullSync step, and one metadata field. Every orthogonal concern lives in its natural layer (issueQueries.mjsfor GraphQL composition,IssueSyncerfor the detection primitive,SyncServicefor orchestration,MetadataManagerfor persistence). ReusesrefetchIssuesByNumberinstead of reimplementing — one recovery primitive, multiple trigger sites. Lost 7 points for the function-vs-constant inconsistency inissueQueries.mjs: this file previously exported only staticconsttemplates, and I'm now introducing the firstexport functionfor dynamic shape. It's the correct call (aliased fields need N-parameterization) but worth flagging that future agents may want to see a header note about the mixed-export convention.
[CONTENT_COMPLETENESS]: 88 — JSDoc with @summary + Anchor & Echo on all new methods (buildIssueTotalsBatchQuery,detectStaleCommentsCounts, theSyncServiceorchestration block). PR body and commit message both follow Fat Ticket conventions with explicit cross-links to #10090 / #7948 / #9535. Lost points because thecommentsTotalfield onMetadataManager.savehas a 3-line comment block explaining its role; a reader would still have to dig intodetectStaleCommentsCountsto understand the lazy-seed semantics. An inline@seepointer would have closed that loop.
[EXECUTION_QUALITY]: 82 — Spec passes (608ms) and covers the three scenarios that matter (seed / no-drift / drift). Live GraphQL probe confirms the aliased batch shape works against real GitHub. What's missing: (a) no coverage of the error path (batch query failure mid-iteration — the loop should continue past errors per the code, but the spec doesn't enforce that contract); (b) no coverage of the batch-size boundary (what happens ifissueNumbers.lengthstraddlesstaleCommentsBatchSize?); (c) no test for theSyncService.runFullSyncwire-up — if thedetectStaleCommentsCounts → refetchIssuesByNumberpipeline breaks between those two calls, nothing catches it until a live user sees stale drift. A mocked SyncService orchestration spec would have been cheap insurance.
[PRODUCTIVITY]: 95 — All ticket acceptance criteria checked in one session, following #10091's momentum without context-switch overhead.
[IMPACT]: 78 — Closes a known silent-drift bug class. Lower than #10091 because the empirical blast radius is smaller: only issues with comment deletions drift, whereas #10091's timeline-cap drift hit any active Epic. Still load-bearing for content authoritativeness, hence not under 70.
[COMPLEXITY]: 50 — Six files, five of them adding ~20-100 lines, one spec. Single new GraphQL pattern (aliased fields) is the only real cognitive load; everything else is plumbing.
[EFFORT_PROFILE]:Quick Win— High-ROI detection primitive delivered at low complexity, leveraging existing recovery machinery.
🕸️ Context & Graph Linking
- Target Epic / Issue ID: Resolves #10092
- Related Graph Nodes:
- #10090 / PR #10091 (pagination fix — introduced
refetchIssuesByNumber, this PR's recovery dependency)- #7948 (prior updatedAt-blind workaround for relationship events — this PR's conceptual precedent with a different primitive)
- #9535 (the concrete 11→10 reproducer that surfaced the bug during #10090's detector sweep)
🧠 Graph Ingestion Notes
[KB_GAP]: The three distinct updatedAt-blind bug classes in the GitHub sync pipeline — relationship events (#7948), timeline truncation (#10090), comment deletions (this PR) — collectively point at a missing architectural abstraction: a "sync invalidation strategy registry" that enumerates every knownupdatedAtblind spot with its detection primitive. Today each one is patched inline with a bespoke mechanism (timeline-scan for events, pagination for truncation, totals-sentinel for deletions). A future refactor could formalize this into a pluggableInvalidationDetectorinterface. Out of scope here; worth capturing as framework R&D inlearn/agentos/.
[KB_GAP]: GraphQL field aliasing is the right primitive for arbitrary-issue-number batch queries againstrepository.issue(number:). This isn't obvious from training data — many agents would reflexively reach forissues(first:, filterBy:)which filters but can't enumerate. Worth a Neo.mjs agentos note: "To batch-query arbitrary GitHub entities by ID without N round-trips, use aliased fields at ~3 complexity units per alias, safely under GitHub's 500-unit per-query ceiling for batches up to ~150."
[TOOLING_GAP]:GraphqlService.querythrows the entire response away if the GraphQL endpoint returnserrors— even ifdatais partially populated. GraphQL semantics explicitly allow partial data alongside per-field errors (an aliased field withissue(number: PR_NUMBER)returns null on that alias and populates all others). The current all-or-nothing error handling means a single bad number in a batch kills the whole batch for my sweep. Not a correctness issue (metadata.issues excludes PRs by contract), but it's a latent fragility that would benefit from graceful partial-data handling insideGraphqlService. Captured as the "per-alias error tolerance" follow-up in the PR body.
[TOOLING_GAP]: Verifying this PR required exercisingdetectStaleCommentsCountsagainst the live GitHub API, which the Playwright spec can't do (it mocksGraphqlService). I used a one-shotnode --input-type=module -e '...'invocation to bootstrap the SDK facade and run a real aliased query. This worked but feels ceremonial — a reusableai/scripts/probe-github-graphql.mjsharness for quick interactive queries would be more honest than inlining multi-line scripts in bash. Worth creating if this pattern recurs.
[RETROSPECTIVE]: The session-level arc — #10030 reproducer → #10090 ticket → detector → pagination fix → #10092 follow-up → sentinel sweep — is a good example of the "one ticket per discrete failure mode" discipline paying off. I consciously did NOT cram deletion detection into #10091 even though the detector already incidentally healed #9535. That restraint preserved the PR's surgical scope (reviewers could assess pagination on its own merits) AND exposed the distinct bug class cleanly for this follow-up. Collapsed, both PRs would have been harder to review and the #7948 → #10090 → #10092 architectural progression would have been invisible to future agents reading the merge history.
[RETROSPECTIVE]: ReusingrefetchIssuesByNumberfrom #10091 as the recovery primitive was the key architectural move that kept this PR small. The sentinel sweep has zero rewrite-logic — it only detects. Every future "updatedAt blind spot" fix should reuserefetchIssuesByNumberas its healing endpoint rather than writing its own. This is the payoff pattern from making recovery a first-class SDK method in #10091.
📋 Required Actions
No hard blockers. Optional polish items:
- (Optional) Add spec coverage for the batch-size boundary (e.g., 150 issues with
staleCommentsBatchSize: 100should produce 2 batches).- (Optional) Mocked
SyncService.runFullSyncintegration spec that exercises thedetect → refetchwire-up.- (Optional) Per-alias error tolerance in
detectStaleCommentsCounts— on batch failure, retry as singles so one bad number doesn't blackhole ~100 legitimate ones.- (Optional)
@see IssueSyncer.detectStaleCommentsCountsJSDoc pointer on thecommentsTotalfield comment inMetadataManagerso readers don't have to hunt for the lazy-seed rationale.- (Optional)
ai/scripts/probe-github-graphql.mjsreusable live-query harness for future PRs that need SDK-bootstrapped GraphQL verification.Reviewer is free to merge as-is and let me fold any of these into a later PR, or ask for polish commits on this branch before merge. Either works — the core correctness is established by three passing unit cases + the live GraphQL probe.
Summary
Delta-sync uses
issue.updatedAtas its invalidation signal, and GitHub does not bumpupdatedAtwhen a comment is deleted. Affected local files keep stalecommentsCount+ stale comment bodies indefinitely — the mode that kept #9535 frozen for weeks during the #10090 investigation. GraphQL exposes noISSUE_COMMENT_DELETED_EVENT, so the timeline-scan pattern from #7948 (for sub-issue / blocking relationship drift) does not transfer here.This PR adds the option-B "totals-only sentinel" approach from the #10092 ticket: a batched GraphQL query that compares live
comments.totalCountagainst a new storedcommentsTotalsentinel in metadata. Mismatches feed the existingrefetchIssuesByNumberrecovery primitive introduced in #10091 — no new surface, no polling, nosince:filter. ~1 rate-limit unit per ~100 issues.Architectural Reality
timelineItemspagination). This PR decides when to fetch the issues thatupdatedAtsilently overlooks.SubIssueAddedEvent,BlockedByAddedEvent) that also don't bumpupdatedAt— but those events still appear in the timeline. Comment deletions leave no event trace; the timeline only shows the surviving nodes. The only correctness-preserving primitive iscomments.totalCountequality.commentsTotalfield (pre-migration metadata) are seeded in place from the live value WITHOUT triggering a refetch. First sync after merge graduates every tracked issue cleanly; subsequent syncs catch drift.Changes
issueQueries.mjs—buildIssueTotalsBatchQuery(numbers)function export (first non-template export in this file) that composes aliased GraphQL fields (issue42: issue(number:42) { comments { totalCount } }) for N issues in one request. Complexity ~3×N, safely under GitHub's per-query limit for the default batch size.IssueSyncer.mjs— newdetectStaleCommentsCounts(metadata)primitive. Lazily seeds missingcommentsTotalentries in place; otherwise records stale entries for downstream recovery. Metadata writes inpullFromGitHubandrefetchIssuesByNumbernow capturecommentsTotalalongsidecontentHash.MetadataManager.mjs— persistscommentsTotalin the pruned issue metadata shape.SyncService.mjs— wires the sweep insiderunFullSyncafter the delta pull; pipes mismatches intorefetchIssuesByNumber; surfaceschecked/stale/seeded/errorscounts in the final log line and the return stats.config.template.mjs— addsstaleCommentsBatchSize: 100knob.Test Coverage
Extended
IssueSyncer.spec.mjswith three cases (all pass, 608ms total for the 4-test suite):commentsTotal→ seeded in place from live total, no refetch triggered.commentsTotal: 11, livetotalCount: 10→ one stale entry recorded, stored sentinel untouched (caller runsrefetchIssuesByNumberto authoritatively re-render).Live Verification
Ran an in-process GraphQL probe against
neomjs/neowith 5 real issue numbers to confirm the aliased-field batch query works end-to-end against live GitHub:issue10090: #10090 commentsTotal=0 issue10092: #10092 commentsTotal=0 issue9535: #9535 commentsTotal=10 issue9999: #9999 commentsTotal=1 issue10030: #10030 commentsTotal=2 rateLimit: {"cost":1,"remaining":4993,...}Avoided Traps
timelineItems./timelinehasDeletedCommentEvent). New infra dependency for a case solvable via existing GraphQL primitives.since:means O(all issues) cost. Batched sentinel at O(issues/100) hits the same correctness at 1% of the cost.Acceptance Criteria
buildIssueTotalsBatchQueryfetches{number, comments{totalCount}}with nosince:filterMetadataManagerpersistscommentsTotalper issueIssueSyncer.detectStaleCommentsCounts(metadata)runs insiderunFullSyncafter the delta pull; mismatches feedrefetchIssuesByNumberrunFullSynclogs + return-stats exposechecked/stale/seeded/errorscountsKnown Edge / Follow-up
issue(number:N)errors hard ifNis a PR instead of an Issue, which fails the entire batch.metadata.issuesexcludes PRs by contract (PRs live inmetadata.pulls), so this is a theoretical edge — but per-alias error isolation (retry-as-singles on batch failure) would be more defensive. Not in scope for this PR; worth capturing if it ever fires in production logs.Test plan
npm run test-unit -- test/playwright/unit/ai/mcp/server/github-workflow/IssueSyncer.spec.mjs— 4 passedsync_all, the log should showStaleCounts: ~500 checked, 0 drifted, ~500 seededas the one-time seeding event. Subsequent syncs should show0 seededand0 driftedexcept when real deletions occur.Resolves #10092
Related: #10090 (pagination fix — introduced
refetchIssuesByNumber, the recovery primitive this PR reuses), #7948 (analogous updatedAt-blind workaround for relationship events)