Skip to content

Improve live activity routing and diagnostics#3685

Open
juliusmarminge wants to merge 1 commit into
mainfrom
t3code/live-activities-improvements
Open

Improve live activity routing and diagnostics#3685
juliusmarminge wants to merge 1 commit into
mainfrom
t3code/live-activities-improvements

Conversation

@juliusmarminge

@juliusmarminge juliusmarminge commented Jul 4, 2026

Copy link
Copy Markdown
Member

Summary

  • Adds relay-aware APNs routing for mobile registrations, including bundle ID and sandbox vs production environment detection.
  • Improves Live Activity reconciliation by re-registering tokens on foreground and cleaning up orphaned activities on sign-out.
  • Reworks the Agent Activity widget to surface per-row status, safer deep links, and better overflow handling.
  • Adds a new Push Delivery diagnostics section in Settings so users can inspect relay registration and last delivery state.
  • Expands relay-side device diagnostics, APNs delivery tracking, and persistence to improve observability and routing accuracy.

Testing

  • vp check
  • vp run typecheck
  • vp test
  • Added/updated tests for mobile diagnostics, remote registration, widget rendering, and relay APNs delivery/device tracking.
  • Not run: manual device or APNs end-to-end verification

Note

Medium Risk
Changes APNs routing, registration gating, and push suppression logic across mobile and relay; mistakes could block notifications or mis-route tokens, though behavior is heavily covered by new tests.

Overview
Improves end-to-end Live Activity and push reliability by registering each iOS install with bundleId and apsEnvironment (sandbox for development, production otherwise), persisting that on the relay, and using it when sending APNs so topic and host match the build variant.

On the mobile app, device registration is deduplicated via a stored account identity + payload signature (cleared on sign-out), and a registered / failed status drives settings so notification and Live Activity switches stay off until the relay actually accepts the device. Foreground triggers Live Activity token re-registration; sign-out ends local activities and tears down listeners. The Agent Activity widget gains per-row phase tinting, local-time “Updated” labels, overflow text, compact “Approval/Input” hints, and safe deep links to the thread that needs attention. Frequent-updates is enabled for the widget extension in Expo config.

On the relay, Live Activity delivery skips unchanged aggregates (and does not fall back to alert pushes when suppressed), still sends on count changes or waiting-for-input/approval, and expires stale activity rows from aggregates (2h running, 24h waiting). Stale-after for Live Activities in APNs payloads moves from 2 to 10 minutes.

Reviewed by Cursor Bugbot for commit 5225bdb. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add per-device APNs routing and improve Live Activity update logic

  • Stores bundle_id and aps_environment on device records (new DB columns) and propagates them through the APNs delivery pipeline so each device's Live Activity and push requests are routed to the correct APNs environment and topic.
  • Adds expiry filtering in makeAggregateState: active states older than 2h (running/starting) or 24h (waiting) are dropped; if only expired rows remain and no terminal state exists, the aggregate returns null.
  • Suppresses redundant Live Activity APNs updates when the aggregate is unchanged; sends immediately when a row needs approval/input even within the throttle window; prevents fallback push notifications when a Live Activity update is suppressed.
  • On the mobile client, device registration payloads now include bundleId and apsEnvironment; registrations are skipped when the persisted identity and payload signature match the last successful registration.
  • Adds an AppState listener so Live Activity tokens are re-registered when the app returns to the foreground, and ends local Live Activities on sign-out.
  • The widget now formats the updated-at label in device-local time, tints rows by phase, adds a deep link to the attention row, and shows an overflow indicator when more activities are active than displayed.
  • Increases STALE_AFTER_SECONDS for Live Activity APNs updates from 2 to 10 minutes to reduce premature dimming.
  • Behavioral Change: aggregates that previously contained only expired active rows will now produce null, stopping APNs deliveries for those devices until a fresh state arrives.

Macroscope summarized 5225bdb.

@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: de289ba6-e152-4568-953c-43ae30485035

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/live-activities-improvements

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added size:XL 500-999 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels Jul 4, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Missing database migration columns
    • Added the drizzle-kit-generated Postgres migration (migration.sql + snapshot.json) adding bundle_id and aps_environment to relay_mobile_devices, chained onto the previous snapshot.
  • ✅ Fixed: Last delivery ignores HTTP status
    • The Last Delivery row now treats a non-2xx lastDeliveryStatus as a failure even when lastDeliveryError is null, showing "Delivery failed (status)" instead of "Delivered".
  • ✅ Fixed: Diagnostics use global attempt window
    • Replaced the global 100-row window with a DISTINCT ON (device_id) query ordered by created_at DESC so each device's true latest delivery attempt is always returned.

Create PR

Or push these changes by commenting:

@cursor push a97e0b6881
Preview (a97e0b6881)
diff --git a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts
--- a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts
+++ b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts
@@ -69,6 +69,28 @@
     ]);
   });
 
