Skip to content

[Bug]: iOS Live Activity keeps showing "Done" after you open the finished thread #15728

Description

@jlipworth

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

(The fix spans apps/server (AgentAwarenessRelay) and infra/relay. No mobile widget change needed.)

Summary

The iOS Live Activity keeps showing "T3 / Done" in the Dynamic Island after the user has opened the app and read the finished thread. Opening the thread never reaches the Live Activity pipeline. Opening the app actually re-sends the Done card and makes it look fresh again.

This is not #11939. #11939 is about the card failing to end once the 15-minute display window passes. This issue is about the card staying during that window (and longer) even though the user already saw the result. Fixing #11939 (for example with #11943) does not fix it. See "Relationship to existing issues" below.

Steps to reproduce

  1. iPhone with a Dynamic Island, T3 Connect linked, Live Activity Updates on, environment publishing agent activity.
  2. Start a turn from the phone so the Live Activity is armed. Optionally run more than one thread.
  3. Let every turn finish. The island shows T3 · Done.
  4. Within 15 minutes, open the T3 app, open each finished thread, and read the result.
  5. Go back to the Home Screen.

Expected behavior

Once every finished thread on the card has been viewed, the Live Activity ends and leaves the Dynamic Island. If one finished thread hasn't been viewed yet, only that row stays on the card. A new completion arriving afterwards still shows normally.

Actual behavior

T3 · Done reappears in the Dynamic Island as soon as the app goes to the background. It stays at least until 15 minutes after the completion. Because of #11939, it often stays for hours. Viewing the threads has no effect.

How iOS expects this to work

From Apple's documentation:

  • HIG, Live Activities: "Always end a Live Activity immediately when the task or event ends, and consider setting a custom dismissal time." and "When a Live Activity ends, the system immediately removes it from the Dynamic Island and in CarPlay. On the Lock Screen […] it remains for up to four hours."
  • HIG: "Your Live Activity appears in the Dynamic Island while your app isn't in use." This is why the card seems to "come back" when you leave the app. It was never ended; iOS just hides it while T3 is in the foreground.
  • Starting and updating Live Activities with ActivityKit push notifications: stale-date only marks content as outdated. Only an end event with a dismissal-date removes the activity. "The system ignores an ActivityKit push notification if it arrives after the Live Activity ended."

T3 deliberately sends Done as an update, not an end. Ending would retire the activity's push token, so the next turn could not reuse the card. That trade-off is reasonable while the result is unseen. Once the user has seen it, the activity is showing a finished task the user already knows about, which Apple's guidance says to end. Today nothing ever tells the relay that the user has seen it.

Root cause (main @ 4ee6bfd)

1. Viewing a thread is recorded but never reaches the relay.

2. Opening the app re-sends Done instead of clearing it.

On every foreground, at most once per 60 seconds per token (remoteRegistration.ts:846), the app re-registers its unchanged activity token. LiveActivities.register treats that like a new card. It resets lastAggregateJson to null and remoteStartedAt to now (LiveActivities.ts:169-176). The replay that follows (MobileRegistrations.ts:78) has no previous aggregate to compare against. It therefore sends a fresh live_activity_update with the Done content, with no alert because it's a replay. That update sets a new stale-date 10 minutes out (ApnsClient.ts:165). The result: opening the app is the action that makes the Done card look fresh again.

3. The reset also suppresses an end for 2 minutes after every app open.

Because remoteStartedAt is reset to now, FRESHLY_ARMED_GRACE_MS (ApnsDeliveries.ts:63, :268) treats an existing card as just created. For 2 minutes, any empty aggregate is dropped instead of ending the card. The grace exists for a genuinely new card whose first publish hasn't arrived yet. It shouldn't apply to a token the relay has been delivering to for minutes. This matters for the fix below: the user opens the app and then views the thread, which happens inside that 2-minute window.

Verified with a temporary scenario test against the real makeAggregateState and ApnsDeliveries.sendForTarget (not committed). The card already received Done at t=0, and the row is shown both as stored and as it looks after same-token re-registration:

App opened at Aggregate registerDevice replay (row untouched) registerLiveActivity replay (row reset)
t+5 min [Done] nothing (unchanged, suppressed) live_activity_update re-sending Done, no alert
t+20 min null live_activity_end nothing (freshly-armed grace)

Both replays fire from the same foreground event, and the client doesn't order them. Past the 15-minute window, whether opening the app ends the card therefore depends on which request the relay handles first. That explains part of why this feels inconsistent from one open to the next.

Proposed fix

Treat a viewed terminal row as gone, using the deletion path that already exists. This needs no contract or widget change.

  1. Server (AgentAwarenessRelay). When a thread's projected state is completed or failed and lastVisitedAt >= latestRunCompletedAt, publish null (a tombstone) instead of the terminal state. Remove thread.visited from the list of events that don't trigger a publish. The existing publish-identity dedupe keeps visits to running threads free. The existing 5-second tombstone confirmation still applies. Compare against latestRunCompletedAt rather than updatedAt, because metadata edits bump updatedAt ([Bug]: Changing the model on a finished thread sends a new "Done" alert to the phone #11938).

  2. Relay. Nothing new for content. The tombstone already does rows.remove, and the aggregate is recomputed:

    • other threads still live or unseen: the seen row disappears and the card stays;
    • nothing left: the aggregate is null, so live_activity_end is sent with the 15-second contentless dismissal. Per Apple, that removes it from the Dynamic Island immediately.

    Android FCM gets the same aggregate.

  3. Relay (LiveActivities.register). When the same activityPushToken re-registers for the same device, keep remoteStartedAt. Without this, step 1's end is usually swallowed by the 2-minute grace, because the user just opened the app. Whether to also keep lastAggregateJson, which would stop re-sending unchanged Done content on every open, is the maintainers' call. Once seen rows are removed, the replay is correct either way.

  4. Tests.

    • server: a thread.visited at or after completion publishes a tombstone; a visit before completion, or on a running thread, publishes nothing;
    • relay: same-token re-registration keeps remote_started_at, and an empty aggregate right afterwards ends the card;
    • relay: two terminal rows with one viewed leaves one row on the card, not an end.

Arming stays as it is. On open, the app only creates a card when activeCount > 0 (remoteRegistration.ts:1138), so ending a seen Done-only card won't be recreated on the next open. A new completion after the end behaves as it does today after any end.

Open product question. The visit watermark is shared across devices, so reading a thread on desktop would also end the phone's card. That matches how the Done marker already clears "on every device". docs/user/mobile-notifications.md says "Viewing a thread on another device does not silence your phone's alerts", but that sentence is about alerts, which have already fired by this point, not about the card. If maintainers want the card to clear only when viewed on the phone itself, the visit needs to carry its origin. That's a larger change.

Relationship to existing issues

Item Why it doesn't cover this
#11939 / PR #11943 The card isn't ended after the 15-minute window when nothing else happens. #11943 adds a sweep that ends cards with no delivery for 15 minutes (coalesce(lastLiveActivityDeliveryAt, remoteStartedAt, …)). It never looks at whether the user saw the result. And because every app open resets remoteStartedAt and delivers a replay (root cause 2), each open restarts that 15-minute idle clock. Keep both issues; this one is about the content of the card, #11939 about its expiry.
#10864 Which clock (delivery time or state time) sets expiry across iOS and Android. A policy follow-up, not about seen state.
#11938 / PR #9081 Metadata changes on a finished thread bump updatedAt and re-send Done alerts. That extends this problem, but it's a separate trigger. The proposed fix compares against latestRunCompletedAt so it doesn't depend on that change.
PR #15015 Duplicate push alerts on push-only devices (iPad). Alerts, not the card.
#15254 (superseded #12656) Dismisses leftover ended activities before starting a new card. Different lifecycle step.

Impact

Minor bug or occasional failure. The Dynamic Island keeps saying "Done" for work the user has already reviewed, so it stops signalling anything. Users learn to ignore it, which defeats the point of the card.

Version or commit

main @ 4ee6bfd

Environment

iPhone with Dynamic Island, T3 Code Mobile with T3 Connect and Live Activity Updates on. Root cause established by code reading plus a scenario test against the relay delivery code at the commit above.

Workaround

Swipe the Live Activity away on the Lock Screen, or turn off Live Activity Updates.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions