LearnNewsExamplesServices
Frontmatter
title>-
authortobiu
stateMerged
createdAtApr 19, 2026, 1:35 PM
updatedAtApr 19, 2026, 1:41 PM
closedAtApr 19, 2026, 1:41 PM
mergedAtApr 19, 2026, 1:41 PM
branchesdevclaude/inspiring-pare-49fa36
urlhttps://github.com/neomjs/neo/pull/10091
Merged
tobiu
tobiu commented on Apr 19, 2026, 1:35 PM

Summary

IssueSyncer silently truncated issue content once the `timelineItems` GraphQL connection grew past `maxTimelineItemsPerIssue` (50). Comment 4275716860 on Epic #10030 — a session handoff authored 2026-04-19T10:33:35Z — was lost this way: local frontmatter received correct `updatedAt` and `commentsCount: 2`, but the comment body never landed. The sync engine reported success.

Since the unified-timeline refactor (#8527 / #8528, Jan 2026), `IssueSyncer.#formatTimelineEvent` renders `IssueComment` events inline through the `issue.timelineItems.nodes` channel. GitHub orders that connection oldest-first with no `orderBy`, so `first: 50` dropped the tail. The metadata channel (scalar `updatedAt`, `comments.totalCount`) kept looking healthy — a divergence between metadata tracking and content rendering with no guard to surface it. PR-based workflow accelerates the trigger since each PR cross-references the Epic.

Architectural Reality

  • Scalar frontmatter fields (`updatedAt`, `commentsCount`) were always correct; rendered timeline entries were silently short.
  • REST `/issues/10030/timeline` showed 55 events; local file capped at 50, matching the `maxTimelineItemsPerIssue` default exactly.
  • Files touched live inside the MCP server tree but the detector + recovery glue live at `ai/scripts/` 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, only the SDK re-exports shift and the scripts stay stable.

Fix (option B — pagination)

  • `issueQueries.mjs` — added `pageInfo { hasNextPage endCursor }` on `timelineItems` in both `FETCH_ISSUES_FOR_SYNC` and `FETCH_SINGLE_ISSUE`; introduced `FETCH_ISSUE_TIMELINE_PAGE` as the continuation query.
  • `IssueSyncer.mjs` — new private `#exhaustTimelineItems(issue)` primitive loops continuation pages until `hasNextPage === false` and emits `logger.warn` when pagination actually kicks in (silent failure was the root issue; observability is non-negotiable). Extracted the existing inline force-refetch loop into a reusable `refetchIssuesByNumber(numbers, metadata)` method. Both pullFromGitHub and external tooling share the one code path.
  • `SyncService.mjs` — exposes `refetchIssuesByNumber({numbers})` as the SDK entry for surgical recovery bypassing delta-sync `updatedAt` gating.

Tooling

  • `ai/scripts/detectTruncatedTimelines.mjs` — walks `resources/content/issues/*.md` (archives skipped by design) and flags files where frontmatter `commentsCount` exceeds rendered `### @login - ` comment blocks, or where total rendered timeline entries sit exactly at the cap. JSON mode pipes straight into the recovery script.
  • `ai/scripts/refetchTruncatedIssues.mjs` — accepts a list of numbers or `--stdin` JSON from the detector and calls `GH_SyncService.refetchIssuesByNumber`. No MCP tool added; matches the `analyzeNlTelemetry.mjs` precedent at `ai/scripts/`.

Avoided Traps

  • Bump cap to 250 and move on. Deferred pain is still pain; PR-accelerated growth would reach 250 too. Silent truncation would remain possible.
  • Decouple comments from timeline via separate pagination + merge at render. Duplicates render logic. Paginating the single unified 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 is right-sized.
  • Full `sync_all` for recovery. Delta sync gates on per-issue `updatedAt`, which did NOT change when truncation occurred. Blanket sync wouldn't heal affected files.

Baseline + Recovery

Detector found 4 affected issues on `dev`:

Issue Evidence pre-fix
#10030 50 rendered, 55 on REST; missing 2026-04-19 handoff comment
#9486 50 rendered, 65 on REST
#9999 50 rendered, 58 on REST
#9535 7 rendered, 11 in frontmatter (anomaly — see Follow-ups)

All four healed via `refetchIssuesByNumber` — committed as part of this PR. Post-fix detector reports zero affected issues.

Acceptance Criteria

  • `ai/scripts/detectTruncatedTimelines.mjs` scans active issues only; primary signal `commentsCount > rendered comment blocks`; secondary heuristic `totalRendered === cap`
  • `issueQueries.mjs` exposes `pageInfo` on `timelineItems` + adds continuation query
  • `IssueSyncer` paginates until `hasNextPage === false`, warn-logs on continuation
  • `refetchIssuesByNumber(numbers, metadata)` extracted + exposed via `GH_SyncService`
  • Playwright spec covers 75-event mocked issue — passes (532ms)
  • Post-fix detector run reports zero affected issues
  • `issue-10030.md` body contains the 2026-04-19 handoff comment — regression proof

Follow-ups

  • #9535 anomaly — surfaced but out-of-scope. Its local frontmatter was `commentsCount: 11` while live GraphQL reported 10. A comment was deleted on GitHub, and GitHub does not bump `issue.updatedAt` on comment deletion, so delta sync never revisited and the stale state stuck. The refetch healed #9535 incidentally (idempotent), but the root-cause invalidation gap is a separate bug class analogous to the `SubIssueAddedEvent` fix in #7948. Will be filed as a follow-up ticket.
  • Detector split bug — during verification I found my initial detector used `content.split('## Timeline')` as a string split, which also matched inside a comment body containing `### Timeline & Next Steps`. Fixed in this PR to use a line-anchored regex (`/^## Timeline$/m`). Minor polish, caught before PR per the Stepping Back protocol.

Test plan

  • `npm run test-unit -- test/playwright/unit/ai/mcp/server/github-workflow/IssueSyncer.spec.mjs` (passes)
  • Detector post-fix: zero affected issues
  • Manual verification that #10030 now contains `Input from Claude Opus 4.7 (Claude Code):` and both comments render
  • (Reviewer) Spot-check healed content in #9486, #9999, #9535 against GitHub

Resolves #10090

tobiu
tobiu commented on Apr 19, 2026, 1:39 PM

Input from Claude Opus 4.7 (Claude Code):

❋ # PR Review Summary

Status: Comment (ready for human merge; non-blocking polish noted)

Self-Review Opening: Self-review of #10090. I chose option B (paginate the unified timelineItems connection) over option A (raise cap to 250) and option C (decouple comments from timeline). B was the only choice that permanently removes the cap-edge failure mode without duplicating render logic. Key trade-offs and gaps noted below — I'm being harder on myself than I was in the PR body because the PR body narrates the happy path and the reviewer mode is where I hunt blind spots.


📊 Evaluation Metrics

  • [ARCH_ALIGNMENT]: 92 — The pagination primitive #exhaustTimelineItems is shared by both pullFromGitHub's main loop and the extracted refetchIssuesByNumber, so there's exactly one code path for "fill the timeline". The SDK facade (ai/services.mjs) is consumed correctly from ai/scripts/, respecting the ongoing migration of services out of the MCP server tree. Lost 8 points because I didn't import maxTimelineItemsPerIssue from config into the detector — TIMELINE_CAP = 50 is duplicated with a "keep in sync" comment, which is a known drift vector.
  • [CONTENT_COMPLETENESS]: 85 — JSDoc with @summary + Anchor & Echo on all new public/private methods (#exhaustTimelineItems, refetchIssuesByNumber on both IssueSyncer and SyncService, FETCH_ISSUE_TIMELINE_PAGE). The PR body follows the Fat Ticket convention. Lost points because script-local helpers (scanDir, analyzeFile, parseStdin, buildComment, buildCrossRef) have no JSDoc. Strictly per Gate 2 they should, though common practice on diagnostic scripts is looser.
  • [EXECUTION_QUALITY]: 78 — The Playwright spec passes (532ms) and covers a 75-event issue that forces one continuation page. Real-world verification healed 4 issues on live data. What's missing: (a) no coverage of the zero-continuation case (issue under cap — the common path); (b) no coverage of multi-page continuation (three-page pagination); (c) no spec for the detector itself — I caught and fixed its split('## Timeline') bug during verification but left no regression guard; (d) #exhaustTimelineItems has no iteration safeguard — if GitHub returned hasNextPage: true with a valid cursor and empty nodes repeatedly, it would loop indefinitely (paranoid but cheap to add).
  • [PRODUCTIVITY]: 95 — All acceptance criteria from #10090 are checked in a single session, including surgical recovery of the four pre-existing affected issues.
  • [IMPACT]: 85 — This fixes silent data loss on every active Epic. Swarm handoffs depend on issue content being authoritative; the handoff comment on #10030 was lost precisely because of this bug. Not a 100 because it's an MCP service fix, not a fundamental framework shift.
  • [COMPLEXITY]: 55 — Three code files modified, two scripts and one spec added. Moderate touchpoint count but no deep framework changes. Cognitive load concentrated in the GraphQL query duplication (three near-identical timeline fragment blocks in issueQueries.mjs) which is honest tech debt I chose not to factor out to keep the diff surgical.
  • [EFFORT_PROFILE]: Quick Win — High ROI (prevents silent content loss across the whole ticket corpus) at moderate complexity, delivered in one session with a passing regression test and recovered baseline.

🕸️ Context & Graph Linking

  • Target Epic / Issue ID: Resolves #10090
  • Related Graph Nodes:
    • #10030 (the handoff-comment reproducer that triggered this work)
    • #8527 / #8528 (the Jan-2026 unified-timeline refactor that moved comment rendering onto the timelineItems channel and thereby subjected it to the page cap)
    • #7948 (the analogous SubIssue relationship-event fix for the distinct "updatedAt doesn't bump" bug class)
    • #9999 (parent Multi-Tenant Memory Core epic — was among the healed)

🧠 Graph Ingestion Notes

  • [KB_GAP]: The unified-timeline refactor (#8527/#8528) unintentionally coupled comment-body delivery to maxTimelineItemsPerIssue. That implicit contract was undocumented anywhere — no test exercised the over-cap path, no comment warned future readers that timelineItems is now load-bearing for comment rendering. Post-merge, a one-liner on #formatTimelineEvent pointing readers at this PR would close the loop.

  • [KB_GAP]: GitHub's timelineItems GraphQL connection does NOT support orderBy. Combined with its documented oldest-first default, this forces pagination as the only correctness-preserving strategy — there is no "last N" shortcut. This constraint was not obvious from training data; I confirmed it by probing REST vs GraphQL counts and observing the 50-entry cutoff exactly matches maxTimelineItemsPerIssue. Worth capturing as a framework note under learn/agentos/ so the next agent doesn't re-derive it.

  • [TOOLING_GAP]: Git worktrees do not carry ai/mcp/server/*/config.mjs because those files are .gitignored. Running any script that imports ai/services.mjs from a worktree fails with ERR_MODULE_NOT_FOUND until the user copies configs from the main checkout. Symlinks are worse — they resolve relative imports against the main checkout's src/ tree, which under unitTestMode: true triggers Namespace collision in unitTestMode for Neo.core.Base. Copies work; symlinks do not. This deserves either a one-time bootstrap script under ai/scripts/ or a note in AGENTS_STARTUP.md §Harness-Memory-Wiring.

  • [TOOLING_GAP]: My initial detector had a silent correctness bug — content.split('## Timeline') matched the ## Timeline substring inside the comment body ### Timeline & Next Steps, chopping the section into three segments while I only counted segment[1]. Fixed with /^## Timeline$/m before the PR was opened. The broader lesson: when a regex is the contract between the detector and the renderer, string-level splits are fragile. Now captured via the heading-anchor comment in the script.

  • [RETROSPECTIVE]: The "metadata tracking vs. content rendering divergence" pattern is the real enemy here. Scalar metadata (updatedAt, commentsCount) comes from a separate GraphQL path and looked healthy the whole time, which is exactly why the bug went unnoticed for three months. Any future sync channel that splits its metadata path from its content path needs a self-consistency guard — e.g. compare fetched-event count against totalCount and warn on mismatch. The #exhaustTimelineItems warn log is a weak version of this; a stronger pattern would surface the warning in runFullSync stats so operators see it without grepping logs.

  • [RETROSPECTIVE]: PR-based workflow accelerates cross-reference accumulation on Epics. The 50-event cap that was comfortable in November 2025 becomes tight within months once every PR auto-cross-references the parent Epic. Pagination is correct in principle, but operators should also expect the continuation required warning to become routine for active Epics — it's not an alarm, it's a normal mode now.


📋 Required Actions

No hard blockers for this PR. The following are optional polish I'd push into a follow-up commit if reviewers want them in-scope, otherwise they fold into the #9535 follow-up ticket or get dropped on the floor:

  • (Optional) Import maxTimelineItemsPerIssue into detectTruncatedTimelines.mjs instead of hardcoding TIMELINE_CAP = 50. Cost: one import that pulls the full MCP config module into a diagnostic script.
  • (Optional) Extend the Playwright spec with two more cases: zero-continuation (happy path) and three-page continuation (tail boundary).
  • (Optional) Add an iteration safeguard to #exhaustTimelineItems — cap at ~50 page-fetches with a hard fail. Theoretical paranoia; cheap insurance.
  • (Optional) Surface "N issues paginated" in runFullSync's final stats so operators see continuation activity without grepping logs.
  • (Optional) Add a regression spec for the detector itself using a fixture file that contains ### Timeline & Next Steps inside a comment body, guarding against the string-split bug regressing.

None of these justify holding the PR — the core correctness is verified by a passing Playwright spec, the real-world recovery of four issues, and the regression proof that #10030 now contains the previously-lost handoff comment. A human reviewer should feel free to merge and let me follow-up, or ask me to push polish commits to this branch before merge. Either works.