+  it("treats a non-2xx delivery status without a reason as a failure", () => {
+    const rows = formatDeviceDiagnosticsRows(
+      makeDevice({
+        bundleId: "com.t3tools.t3code.preview",
+        apsEnvironment: "production",
+        hasPushToken: true,
+        hasPushToStartToken: true,
+        hasLiveActivityToken: true,
+        lastDeliveryAt: "2026-06-05T01:02:59.566Z",
+        lastDeliveryKind: "live_activity_end",
+        lastDeliveryStatus: 400,
+        lastDeliveryError: null,
+      }),
+    );
+
+    expect(rows[4]).toEqual({
+      label: "Last Delivery",
+      value: "Delivery failed (400)",
+      tone: "warn",
+    });
+  });
+
   it("reports healthy registrations with a successful delivery", () => {
     const rows = formatDeviceDiagnosticsRows(
       makeDevice({

diff --git a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts
--- a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts
+++ b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts
@@ -73,14 +73,21 @@
     });
   }
 
+  // APNs can fail with a non-2xx status without a reason body, so a null
+  // error alone does not mean the delivery succeeded.
+  const deliveryFailed =
+    diagnostics.lastDeliveryError !== null ||
+    (diagnostics.lastDeliveryStatus !== null &&
+      (diagnostics.lastDeliveryStatus < 200 || diagnostics.lastDeliveryStatus >= 300));
+
   if (diagnostics.lastDeliveryAt === null) {
     rows.push({ label: "Last Delivery", value: "None yet", tone: "muted" });
-  } else if (diagnostics.lastDeliveryError !== null) {
+  } else if (deliveryFailed) {
     const status =
       diagnostics.lastDeliveryStatus === null ? "" : ` (${diagnostics.lastDeliveryStatus})`;
     rows.push({
       label: "Last Delivery",
-      value: `${diagnostics.lastDeliveryError}${status}`,
+      value: `${diagnostics.lastDeliveryError ?? "Delivery failed"}${status}`,
       tone: "warn",
     });
   } else {

diff --git a/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/migration.sql b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/migration.sql
new file mode 100644
--- /dev/null
+++ b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/migration.sql
@@ -1,0 +1,3 @@
+ALTER TABLE "relay_mobile_devices" ADD COLUMN "bundle_id" varchar(255);
+--> statement-breakpoint
+ALTER TABLE "relay_mobile_devices" ADD COLUMN "aps_environment" varchar(16);

diff --git a/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/snapshot.json b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/snapshot.json
new file mode 100644
--- /dev/null
+++ b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/snapshot.json
@@ -1,0 +1,1479 @@
+{
+  "dialect": "postgres",
+  "id": "e00cc7df-a31e-4b8b-b258-d04e7db7758a",
+  "prevIds": ["385d476b-d4f6-48a3-99e6-0a95af4ee4e4"],
+  "version": "8",
+  "ddl": [
+    {
+      "isRlsEnabled": false,
+      "name": "relay_agent_activity_rows",
+      "entityType": "tables",
+      "schema": "public"
+    },
+    {
+      "isRlsEnabled": false,
+      "name": "relay_delivery_attempts",
+      "entityType": "tables",
+      "schema": "public"
+    },
+    {
+      "isRlsEnabled": false,
+      "name": "relay_dpop_proofs",
+      "entityType": "tables",
+      "schema": "public"
+    },
+    {
+      "isRlsEnabled": false,
+      "name": "relay_environment_credentials",
+      "entityType": "tables",
+      "schema": "public"
+    },
+    {
+      "isRlsEnabled": false,
+      "name": "relay_environment_links",
+      "entityType": "tables",
+      "schema": "public"
+    },
+    {
+      "isRlsEnabled": false,
+      "name": "relay_live_activities",
+      "entityType": "tables",
+      "schema": "public"
+    },
+    {
+      "isRlsEnabled": false,
+      "name": "relay_managed_endpoint_allocations",
+      "entityType": "tables",
+      "schema": "public"
+    },
+    {
+      "isRlsEnabled": false,
+      "name": "relay_mobile_devices",
+      "entityType": "tables",
+      "schema": "public"
+    },
+    {
+      "type": "varchar(191)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "environment_id",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_agent_activity_rows"
+    },
+    {
+      "type": "text",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "environment_public_key",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_agent_activity_rows"
+    },
+    {
+      "type": "varchar(191)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "thread_id",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_agent_activity_rows"
+    },
+    {
+      "type": "jsonb",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "state_json",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_agent_activity_rows"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "updated_at",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_agent_activity_rows"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "created_at",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_agent_activity_rows"
+    },
+    {
+      "type": "varchar(36)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "id",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_delivery_attempts"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "created_at",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_delivery_attempts"
+    },
+    {
+      "type": "varchar(255)",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "user_id",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_delivery_attempts"
+    },
+    {
+      "type": "varchar(191)",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "environment_id",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_delivery_attempts"
+    },
+    {
+      "type": "varchar(191)",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "thread_id",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_delivery_attempts"
+    },
+    {
+      "type": "varchar(255)",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "device_id",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_delivery_attempts"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "kind",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_delivery_attempts"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "source_job_id",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_delivery_attempts"
+    },
+    {
+      "type": "varchar(16)",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "token_suffix",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_delivery_attempts"
+    },
+    {
+      "type": "integer",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "apns_status",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_delivery_attempts"
+    },
+    {
+      "type": "text",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "apns_reason",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_delivery_attempts"
+    },
+    {
+      "type": "varchar(128)",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "apns_id",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_delivery_attempts"
+    },
+    {
+      "type": "text",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "transport_error",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_delivery_attempts"
+    },
+    {
+      "type": "varchar(128)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "thumbprint",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_dpop_proofs"
+    },
+    {
+      "type": "varchar(255)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "jti",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_dpop_proofs"
+    },
+    {
+      "type": "integer",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "iat",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_dpop_proofs"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "expires_at",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_dpop_proofs"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "created_at",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_dpop_proofs"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "credential_id",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_credentials"
+    },
+    {
+      "type": "varchar(191)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "environment_id",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_credentials"
+    },
+    {
+      "type": "text",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "environment_public_key",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_credentials"
+    },
+    {
+      "type": "varchar(191)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "credential_hash",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_credentials"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "revoked_at",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_credentials"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "created_at",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_credentials"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "updated_at",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_credentials"
+    },
+    {
+      "type": "varchar(191)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "user_id",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_links"
+    },
+    {
+      "type": "varchar(191)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "environment_id",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_links"
+    },
+    {
+      "type": "text",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": "'T3 Environment'",
+      "generated": null,
+      "identity": null,
+      "name": "environment_label",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_links"
+    },
+    {
+      "type": "text",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "environment_public_key",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_links"
+    },
+    {
+      "type": "text",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "endpoint_http_base_url",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_links"
+    },
+    {
+      "type": "text",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "endpoint_ws_base_url",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_links"
+    },
+    {
+      "type": "varchar(32)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "endpoint_provider_kind",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_links"
+    },
+    {
+      "type": "boolean",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": "true",
+      "generated": null,
+      "identity": null,
+      "name": "notifications_enabled",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_links"
+    },
+    {
+      "type": "boolean",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": "true",
+      "generated": null,
+      "identity": null,
+      "name": "live_activities_enabled",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_links"
+    },
+    {
+      "type": "boolean",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": "false",
+      "generated": null,
+      "identity": null,
+      "name": "managed_tunnels_enabled",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_links"
+    },
+    {
+      "type": "varchar(191)",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "created_by_device_id",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_links"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "revoked_at",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_links"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "created_at",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_links"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "updated_at",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_environment_links"
+    },
+    {
+      "type": "varchar(255)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "user_id",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_live_activities"
+    },
+    {
+      "type": "varchar(255)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "device_id",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_live_activities"
+    },
+    {
+      "type": "text",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "activity_push_token",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_live_activities"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "remote_start_queued_at",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_live_activities"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "remote_started_at",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_live_activities"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "ended_at",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_live_activities"
+    },
+    {
+      "type": "jsonb",
+      "typeSchema": null,
+      "notNull": false,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
... diff truncated: showing 800 of 1681 lines

You can send follow-ups to the cloud agent here.

Comment thread infra/relay/src/persistence/schema.ts
Comment thread apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts Outdated
Comment thread infra/relay/src/agentActivity/Devices.ts Outdated
platform: varchar("platform", { length: 16 }).notNull().$type<"ios">(),
iosMajorVersion: integer("ios_major_version").notNull(),
appVersion: varchar("app_version", { length: 64 }),
bundleId: varchar("bundle_id", { length: 255 }),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High persistence/schema.ts:27

The new bundle_id and aps_environment columns are added to the relay_mobile_devices schema, but no SQL migration is included to create them. After deploy, the code in Devices.ts immediately inserts and selects these columns, so PostgreSQL rejects every device registration and list query with column "bundle_id" does not exist (and aps_environment likewise). Add the corresponding ALTER TABLE relay_mobile_devices ADD COLUMN ... migration under infra/relay/migrations/ so the columns exist before the new code runs.

Also found in 1 other location(s)

infra/relay/src/agentActivity/LiveActivities.ts:201

The new relayMobileDevices.bundleId / relayMobileDevices.apsEnvironment columns are referenced by listTargets (bundle_id, aps_environment) and other relay code, but this PR does not add a matching SQL migration for relay_mobile_devices. After deploying the code to an existing database, any listTargets query will start failing with column relay_mobile_devices.bundle_id does not exist (and likewise for aps_environment), breaking live-activity target lookup until the schema is manually patched.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/persistence/schema.ts around line 27:

The new `bundle_id` and `aps_environment` columns are added to the `relay_mobile_devices` schema, but no SQL migration is included to create them. After deploy, the code in `Devices.ts` immediately inserts and selects these columns, so PostgreSQL rejects every device registration and list query with `column "bundle_id" does not exist` (and `aps_environment` likewise). Add the corresponding `ALTER TABLE relay_mobile_devices ADD COLUMN ...` migration under `infra/relay/migrations/` so the columns exist before the new code runs.

Also found in 1 other location(s):
- infra/relay/src/agentActivity/LiveActivities.ts:201 -- The new `relayMobileDevices.bundleId` / `relayMobileDevices.apsEnvironment` columns are referenced by `listTargets` (`bundle_id`, `aps_environment`) and other relay code, but this PR does not add a matching SQL migration for `relay_mobile_devices`. After deploying the code to an existing database, any `listTargets` query will start failing with `column relay_mobile_devices.bundle_id does not exist` (and likewise for `aps_environment`), breaking live-activity target lookup until the schema is manually patched.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium

return yield* liveActivities.listTargets({ userId: input.target.user_id }).pipe(

The stale-job check in processSignedJob only compares the token via isCurrentSignedJobToken, so a queued job created before the device registered bundleId/apsEnvironment (or before those values changed) still passes the staleness guard when the token is unchanged. The job is then sent with the stale or missing routing metadata from the signed payload instead of the device's current values, causing APNs to reject the delivery with BadDeviceToken/DeviceTokenNotForTopic even though the registration has already been corrected. Consider comparing bundleId and apsEnvironment against the current target in the staleness check so jobs with outdated routing are treated as stale.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/ApnsDeliveries.ts around line 506:

The stale-job check in `processSignedJob` only compares the token via `isCurrentSignedJobToken`, so a queued job created before the device registered `bundleId`/`apsEnvironment` (or before those values changed) still passes the staleness guard when the token is unchanged. The job is then sent with the stale or missing routing metadata from the signed payload instead of the device's current values, causing APNs to reject the delivery with `BadDeviceToken`/`DeviceTokenNotForTopic` even though the registration has already been corrected. Consider comparing `bundleId` and `apsEnvironment` against the current target in the staleness check so jobs with outdated routing are treated as stale.

@macroscopeapp

macroscopeapp Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

8 blocking correctness issues found. This PR introduces significant new runtime behavior including database schema changes, registration status tracking, AppState listeners, and widget deep linking. Multiple unresolved review comments identify high-severity issues including rolling deploy signature breakage and stuck registration states that require human attention.

You can customize Macroscope's approvability policy. Learn more.

@juliusmarminge juliusmarminge force-pushed the t3code/live-activities-improvements branch from f81c2ca to 1d6309e Compare July 6, 2026 05:57
Comment thread apps/mobile/src/features/settings/SettingsRouteScreen.tsx Outdated
const deepLinkRow = attentionRow ?? row0;
const deepLink =
deepLinkRow && deepLinkRow.deepLink.startsWith("/") && !deepLinkRow.deepLink.startsWith("//")
? `t3code://${deepLinkRow.deepLink.slice(1)}`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium widgets/AgentActivity.tsx:81

widgetURL is hardcoded to the t3code:// scheme, but the app registers t3code-dev for development builds and t3code-preview for preview builds. On dev/preview builds the widget banner's tap URL uses a scheme the containing app does not register, so iOS will not open the app when the banner is tapped. Consider using a scheme that is registered in all build variants (or injecting the active scheme per-build).

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/widgets/AgentActivity.tsx around line 81:

`widgetURL` is hardcoded to the `t3code://` scheme, but the app registers `t3code-dev` for development builds and `t3code-preview` for preview builds. On dev/preview builds the widget banner's tap URL uses a scheme the containing app does not register, so iOS will not open the app when the banner is tapped. Consider using a scheme that is registered in all build variants (or injecting the active scheme per-build).

// while a user ignores an approval prompt, so they get a longer window. The
// underlying database row is left in place: a late publish for the thread
// refreshes updatedAt and the row becomes visible again.
const RUNNING_AGENT_ACTIVITY_ROW_TTL_MS = 2 * 60 * 60 * 1_000;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium agentActivity/AgentActivityPublisher.ts:203

RUNNING_AGENT_ACTIVITY_ROW_TTL_MS expires running/starting states after 2 hours based on updatedAt, but relay publishes are event-driven — a thread that stays in the same running phase for over 2 hours without a new event keeps its old updatedAt, so isExpiredAgentActivityState returns true and makeAggregateState filters it out. The aggregate then reports activeCount: 0 (or null/terminal) even though the job is still running, hiding active work from clients. Consider raising the running TTL to match the waiting TTL, or filtering on a heartbeat/last-seen timestamp that is refreshed independently of phase-change events.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/AgentActivityPublisher.ts around line 203:

`RUNNING_AGENT_ACTIVITY_ROW_TTL_MS` expires `running`/`starting` states after 2 hours based on `updatedAt`, but relay publishes are event-driven — a thread that stays in the same `running` phase for over 2 hours without a new event keeps its old `updatedAt`, so `isExpiredAgentActivityState` returns `true` and `makeAggregateState` filters it out. The aggregate then reports `activeCount: 0` (or `null`/terminal) even though the job is still running, hiding active work from clients. Consider raising the running TTL to match the waiting TTL, or filtering on a heartbeat/last-seen timestamp that is refreshed independently of phase-change events.

Comment thread apps/mobile/src/widgets/AgentActivity.tsx
Comment thread infra/relay/src/agentActivity/Devices.ts Outdated
Comment thread infra/relay/src/agentActivity/ApnsDeliveries.ts
Comment thread apps/mobile/src/features/agent-awareness/registrationPayload.ts
@juliusmarminge juliusmarminge force-pushed the t3code/live-activities-improvements branch from 1d6309e to 39d9dca Compare July 6, 2026 06:07
@github-actions github-actions Bot added size:L 100-499 changed lines (additions + deletions). and removed size:XL 500-999 changed lines (additions + deletions). labels Jul 6, 2026
runRegistrationInBackground(
refreshActiveLiveActivityRemoteRegistration(),
"active live activity reconciliation after app foreground failed",
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Foreground replay skips updates

Medium Severity

Foreground reconciliation re-registers Live Activity tokens so the relay replays the aggregate, but replayForLiveActivityRegistration still uses sendForTarget with the same shouldUpdateLiveActivity gating. When the aggregate matches last_aggregate_json or is inside the 15s throttle, delivery returns "suppressed" and no APNs update is sent, so lock-screen content can stay stale after returning to the app.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 39d9dca. Configure here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bugbot Autofix determined this is a false positive.

Foreground reconciliation goes through MobileRegistrations.registerLiveActivity, where LiveActivities.register resets lastAggregateJson and lastLiveActivityDeliveryAt to null before the replay runs, so shouldUpdateLiveActivity short-circuits to true (null previousAggregate) and the replayed update is never suppressed.

You can send follow-ups to the cloud agent here.

@cursor

cursor Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Bugbot Autofix prepared fixes for 2 of the 4 issues found in the latest run.

  • ✅ Fixed: APNs environment from app variant
    • resolveApsEnvironment now derives the environment from the embedded provisioning profile's aps-environment entitlement via expo-application's getIosPushNotificationServiceEnvironmentAsync, falling back to the app variant only when the entitlement is unavailable, so debug-signed installs of any variant register as sandbox.
  • ✅ Fixed: Ended activities show connected
    • The diagnostics query now selects ended_at and hasLiveActivityToken only counts tokens whose activity row has ended_at null, so finished Live Activities no longer show as connected in Settings.

Create PR

Or push these changes by commenting:

@cursor push 4b511de34f
Preview (4b511de34f)
diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts
--- a/apps/mobile/app.config.ts
+++ b/apps/mobile/app.config.ts
@@ -161,6 +161,9 @@
         bundleIdentifier: `${variant.iosBundleIdentifier}.widgets`,
         groupIdentifier: `group.${variant.iosBundleIdentifier}`,
         enablePushNotifications: true,
+        // Agent activity can update many times an hour; without the
+        // frequent-updates entitlement iOS throttles the update budget sooner.
+        frequentUpdates: true,
         widgets: [
           {
             name: "AgentActivity",

diff --git a/apps/mobile/package.json b/apps/mobile/package.json
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -67,6 +67,7 @@
     "diff": "8.0.3",
     "effect": "catalog:",
     "expo": "~56.0.12",
+    "expo-application": "~56.0.3",
     "expo-asset": "~56.0.17",
     "expo-auth-session": "~56.0.14",
     "expo-build-properties": "~56.0.19",

diff --git a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts
new file mode 100644
--- /dev/null
+++ b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts
@@ -1,0 +1,95 @@
+import { describe, expect, it } from "vite-plus/test";
+import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay";
+
+import { formatDeviceDiagnosticsRows } from "./deviceDiagnostics";
+
+function makeDevice(diagnostics: RelayClientDeviceRecord["diagnostics"]): RelayClientDeviceRecord {
+  return {
+    deviceId: "device-1",
+    label: "iPhone",
+    platform: "ios",
+    iosMajorVersion: 18,
+    appVersion: "1.0.0",
+    notifications: {
+      enabled: true,
+      notifyOnApproval: true,
+      notifyOnInput: true,
+      notifyOnCompletion: true,
+      notifyOnFailure: true,
+    },
+    liveActivities: { enabled: true },
+    ...(diagnostics ? { diagnostics } : {}),
+    updatedAt: "2026-07-04T00:00:00.000Z",
+  } as RelayClientDeviceRecord;
+}
+
+describe("formatDeviceDiagnosticsRows", () => {
+  it("flags an unregistered device", () => {
+    expect(formatDeviceDiagnosticsRows(null)).toEqual([
+      { label: "Relay Registration", value: "Not registered", tone: "warn" },
+    ]);
+  });
+
+  it("explains when the relay predates delivery diagnostics", () => {
+    expect(formatDeviceDiagnosticsRows(makeDevice(undefined))).toEqual([
+      { label: "Relay Registration", value: "Registered", tone: "ok" },
+      { label: "Delivery Details", value: "Requires a relay update", tone: "muted" },
+    ]);
+  });
+
+  it("warns about missing tokens and surfaces the last delivery failure", () => {
+    const rows = formatDeviceDiagnosticsRows(
+      makeDevice({
+        bundleId: "com.t3tools.t3code.preview",
+        apsEnvironment: "production",
+        hasPushToken: false,
+        hasPushToStartToken: false,
+        hasLiveActivityToken: false,
+        lastDeliveryAt: "2026-06-05T01:02:59.566Z",
+        lastDeliveryKind: "live_activity_end",
+        lastDeliveryStatus: 400,
+        lastDeliveryError: "DeviceTokenNotForTopic",
+      }),
+    );
+
+    expect(rows).toEqual([
+      { label: "Notification Token", value: "Missing", tone: "warn" },
+      { label: "Live Activity Start Token", value: "Missing", tone: "warn" },
+      { label: "Active Live Activity", value: "None", tone: "muted" },
+      {
+        label: "APNs Route",
+        value: "com.t3tools.t3code.preview (production)",
+        tone: "muted",
+      },
+      {
+        label: "Last Delivery",
+        value: "DeviceTokenNotForTopic (400)",
+        tone: "warn",
+      },
+    ]);
+  });
+
+  it("reports healthy registrations with a successful delivery", () => {
+    const rows = formatDeviceDiagnosticsRows(
+      makeDevice({
+        bundleId: "com.t3tools.t3code",
+        apsEnvironment: "production",
+        hasPushToken: true,
+        hasPushToStartToken: true,
+        hasLiveActivityToken: true,
+        lastDeliveryAt: "2026-07-04T00:00:00.000Z",
+        lastDeliveryKind: "live_activity_update",
+        lastDeliveryStatus: 200,
+        lastDeliveryError: null,
+      }),
+    );
+
+    expect(rows.slice(0, 3)).toEqual([
+      { label: "Notification Token", value: "Registered", tone: "ok" },
+      { label: "Live Activity Start Token", value: "Registered", tone: "ok" },
+      { label: "Active Live Activity", value: "Connected", tone: "ok" },
+    ]);
+    expect(rows[4]).toMatchObject({ label: "Last Delivery", tone: "ok" });
+    expect(rows[4]?.value).toContain("Delivered");
+  });
+});

diff --git a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts
new file mode 100644
--- /dev/null
+++ b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts
@@ -1,0 +1,96 @@
+import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay";
+
+export interface DeviceDiagnosticsRow {
+  readonly label: string;
+  readonly value: string;
+  readonly tone: "ok" | "warn" | "muted";
+}
+
+function formatLastDeliveryTimestamp(iso: string): string {
+  const date = new Date(iso);
+  if (Number.isNaN(date.getTime())) {
+    return "";
+  }
+  return date.toLocaleString(undefined, {
+    month: "short",
+    day: "numeric",
+    hour: "numeric",
+    minute: "2-digit",
+  });
+}
+
+// Renders the relay's view of this device so "why am I not getting pushes"
+// is answerable from the Settings screen instead of the relay database.
+export function formatDeviceDiagnosticsRows(
+  device: RelayClientDeviceRecord | null,
+): ReadonlyArray<DeviceDiagnosticsRow> {
+  if (device === null) {
+    return [
+      {
+        label: "Relay Registration",
+        value: "Not registered",
+        tone: "warn",
+      },
+    ];
+  }
+  const diagnostics = device.diagnostics;
+  if (!diagnostics) {
+    return [
+      { label: "Relay Registration", value: "Registered", tone: "ok" },
+      { label: "Delivery Details", value: "Requires a relay update", tone: "muted" },
+    ];
+  }
+
+  const rows: Array<DeviceDiagnosticsRow> = [
+    {
+      label: "Notification Token",
+      value: diagnostics.hasPushToken ? "Registered" : "Missing",
+      tone: diagnostics.hasPushToken ? "ok" : "warn",
+    },
+    {
+      label: "Live Activity Start Token",
+      value: diagnostics.hasPushToStartToken ? "Registered" : "Missing",
+      tone: diagnostics.hasPushToStartToken ? "ok" : "warn",
+    },
+    {
+      label: "Active Live Activity",
+      value: diagnostics.hasLiveActivityToken ? "Connected" : "None",
+      tone: diagnostics.hasLiveActivityToken ? "ok" : "muted",
+    },
+  ];
+
+  if (diagnostics.bundleId) {
+    rows.push({
+      label: "APNs Route",
+      value: `${diagnostics.bundleId} (${diagnostics.apsEnvironment ?? "default"})`,
+      tone: "muted",
+    });
+  } else {
+    rows.push({
+      label: "APNs Route",
+      value: "Relay default (update the app to register)",
+      tone: "warn",
+    });
+  }
+
+  if (diagnostics.lastDeliveryAt === null) {
+    rows.push({ label: "Last Delivery", value: "None yet", tone: "muted" });
+  } else if (diagnostics.lastDeliveryError !== null) {
+    const status =
+      diagnostics.lastDeliveryStatus === null ? "" : ` (${diagnostics.lastDeliveryStatus})`;
+    rows.push({
+      label: "Last Delivery",
+      value: `${diagnostics.lastDeliveryError}${status}`,
+      tone: "warn",
+    });
+  } else {
+    const timestamp = formatLastDeliveryTimestamp(diagnostics.lastDeliveryAt);
+    rows.push({
+      label: "Last Delivery",
+      value: timestamp ? `Delivered ${timestamp}` : "Delivered",
+      tone: "ok",
+    });
+  }
+
+  return rows;
+}

diff --git a/apps/mobile/src/features/agent-awareness/registrationPayload.ts b/apps/mobile/src/features/agent-awareness/registrationPayload.ts
--- a/apps/mobile/src/features/agent-awareness/registrationPayload.ts
+++ b/apps/mobile/src/features/agent-awareness/registrationPayload.ts
@@ -2,11 +2,33 @@
 
 import type { Preferences } from "../../lib/storage";
 
+// The APNs environment follows code signing, not the app variant: any
+// Xcode-signed debug install (regardless of APP_VARIANT) receives sandbox
+// device tokens, while distribution-signed builds use production APNs. The
+// embedded provisioning profile's aps-environment entitlement is the
+// authoritative signal; the variant is only a fallback for builds where the
+// entitlement cannot be read (for example App Store installs, which strip the
+// embedded profile and always use production APNs).
+export function resolveApsEnvironment(input: {
+  readonly appVariant: unknown;
+  readonly pushEnvironment: "development" | "production" | null;
+}): "sandbox" | "production" {
+  if (input.pushEnvironment === "development") {
+    return "sandbox";
+  }
+  if (input.pushEnvironment === "production") {
+    return "production";
+  }
+  return input.appVariant === "development" ? "sandbox" : "production";
+}
+
 export function makeRelayDeviceRegistrationRequest(input: {
   readonly deviceId: string;
   readonly label: string;
   readonly iosMajorVersion: number;
   readonly appVersion?: string;
+  readonly bundleId?: string;
+  readonly apsEnvironment?: "sandbox" | "production";
   readonly pushToken?: string;
   readonly pushToStartToken?: string;
   readonly notificationsEnabled: boolean;
@@ -19,6 +41,8 @@
     platform: "ios",
     iosMajorVersion: input.iosMajorVersion,
     appVersion: input.appVersion,
+    ...(input.bundleId ? { bundleId: input.bundleId } : {}),
+    ...(input.apsEnvironment ? { apsEnvironment: input.apsEnvironment } : {}),
     ...(input.pushToken ? { pushToken: input.pushToken } : {}),
     ...(input.pushToStartToken ? { pushToStartToken: input.pushToStartToken } : {}),
     preferences: {

diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts
--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts
+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts
@@ -16,7 +16,7 @@
 import type { SavedRemoteConnection } from "../../lib/connection";
 import { cryptoLayer } from "../cloud/dpop";
 import { managedRelayClientLayer } from "../cloud/managedRelayLayer";
-import { makeRelayDeviceRegistrationRequest } from "./registrationPayload";
+import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload";
 import {
   AgentAwarenessOperationError,
   __resetAgentAwarenessRemoteRegistrationForTest,
@@ -41,7 +41,14 @@
     readonly resolve: (exit: Exit.Exit<unknown, unknown>) => void;
   }>,
 }));
+const appStateMock = vi.hoisted(() => ({
+  listeners: [] as Array<(state: string) => void>,
+}));
 
+vi.mock("expo-application", () => ({
+  getIosPushNotificationServiceEnvironmentAsync: vi.fn(() => Promise.resolve(null)),
+}));
+
 vi.mock("expo-constants", () => ({
   default: {
     expoConfig: {
@@ -100,6 +107,19 @@
     OS: "ios",
     Version: "18.0",
   },
+  AppState: {
+    addEventListener: (_event: string, listener: (state: string) => void) => {
+      appStateMock.listeners.push(listener);
+      return {
+        remove: () => {
+          const index = appStateMock.listeners.indexOf(listener);
+          if (index >= 0) {
+            appStateMock.listeners.splice(index, 1);
+          }
+        },
+      };
+    },
+  },
 }));
 
 vi.mock("../../lib/runtime", () => ({
@@ -176,6 +196,7 @@
     backgroundRuntime.pending.length = 0;
     Constants.expoConfig!.extra = {};
     __resetAgentAwarenessRemoteRegistrationForTest();
+    appStateMock.listeners.length = 0;
     widgetMocks.getInstances.mockReset();
     widgetMocks.getInstances.mockReturnValue([]);
   });
@@ -213,6 +234,45 @@
     });
   });
 
+  it("registers the app's APNs routing so the relay targets the right bundle", () => {
+    expect(
+      makeRelayDeviceRegistrationRequest({
+        deviceId: "device-1",
+        label: "Julius's iPhone",
+        iosMajorVersion: 18,
+        appVersion: "1.0.0",
+        bundleId: "com.t3tools.t3code.preview",
+        apsEnvironment: resolveApsEnvironment({ appVariant: "preview", pushEnvironment: null }),
+        notificationsEnabled: true,
+        preferences: {},
+      }),
+    ).toMatchObject({
+      bundleId: "com.t3tools.t3code.preview",
+      apsEnvironment: "production",
+    });
+  });
+
+  it("routes builds by their signed APNs entitlement before the app variant", () => {
+    expect(resolveApsEnvironment({ appVariant: "preview", pushEnvironment: "development" })).toBe(
+      "sandbox",
+    );
+    expect(
+      resolveApsEnvironment({ appVariant: "development", pushEnvironment: "production" }),
+    ).toBe("production");
+    expect(resolveApsEnvironment({ appVariant: "development", pushEnvironment: null })).toBe(
+      "sandbox",
+    );
+    expect(resolveApsEnvironment({ appVariant: "preview", pushEnvironment: null })).toBe(
+      "production",
+    );
+    expect(resolveApsEnvironment({ appVariant: "production", pushEnvironment: null })).toBe(
+      "production",
+    );
+    expect(resolveApsEnvironment({ appVariant: undefined, pushEnvironment: null })).toBe(
+      "production",
+    );
+  });
+
   it("marks notification delivery disabled when APNs permission is unavailable", () => {
     expect(
       makeRelayDeviceRegistrationRequest({
@@ -329,6 +389,53 @@
     },
   );
 
+  it.effect(
+    "re-registers active Live Activity tokens when the app returns to the foreground",
+    () => {
+      const activity = {
+        getPushToken: vi.fn(() => Promise.resolve("activity-token")),
+        addPushTokenListener: vi.fn(),
+      };
+      widgetMocks.getInstances.mockReturnValue([activity] as never);
+      setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
+
+      return Effect.gen(function* () {
+        yield* runBackgroundOperations();
+        activity.getPushToken.mockClear();
+
+        expect(appStateMock.listeners).toHaveLength(1);
+        for (const listener of appStateMock.listeners) {
+          listener("background");
+        }
+        yield* runBackgroundOperations();
+        expect(activity.getPushToken).not.toHaveBeenCalled();
+
+        for (const listener of appStateMock.listeners) {
+          listener("active");
+        }
+        yield* runBackgroundOperations();
+        expect(activity.getPushToken).toHaveBeenCalled();
+      }).pipe(Effect.provide(relayTestLayer));
+    },
+  );
+
+  it("ends local Live Activities and stops foreground reconciliation on cloud sign-out", () => {
+    const end = vi.fn(() => Promise.resolve());
+    const activity = {
+      getPushToken: vi.fn(() => Promise.resolve("activity-token")),
+      addPushTokenListener: vi.fn(),
+      end,
+    };
+    widgetMocks.getInstances.mockReturnValue([activity] as never);
+    setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
+    expect(appStateMock.listeners).toHaveLength(1);
+
+    setAgentAwarenessRelayTokenProvider(null);
+
+    expect(end).toHaveBeenCalledWith("immediate");
+    expect(appStateMock.listeners).toHaveLength(0);
+  });
+
   it.effect("refreshes APNs registration for connected environments after settings changes", () => {
     registerAgentAwarenessConnection(savedConnection());
     return Effect.gen(function* () {

diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
@@ -1,9 +1,10 @@
 import { addPushToStartTokenListener, type LiveActivity } from "expo-widgets";
+import * as Application from "expo-application";
 import Constants from "expo-constants";
 import * as Notifications from "expo-notifications";
 import * as Effect from "effect/Effect";
 import * as Schema from "effect/Schema";
-import { Platform } from "react-native";
+import { AppState, Platform } from "react-native";
 import type { EnvironmentId } from "@t3tools/contracts";
 import {
   type RelayDeviceRegistrationRequest,
@@ -26,13 +27,14 @@
 } from "../../lib/storage";
 import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity";
 import { resolveCloudPublicConfig } from "../cloud/publicConfig";
-import { makeRelayDeviceRegistrationRequest } from "./registrationPayload";
+import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload";
 
 const REMOTE_ACTIVITY_REGISTRATION_RETRY_MS = 15_000;
 
 const AgentAwarenessOperation = Schema.Literals([
   "read-notification-permissions",
   "read-native-push-token",
+  "read-aps-environment",
   "read-device-registration-relay-token",
   "read-device-unregistration-relay-token",
   "read-live-activity-registration-relay-token",
@@ -60,6 +62,7 @@
 const activityPushTokenListeners = new WeakSet<LiveActivity<AgentActivityProps>>();
 let pushToStartSubscription: { remove: () => void } | null = null;
 let pushTokenSubscription: { remove: () => void } | null = null;
+let appStateSubscription: { remove: () => void } | null = null;
 let activeLiveActivityRegistrationRetry: ReturnType<typeof setTimeout> | null = null;
 let relayTokenProvider: (() => Promise<string | null>) | null = null;
 let relayTokenProviderIdentity: string | null = null;
@@ -128,14 +131,20 @@
     pushToStartSubscription = null;
     pushTokenSubscription?.remove();
     pushTokenSubscription = null;
+    appStateSubscription?.remove();
+    appStateSubscription = null;
     if (activeLiveActivityRegistrationRetry) {
       clearTimeout(activeLiveActivityRegistrationRetry);
       activeLiveActivityRegistrationRetry = null;
     }
+    // Without a signed-in user the relay can no longer update or end these
+    // activities, so they would sit orphaned on the lock screen.
+    endLocalLiveActivities("live activity cleanup after cloud sign-out failed");
     return;
   }
   ensurePushToStartListener();
   ensurePushTokenListener();
+  ensureAppStateListener();
   runRegistrationInBackground(
     refreshActiveLiveActivityRemoteRegistration(),
     "active live activity registration after cloud sign-in failed",
@@ -155,6 +164,26 @@
   return Number.isFinite(major) ? major : 18;
 }
 
+// Reads the aps-environment entitlement from the embedded provisioning
+// profile so the relay routes pushes to the APNs host that issued this
+// build's tokens. Store installs strip the embedded profile (null), so the
+// caller falls back to the app variant.
+const nativePushEnvironment = Effect.tryPromise({
+  try: () => Application.getIosPushNotificationServiceEnvironmentAsync(),
+  catch: (cause) =>
+    new AgentAwarenessOperationError({
+      operation: "read-aps-environment",
+      cause,
+    }),
+}).pipe(
+  Effect.tapError((error) =>
+    Effect.sync(() => {
+      logRegistrationError("APNs environment lookup failed", error);
+    }),
+  ),
+  Effect.orElseSucceed(() => null),
+);
+
 function nativePushTokenRegistration(observedPushToken?: string) {
   return Effect.gen(function* () {
     if (!canRegisterRemoteLiveActivities()) {
@@ -439,17 +468,26 @@
           }),
       }),
     ]);
-    const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken);
+    const [pushTokenRegistration, pushEnvironment] = yield* Effect.all([
+      nativePushTokenRegistration(input?.observedPushToken),
+      nativePushEnvironment,
+    ]);
     logRegistrationDebug("device registration local state ready", {
       expectedGeneration,
       notificationsEnabled: pushTokenRegistration.notificationsEnabled,
     });
+    const bundleId = Constants.expoConfig?.ios?.bundleIdentifier?.trim();
     yield* registerDeviceWithRelay(
       makeRelayDeviceRegistrationRequest({
         deviceId,
         label: Constants.deviceName?.trim() || "iOS device",
         iosMajorVersion: iosMajorVersion(),
         appVersion: Constants.expoConfig?.version,
+        ...(bundleId ? { bundleId } : {}),
+        apsEnvironment: resolveApsEnvironment({
+          appVariant: Constants.expoConfig?.extra?.appVariant,
+          pushEnvironment,
+        }),
         ...(pushTokenRegistration.pushToken ? { pushToken: pushTokenRegistration.pushToken } : {}),
         ...(input?.pushToStartToken ? { pushToStartToken: input.pushToStartToken } : {}),
         notificationsEnabled: pushTokenRegistration.notificationsEnabled,
@@ -498,6 +536,41 @@
   });
 }
 
+// Re-registering activity tokens on foreground makes the relay replay the
+// current aggregate to this device, which updates content that drifted while
+// pushes could not be delivered and ends orphaned activities whose end push
+// never arrived.
+function ensureAppStateListener(): void {
+  if (appStateSubscription || !canRegisterRemoteLiveActivities()) {
+    return;
+  }
+
+  appStateSubscription = AppState.addEventListener("change", (state) => {
+    if (state !== "active") {
+      return;
+    }
+    runRegistrationInBackground(
+      refreshActiveLiveActivityRemoteRegistration(),
+      "active live activity reconciliation after app foreground failed",
+    );
+  });
+}
+
+function endLocalLiveActivities(context: string): void {
+  if (!canRegisterRemoteLiveActivities()) {
+    return;
+  }
+  try {
+    for (const activity of AgentActivity.getInstances()) {
+      activity.end("immediate").catch((error: unknown) => {
+        logRegistrationError(context, error);
+      });
+    }
+  } catch (error) {
+    logRegistrationError(context, error);
+  }
+}
+
 export function registerAgentAwarenessConnection(connection: SavedRemoteConnection): void {
   if (!canRegisterRemoteLiveActivities()) {
     return;
@@ -506,6 +579,7 @@
   environmentConnections.set(connection.environmentId, connection);
   ensurePushToStartListener();
   ensurePushTokenListener();
+  ensureAppStateListener();
   enqueueDeviceRegistration({}, "device registration failed");
   runRegistrationInBackground(
     refreshActiveLiveActivityRemoteRegistration(),
@@ -527,6 +601,8 @@
   pushToStartSubscription = null;
   pushTokenSubscription?.remove();
   pushTokenSubscription = null;
+  appStateSubscription?.remove();
+  appStateSubscription = null;
   if (activeLiveActivityRegistrationRetry) {
     clearTimeout(activeLiveActivityRegistrationRetry);
     activeLiveActivityRegistrationRetry = null;
@@ -553,6 +629,8 @@
   pushToStartSubscription = null;
   pushTokenSubscription?.remove();
   pushTokenSubscription = null;
+  appStateSubscription?.remove();
+  appStateSubscription = null;
   if (activeLiveActivityRegistrationRetry) {
     clearTimeout(activeLiveActivityRegistrationRetry);
     activeLiveActivityRegistrationRetry = null;

diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
--- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
@@ -17,7 +17,12 @@
   settlePromise,
   squashAtomCommandFailure,
 } from "@t3tools/client-runtime/state/runtime";
+import { ManagedRelay } from "@t3tools/client-runtime/relay";
 import { AppText as Text } from "../../components/AppText";
+import {
+  formatDeviceDiagnosticsRows,
+  type DeviceDiagnosticsRow,
+} from "../agent-awareness/deviceDiagnostics";
 import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences";
 import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions";
 import { refreshAgentAwarenessRegistration } from "../agent-awareness/remoteRegistration";
@@ -27,7 +32,7 @@
 import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
 import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
 import { runtime } from "../../lib/runtime";
-import { loadPreferences } from "../../lib/storage";
+import { loadAgentAwarenessDeviceId, loadPreferences } from "../../lib/storage";
 import { useThemeColor } from "../../lib/useThemeColor";
 import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
 import { SettingsRow } from "./components/SettingsRow";
@@ -409,6 +414,8 @@
           />
         </SettingsSection>
 
+        {isSignedIn ? <PushDeliverySection getToken={getToken} /> : null}
+
         <SettingsSection title="Appearance">
           <SettingsRow icon="paintbrush" label="Appearance" target="SettingsAppearance" />
         </SettingsSection>
@@ -421,6 +428,76 @@
   );
 }
 
+function PushDeliverySection(props: { readonly getToken: ReturnType<typeof useAuth>["getToken"] }) {
+  const { getToken } = props;
+  const [rows, setRows] = useState<ReadonlyArray<DeviceDiagnosticsRow> | null>(null);
+
+  useEffect(() => {
+    let cancelled = false;
+    void (async () => {
+      const result = await settleAsyncResult(() =>
+        runtime.runPromiseExit(
+          Effect.gen(function* () {
+            const [deviceId, clerkToken] = yield* Effect.all([
+              Effect.promise(() => loadAgentAwarenessDeviceId()),
+              Effect.promise(() => getToken(resolveRelayClerkTokenOptions())),
+            ]);
+            if (!deviceId || !clerkToken) {
+              return formatDeviceDiagnosticsRows(null);
+            }
+            const client = yield* ManagedRelay.ManagedRelayClient;
+            const devices = yield* client.listDevices({ clerkToken });
+            return formatDeviceDiagnosticsRows(
+              devices.find((device) => device.deviceId === deviceId) ?? null,
+            );
+          }),
+        ),
+      );
+      if (cancelled) {
+        return;
+      }
+      if (result._tag === "Failure") {
+        if (!isAtomCommandInterrupted(result)) {
+          reportAtomCommandResult(result, { label: "push delivery diagnostics" });
+        }
+        setRows([{ label: "Relay Registration", value: "Unavailable", tone: "muted" }]);
+        return;
+      }
+      setRows(result.value);
+    })();
+    return () => {
+      cancelled = true;
+    };
+  }, [getToken]);
+
+  return (
+    <SettingsSection title="Push Delivery">
+      {(rows ?? [{ label: "Relay Registration", value: "Checking", tone: "muted" as const }]).map(
+        (row) => (
+          <View key={row.label} className="flex-row items-center gap-4 p-4">
+            <Text className="shrink-0 text-lg text-foreground" numberOfLines={1}>
+              {row.label}
+            </Text>
+            <View className="min-w-0 flex-1 items-end">
+              <Text
+                className={
+                  row.tone === "warn"
+                    ? "text-right text-base text-danger-foreground"
+                    : "text-right text-base text-foreground-muted"
+                }
+                ellipsizeMode="middle"
+                numberOfLines={1}
+              >
+                {row.value}
+              </Text>
+            </View>
+          </View>
+        ),
+      )}
+    </SettingsSection>
+  );
+}
+
 function AppSettingsSection() {
   const icon = useThemeColor("--color-icon");
 

diff --git a/apps/mobile/src/widgets/AgentActivity.test.ts b/apps/mobile/src/widgets/AgentActivity.test.ts
--- a/apps/mobile/src/widgets/AgentActivity.test.ts
+++ b/apps/mobile/src/widgets/AgentActivity.test.ts
@@ -12,14 +12,34 @@
   foregroundStyle: (value: unknown) => value,
   lineLimit: (value: unknown) => value,
   padding: (value: unknown) => value,
+  widgetURL: (value: unknown) => ({ widgetURL: value }),
 }));
 
 vi.mock("expo-widgets", () => ({
   createLiveActivity: vi.fn((name: string, layout: unknown) => ({ layout, name })),
 }));
 
-import { AgentActivity, type AgentActivityProps } from "./AgentActivity";
+import {
+  AgentActivity,
+  type AgentActivityProps,
+  type AgentActivityRowProps,
+} from "./AgentActivity";
 
+function makeRow(overrides: Partial<AgentActivityRowProps>): AgentActivityRowProps {
+  return {
+    environmentId: "env-1",
+    threadId: "thread-1",
+    projectTitle: "Project",
+    threadTitle: "Thread",
+    modelTitle: "gpt-5.4",
+    phase: "running",
+    status: "Working",
+    updatedAt: "2026-05-25T13:07:00.000Z",
+    deepLink: "/threads/env-1/thread-1",
+    ...overrides,
+  };
+}
+
 const props = {
   title: "T3 Code",
   subtitle: "Agent work in progress",
@@ -33,10 +53,16 @@
   isLuminanceReduced: false,
 } as const;
 
+function expectedLocalTime(iso: string): string {
+  const date = new Date(iso);
+  const minutes = date.getMinutes();
+  return `${date.getHours() % 12 || 12}:${minutes < 10 ? "0" : ""}${minutes}`;
+}
+
 describe("AgentActivity widget layout", () => {
-  it("formats its updated-at label without app-runtime helper references", () => {
+  it("formats its updated-at label in device-local time", () => {
     expect(JSON.stringify(AgentActivity(props, environment as never))).toContain(
-      '"children":["Updated ","1:07"]',
+      `"children":["Updated ","${expectedLocalTime(props.updatedAt)}"]`,
     );
     expect(AgentActivity.toString()).not.toContain("formatAgentActivityUpdatedAtLabel");
   });
@@ -46,4 +72,91 @@
       JSON.stringify(AgentActivity({ ...props, updatedAt: "not-a-date" }, environment as never)),
     ).toContain('"children":["Updated ","now"]');
   });
+
+  it("tints each row by its own phase", () => {
+    const layout = AgentActivity(
+      {
+        ...props,
+        activeCount: 2,
+        activities: [
+          makeRow({}),
+          makeRow({ threadId: "thread-2", phase: "waiting_for_approval", status: "Approval" }),
+        ],
... diff truncated: showing 800 of 3579 lines

You can send follow-ups to the cloud agent here.

- Per-device APNs routing (bundle id + environment) so preview/dev builds
  receive pushes from the shared relay (+ migration for the new columns)
- Settings notification/Live Activity toggles reflect actual relay
  registration success instead of local permission/preference, so they
  cannot read as enabled when the device cannot receive anything
- Persistent register-once: skip re-registering an unchanged payload for
  the same account across launches; clear only on sign-out
- Foreground Live Activity refresh, stale/ghost-row hardening, dedup fixes
- Tighten widget deep linking and per-row rendering for active agents

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@juliusmarminge juliusmarminge force-pushed the t3code/live-activities-improvements branch from 39d9dca to 5225bdb Compare July 6, 2026 07:37
@github-actions github-actions Bot added size:XXL 1,000+ changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Jul 6, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

There are 3 total unresolved issues (including 1 from previous review).

Fix All in Cursor

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Registration status stuck pending
    • The device-registration queue completion handler now resets a status still left as 'pending' back to 'unknown' when the effect exits without reaching the relay (signed out, missing relay config, cancelled generation, or unsupported platform).
  • ✅ Fixed: Sign-out cleanup on provider clear
    • Added a non-destructive suspend path (suspendAgentAwarenessRelayTokenProvider / suspendCloudRelayAccount) used by CloudAuthBridge unmount and missing-config teardown so Live Activities and the persisted registration record survive while the user remains signed in, keeping destructive cleanup only for real sign-out and account switches.

Create PR

Or push these changes by commenting:

@cursor push fa19061765
Preview (fa19061765)
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
@@ -143,6 +143,30 @@
   return identity === undefined || identity !== previousIdentity;
 }
 
+function removeRelaySubscriptions(): void {
+  pushToStartSubscription?.remove();
+  pushToStartSubscription = null;
+  pushTokenSubscription?.remove();
+  pushTokenSubscription = null;
+  appStateSubscription?.remove();
+  appStateSubscription = null;
+  if (activeLiveActivityRegistrationRetry) {
+    clearTimeout(activeLiveActivityRegistrationRetry);
+    activeLiveActivityRegistrationRetry = null;
+  }
+}
+
+// Drops the relay token provider without sign-out semantics: the Clerk session
+// can still be valid while the auth bridge tears down (remounts, provider tree
+// changes, missing cloud config). Listeners stop, but local Live Activities,
+// the persisted registration record, and the provider identity stay intact so
+// re-activating the same account does not end activities or force
+// re-registration.
+export function suspendAgentAwarenessRelayTokenProvider(): void {
+  relayTokenProvider = null;
+  removeRelaySubscriptions();
+}
+
 export function setAgentAwarenessRelayTokenProvider(
   provider: (() => Promise<string | null>) | null,
   identity?: string,
@@ -158,16 +182,7 @@
   relayTokenProvider = provider;
   relayTokenProviderIdentity = provider ? (identity ?? null) : null;
   if (!provider) {
-    pushToStartSubscription?.remove();
-    pushToStartSubscription = null;
-    pushTokenSubscription?.remove();
-    pushTokenSubscription = null;
-    appStateSubscription?.remove();
-    appStateSubscription = null;
-    if (activeLiveActivityRegistrationRetry) {
-      clearTimeout(activeLiveActivityRegistrationRetry);
-      activeLiveActivityRegistrationRetry = null;
-    }
+    removeRelaySubscriptions();
     // Without a signed-in user the relay can no longer update or end these
     // activities, so they would sit orphaned on the lock screen.
     endLocalLiveActivities("live activity cleanup after cloud sign-out failed");
@@ -472,6 +487,11 @@
     if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
       setRegistrationStatus("failed");
       logRegistrationError(next.context, squashAtomCommandFailure(result));
+    } else if (registrationStatus === "pending") {
+      // The registration exited without reaching the relay (signed out,
+      // missing relay config, or superseded by a newer generation); don't
+      // leave the status stuck on pending.
+      setRegistrationStatus("unknown");
     }
     logRegistrationDebug("device registration finished", { generation });
     if (activeDeviceRegistration === registration) {

diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx
--- a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx
+++ b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx
@@ -15,6 +15,7 @@
 import { useAtomCommand } from "../../state/use-atom-command";
 import {
   setAgentAwarenessRelayTokenProvider,
+  suspendAgentAwarenessRelayTokenProvider,
   unregisterAgentAwarenessDeviceForCurrentUser,
 } from "../agent-awareness/remoteRegistration";
 import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig";
@@ -32,6 +33,14 @@
   setManagedRelaySession(appAtomRegistry, null);
 }
 
+// Teardown without sign-out semantics: the Clerk session may still be valid
+// (bridge remounts, provider tree changes, missing cloud config), so local
+// Live Activities and the persisted registration record must survive.
+export function suspendCloudRelayAccount(): void {
+  suspendAgentAwarenessRelayTokenProvider();
+  setManagedRelaySession(appAtomRegistry, null);
+}
+
 export function activateCloudRelayAccount(
   accountId: string,
   tokenProvider: () => Promise<string | null>,
@@ -143,7 +152,7 @@
   useEffect(
     () => () => {
       previousTokenProviderRef.current = null;
-      deactivateCloudRelayAccount();
+      suspendCloudRelayAccount();
     },
     [],
   );
@@ -158,7 +167,7 @@
 
   useEffect(() => {
     if (!publishableKey || !relayUrl) {
-      deactivateCloudRelayAccount();
+      suspendCloudRelayAccount();
     }
   }, [publishableKey, relayUrl]);

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 5225bdb. Configure here.

hasObservedPushToken: next.input.observedPushToken !== undefined,
hasPushToStartToken: next.input.pushToStartToken !== undefined,
});
if (registrationStatus !== "registered") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Registration status stuck pending

Medium Severity

The device registration status can get stuck as 'pending' when registerDeviceWithRelay exits early, such as when a user isn't signed in or the request is cancelled. This leaves the UI incorrectly showing the device as unregistered and can prevent subsequent registration attempts for the same user.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5225bdb. Configure here.

// next sign-in re-registers.
void clearAgentAwarenessRegistrationRecord().catch((error: unknown) => {
logRegistrationError("clear registration record on sign-out failed", error);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sign-out cleanup on provider clear

Medium Severity

Clearing the relay token provider now always ends local Live Activities and wipes the persisted registration record. deactivateCloudRelayAccount uses that path, and CloudAuthBridge calls it on component unmount, so a remount while the user is still signed in can dismiss lock-screen activities and force unnecessary re-registration.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5225bdb. Configure here.

Comment on lines +334 to +335
setRegistrationStatus("registered");
yield* Effect.promise(() =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium agent-awareness/remoteRegistration.ts:334

After client.registerDevice(...) resolves, registerDeviceWithRelay sets registrationStatus to "registered" and saves the registration record without re-checking deviceRegistrationGeneration. If the user signs out while the request is in flight, setAgentAwarenessRelayTokenProvider(null) increments the generation and clears the stored record, but the stale request still overwrites both values — leaving the signed-out app showing "registered" and making the next sign-in incorrectly skip re-registration for the previous account. Consider re-checking expectedGeneration !== deviceRegistrationGeneration after the relay call before updating status and persisting the record.

    setRegistrationStatus("registered");
+    if (expectedGeneration !== deviceRegistrationGeneration) {
+      logRegistrationDebug("device registration cancelled after relay request", {
+        expectedGeneration,
+        currentGeneration: deviceRegistrationGeneration,
+      });
+      return;
+    }
    yield* Effect.promise(() =>
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/agent-awareness/remoteRegistration.ts around lines 334-335:

After `client.registerDevice(...)` resolves, `registerDeviceWithRelay` sets `registrationStatus` to `"registered"` and saves the registration record without re-checking `deviceRegistrationGeneration`. If the user signs out while the request is in flight, `setAgentAwarenessRelayTokenProvider(null)` increments the generation and clears the stored record, but the stale request still overwrites both values — leaving the signed-out app showing `"registered"` and making the next sign-in incorrectly skip re-registration for the previous account. Consider re-checking `expectedGeneration !== deviceRegistrationGeneration` after the relay call before updating status and persisting the record.

// Only reads as on when this device is actually registered with the
// relay; otherwise notifications cannot be delivered regardless of
// the local iOS permission.
value={notificationStatus === "enabled" && deviceRegistered}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium settings/SettingsRouteScreen.tsx:438

The deviceRegistered gate on the Device Notifications switch renders it false when relay registration fails, even though notificationStatus is "enabled". Because the switch shows off, the user cannot toggle it to reach handleDeviceNotificationsChange(false), which is the only path that opens iOS Settings to disable notifications. In the "permission granted, relay registration failed" case the switch misreports notifications as off and removes the in-app affordance for managing them. Consider gating only the Live Activity switch on deviceRegistered, or keeping the Device Notifications switch reflecting the actual iOS permission state so the user can still navigate to system Settings.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/settings/SettingsRouteScreen.tsx around line 438:

The `deviceRegistered` gate on the `Device Notifications` switch renders it `false` when relay registration fails, even though `notificationStatus` is `"enabled"`. Because the switch shows off, the user cannot toggle it to reach `handleDeviceNotificationsChange(false)`, which is the only path that opens iOS Settings to disable notifications. In the "permission granted, relay registration failed" case the switch misreports notifications as off and removes the in-app affordance for managing them. Consider gating only the Live Activity switch on `deviceRegistered`, or keeping the `Device Notifications` switch reflecting the actual iOS permission state so the user can still navigate to system Settings.

userId: input.userId,
deviceId: input.deviceId,
token: input.token,
...(input.bundleId ? { bundleId: input.bundleId } : {}),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High agentActivity/apnsDeliveryJobs.ts:247

makeApnsDeliveryJobPayload includes target.bundleId and target.apsEnvironment in the payload, and signatureForPayload signs that full object. Older relay workers that decode with a previous ApnsDeliveryJobPayload schema strip the new keys on decode (default effect Schema.Struct behavior) and recompute the HMAC over the reduced payload, so verifySignedApnsDeliveryJob returns ApnsDeliveryJobSignatureInvalid for every newly queued job carrying per-device routing. This breaks APNs delivery during rolling deploys when new producers share a queue with old consumers. Consider gating inclusion of these fields behind a flag, or otherwise ensuring the signed payload shape remains decodable by older schema versions, so signatures stay stable across the rollout.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/apnsDeliveryJobs.ts around line 247:

`makeApnsDeliveryJobPayload` includes `target.bundleId` and `target.apsEnvironment` in the payload, and `signatureForPayload` signs that full object. Older relay workers that decode with a previous `ApnsDeliveryJobPayload` schema strip the new keys on decode (default `effect` `Schema.Struct` behavior) and recompute the HMAC over the reduced payload, so `verifySignedApnsDeliveryJob` returns `ApnsDeliveryJobSignatureInvalid` for every newly queued job carrying per-device routing. This breaks APNs delivery during rolling deploys when new producers share a queue with old consumers. Consider gating inclusion of these fields behind a flag, or otherwise ensuring the signed payload shape remains decodable by older schema versions, so signatures stay stable across the rollout.

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

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant