Improve live activity routing and diagnostics#3685
Conversation
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
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.
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 linesYou can send follow-ups to the cloud agent here.
| 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 }), |
There was a problem hiding this comment.
🟠 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.apsEnvironmentcolumns are referenced bylistTargets(bundle_id,aps_environment) and other relay code, but this PR does not add a matching SQL migration forrelay_mobile_devices. After deploying the code to an existing database, anylistTargetsquery will start failing withcolumn relay_mobile_devices.bundle_id does not exist(and likewise foraps_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.
There was a problem hiding this comment.
🟡 Medium
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.
ApprovabilityVerdict: Needs human review 8 blocking correctness issues found. This PR introduces significant new runtime behavior including database schema changes, registration status tracking, AppState listeners, and widget deep linking. Multiple unresolved review comments identify high-severity issues including rolling deploy signature breakage and stuck registration states that require human attention. You can customize Macroscope's approvability policy. Learn more. |
f81c2ca to
1d6309e
Compare
| const deepLinkRow = attentionRow ?? row0; | ||
| const deepLink = | ||
| deepLinkRow && deepLinkRow.deepLink.startsWith("/") && !deepLinkRow.deepLink.startsWith("//") | ||
| ? `t3code://${deepLinkRow.deepLink.slice(1)}` |
There was a problem hiding this comment.
🟡 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; |
There was a problem hiding this comment.
🟡 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.
1d6309e to
39d9dca
Compare
| runRegistrationInBackground( | ||
| refreshActiveLiveActivityRemoteRegistration(), | ||
| "active live activity reconciliation after app foreground failed", | ||
| ); |
There was a problem hiding this comment.
Foreground replay skips updates
Medium Severity
Foreground reconciliation re-registers Live Activity tokens so the relay replays the aggregate, but replayForLiveActivityRegistration still uses sendForTarget with the same shouldUpdateLiveActivity gating. When the aggregate matches last_aggregate_json or is inside the 15s throttle, delivery returns "suppressed" and no APNs update is sent, so lock-screen content can stay stale after returning to the app.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 39d9dca. Configure here.
There was a problem hiding this comment.
Bugbot Autofix determined this is a false positive.
Foreground reconciliation goes through MobileRegistrations.registerLiveActivity, where LiveActivities.register resets lastAggregateJson and lastLiveActivityDeliveryAt to null before the replay runs, so shouldUpdateLiveActivity short-circuits to true (null previousAggregate) and the replayed update is never suppressed.
You can send follow-ups to the cloud agent here.
|
Bugbot Autofix prepared fixes for 2 of the 4 issues found in the latest run.
Or push these changes by commenting: 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 linesYou can send follow-ups to the cloud agent here. |
- Per-device APNs routing (bundle id + environment) so preview/dev builds receive pushes from the shared relay (+ migration for the new columns) - Settings notification/Live Activity toggles reflect actual relay registration success instead of local permission/preference, so they cannot read as enabled when the device cannot receive anything - Persistent register-once: skip re-registering an unchanged payload for the same account across launches; clear only on sign-out - Foreground Live Activity refresh, stale/ghost-row hardening, dedup fixes - Tighten widget deep linking and per-row rendering for active agents Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
39d9dca to
5225bdb
Compare
There was a problem hiding this comment.
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).
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.
Or push these changes by commenting:
@cursor push fa19061765
Preview (fa19061765)
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
@@ -143,6 +143,30 @@
return identity === undefined || identity !== previousIdentity;
}
+function removeRelaySubscriptions(): void {
+ pushToStartSubscription?.remove();
+ pushToStartSubscription = null;
+ pushTokenSubscription?.remove();
+ pushTokenSubscription = null;
+ appStateSubscription?.remove();
+ appStateSubscription = null;
+ if (activeLiveActivityRegistrationRetry) {
+ clearTimeout(activeLiveActivityRegistrationRetry);
+ activeLiveActivityRegistrationRetry = null;
+ }
+}
+
+// Drops the relay token provider without sign-out semantics: the Clerk session
+// can still be valid while the auth bridge tears down (remounts, provider tree
+// changes, missing cloud config). Listeners stop, but local Live Activities,
+// the persisted registration record, and the provider identity stay intact so
+// re-activating the same account does not end activities or force
+// re-registration.
+export function suspendAgentAwarenessRelayTokenProvider(): void {
+ relayTokenProvider = null;
+ removeRelaySubscriptions();
+}
+
export function setAgentAwarenessRelayTokenProvider(
provider: (() => Promise<string | null>) | null,
identity?: string,
@@ -158,16 +182,7 @@
relayTokenProvider = provider;
relayTokenProviderIdentity = provider ? (identity ?? null) : null;
if (!provider) {
- pushToStartSubscription?.remove();
- pushToStartSubscription = null;
- pushTokenSubscription?.remove();
- pushTokenSubscription = null;
- appStateSubscription?.remove();
- appStateSubscription = null;
- if (activeLiveActivityRegistrationRetry) {
- clearTimeout(activeLiveActivityRegistrationRetry);
- activeLiveActivityRegistrationRetry = null;
- }
+ removeRelaySubscriptions();
// Without a signed-in user the relay can no longer update or end these
// activities, so they would sit orphaned on the lock screen.
endLocalLiveActivities("live activity cleanup after cloud sign-out failed");
@@ -472,6 +487,11 @@
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
setRegistrationStatus("failed");
logRegistrationError(next.context, squashAtomCommandFailure(result));
+ } else if (registrationStatus === "pending") {
+ // The registration exited without reaching the relay (signed out,
+ // missing relay config, or superseded by a newer generation); don't
+ // leave the status stuck on pending.
+ setRegistrationStatus("unknown");
}
logRegistrationDebug("device registration finished", { generation });
if (activeDeviceRegistration === registration) {
diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx
--- a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx
+++ b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx
@@ -15,6 +15,7 @@
import { useAtomCommand } from "../../state/use-atom-command";
import {
setAgentAwarenessRelayTokenProvider,
+ suspendAgentAwarenessRelayTokenProvider,
unregisterAgentAwarenessDeviceForCurrentUser,
} from "../agent-awareness/remoteRegistration";
import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig";
@@ -32,6 +33,14 @@
setManagedRelaySession(appAtomRegistry, null);
}
+// Teardown without sign-out semantics: the Clerk session may still be valid
+// (bridge remounts, provider tree changes, missing cloud config), so local
+// Live Activities and the persisted registration record must survive.
+export function suspendCloudRelayAccount(): void {
+ suspendAgentAwarenessRelayTokenProvider();
+ setManagedRelaySession(appAtomRegistry, null);
+}
+
export function activateCloudRelayAccount(
accountId: string,
tokenProvider: () => Promise<string | null>,
@@ -143,7 +152,7 @@
useEffect(
() => () => {
previousTokenProviderRef.current = null;
- deactivateCloudRelayAccount();
+ suspendCloudRelayAccount();
},
[],
);
@@ -158,7 +167,7 @@
useEffect(() => {
if (!publishableKey || !relayUrl) {
- deactivateCloudRelayAccount();
+ suspendCloudRelayAccount();
}
}, [publishableKey, relayUrl]);You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 5225bdb. Configure here.
| hasObservedPushToken: next.input.observedPushToken !== undefined, | ||
| hasPushToStartToken: next.input.pushToStartToken !== undefined, | ||
| }); | ||
| if (registrationStatus !== "registered") { |
There was a problem hiding this comment.
Registration status stuck pending
Medium Severity
The device registration status can get stuck as 'pending' when registerDeviceWithRelay exits early, such as when a user isn't signed in or the request is cancelled. This leaves the UI incorrectly showing the device as unregistered and can prevent subsequent registration attempts for the same user.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 5225bdb. Configure here.
| // next sign-in re-registers. | ||
| void clearAgentAwarenessRegistrationRecord().catch((error: unknown) => { | ||
| logRegistrationError("clear registration record on sign-out failed", error); | ||
| }); |
There was a problem hiding this comment.
Sign-out cleanup on provider clear
Medium Severity
Clearing the relay token provider now always ends local Live Activities and wipes the persisted registration record. deactivateCloudRelayAccount uses that path, and CloudAuthBridge calls it on component unmount, so a remount while the user is still signed in can dismiss lock-screen activities and force unnecessary re-registration.
Reviewed by Cursor Bugbot for commit 5225bdb. Configure here.
| setRegistrationStatus("registered"); | ||
| yield* Effect.promise(() => |
There was a problem hiding this comment.
🟡 Medium agent-awareness/remoteRegistration.ts:334
After client.registerDevice(...) resolves, registerDeviceWithRelay sets registrationStatus to "registered" and saves the registration record without re-checking deviceRegistrationGeneration. If the user signs out while the request is in flight, setAgentAwarenessRelayTokenProvider(null) increments the generation and clears the stored record, but the stale request still overwrites both values — leaving the signed-out app showing "registered" and making the next sign-in incorrectly skip re-registration for the previous account. Consider re-checking expectedGeneration !== deviceRegistrationGeneration after the relay call before updating status and persisting the record.
setRegistrationStatus("registered");
+ if (expectedGeneration !== deviceRegistrationGeneration) {
+ logRegistrationDebug("device registration cancelled after relay request", {
+ expectedGeneration,
+ currentGeneration: deviceRegistrationGeneration,
+ });
+ return;
+ }
yield* Effect.promise(() =>🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/agent-awareness/remoteRegistration.ts around lines 334-335:
After `client.registerDevice(...)` resolves, `registerDeviceWithRelay` sets `registrationStatus` to `"registered"` and saves the registration record without re-checking `deviceRegistrationGeneration`. If the user signs out while the request is in flight, `setAgentAwarenessRelayTokenProvider(null)` increments the generation and clears the stored record, but the stale request still overwrites both values — leaving the signed-out app showing `"registered"` and making the next sign-in incorrectly skip re-registration for the previous account. Consider re-checking `expectedGeneration !== deviceRegistrationGeneration` after the relay call before updating status and persisting the record.
| // Only reads as on when this device is actually registered with the | ||
| // relay; otherwise notifications cannot be delivered regardless of | ||
| // the local iOS permission. | ||
| value={notificationStatus === "enabled" && deviceRegistered} |
There was a problem hiding this comment.
🟡 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 } : {}), |
There was a problem hiding this comment.
🟠 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.



Summary
Testing
vp checkvp run typecheckvp testNote
Medium Risk
Changes APNs routing, registration gating, and push suppression logic across mobile and relay; mistakes could block notifications or mis-route tokens, though behavior is heavily covered by new tests.
Overview
Improves end-to-end Live Activity and push reliability by registering each iOS install with
bundleIdandapsEnvironment(sandbox for development, production otherwise), persisting that on the relay, and using it when sending APNs so topic and host match the build variant.On the mobile app, device registration is deduplicated via a stored account identity + payload signature (cleared on sign-out), and a
registered/failedstatus drives settings so notification and Live Activity switches stay off until the relay actually accepts the device. Foreground triggers Live Activity token re-registration; sign-out ends local activities and tears down listeners. The Agent Activity widget gains per-row phase tinting, local-time “Updated” labels, overflow text, compact “Approval/Input” hints, and safe deep links to the thread that needs attention. Frequent-updates is enabled for the widget extension in Expo config.On the relay, Live Activity delivery skips unchanged aggregates (and does not fall back to alert pushes when suppressed), still sends on count changes or waiting-for-input/approval, and expires stale activity rows from aggregates (2h running, 24h waiting). Stale-after for Live Activities in APNs payloads moves from 2 to 10 minutes.
Reviewed by Cursor Bugbot for commit 5225bdb. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Add per-device APNs routing and improve Live Activity update logic
bundle_idandaps_environmenton device records (new DB columns) and propagates them through the APNs delivery pipeline so each device's Live Activity and push requests are routed to the correct APNs environment and topic.makeAggregateState: active states older than 2h (running/starting) or 24h (waiting) are dropped; if only expired rows remain and no terminal state exists, the aggregate returns null.bundleIdandapsEnvironment; registrations are skipped when the persisted identity and payload signature match the last successful registration.AppStatelistener so Live Activity tokens are re-registered when the app returns to the foreground, and ends local Live Activities on sign-out.STALE_AFTER_SECONDSfor Live Activity APNs updates from 2 to 10 minutes to reduce premature dimming.Macroscope summarized 5225bdb.