Skip to content

fix(sync): join a landed sync clip to the live episode at intake, not after notes - #23563

Merged
Git-on-my-level merged 2 commits into
mainfrom
lane/home-pending-fold
Oct 25, 2026
Merged

Git-on-my-level merged 2 commits into
mainfrom
lane/home-pending-fold

Conversation

@Git-on-my-level

Copy link
Copy Markdown
Member

Problem

While a live episode was on screen ("Listening now · Live 10m"), pendant offline-sync clips (WAL, capture_evidence.origin=sync_vad, finalization trigger sync_update) rendered on Home as standalone rows with raw first-line titles ("Mm, your phone." 19s, "Check." 7s, …) for the 5–10 minutes until enrichment finished and the server merged/titled them. David's expectation: clips of the ongoing occasion are part of the live episode and should never appear as separate junk rows.

Root cause

_attach_synced (called only from on_conversation_finalized) was the only place a sync conversation joined an episode, and it runs after the clip's normal processing completes. Until then the clip is a standalone conversation and Home renders it. Additionally, if episode_started is not None and started < episode_started: return 'no_episode' permanently orphaned a clip that starts slightly before the live episode's recorded start (the pendant and the phone clock separately, and the episode's recorded start can sit slightly after the speech).

Change

Attach at intake — the moment a sync clip row is persisted (which is when Home can first see it):

  • utils/episodes/service.py attach_synced_intake: joins the landed row to the current live episode via the same _attach_synced rules — no model call, never opens or closes an episode, joins only the current live episode covering its span, and a conversation already owned by an episode (live or closed) never moves. Fail-open, never raises.
  • utils/episodes/finalize.py attach_synced_at_intake: the sync-lane hook (gating + deferred service import + pydantic-enum source normalization, mirroring close_now).
  • utils/sync/intake_completion.py complete_sync_intake / utils/sync/pipeline.py process_segment: the hook runs right after intake enrollment, before audio storage or enrichment can stall behind it, whenever the intake wrote content (not for transient repeat-only intakes).

Tolerance — _attach_synced no longer hard-rejects a clip that started before the episode's recorded start. It joins when the clip's span reaches the episode (a parallel capture that caught the opening words) or it began within CONTINUE_GAP_SECONDS (120 s) of the episode's start (clock skew). A clip that ended earlier than that belongs to an occasion already over and stays an ordinary row. The boundary rules (ambiguous gap, different calendar event, hard cap) still gate every join.

Idempotent finalize — the finalize-time sync_update join stays as the safety net for rows that landed before intake could attach (pre-deploy rows, a failed intake attach, or an episode that opened only after the clip landed); with membership already present it is a no-op ('finalized'), feeding no duplicate text and making no model call.

Merges (verified, no new machinery needed) — smart-merge donors already repoint episode membership to the survivor (_repoint_absorbed via _episode_after_absorb), and intake-assignment absorption (sync_merged_into) tombstones donors as deleted+discarded, so Home can never render a dead donor. A test covers an intake-joined clip whose membership follows to the survivor.

Mobile Home fold (verified, no app change) — hiddenLiveConversations(episodes.liveConversationIds) in app/lib/pages/conversations/live_episode_visibility.dart hides episode members under the live row as soon as membership exists in the provider, EpisodeProvider polls refreshLive() every 30 s while the live row is on screen (LiveEpisodeRow.watch()), and pull-to-refresh refreshes it too. With membership now existing within ~a second of the row, the fold happens on the next poll instead of after enrichment.

Gating — the intake attach respects EPISODES_ENABLED / episodes_enabled_for(uid) and the client_gate capability marker (client_seen_cached, so a faulting Redis costs one read per 120 s window instead of one per segment). EPISODES_CLIENT_CHANNELS and env defaults are untouched.

Docs: backend/utils/episodes/ARCHITECTURE.md and the service/finalize module docstrings describe the new boundary.

Tests

New/updated backend unit tests:

  • intake attach joins the live episode, no model call, no text fed; finalize hook afterwards stays idempotent; replay is 'attached' (test_episodes_service.py)
  • intake attach gating: kill switch 'disabled', no capability marker 'skipped_no_client', live-episode member 'attached', closed-episode owner 'no_episode', no live episode creates nothing
  • grace boundary: span overlap joins, started-within-CONTINUE_GAP_SECONDS (ended just before the recorded start) joins, ended earlier than that stays out
  • intake-joined clip merged away follows membership to the survivor
  • hook normalization (pydantic enum source → wire value) and fail-open (test_episodes_finalize_paths.py)
  • complete_sync_intake calls the attach before audio/enrichment, skips repeat-only intakes; process_segment hands the persisted row to the hook (test_sync_lineage_diagnostics.py, test_sync_capture_pipeline.py)
  • the frozen-main sync parity test filters the new additive episodes telemetry the way it already filters shadow telemetry; the cross-job harness gets a no-op stand-in so the deferred service import stays out of its timing budget

Run (this worktree, per-file contract runner):

BACKEND_UNIT_TEST_FILE_LIST=<55 files importing the touched modules> bash backend/test.sh   # exit 0
bash backend/test.sh   # selection for the changed files: full CI selection (2294 files) is the sharded CI contract
python3 scripts/failure-class validate --pr-body-file <pr body>   # via pr-preflight

Failure-Class: new

Line-Count-Exception: backend/utils/sync/pipeline.py | 3755 -> 3756 | one intake kwarg hands the landed row to the episodes attach; no other line changes

🤖 Generated with Claude Code

… after notes

Offline-sync (WAL) clips rendered on Home as standalone rows with raw
first-line titles until enrichment finalized and _attach_synced finally
joined them to the live episode. The join now runs the moment intake
persisted the row (complete_sync_intake -> finalize.attach_synced_at_intake
-> service.attach_synced_intake), with the same cheap rules: no model call,
never opens or closes an episode, joins only the current live episode
covering the span. The finalize-time sync_update join stays as an idempotent
safety net.

A clip that starts before the episode's recorded start now joins when its
span reaches the episode or it began within CONTINUE_GAP_SECONDS of that
start (pendant and phone clock separately); one that ended earlier belongs
to an occasion already over and stays an ordinary row.

Smart-merge donors already repoint membership to the survivor and intake
absorption tombstones donors deleted+discarded, so Home never shows a dead
donor; a test covers the survivor repoint for an intake-joined clip.

Failure-Class: FC-episode-membership-waits-for-enrichment

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-25T07:28:04.786438Z e18f0b3 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2f9298d523

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

owner = episodes_db.get_episode_for_conversation(uid, conversation_id, firestore_client=c.firestore_client)
if owner is not None:
return 'attached' if policy.is_live(owner) else 'no_episode'
return _attach_synced(uid, conversation_id, row, c)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep intake-only sync members from suppressing occasion closure

When a WAL clip joins before the mobile occasion conversation finishes, _attach_synced appends its ID to conversation_ids. However, _close_for_finalize treats ids[-1] != conversation_id as proof that another capture is continuing, so a subsequent capture_end or server_recovery for the mobile conversation returns continued instead of closing the episode. The sync member cannot close it either because sync_update never closes episodes. This newly affects clips whose enrichment is still pending when capture ends: the ended occasion remains live until idle cleanup and can absorb subsequent speech. Exclude parallel sync membership from the ordering used to decide whether the live capture has ended.

Useful? React with 👍 / 👎.

… off the sync hot path

Review follow-ups on intake attach: a discarded fragment or merge donor never
joins; a member later folded by sync intake follows its survivor via
sync_merged_from; the join runs on the episode pool so sync intake never waits
on episode Firestore reads (finalize-time join stays the safety net).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e18f0b3de5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +66 to +67
except Exception as error:
logger.warning('event=episode_sync_intake_attach outcome=error exception_type=%s', type(error).__name__)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Record shared fallback telemetry when intake attachment fails

When the episode pool rejects submission, this handler logs a warning and lets sync continue without the intake fold, but never records the degraded outcome through utils.observability.fallback.record_fallback. The new exception handlers in attach_synced_at_intake and service.attach_synced_intake have the same omission. Consequently, failed intake attachment can restore the minutes-late standalone Home rows without appearing in the shared fallback metrics. Instrument these new fail-open paths with the shared helper and outcome='degraded', as required by the backend guide.

AGENTS.md reference: backend/AGENTS.md:L316-L316

Useful? React with 👍 / 👎.

Comment on lines +918 to +921
if row.get('discarded') or row.get('deleted') or row.get('sync_merged_into'):
# A discarded fragment or a merge donor never joins (the finalize path's rule).
return 'skipped_discarded'
_repoint_sync_donors(uid, conversation_id, row, c)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Revalidate donor state before attaching a queued intake snapshot

When a later sync intake absorbs a clip before its queued episode callback executes, row still contains the pre-merge snapshot copied by schedule_synced_intake_attach, so this check misses the persisted deleted/sync_merged_into tombstone. The episode executor has multiple workers, allowing the survivor callback to run first, find no donor membership to repoint, and attach the survivor; the delayed donor callback then appends the deleted donor. Neither sync finalization nor bridge cleanup subsequently removes that membership. This leaves a dead conversation in the episode, and PreviousEpisodePage renders an Open Conversation button for it that cannot load the deleted row. Revalidate the persisted deletion/redirect state when executing the attachment rather than relying solely on the queued snapshot.

Useful? React with 👍 / 👎.

@Git-on-my-level
Git-on-my-level merged commit 4054fed into main Oct 25, 2026
56 checks passed
@Git-on-my-level
Git-on-my-level deleted the lane/home-pending-fold branch October 25, 2026 07:37
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.

1 participant