Skip to content

fix(claude-code): dedupe compacted continuation sessions - #693

Merged
Harry19081 merged 4 commits into
developfrom
fix/claude-code-continuation-lineage
Aug 6, 2026
Merged

fix(claude-code): dedupe compacted continuation sessions#693
Harry19081 merged 4 commits into
developfrom
fix/claude-code-continuation-lineage

Conversation

@Neonforge98

@Neonforge98 Neonforge98 commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Problem

Claude Code context compaction can rewrite the beginning of a continued transcript. The imported-history cache grouped continuation siblings only by the first user-message UUID, so a compacted conversation could split into multiple families even though the files shared a compact-boundary UUID. The sidebar then showed multiple same-title sessions. When an older sibling was already open, the reveal overlay could also render it beside the canonical roster winner.

The repository performance guard also lacked a provider × raw transition × UI state × topology gate. Earlier dual-machine evidence could therefore validate transport while leaving Claude Code compaction, raw identity derivation, and active-row rescan behavior untested.

Solution

  • Bump the Claude metadata parser to v11 and capture a bounded ancestry set: the first-user UUID plus up to 63 newest compact-boundary UUIDs.
  • Elect continuation families as connected components of intersecting markers in O(session count × 64), keep only the newest top-level sibling listable, and stamp a stable continuationLineageId while preserving legacy group-key metadata.
  • Carry the optional lineage id through the sidebar IPC/schema/store projection.
  • When an older continuation is explicitly revealed because it is open, suppress the other roster sibling with the same lineage so the active session stays accessible without a duplicate row.
  • Harden org2-performance-guard to separate provider ingestion, local identity/listability, UI hydration, cloud transport, and remote rendering; require raw provider transitions, assumption-breaking identity fixtures, active/open/pinned rescan coverage, and explicit untested matrix cells.

Potential risks

  • Parser v11 causes one full Claude metadata reindex after upgrade so existing transcripts can acquire compact-boundary markers. This is a one-time local CPU/I/O cost; there is no database migration or persistence-format incompatibility.
  • Marker storage is deliberately capped at 64. A conversation with more than 63 compactions and missing all intermediate transcript files could exceed the retained ancestry window and remain split.
  • Continuation forks that reuse the same ancestry markers follow the existing continuation policy: only the newest sibling is listed. This PR extends that existing policy across compaction epochs rather than introducing a new fork distinction.
  • The new IPC field is optional, so mixed-version or legacy cached rows remain compatible. Reverting the commit is sufficient rollback; the cache is rebuildable from source transcripts.
  • The skill change intentionally makes future verification stricter and may produce a blocked verdict until relevant provider lifecycle cells are exercised. It changes no application runtime behavior and can be reverted independently.

Architecture audit

Covered discovery/parser state, watermark invalidation, cache metadata and election ownership, exact-id lookup, optional IPC/schema compatibility, session-store projection, and sidebar reveal behavior. Execution dispatch, cloud sync, database schema, and source transcript formats were intentionally unchanged.

Performance audit

  • No new timer, poller, subscription, worker, or background task.
  • Each row contributes at most 64 markers; election uses hash-based union/find rather than pairwise comparison.
  • Initial v11 reindex is the only new full-file work. Later scans reuse the existing watermark/cache lifecycle and rewrite lineage metadata only when it changes.
  • Live macOS minimized sampling over 15 seconds: CPU stayed at 0.0%; RSS moved from 303648 KB to 303584 KB. Visible idle sampling moved from 1.3% to 0.7% while startup work settled, with flat ~303 MB RSS.
  • The skill update is documentation/metadata only and creates no runtime resource.

Verification

  • cargo test -p orgtrack_core --lib -- --test-threads=1 — 537 passed, 8 ignored.
  • cargo check -p org2 — passed.
  • Pre-commit scoped cargo clippy for orgtrack_core — passed.
  • pnpm exec tsc --noEmit --pretty false — passed.
  • Targeted ESLint and Prettier checks for every changed TypeScript file — passed.
  • vitest run for sidebar loader + continuation visibility — 23 passed.
  • Full vitest run — 7852 passed, 1 pre-existing local-timezone failure in PrCommitsTab.test.ts; reproduced unchanged on the fix(key-vault): make CLI OAuth autodetect reliable #689/develop merge worktree. The failing test passes with TZ=UTC. The run also reported the existing Undici WebSocket/Event unhandled error from useCloudOrgSyncStatus.test.ts.
  • Built an unsigned isolated macOS app bundle successfully.
  • Real-machine scan against the three reported Claude transcripts: all three received the same lineage id and listability became 0 / 0 / 1; searching the title rendered exactly one sidebar row.
  • Active-overlay scenario: opened the oldest transcript first, introduced the two continuation files, then manually rescanned Claude Code. The oldest row became unlistable but remained accessible as the active row, and the sidebar still rendered exactly one row.
  • Official skill quick_validate.py in an isolated PyYAML environment — passed (Skill is valid!).
  • Official generate_openai_yaml.py regenerated the skill interface metadata — passed.
  • Prettier and repository pre-commit checks for the skill files — passed.
  • Reverse-applied the new rejection rules to the previous fixtures: fabricated identical family keys are rejected, and Codex append/A-to-cloud evidence cannot be generalized to Claude Code compaction or active-old-row rescan.

UI evidence

Validated both the ordinary roster and the harder active-old-sibling state in the isolated app. A static screenshot is not attached because the acceptance signal is the absence of a second identical row; the manual flow above was cross-checked against the exact three-row SQLite listability/lineage state and the accessibility tree reporting one matching button.

Review follow-ups for the lineage election:

- Union stamped lineage ids alongside raw ancestry markers. Deleting an
  intermediate transcript can disconnect a family's marker graph after
  stamping; a later rescan then reinserted the old sibling as a listable
  row in its own component, resurrecting the duplicate this feature
  removes, while the exact-id lookup kept treating both halves as one
  family through the shared lineage. Lineage ids are member uuids, so
  they share the marker namespace without cross-conversation collisions.
- Preserve a stamped continuationLineageId across rescan upserts. The
  parser never emits the elected id, so the plain column replace eroded
  the stamp on every rescan and the reveal/dedupe comparison only held
  until the next scan touched the row. A metadata rewrite that loses
  continuation identity still drops the stamp with it.
- Make DisjointSet::find iterative; a pathological union order could
  chain O(component) parents and recurse that deep on the sync thread.

Pre-commit hook ran. Total eslint: 18, total circular: 0
@Neonforge98

Copy link
Copy Markdown
Collaborator Author

Review + follow-up fixes landed in 3f568bd (fix(claude-code): keep split continuation families deduplicated), plus a Windows real-machine verification pass.

Findings fixed:

  1. Split families resurrected the duplicate row. Deleting an intermediate transcript disconnects a family's marker graph after stamping; a later rescan reinserts the old sibling as a fresh listable row in its own union-find component, so the election lists both halves' winners — while has_newer_continuation_sibling's lineage path keeps treating them as one family (exact-id lookup says superseded, roster says listable). Stamped lineage ids now join the connectivity keys in the election's union phase; lineage ids are member uuids, so they share the marker namespace without cross-conversation collisions, and the two family definitions converge.
  2. Rescans eroded the stamp the design relies on. The upsert's plain source_metadata_json = excluded.… replace dropped continuationLineageId on every rescan (the parser never emits it), so the "preserve an already-elected id" guarantee only held until the next scan touched the row. The upsert now carries the stamp over via json_set when the fresh metadata is valid and unstamped; a rewrite that loses continuation identity still drops it.
  3. DisjointSet::find recursed O(component) deep under a pathological union order; now iterative with path compression.

Both fixes have direct regression tests (continuation_election_survives_a_family_split_after_stamping, rescan_upsert_preserves_a_stamped_lineage_id).

Cloud-plane interaction (audited, no change needed): newly-demoted siblings keep their pushed cloud rows — the vanished sweep's resolver runs includeContinuationSuperseded: true and demotion only flips listable, so no retract wave fires on the v11 reindex. Consequence worth noting in the docs: the dedupe is local-only; a previously-pushed old sibling's Team Sessions row lingers until the retention window ages it out.

Verification: orgtrack_core imported-history + claude tests green (the 8 pre-existing copilot/kimi failures are Windows-environment, present on the unmodified branch); tsc green; sidebar-loader + continuation-visibility vitest 23/23.

Real-machine (Windows, v11 build), per the new coverage matrix:

Provider Raw transition App/UI state Topology/boundary Expected invariant Observed evidence
claude_code 3-file compact chain (real compact_boundary lines, disjoint first-user uuids, shared boundary uuids) cold start, isolated profile local ingest one listable row; transitive lineage backend listing returned only s3; all three stamped lineage = oldest member's first-user uuid
claude_code introduce continuations while oldest transcript open live scan + manual rescan, real profile local ingest + UI hydration open old sibling stays accessible; roster renders one row pre-refresh: s1 remained open and the only visible row; post-reload roster: exactly one row (s3); atom carried both with one lineage
claude_code parser v10→v11 reindex over the user's real ~/.claude cold start, real profile local ingest no collateral demotion every real session listed with its own single-member lineage; row set unchanged
claude_code restart after election restart local ingest idempotent single row one row after reload; no re-demote churn
claude_code active-old-sibling suppression of the roster winner force-reveal overlay UI hydration winner suppressed while old sibling revealed not re-run here — covered by the 23 unit tests and the PR author's macOS device run
any cloud upload/download of demoted siblings A-to-cloud/cloud-to-B unchanged by this PR code-audited (superseded-inclusive sweep); not exercised live

Fresh-state runs sample only the post-change state space, so bugs living
in the version TRANSITION stay invisible: PR #692's costliest defect
(legacy flat cursors forced O(total) epoch rewrites) escaped every run
that built its anchors with the new binary, and PR #693's lineage stamp
was erased by the very next rescan the new build itself performed. The
protocol now demands one cell where the OLD build writes the durable
state and the NEW build must ride the ordinary incremental path over it,
plus a second-order cycle proving state the new build stamps survives
its own next scan. Fault-injection guidance also gains "inject the fault
point the change ADDS", since the rotation list only encodes yesterday's
failure modes.

Pre-commit hook ran. Total eslint: 18, total circular: 0
@Harry19081
Harry19081 merged commit ec9635f into develop Aug 6, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants