---
number: 12195
title: 'fix(github-workflow): needsUpdate compares persisted updatedAt field (#12191)'
author: neo-opus-ada
state: MERGED
createdAt: '2026-05-30T06:25:36Z'
updatedAt: '2026-05-30T06:33:39Z'
closedAt: '2026-05-30T06:33:36Z'
mergedAt: '2026-05-30T06:33:35Z'
head: fix/gh-syncer-efficiency-12191
base: dev
url: 'https://github.com/neomjs/neo/pull/12195'
---
Authored by Opus 4.8 (Claude Code). Session de8830c3-8d1b-47f8-90db-bd236c8b9bd2.

Resolves #12191
Related: #12186

`IssueSyncer` `needsUpdate` compared `oldIssue.updated`, but the persisted metadata field is `updatedAt` (`MetadataManager` + every writer). So `oldIssue.updated` was always `undefined` → `needsUpdate` always `true` → **every issue re-formatted, re-hashed and re-written every sync**, defeating the content-hash short-circuit. One-character field fix.

FAIR-band: over-target — sole active implementer (@neo-gpt rate-limited, @neo-gemini benched) + operator-directed lane (epic #12186 P2).

Evidence: L1 (unit: an unchanged archived issue is left on disk untouched — 1 new test, 209 green) → L1 required (no runtime-only ACs). No residuals.

## Deltas from ticket (if any)
#12191's second item — **hoisting the per-iteration `#planBuckets(metadata)` out of the reconcile loop (`:1010`)** — was investigated and **dropped as unsafe** (not deferred):
- `#planBuckets` derives each issue's `oldVersion` from `metadata.issues[*].path` (`:300-306`), and the reconcile loop **mutates** `metadata.issues[issueNumber].path` per-iteration (`:1046`, after each archive move). The call is therefore **not loop-invariant** — per-iteration recomputation reflects in-progress moves; hoisting to a pre-loop snapshot would change chunk-packing behavior.
- The loop iterates **active-closed** issues only (`:1000-1006` skip already-archived + non-closed), typically few — so the real cost is small, not the ~77M-touch figure the investigation estimated (which assumed iteration over the full archive).

A genuine optimization needs a deeper refactor (stable plan + incremental packing) — out of scope and low-value at this N. The field fix is the substantive bug in #12191.

## Test Evidence
- `npm run test-unit -- test/playwright/unit/ai/services/github-workflow/` → **209 passed** (1 new, 0 regressions).
- New test (`IssueSyncer.spec`): an unchanged archived issue (same `updatedAt`; sealed-chunk guarantees the path matches, so `updatedAt` is the sole discriminator) is **not** re-rendered — its on-disk sentinel survives the sync. Fails pre-fix (always-true `needsUpdate` overwrites it).
- `node --check`; pre-commit whitespace/shorthand hooks green.

## Post-Merge Validation
- [ ] On the next live sync, confirm unchanged issues are no longer re-written every run (`stats.pulled` reflects only genuinely-changed issues, not the whole corpus).
