Skip to content

Improve live activity routing and diagnostics#3685

Open
juliusmarminge wants to merge 15 commits into
mainfrom
t3code/live-activities-improvements
Open

Improve live activity routing and diagnostics#3685
juliusmarminge wants to merge 15 commits 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 touch relay registration, sign-out/foreground Live Activity lifecycle, and cloud link modes (managed vs publish-only); incorrect state could leave users with wrong toggle UX or stale lock-screen activities, though behavior is heavily tested.

Overview
Mobile agent awareness now registers devices with bundleId and apsEnvironment (sandbox for development builds), tracks registered / pending / failed relay status with persisted skip-on-launch dedupe, and gates notification/Live Activity settings on actual relay acceptance. Push-to-start registration is removed; Live Activities are primed in the foreground from a relay snapshot when none are active, tokens are re-registered on app foreground (with a 60s dedupe window) for aggregate replay, and orphaned activities end on cloud sign-out while auth-bridge unmount uses a non-destructive provider release.

The Agent Activity Live Activity/widget UI is reworked: attention-first row ordering, web-sidebar phase colors (light/dark), t3code:// deep links, up to five banner rows, and the T3 mark via new Expo config plugins (frequentUpdates, asset catalog + actool shell phase).

Server / web T3 Connect splits managed tunnel vs publish agent activity: publish_only link mode (manual endpoint, no tunnel), t3 connect publish / --publish-only link, managedTunnelActive in link state, and settings toggles that reconcile link mode and publish preference independently.

Relay infra adds nullable bundle_id and aps_environment on relay_mobile_devices. Agent activity publishing tests expand around aggregate expiry, terminal “Done” rows, and persisting completed state instead of immediate row removal.

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

Note

Improve Live Activity APNs routing, alert delivery, and publish-only relay link mode

  • Adds per-device bundleId and apsEnvironment fields to device registration, the delivery queue, and APNs request routing so each device can be addressed on the correct APNs topic and environment.
  • Introduces alert payloads on Live Activity update/end events: attention-phase transitions and newly-terminal threads now trigger high-priority push alerts, honoring per-user notification preferences.
  • Rewrites chooseLiveActivityDelivery to suppress remote starts when no update token exists, suppress no-op idle-to-idle redraws, and return an explicit "suppressed" sentinel instead of a null delivery.
  • Adds a getAgentActivitySnapshot relay endpoint (mobile DPoP-gated) and a matching ManagedRelayClient method so the mobile app can prime a Live Activity on foreground if none exists.
  • Introduces a publish_only relay link mode: linking without a managed Cloudflare tunnel uses providerKind: "manual", omits managed_tunnels from proof scopes, deprovisions any existing tunnel, and is controllable via t3 connect publish CLI and a new Settings toggle.
  • Reworks the AgentActivity widget: attention-first row ordering, per-phase tinting from dark/light web palettes, banner header with attention suffix, safe deep-link construction, and a branded logo replacing text wordmarks.
  • Adds ApnsProviderTokens service with quantized JWT reuse windows and an isolate-local cache; replaces inline JWT signing in ApnsClient.
  • Adds a cron-driven pruneTerminal step that removes terminal agent activity rows older than 30 minutes.
  • Risk: statusForPhase('running') now returns 'Connecting' instead of 'Starting', changing visible status labels on existing Live Activities.

Macroscope summarized 2e02973.

@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: 6167202d-8921-4c1d-99b5-34530971fa00

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

19 blocking correctness issues found. Diff is too large for automated approval analysis. A human reviewer should evaluate this PR.

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
Comment thread apps/mobile/src/features/agent-awareness/remoteRegistration.ts
@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.

@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).

Autofix Details

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.

Comment thread apps/mobile/src/features/agent-awareness/remoteRegistration.ts
Comment thread apps/mobile/src/features/agent-awareness/remoteRegistration.ts
Comment thread apps/mobile/src/features/agent-awareness/remoteRegistration.ts
// 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.

@juliusmarminge juliusmarminge force-pushed the t3code/live-activities-improvements branch from 5225bdb to 5086c14 Compare July 6, 2026 16:13
Comment thread apps/mobile/app.config.ts

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

infoPlist: {
NSAppTransportSecurity: {

The frequentUpdates: true option under the expo-widgets plugin does not add the required NSSupportsLiveActivitiesFrequentUpdates key to Info.plist, so iOS keeps the standard Live Activity update budget and frequent updates are never enabled. Set NSSupportsLiveActivitiesFrequentUpdates: true in the ios.infoPlist block to actually grant the entitlement.

    infoPlist: {
+     NSSupportsLiveActivitiesFrequentUpdates: true,
      NSAppTransportSecurity: {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/app.config.ts around lines 96-97:

The `frequentUpdates: true` option under the `expo-widgets` plugin does not add the required `NSSupportsLiveActivitiesFrequentUpdates` key to `Info.plist`, so iOS keeps the standard Live Activity update budget and frequent updates are never enabled. Set `NSSupportsLiveActivitiesFrequentUpdates: true` in the `ios.infoPlist` block to actually grant the entitlement.

return existing?.trim() ? existing : null;
}

export interface AgentAwarenessRegistrationRecord {

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 lib/storage.ts:228

AgentAwarenessRegistrationRecord stores only identity and signature, so when the relay base URL changes but the user identity and device payload stay the same, loadAgentAwarenessRegistrationRecord returns the stale record and the app skips registration against the new relay. The new relay never receives this device's registration until sign-out clears the record. Consider including the relay base URL (or a hash of it) in the persisted record so a URL change invalidates the cached registration.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/lib/storage.ts around line 228:

`AgentAwarenessRegistrationRecord` stores only `identity` and `signature`, so when the relay base URL changes but the user identity and device payload stay the same, `loadAgentAwarenessRegistrationRecord` returns the stale record and the app skips registration against the new relay. The new relay never receives this device's registration until sign-out clears the record. Consider including the relay base URL (or a hash of it) in the persisted record so a URL change invalidates the cached registration.

Comment thread apps/mobile/src/features/agent-awareness/remoteRegistration.ts
value={notificationStatus === "enabled" && deviceRegistered}
onValueChange={handleDeviceNotificationsChange}
/>
<SettingsSwitchRow

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 settings/SettingsRouteScreen.tsx:441

When deviceRegistered is false but liveActivitiesEnabled is true in storage, the Live Activity Updates switch renders off even though liveActivityStatus is "enabled". Because onValueChange receives the next visual state (true), tapping the switch calls linkEnvironments() (the enable path) instead of persisting enabled: false, so the user cannot disable Live Activity updates from Settings until registration succeeds. Consider gating the disabled state or the alert on deviceRegistered rather than the switch value, so the toggle still reflects the saved preference and onValueChange fires with the correct next state.

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

When `deviceRegistered` is `false` but `liveActivitiesEnabled` is `true` in storage, the `Live Activity Updates` switch renders off even though `liveActivityStatus` is `"enabled"`. Because `onValueChange` receives the next visual state (`true`), tapping the switch calls `linkEnvironments()` (the enable path) instead of persisting `enabled: false`, so the user cannot disable Live Activity updates from Settings until registration succeeds. Consider gating the disabled state or the alert on `deviceRegistered` rather than the switch `value`, so the toggle still reflects the saved preference and `onValueChange` fires with the correct next state.

Comment thread infra/relay/src/agentActivity/ApnsDeliveries.ts

@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.

There are 6 total unresolved issues (including 3 from previous reviews).

Autofix Details

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

  • ✅ Fixed: Foreground replay ends live activities
    • Replay now skips delivery when the aggregate is null but non-terminal rows still exist, so TTL-expired rows from a healthy long-running agent no longer trigger live_activity_end on foreground while truly orphaned activities (rows removed) are still ended.
  • ✅ Fixed: Same identity skips registration retry
    • setAgentAwarenessRelayTokenProvider now only skips re-enqueueing device registration for an unchanged identity when the registration status is "registered", so failed or stalled registrations retry when the auth effect re-runs.

Create PR

Or push these changes by commenting:

@cursor push 59517fdb7d
Preview (59517fdb7d)
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
@@ -186,7 +186,11 @@
     refreshActiveLiveActivityRemoteRegistration(),
     "active live activity registration after cloud sign-in failed",
   );
-  if (isExistingIdentity) {
+  // An unchanged identity only skips re-registration when the previous attempt
+  // actually reached the relay; a failed or stalled attempt must retry the next
+  // time the auth effect re-runs, or the device stays unregistered until an
+  // unrelated event (token rotation, environment link) happens to enqueue one.
+  if (isExistingIdentity && registrationStatus === "registered") {
     return;
   }
   enqueueDeviceRegistration({}, "device registration after cloud sign-in failed");

diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts
--- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts
+++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts
@@ -119,6 +119,16 @@
         terminalState: null,
         nowMs: now.epochMilliseconds,
       });
+      // A null aggregate is ambiguous while non-terminal rows still exist:
+      // they may belong to a dead environment, or to a healthy long-running
+      // agent whose meaningful state (and therefore updatedAt) has not changed
+      // within the TTL. Sending in that case would end a possibly-healthy Live
+      // Activity on every foreground, so replay only delivers when live rows
+      // remain or the rows are truly gone (the thread ended and its end push
+      // may have been lost).
+      if (aggregate === null && activeStates.some((state) => !isTerminalPhase(state))) {
+        return null;
+      }
       return yield* apnsDeliveries.sendForTarget({
         target,
         aggregate,

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

Comment thread infra/relay/src/agentActivity/AgentActivityPublisher.ts
Comment thread apps/mobile/src/features/agent-awareness/remoteRegistration.ts
Comment thread apps/mobile/src/features/agent-awareness/remoteRegistration.ts
@juliusmarminge juliusmarminge force-pushed the t3code/live-activities-improvements branch from 5086c14 to a31735c Compare July 6, 2026 17:03
Comment thread apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Comment thread apps/server/src/cli/connect.ts

@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 1 potential issue.

There are 7 total unresolved issues (including 6 from previous reviews).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Parallel registration marks failed incorrectly
    • Registration attempts now capture a success counter at start and only mark the status failed when no concurrent attempt succeeded while they were in flight, so a stale failure can no longer overwrite a relay-accepted registration.

Create PR

Or push these changes by commenting:

@cursor push d469c8569a
Preview (d469c8569a)
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
@@ -535,6 +535,48 @@
     }).pipe(Effect.provide(relayTestLayer));
   });
 
+  it.effect("keeps the registered status when a stale concurrent attempt fails", () => {
+    const fetchMock = vi.fn((request: RequestInfo | URL) => {
+      const url = request instanceof Request ? request.url : String(request);
+      return Promise.resolve(
+        Response.json(
+          url.endsWith("/v1/client/dpop-token")
+            ? {
+                access_token: "relay-dpop-token",
+                issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
+                token_type: "DPoP",
+                expires_in: 300,
+                scope: "mobile:registration",
+              }
+            : { ok: true },
+        ),
+      );
+    });
+    vi.stubGlobal("fetch", fetchMock);
+    Constants.expoConfig!.extra = {
+      relay: {
+        url: "https://relay.example.test/",
+      },
+    };
+
+    // Sign-in enqueues a registration attempt that stays parked in the
+    // background queue while the direct refresh below runs.
+    setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
+
+    return Effect.gen(function* () {
+      yield* refreshAgentAwarenessRegistration();
+      expect(getAgentAwarenessRegistrationStatus()).toBe("registered");
+
+      // The queued sign-in attempt now fails; its stale failure must not
+      // overwrite the registration the relay already accepted.
+      vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockRejectedValueOnce(
+        new Error("device id unavailable"),
+      );
+      yield* runBackgroundOperations();
+      expect(getAgentAwarenessRegistrationStatus()).toBe("registered");
+    }).pipe(Effect.provide(relayTestLayer));
+  });
+
   it("clears registration status on cloud sign-out", () => {
     setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
     setAgentAwarenessRelayTokenProvider(null);

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
@@ -74,6 +74,13 @@
 let registrationStatus: AgentAwarenessRegistrationStatus = "unknown";
 const registrationStatusListeners = new Set<() => void>();
 
+// Registration runs both through the background queue and directly via
+// refreshAgentAwarenessRegistration, so attempts can overlap. Counting relay
+// acceptances lets a failing attempt detect that a concurrent attempt already
+// succeeded while it was in flight, so a stale failure cannot overwrite a
+// registration the relay accepted.
+let registrationSuccessCount = 0;
+
 function setRegistrationStatus(next: AgentAwarenessRegistrationStatus): void {
   if (registrationStatus === next) {
     return;
@@ -84,6 +91,18 @@
   }
 }
 
+function markRegistrationSucceeded(): void {
+  registrationSuccessCount++;
+  setRegistrationStatus("registered");
+}
+
+function markRegistrationFailed(successCountAtAttemptStart: number): void {
+  if (registrationSuccessCount !== successCountAtAttemptStart) {
+    return;
+  }
+  setRegistrationStatus("failed");
+}
+
 export function getAgentAwarenessRegistrationStatus(): AgentAwarenessRegistrationStatus {
   return registrationStatus;
 }
@@ -316,7 +335,7 @@
       catch: (cause) => cause,
     }).pipe(Effect.orElseSucceed(() => null));
     if (persisted && persisted.identity === identity && persisted.signature === signature) {
-      setRegistrationStatus("registered");
+      markRegistrationSucceeded();
       logRegistrationDebug("relay device registration skipped; already registered for account", {
         expectedGeneration,
       });
@@ -331,7 +350,7 @@
       clerkToken: token,
       payload: body,
     });
-    setRegistrationStatus("registered");
+    markRegistrationSucceeded();
     yield* Effect.promise(() =>
       saveAgentAwarenessRegistrationRecord({ identity, signature }).catch((error: unknown) => {
         logRegistrationError("persist registration record failed", error);
@@ -466,11 +485,12 @@
   };
   activeDeviceRegistration = registration;
   registration.operation = (async () => {
+    const successCountAtStart = registrationSuccessCount;
     const result = await settleAsyncResult(() =>
       runtime.runPromiseExit(registerDevice(next.input, generation)),
     );
     if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
-      setRegistrationStatus("failed");
+      markRegistrationFailed(successCountAtStart);
       logRegistrationError(next.context, squashAtomCommandFailure(result));
     }
     logRegistrationDebug("device registration finished", { generation });
@@ -675,14 +695,17 @@
   never,
   ManagedRelay.ManagedRelayClient
 > {
-  return registerDeviceForCurrentUser().pipe(
-    Effect.catch((error) =>
-      Effect.sync(() => {
-        setRegistrationStatus("failed");
-        logRegistrationError("device registration refresh failed", error);
-      }),
-    ),
-  );
+  return Effect.suspend(() => {
+    const successCountAtStart = registrationSuccessCount;
+    return registerDeviceForCurrentUser().pipe(
+      Effect.catch((error) =>
+        Effect.sync(() => {
+          markRegistrationFailed(successCountAtStart);
+          logRegistrationError("device registration refresh failed", error);
+        }),
+      ),
+    );
+  });
 }
 
 export function __resetAgentAwarenessRemoteRegistrationForTest(): void {
@@ -703,6 +726,7 @@
   activeDeviceRegistration = null;
   pendingDeviceRegistration = null;
   registrationStatus = "unknown";
+  registrationSuccessCount = 0;
   registrationStatusListeners.clear();
 }

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

Comment thread apps/mobile/src/features/agent-awareness/remoteRegistration.ts Outdated
- 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; cannot read as enabled when the device is not registered
- Persistent register-once: skip re-registering an unchanged payload for the
  same account across launches; clear only on sign-out
- Headless publish + tunnel-free linking: `t3 connect publish` toggles agent
  activity publishing and auto-establishes a publish-only (no managed tunnel)
  link, and `t3 connect link --publish-only` links for publishing alone, so
  activity flows to mobile clients that reach the environment out of band
  (e.g. Tailscale) without T3 Connect. Relay accepts publish-only links with a
  nominal endpoint; env proofs advertise the manual provider kind
- 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 a31735c to 9c3eb7b Compare July 6, 2026 17:24
Comment thread apps/mobile/src/features/agent-awareness/remoteRegistration.ts
// Development builds are Xcode-signed and receive sandbox APNs tokens;
// preview and production builds are distribution-signed and use production
// APNs. The relay routes each device's pushes accordingly.
export function resolveApsEnvironment(appVariant: unknown): "sandbox" | "production" {

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/registrationPayload.ts:8

resolveApsEnvironment returns "production" for every value except the literal string "development". Locally run ios:preview and ios:prod builds launched via expo run:ios are development-signed and receive sandbox APNs tokens, but this function classifies them as "production". The relay then routes pushes to the production APNs gateway for a sandbox token, so notifications and Live Activities fail to deliver for those builds. Consider keying off the signing/provisioning environment (e.g. EAS build profile or Configuration/ entitlements) rather than the variant name, or document why locally-run preview/prod builds are expected to use production APNs.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/agent-awareness/registrationPayload.ts around line 8:

`resolveApsEnvironment` returns `"production"` for every value except the literal string `"development"`. Locally run `ios:preview` and `ios:prod` builds launched via `expo run:ios` are development-signed and receive sandbox APNs tokens, but this function classifies them as `"production"`. The relay then routes pushes to the production APNs gateway for a sandbox token, so notifications and Live Activities fail to deliver for those builds. Consider keying off the signing/provisioning environment (e.g. EAS build profile or `Configuration`/ entitlements) rather than the variant name, or document why locally-run `preview`/`prod` builds are expected to use production APNs.

Comment thread apps/server/src/cli/connect.ts
Comment thread apps/mobile/src/features/agent-awareness/remoteRegistration.ts
Comment thread packages/contracts/src/environmentHttp.ts Outdated
Comment on lines 1745 to 1750
primaryCloudLinkState.refresh();
const refreshResult = await refreshRelayEnvironments();
if (refreshResult._tag === "Failure") {
if (!isAtomCommandInterrupted(refreshResult)) {
reportUpdateFailure(squashAtomCommandFailure(refreshResult));
}
setIsUpdating(false);
return;
if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) {
reportUpdateFailure(squashAtomCommandFailure(refreshResult));
return false;
}

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/ConnectionsSettings.tsx:1745

When reconcileCloudState completes a link/unlink and preference update successfully, the mutation is already committed and primaryCloudLinkState.refresh() has been called. If the follow-up refreshRelayEnvironments() call at line 1747 then fails, the function returns false, shows an error toast, and suppresses the success toast — so the user is told their T3 Connect change failed even though it was actually applied. Consider returning true before the relay refresh (or not reporting its failure as a mutation failure), so transient relay-discovery errors don't mask a successful mutation.

    primaryCloudLinkState.refresh();
+    return true;
     const refreshResult = await refreshRelayEnvironments();
     if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) {
       reportUpdateFailure(squashAtomCommandFailure(refreshResult));
       return false;
     }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/settings/ConnectionsSettings.tsx around lines 1745-1750:

When `reconcileCloudState` completes a link/unlink and preference update successfully, the mutation is already committed and `primaryCloudLinkState.refresh()` has been called. If the follow-up `refreshRelayEnvironments()` call at line 1747 then fails, the function returns `false`, shows an error toast, and suppresses the success toast — so the user is told their T3 Connect change failed even though it was actually applied. Consider returning `true` before the relay refresh (or not reporting its failure as a mutation failure), so transient relay-discovery errors don't mask a successful mutation.

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 cloud/http.ts:369

makeCloudLinkProof calls isAllowedEndpointOrigin for every link proof, including manual providerKind requests. That helper only permits loopback hosts, so a manual or publish-only link from a non-loopback origin (e.g. Tailscale or directly exposed host) is rejected with Invalid managed endpoint origin., blocking the out-of-band endpoint flow that isSupportedLinkProviderKind was added to support. Consider skipping the loopback origin check when providerKind === "manual".

  if (
    !isSupportedLinkProviderKind(request) ||
    (request.endpoint.providerKind === "cloudflare_tunnel" &&
      !isAllowedEndpointOrigin({
        origin: request.origin,
        requestUrl,
      }))
  ) {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/cloud/http.ts around lines 369-375:

`makeCloudLinkProof` calls `isAllowedEndpointOrigin` for every link proof, including manual `providerKind` requests. That helper only permits loopback hosts, so a manual or publish-only link from a non-loopback origin (e.g. Tailscale or directly exposed host) is rejected with `Invalid managed endpoint origin.`, blocking the out-of-band endpoint flow that `isSupportedLinkProviderKind` was added to support. Consider skipping the loopback origin check when `providerKind === "manual"`.

…re-registration spam

- Rework the AgentActivity layout: attention-first row ordering, single-line
  rows (title + inline project + status) shared by the banner and expanded
  Dynamic Island, centered dot-separated banner headline, up to 5 banner rows,
  a dedicated watch/CarPlay bannerSmall, and a phase-glyph minimal view for
  the shared island
- Match status tints to the web sidebar pills (Sidebar.logic.ts), switching
  between the light (-600) and dark (-300) palette off the activity color
  scheme: iPhone renders on dark material, but macOS mirroring/notification
  center renders on light where the dark palette was illegible
- Align the relay's "Starting" status label to the web sidebar's "Connecting"
- Ship the branded T3 mark to the widget extension: expo-widgets generates the
  target with no Resources phase and hand-added ones are never scheduled, so
  withWidgetLogoAsset.cjs installs the SVG template image set and an
  alwaysOutOfDate actool shell phase (scripts/wire-widget-asset-catalog.cjs
  applies the same wiring to an already-generated ios/ without a prebuild)
- Register a Live Activity push token with the relay only once per accepted
  token: the refresh runs on sign-in, app foreground, and every environment
  connection update, and was re-POSTing identical {deviceId, token} payloads
  in a loop; the register-once set clears on sign-out/identity change

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
// Any registered scheme variant routes back to this app; taps are delivered
// to the widget's containing app, so the prod scheme is safe for all builds.
const deepLinkRow = attentionRow ?? row0;
const deepLink =

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:140

The deep-link guard at deepLinkRow.deepLink.startsWith("/") && !deepLinkRow.deepLink.startsWith("//") accepts any path, so values like /settings or /threads/env/thread?x=1 are turned into t3code://settings or t3code://threads/env/thread?x=1. Unlike extractAgentNotificationDeepLink, this allows non-thread paths and query/hash-bearing links to route Live Activity taps to the wrong screen. Consider matching the app's existing normalization so only valid thread links produce a deepLink.

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

The deep-link guard at `deepLinkRow.deepLink.startsWith("/") && !deepLinkRow.deepLink.startsWith("//")` accepts any path, so values like `/settings` or `/threads/env/thread?x=1` are turned into `t3code://settings` or `t3code://threads/env/thread?x=1`. Unlike `extractAgentNotificationDeepLink`, this allows non-thread paths and query/hash-bearing links to route Live Activity taps to the wrong screen. Consider matching the app's existing normalization so only valid thread links produce a `deepLink`.

@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 1 potential issue.

There are 8 total unresolved issues (including 7 from previous reviews).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Foreground skip blocks relay replay
    • Cleared the registeredActivityPushTokens session cache in the app-foreground handler so the refresh re-registers activity tokens with the relay and triggers the content replay, while keeping deduplication for the sign-in and connection-update paths.

Create PR

Or push these changes by commenting:

@cursor push 024757ad14
Preview (024757ad14)
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
@@ -66,7 +66,9 @@
 // pure no-op relay round-trip — and the refresh runs on sign-in, every app
 // foreground, and every environment-connection update, which spammed identical
 // registrations. Cleared on sign-out/identity change alongside the device
-// registration state.
+// registration state, and on app foreground: re-registering there is not
+// redundant because it makes the relay replay the current aggregate (see
+// ensureAppStateListener).
 const registeredActivityPushTokens = new Set<string>();
 let pushToStartSubscription: { remove: () => void } | null = null;
 let pushTokenSubscription: { remove: () => void } | null = null;
@@ -618,6 +620,9 @@
     if (state !== "active") {
       return;
     }
+    // Drop the session cache so the tokens are re-sent: the relay replay is
+    // the whole point of this refresh, and a cache hit would skip it.
+    registeredActivityPushTokens.clear();
     runRegistrationInBackground(
       refreshActiveLiveActivityRemoteRegistration(),
       "active live activity reconciliation after app foreground failed",

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

Comment thread apps/mobile/src/features/agent-awareness/remoteRegistration.ts
juliusmarminge and others added 2 commits July 6, 2026 16:05
- `t3 connect publish` no longer downgrades an already-pending managed link
  to publish-only; it defers to the pending link, which also covers publishing
- Device register-once now carries the last accepted push-to-start token into
  registrations triggered without a token event, so token absence reads as
  "unchanged" and the per-launch skip actually fires (previously the signature
  alternated between trigger shapes and re-POSTed twice every launch)
- Activity-token registrations dedupe within a 60s window instead of forever:
  bursts collapse to one request while a warm foreground after real time away
  still triggers the relay's aggregate replay (the drift/orphan repair)
- Downgrading a managed link to publish-only now deprovisions the previously
  provisioned tunnel + DNS on the relay (best effort; nothing else released
  them before a full unlink)
- managedTunnelActive is optional in the link-state contract so newer clients
  tolerate older environment servers; the web falls back to `linked` (old
  semantics) when absent
- Web reconcile refreshes rendered link state on failure exits that follow a
  partially applied mutation, and the tunnel-off toast says "tunnel disabled"
  instead of "unlinked" when publishing stays on

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live Activity updates were always silent (priority 5, no alert), so for users
with Live Activities enabled a thread flipping to Approval/Input redrew the
lock-screen card without any haptic — and since the notification channel is
suppressed when Live Activities are on, they got no interruption at all.

- APNs liveactivity update/end payloads can now carry an alert dict
  (title/body/sound), which makes iOS wake the screen and play the haptic;
  alerting updates are sent at priority 10
- An update alerts when a thread newly enters an attention phase relative to
  the previously delivered aggregate (no baseline → no alert, so replays and
  fresh registrations cannot buzz); multiple newly blocked threads collapse
  into one summary alert
- An end event alerts with the Done/Failed row only when the companion push
  notification is not also being sent, so notification-enabled users are not
  buzzed twice while LA-only users still get the completion buzz
- Alerts honor the same per-event switches as the push channel
  (notifyOnApproval/Input/Completion/Failure)
- The alert rides the signed delivery job (optional field, omitted when absent
  so signatures of alert-less jobs match older relay builds)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
// full unlink. Best effort: a cleanup failure must not block the link
// itself, and the provider treats an absent allocation as already
// deprovisioned, so retrying on every non-tunnel link is cheap.
if (!input.request.managedTunnelsEnabled) {

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 environments/EnvironmentLinker.ts:295

managedEndpointProvider.deprovision() runs before links.upsert() and credentials.create(), so if either of those later persistence steps fails, the old managed tunnel and DNS are already deleted but the existing link record still points at the now-deprovisioned endpoint. This leaves the environment with a broken link until the user retries. Consider moving the deprovision call after the upsert and create steps so the old allocation is only released once the new link is successfully persisted.

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

`managedEndpointProvider.deprovision()` runs before `links.upsert()` and `credentials.create()`, so if either of those later persistence steps fails, the old managed tunnel and DNS are already deleted but the existing link record still points at the now-deprovisioned endpoint. This leaves the environment with a broken link until the user retries. Consider moving the deprovision call after the `upsert` and `create` steps so the old allocation is only released once the new link is successfully persisted.

Comment on lines +152 to +155
): boolean {
if (preferences === null) {
return true;
}

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/ApnsDeliveries.ts:152

alertAllowedForPhase only checks per-phase toggles and never checks preferences.notificationsEnabled. When a user has Live Activities enabled but notifications disabled, alertForAttentionTransition and alertForTerminalAggregate still return alert objects, so Live Activity updates carry haptics/screen wakeups despite the user turning off notifications. notificationForAggregate already gates on notificationsEnabled, so the Live Activity alert path is inconsistent with the push path. Consider adding if (preferences?.notificationsEnabled === false) return false; at the top of alertAllowedForPhase.

Suggested change
): boolean {
if (preferences === null) {
return true;
}
): boolean {
if (preferences?.notificationsEnabled === false) {
return false;
}
if (preferences === null) {
return true;
}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/ApnsDeliveries.ts around lines 152-155:

`alertAllowedForPhase` only checks per-phase toggles and never checks `preferences.notificationsEnabled`. When a user has Live Activities enabled but notifications disabled, `alertForAttentionTransition` and `alertForTerminalAggregate` still return alert objects, so Live Activity updates carry haptics/screen wakeups despite the user turning off notifications. `notificationForAggregate` already gates on `notificationsEnabled`, so the Live Activity alert path is inconsistent with the push path. Consider adding `if (preferences?.notificationsEnabled === false) return false;` at the top of `alertAllowedForPhase`.

if (input.previousAggregate === null) {
return null;
}
const previouslyAttention = new Set(

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/ApnsDeliveries.ts:182

alertForAttentionTransition keys previouslyAttention by threadId only, so when a thread transitions from waiting_for_approval to waiting_for_input (or vice versa), the new attention phase is suppressed because the threadId is already in the set. The user misses the alert for the second phase even though it is a distinct event. Consider tracking the prior (threadId, phase) pairs instead of threadId alone so a phase change still produces an alert.

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

`alertForAttentionTransition` keys `previouslyAttention` by `threadId` only, so when a thread transitions from `waiting_for_approval` to `waiting_for_input` (or vice versa), the new attention phase is suppressed because the `threadId` is already in the set. The user misses the alert for the second phase even though it is a distinct event. Consider tracking the prior `(threadId, phase)` pairs instead of `threadId` alone so a phase change still produces an alert.

@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 8 total unresolved issues (including 6 from previous reviews).

Autofix Details

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

  • ✅ Fixed: Same thread input alert missing
    • Changed the previously-in-attention set in alertForAttentionTransition to key by threadId and phase so a thread transitioning between attention kinds (approval to input) triggers a new alert.
  • ✅ Fixed: Deprovision before link persists
    • Moved the best-effort managed endpoint deprovision on publish-only links to after links.upsert and credential creation so the irreversible tunnel teardown only happens once the link record is durably persisted.

Create PR

Or push these changes by commenting:

@cursor push 38ddcd372e
Preview (38ddcd372e)
diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts
--- a/infra/relay/src/agentActivity/ApnsDeliveries.ts
+++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts
@@ -167,10 +167,13 @@
   }
 }
 
-// Alert copy for an update whose aggregate contains threads that were NOT in an
-// attention phase in the previously delivered aggregate. A null previous
-// aggregate means there is no known baseline (fresh registration, replay after
-// data loss) — alerting there would buzz on reconnect, not on a transition.
+// Alert copy for an update whose aggregate contains threads that were NOT in
+// the same attention phase in the previously delivered aggregate. Keying by
+// thread AND phase means a thread moving between attention kinds (approval →
+// input) alerts again — the user is blocked on a new kind of attention. A null
+// previous aggregate means there is no known baseline (fresh registration,
+// replay after data loss) — alerting there would buzz on reconnect, not on a
+// transition.
 export function alertForAttentionTransition(input: {
   readonly previousAggregate: RelayAgentActivityAggregateState | null;
   readonly nextAggregate: RelayAgentActivityAggregateState;
@@ -182,12 +185,12 @@
   const previouslyAttention = new Set(
     input.previousAggregate.activities
       .filter((row) => isAttentionPhase(row.phase))
-      .map((row) => row.threadId),
+      .map((row) => `${row.threadId}\u0000${row.phase}`),
   );
   const newlyAttention = input.nextAggregate.activities.filter(
     (row) =>
       isAttentionPhase(row.phase) &&
-      !previouslyAttention.has(row.threadId) &&
+      !previouslyAttention.has(`${row.threadId}\u0000${row.phase}`) &&
       alertAllowedForPhase(input.preferences, row.phase),
   );
   const first = newlyAttention[0];

diff --git a/infra/relay/src/environments/EnvironmentLinker.ts b/infra/relay/src/environments/EnvironmentLinker.ts
--- a/infra/relay/src/environments/EnvironmentLinker.ts
+++ b/infra/relay/src/environments/EnvironmentLinker.ts
@@ -287,27 +287,6 @@
           stage: "validate_origin",
         });
       }
-      // Downgrading a managed link to publish-only must release the tunnel and
-      // DNS that were provisioned for it — nothing else cleans them up until a
-      // full unlink. Best effort: a cleanup failure must not block the link
-      // itself, and the provider treats an absent allocation as already
-      // deprovisioned, so retrying on every non-tunnel link is cheap.
-      if (!input.request.managedTunnelsEnabled) {
-        yield* managedEndpointProvider
-          .deprovision({
-            userId: input.userId,
-            environmentId: verified.environmentId,
-          })
-          .pipe(
-            Effect.tapError((error) =>
-              Effect.logWarning("managed endpoint deprovision on publish-only link failed", {
-                environmentId: verified.environmentId,
-                errorTag: error._tag,
-              }),
-            ),
-            Effect.ignore,
-          );
-      }
       const provisioned = input.request.managedTunnelsEnabled
         ? yield* managedEndpointProvider.provision({
             userId: input.userId,
@@ -333,6 +312,30 @@
         environmentId: verified.environmentId,
         environmentPublicKey: verified.environmentPublicKey,
       });
+      // Downgrading a managed link to publish-only must release the tunnel and
+      // DNS that were provisioned for it — nothing else cleans them up until a
+      // full unlink. Deprovisioning is an irreversible side effect, so it runs
+      // only after the link and credential are durably persisted; a failure
+      // beforehand must not leave the stored link pointing at a torn-down
+      // tunnel. Best effort: a cleanup failure must not block the link itself,
+      // and the provider treats an absent allocation as already deprovisioned,
+      // so retrying on every non-tunnel link is cheap.
+      if (!input.request.managedTunnelsEnabled) {
+        yield* managedEndpointProvider
+          .deprovision({
+            userId: input.userId,
+            environmentId: verified.environmentId,
+          })
+          .pipe(
+            Effect.tapError((error) =>
+              Effect.logWarning("managed endpoint deprovision on publish-only link failed", {
+                environmentId: verified.environmentId,
+                errorTag: error._tag,
+              }),
+            ),
+            Effect.ignore,
+          );
+      }
       return {
         environmentId: verified.environmentId,
         endpoint,

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

Comment thread infra/relay/src/agentActivity/ApnsDeliveries.ts
Comment thread infra/relay/src/environments/EnvironmentLinker.ts
juliusmarminge and others added 2 commits July 6, 2026 16:37
…view

- Early registration exits (no relay config, no token) now report status
  "unknown" instead of leaving the pending state stuck, and failure paths
  no longer demote an already-registered device.
- Discard stale registration successes that complete after sign-out by
  re-checking the account generation around the relay call, and scope the
  dedupe signature to the relay URL so switching relays re-registers.
- Carry the persisted push-to-start token into re-registrations so PTS
  alternation can't defeat the register-once skip.
- CloudAuthBridge unmount now releases the token provider without ending
  lock-screen activities or wiping the persisted registration; real
  sign-out paths are unchanged.
- `t3 connect publish --disable` clears a pending publish-only link, and
  `t3 connect link --publish-only` enables the publish secret it depends on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Device registrations were timing out client-side at exactly 10s with no
server trace: the client abort killed the worker invocation with the
request span still open, so the batched spans never exported.

- The relay worker now enforces a 9s request deadline (inside the tracer
  middleware, under the client's 10s abort). A hung request becomes a
  completed 504 whose exported trace contains the exact child span that
  stalled, annotated with relay.request.deadline_exceeded.
- ManagedRelayRequestTimeoutError now carries the current trace id, so
  client timeout logs are searchable against server-side traces instead
  of dead-ending at "timed out".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@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 1 potential issue.

There are 5 total unresolved issues (including 4 from previous reviews).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Release races device registration
    • releaseAgentAwarenessRelayTokenProvider now bumps deviceRegistrationGeneration and clears queued/active registrations, so in-flight registrations bail at their next generation checkpoint instead of reading the nulled provider/identity and resetting the status or persisting an empty-identity record.

Create PR

Or push these changes by commenting:

@cursor push dea6bc639a
Preview (dea6bc639a)
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
@@ -216,6 +216,15 @@
 // the persisted registration would be wrong — the relay still holds a valid
 // registration and the next mount reuses it.
 export function releaseAgentAwarenessRelayTokenProvider(): void {
+  // Cancel queued and in-flight registrations: they re-read the provider and
+  // identity from module state after await boundaries, so one that crosses
+  // this release would observe a null provider and reset the status to
+  // "unknown" (or persist a record with a blank identity). The generation
+  // bump makes them bail at their next checkpoint, leaving the status and
+  // the persisted record untouched.
+  deviceRegistrationGeneration++;
+  activeDeviceRegistration = null;
+  pendingDeviceRegistration = null;
   relayTokenProvider = null;
   relayTokenProviderIdentity = null;
   pushToStartSubscription?.remove();

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

Comment thread apps/mobile/src/features/agent-awareness/remoteRegistration.ts
const secrets = yield* ServerSecretStore.ServerSecretStore;
const tokens = yield* CliTokenManager.CloudCliTokenManager;
const enabled = !flags.disable;
yield* secrets.set(

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 cli/connect.ts:498

Running t3 connect publish while signed out persists PUBLISH_AGENT_ACTIVITY_SECRET as "true" (lines 498–501), but then returns early after printing a login hint (lines 526–530) without setting a desired link. After the user runs t3 connect login, no publish_only link was scheduled, so the environment never provisions a relay link and publishes nothing on restart — yet t3 connect status reports publishing as enabled. This leaves the user in a state where publishing appears enabled but silently does not work until they re-run t3 connect publish after authenticating. Consider deferring the PUBLISH_AGENT_ACTIVITY_SECRET write until after the authentication check, or record the desired publish_only link so a subsequent login completes provisioning on restart.

Also found in 1 other location(s)

apps/mobile/src/features/agent-awareness/remoteRegistration.ts:189

clearAgentAwarenessRegistrationRecord() is started fire-and-forget at line 189 during sign-out, but the next sign-in can start registerDeviceWithRelay() before that async clear finishes. Because the skip logic later trusts the persisted { identity, signature } record, a rapid sign-out/sign-in to the same account can read the stale record and mark the device registered without sending a new relay registration request, even though sign-out is supposed to invalidate the stored registration.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/cli/connect.ts around line 498:

Running `t3 connect publish` while signed out persists `PUBLISH_AGENT_ACTIVITY_SECRET` as `"true"` (lines 498–501), but then returns early after printing a login hint (lines 526–530) without setting a desired link. After the user runs `t3 connect login`, no `publish_only` link was scheduled, so the environment never provisions a relay link and publishes nothing on restart — yet `t3 connect status` reports publishing as enabled. This leaves the user in a state where publishing appears enabled but silently does not work until they re-run `t3 connect publish` after authenticating. Consider deferring the `PUBLISH_AGENT_ACTIVITY_SECRET` write until after the authentication check, or record the desired `publish_only` link so a subsequent `login` completes provisioning on restart.

Also found in 1 other location(s):
- apps/mobile/src/features/agent-awareness/remoteRegistration.ts:189 -- `clearAgentAwarenessRegistrationRecord()` is started fire-and-forget at line `189` during sign-out, but the next sign-in can start `registerDeviceWithRelay()` before that async clear finishes. Because the skip logic later trusts the persisted `{ identity, signature }` record, a rapid sign-out/sign-in to the same account can read the stale record and mark the device `registered` without sending a new relay registration request, even though sign-out is supposed to invalidate the stored registration.

juliusmarminge and others added 2 commits July 6, 2026 17:36
…ct.all

Root cause of every mobile device registration timing out at the client's
10s abort: alchemy's Drizzle.postgres returns a lazy proxy chain that only
materializes into a real Effect when consumed via yield*. Devices.register
passed the two APNs token-claim UPDATEs to Effect.all as values, so the
fiber runtime probed the raw Proxy for its internal effect fields, got a
fresh truthy Proxy back for every read, and spun the isolate at 100% CPU
(3-20s cpuTime per invocation in wrangler tail) until the client aborted.
No SQL ever reached the database, no spans exported (the event loop never
yielded, which also starved the 9s deadline timer), and the invocation
died as "canceled" with no server-side trace.

This only fired when pushToken/pushToStartToken were present — the
tokenless slots were Effect.void, which is why unauthenticated probes and
tokenless registrations completed in milliseconds while every real phone
registration hung. Devices.unregister had the same Effect.all shape for
its two deletes and would hang unconditionally.

Both sites now yield* each db chain sequentially (Effect.all's default was
sequential anyway, so no concurrency is lost).

Verified against the dev relay with a real minted mobile:registration DPoP
token: registration with tokens went from a guaranteed 20s+ hang to ~300ms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
APNs throttles providers that refresh their auth token more than about
once per 20 minutes. The relay signed a fresh ES256 JWT for every single
push, so bursty Live Activity traffic drew 429 TooManyProviderTokenUpdates
and the throttled updates were silently dropped — observed live as a
lock-screen activity stuck at "1 active agent" while the update carrying
the 2-agent aggregate 429'd (delivery attempts 00:55:07-13Z).

Cache the signed JWT per team/key at module level and reuse it for 45
minutes of its 60-minute validity, so APNs sees a byte-identical token
across pushes. The cache key fingerprints the private key so rotated
credentials mint fresh tokens immediately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread infra/relay/src/agentActivity/ApnsClient.ts Outdated
Module-level caching only stabilizes the provider token within one worker
isolate; Workers spin isolates up and down freely and can multiplex their
egress connections, so isolates alternating their own tokens could still
trip APNs' TooManyProviderTokenUpdates throttle.

Move token reuse into an ApnsProviderTokens service backed by a
relay_apns_provider_tokens row per signing key (drizzle migration
auto-generates on deploy). The isolate Map stays as the fast path — the
hot delivery path does zero extra DB work — and on a miss the service
adopts the shared row when fresh, or mints and publishes via a
newest-wins upsert so concurrent refreshers converge on a single token.
Database failures degrade to minting locally instead of failing the push.
JWT signing moves to apnsJwt.ts (error classes re-exported from
ApnsClient for existing consumers); ApnsClient tests use the in-memory
layer, and the DB layer gets its own suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@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 7 total unresolved issues (including 5 from previous reviews).

Autofix Details

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

  • ✅ Fixed: Missing provider tokens migration
    • Generated the missing Postgres migration (migration.sql + snapshot.json) for relay_apns_provider_tokens using the same drizzle-kit API flow alchemy's Drizzle.Schema uses, chained off the latest snapshot so deploys apply it cleanly.
  • ✅ Fixed: Isolate cache ignores newer DB token
    • Changed the shared-row upsert from newest-wins to first-wins within the reuse window, so late-racing isolates adopt the stored token via RETURNING and the isolate cache can never hold a token that differs from the shared row while it is reusable.

Create PR

Or push these changes by commenting:

@cursor push 294660cb9e
Preview (294660cb9e)
diff --git a/infra/relay/migrations/postgres/20260707012622_apns_provider_tokens/migration.sql b/infra/relay/migrations/postgres/20260707012622_apns_provider_tokens/migration.sql
new file mode 100644
--- /dev/null
+++ b/infra/relay/migrations/postgres/20260707012622_apns_provider_tokens/migration.sql
@@ -1,0 +1,7 @@
+CREATE TABLE "relay_apns_provider_tokens" (
+	"cache_key" varchar(191) PRIMARY KEY,
+	"jwt" text NOT NULL,
+	"issued_at" integer NOT NULL,
+	"updated_at" varchar(64) NOT NULL
+);
+

diff --git a/infra/relay/migrations/postgres/20260707012622_apns_provider_tokens/snapshot.json b/infra/relay/migrations/postgres/20260707012622_apns_provider_tokens/snapshot.json
new file mode 100644
--- /dev/null
+++ b/infra/relay/migrations/postgres/20260707012622_apns_provider_tokens/snapshot.json
@@ -1,0 +1,1545 @@
+{
+  "dialect": "postgres",
+  "id": "f2fc19cc-d049-4b85-a774-33b547744dc7",
+  "prevIds": ["f4151a65-091a-4601-bde8-029b155255b0"],
+  "version": "8",
+  "ddl": [
+    {
+      "isRlsEnabled": false,
+      "name": "relay_agent_activity_rows",
+      "entityType": "tables",
+      "schema": "public"
+    },
+    {
+      "isRlsEnabled": false,
+      "name": "relay_apns_provider_tokens",
+      "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(191)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "cache_key",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_apns_provider_tokens"
+    },
+    {
+      "type": "text",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "jwt",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_apns_provider_tokens"
+    },
+    {
+      "type": "integer",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "issued_at",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_apns_provider_tokens"
+    },
+    {
+      "type": "varchar(64)",
+      "typeSchema": null,
+      "notNull": true,
+      "dimensions": 0,
+      "default": null,
+      "generated": null,
+      "identity": null,
+      "name": "updated_at",
+      "entityType": "columns",
+      "schema": "public",
+      "table": "relay_apns_provider_tokens"
+    },
+    {
+      "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,
... diff truncated: showing 800 of 1605 lines

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

Comment thread infra/relay/src/persistence/schema.ts Outdated
Comment thread infra/relay/src/agentActivity/ApnsProviderTokens.ts Outdated
juliusmarminge and others added 2 commits July 6, 2026 18:20
Storing the shared provider JWT in the database left a live bearer
credential sitting in plaintext next to the device push tokens it
unlocks. Rather than encrypt it, stop persisting it entirely: sign with
deterministic ES256 (RFC 6979 via @noble/curves — node's signer is
randomized) and quantize iat to the 45-minute reuse window, so every
worker isolate independently derives the byte-identical token. The fleet
presents one stable token per window to APNs with no shared storage, no
cold-start read, and nothing at rest; APNs' 60-minute iat limit is never
exceeded and node crypto verifies noble's signatures (interop checked).

Drops the relay_apns_provider_tokens table (never deployed) and the
database-backed layer; the isolate map remains purely to avoid
re-signing on every push.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When an agent finished while others were still running, its row simply
vanished from the next Live Activity update: terminal publishes deleted
the thread row, the aggregate filtered terminal phases out of the
activity list, and update-event alerts only covered attention
transitions — so mid-flight completions produced neither a Done row nor
a haptic (the end-event alert path only fires when the last agent
finishes).

- Terminal publishes now upsert the row; the aggregate appends recently
  finished threads (5-minute display window) after the active ones, with
  activeCount still counting only live work and active agents keeping
  display-slot priority.
- Updates alert on newly terminal threads, gated by notifyOnCompletion /
  notifyOnFailure — same transition rules as attention alerts (requires
  a previously delivered non-terminal baseline, so replays never ring).
- Lingering terminal rows never resurrect an activity on their own: the
  end/replay semantics when nothing is active are unchanged, and the
  5-minute cron sweeps terminal rows once they age out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@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 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Completion alert dropped at cap
    • makeAggregateState now caps actives at two rows whenever a recent terminal thread exists, guaranteeing the freshest completion a display slot so alertForNewlyTerminal can detect it and fire the completion alert.

Create PR

Or push these changes by commenting:

@cursor push cf34412e86
Preview (cf34412e86)
diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts
--- a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts
+++ b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts
@@ -762,7 +762,7 @@
     ).toBeNull();
   });
 
-  it("gives active agents the display slots before finished ones", () => {
+  it("reserves a display slot for a recent completion even when actives fill the cap", () => {
     const mkActive = (id: string): RelayAgentActivityState => ({
       ...state,
       threadId: id as RelayAgentActivityState["threadId"],
@@ -784,7 +784,7 @@
     expect(aggregate?.activities).toMatchObject([
       { threadId: "a-1" },
       { threadId: "a-2" },
-      { threadId: "a-3" },
+      { threadId: "thread-done", phase: "completed", status: "Done" },
     ]);
   });
 });

diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts
--- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts
+++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts
@@ -284,13 +284,20 @@
     // fresh registration doesn't resurrect a finished thread.
     return input.terminalState === null ? null : terminalAggregateState(input.terminalState);
   }
-  // Recently finished threads ride along after the active ones (display slots
-  // permitting) so a completion is visible as Done/Failed instead of the row
-  // silently vanishing while other agents keep the activity alive.
+  // Recently finished threads ride along after the active ones so a completion
+  // is visible as Done/Failed instead of the row silently vanishing while
+  // other agents keep the activity alive. The freshest one is guaranteed a
+  // display slot even when actives could fill every row: the completion alert
+  // (alertForNewlyTerminal) only sees threads present in the aggregate, so a
+  // capped-out terminal row would otherwise finish without a buzz.
   const recentTerminalStates = input.activeStates
     .filter((state) => isRecentTerminalState(state, input.nowMs))
     .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
-  const displayedStates = [...activeStates.slice(0, 3), ...recentTerminalStates].slice(0, 3);
+  const activeSlots = recentTerminalStates.length > 0 ? 2 : 3;
+  const displayedStates = [...activeStates.slice(0, activeSlots), ...recentTerminalStates].slice(
+    0,
+    3,
+  );
   const updatedAt = [...activeStates, ...recentTerminalStates].reduce((latest, state) =>
     state.updatedAt.localeCompare(latest.updatedAt) > 0 ? state : latest,
   ).updatedAt;

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

Comment thread infra/relay/src/agentActivity/AgentActivityPublisher.ts
juliusmarminge and others added 2 commits July 6, 2026 19:15
Live repro of a lock-screen activity frozen at "Working" forever: three
publishes landed within four seconds of a turn finishing, and the queue
batch delivered a start (chosen from the running aggregate while the
target row briefly looked inactive) 250ms before the terminal publish's
end. The end killed the old activity; the start birthed a new one
carrying the stale running aggregate. No token is ever registered for
that orphan, so nothing can update or end it.

Start jobs now recheck at delivery time whether the user still has any
live (non-terminal, non-expired) agent-activity rows; if not, the job is
skipped as stale ("Stale APNs start job skipped."), the queued-start
marker is cleared so future work can start an activity again, and
nothing reaches APNs. The recheck fails open on persistence errors so a
database hiccup never drops a legitimate start.

isTerminalPhase/isExpiredAgentActivityState move to
agentActivityPayloads.ts so ApnsDeliveries can share them without a
module cycle through the publisher.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live repro: a push-to-start birthed a fresh lock-screen activity, but the
relay's live-activity row still held the PREVIOUS activity's update token,
so every subsequent update and end (all APNs 200) routed to the dead
activity while the new card sat frozen on its start content — "Working"
even though the thread had moved to waiting-for-input (the correctly
built Input aggregate went to the corpse token at 02:21:16Z).

markDelivery now clears activity_push_token when a start (new activity
generation) or end (activity gone) is delivered, and refreshes
remote_started_at on starts instead of keeping the previous generation's
timestamp. Deliveries pause until the app registers the fresh token;
registerLiveActivity fully resets the row and the registration replay
already reconciles the card (update to current content, or end it when
work has finished), while queued jobs for the retired token are dropped
by the existing current-token staleness check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@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 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Start delivery clears registered token
    • markDelivery now clears activity_push_token on a delivered start only when the row's remote_started_at predates the delivery, so a token the app registered after the start push was sent (which stamps a later remote_started_at) survives instead of being unconditionally nulled.

Create PR

Or push these changes by commenting:

@cursor push 15a07abb45
Preview (15a07abb45)
diff --git a/infra/relay/src/agentActivity/LiveActivities.test.ts b/infra/relay/src/agentActivity/LiveActivities.test.ts
--- a/infra/relay/src/agentActivity/LiveActivities.test.ts
+++ b/infra/relay/src/agentActivity/LiveActivities.test.ts
@@ -200,6 +200,7 @@
 
   it.effect("retires the previous activity token when a start or end is delivered", () => {
     const conflictConfigs: Array<{ readonly set?: Record<string, unknown> }> = [];
+    const dialect = new PgDialect();
     const fakeDb = {
       insert: () => ({
         values: () => ({
@@ -227,11 +228,19 @@
 
       // A start begins a new activity generation and an end retires the
       // current one; both must drop the stored update token so later
-      // deliveries can't route to the dead activity. Plain updates keep it.
+      // deliveries can't route to the dead activity. Plain updates keep it,
+      // and a start keeps a token the app registered AFTER the start was
+      // sent (the registration races the APNs round trip and must survive).
+      const startTokenSql = dialect.sqlToQuery(conflictConfigs[0]?.set?.activityPushToken as SQL);
+      expect(startTokenSql.sql.replaceAll(/\s+/g, " ")).toBe(
+        'case when "relay_live_activities"."remote_started_at" > excluded.remote_started_at then "relay_live_activities"."activity_push_token" else null end',
+      );
+      const startStartedAtSql = dialect.sqlToQuery(conflictConfigs[0]?.set?.remoteStartedAt as SQL);
+      expect(startStartedAtSql.sql.replaceAll(/\s+/g, " ")).toBe(
+        'greatest( "relay_live_activities"."remote_started_at", excluded.remote_started_at )',
+      );
       expect(conflictConfigs[0]?.set).toEqual(
         expect.objectContaining({
-          activityPushToken: null,
-          remoteStartedAt: "2026-05-25T00:00:10.000Z",
           endedAt: null,
         }),
       );

diff --git a/infra/relay/src/agentActivity/LiveActivities.ts b/infra/relay/src/agentActivity/LiveActivities.ts
--- a/infra/relay/src/agentActivity/LiveActivities.ts
+++ b/infra/relay/src/agentActivity/LiveActivities.ts
@@ -288,14 +288,26 @@
               // reconcile content (or end the activity if work already
               // finished). Without this, updates and ends route to the dead
               // token and the new lock-screen card is stranded at its start
-              // content forever.
+              // content forever. The app can register the fresh activity's
+              // token BEFORE this mark lands (registration races the APNs
+              // round trip); such a registration stamps remote_started_at
+              // later than this delivery, and its token must survive the mark.
               activityPushToken:
                 input.kind === "live_activity_update"
                   ? sql`${relayLiveActivities.activityPushToken}`
-                  : null,
+                  : input.kind === "live_activity_start"
+                    ? sql`case
+                        when ${relayLiveActivities.remoteStartedAt} > excluded.remote_started_at
+                          then ${relayLiveActivities.activityPushToken}
+                        else null
+                      end`
+                    : null,
               remoteStartedAt:
                 input.kind === "live_activity_start"
-                  ? input.deliveredAt
+                  ? sql`greatest(
+                      ${relayLiveActivities.remoteStartedAt},
+                      excluded.remote_started_at
+                    )`
                   : sql`coalesce(
                       ${relayLiveActivities.remoteStartedAt},
                       excluded.remote_started_at

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

Comment thread infra/relay/src/agentActivity/LiveActivities.ts
Remote push-to-start was the source of every hard Live Activity bug this
branch fought: it depends on iOS waking the app in the background to hand
the new activity's update token over, and that handoff proved unreliable
(orphan cards frozen on their start content, deliveries routed to dead
tokens, first-transition races inside the same queue batch).

Now activities are only ever created by the app, in the foreground, where
the token is observable and registered immediately:

- Mobile: the existing sign-in / app-foreground / connection reconcile
  pass primes the card — if no activity is live and Live Activity updates
  are enabled, it starts one showing an idle state and registers its
  token on the spot. All push-to-start plumbing (token listener,
  registration ride-along, carry-forward merge logic) is removed; the
  contract and stored-record fields stay optional for rolling compat.
- Relay: chooseLiveActivityDelivery never emits live_activity_start.
  An armed card with an empty aggregate idles ("Waiting for agents")
  instead of ending, so the token survives session gaps and agents
  started from other machines appear on it while the phone stays locked;
  identical idle redraws are suppressed. Ends remain only for the
  Live-Activities-disabled toggle (plus permanent token failures), and
  attention transitions without any armed card fall back to push
  notifications as before.

Terminal aggregates now flow through the update path, so completion
buzzes ride the newly-terminal alert and the Done card lingers as the
session dashboard rather than tearing down and forcing the next session
through a remote start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@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 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Default LA prefs skip priming
    • Changed the priming gate from requiring liveActivitiesEnabled to be explicitly true to !== false, matching the enabled-by-default semantics used by device registration and the settings screen.

Create PR

Or push these changes by commenting:

@cursor push 98a4de369b
Preview (98a4de369b)
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
@@ -923,7 +923,7 @@
             cause,
           }),
       }).pipe(Effect.orElseSucceed(() => null));
-      if (preferences?.liveActivitiesEnabled) {
+      if (preferences?.liveActivitiesEnabled !== false) {
         const primed = yield* Effect.try({
           try: () =>
             AgentActivity.start({

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

cause,
}),
}).pipe(Effect.orElseSucceed(() => null));
if (preferences?.liveActivitiesEnabled) {

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.

Default LA prefs skip priming

High Severity

Live Activity priming in refreshActiveLiveActivityRemoteRegistration only runs when liveActivitiesEnabled is explicitly true. Device registration, however, treats an unset preference as enabled. This mismatch means users with default settings register Live Activities with the relay but the app doesn't prime the card locally, preventing lock-screen updates.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a2e0830. Configure here.

input.target.activity_push_token !== null);
}): ChosenLiveActivityDelivery | "suppressed" | null {
const preferences = parsePreferences(input.target.preferences_json);
if (preferences?.liveActivitiesEnabled === false) {

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/ApnsDeliveries.ts:357

When preferences?.liveActivitiesEnabled === false and activity_push_token is present, chooseLiveActivityDelivery always returns a live_activity_end — even if the activity was already ended on a previous publish. markDelivery preserves activity_push_token after an end, so every subsequent publish re-selects live_activity_end and chooseDelivery returns it immediately, skipping the push_notification fallback. Disabled users silently stop receiving push notifications after their last activity ends. Consider gating the end on whether the activity is still active (e.g. checking ended_at) so a token that was already ended falls through to the push-notification path.

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

When `preferences?.liveActivitiesEnabled === false` and `activity_push_token` is present, `chooseLiveActivityDelivery` always returns a `live_activity_end` — even if the activity was already ended on a previous publish. `markDelivery` preserves `activity_push_token` after an end, so every subsequent publish re-selects `live_activity_end` and `chooseDelivery` returns it immediately, skipping the `push_notification` fallback. Disabled users silently stop receiving push notifications after their last activity ends. Consider gating the end on whether the activity is still active (e.g. checking `ended_at`) so a token that was already ended falls through to the push-notification path.

cause,
}),
}).pipe(Effect.orElseSucceed(() => null));
if (preferences?.liveActivitiesEnabled) {

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:926

refreshActiveLiveActivityRemoteRegistration skips priming a Live Activity when preferences?.liveActivitiesEnabled is falsy, but loadPreferences() returns {} for first-run users. Since the rest of the codebase treats an absent flag as enabled (liveActivitiesEnabled !== false), default-enabled users who never touched the setting will never get the local card primed, so the relay cannot deliver updates to them. Consider treating preferences?.liveActivitiesEnabled !== false as the enabled condition to match the default-enabled semantics used elsewhere.

Suggested change
if (preferences?.liveActivitiesEnabled) {
if (preferences?.liveActivitiesEnabled !== false) {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/agent-awareness/remoteRegistration.ts around line 926:

`refreshActiveLiveActivityRemoteRegistration` skips priming a Live Activity when `preferences?.liveActivitiesEnabled` is falsy, but `loadPreferences()` returns `{}` for first-run users. Since the rest of the codebase treats an absent flag as enabled (`liveActivitiesEnabled !== false`), default-enabled users who never touched the setting will never get the local card primed, so the relay cannot deliver updates to them. Consider treating `preferences?.liveActivitiesEnabled !== false` as the enabled condition to match the default-enabled semantics used elsewhere.

Priming an idle card on every app open put an empty "0 agents" activity
on the lock screen. The app now asks the relay what the card would show
before creating one: a new GET /v1/mobile/agent-activity endpoint returns
the current aggregate (same DPoP auth and aggregate computation as the
registration replay). If nothing is live, no card is created — arming
then happens the next time the app opens while agents are running,
including via a tap on an agent notification. When there is live work,
the activity is born seeded with the real aggregate instead of a
placeholder, which also removes the idle flash before the replay landed.

The snapshot request only fires when no card is currently armed and Live
Activity updates are enabled; once a card exists, app opens make no
extra requests. Piggybacking on the device registration response was
considered instead of a new endpoint, but the register-once skip means
warm opens send no registration request at all — exactly when the signal
is needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@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 1 potential issue.

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

Fix All in Cursor

Bugbot Autofix is ON. A cloud agent has been kicked off to fix the reported issue. You can view the agent here.

Reviewed by Cursor Bugbot for commit 2e02973. Configure here.

activeCount: aggregate.activeCount,
});
activities = [primed];
}

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.

Snapshot failure skips arming retry

Medium Severity

The readAgentActivitySnapshot function returns null on any error, which prevents Live Activity priming even when agents are active. This also skips scheduling retries, so users won't see an active card until they foreground the app.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2e02973. Configure here.

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