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 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. |
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
|
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. |
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).
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.
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.
| // 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.
5225bdb to
5086c14
Compare
There was a problem hiding this comment.
🟡 Medium
t3code/apps/mobile/app.config.ts
Lines 96 to 97 in 5086c14
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 { |
There was a problem hiding this comment.
🟡 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.
| value={notificationStatus === "enabled" && deviceRegistered} | ||
| onValueChange={handleDeviceNotificationsChange} | ||
| /> | ||
| <SettingsSwitchRow |
There was a problem hiding this comment.
🟠 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.
There was a problem hiding this comment.
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.
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.
5086c14 to
a31735c
Compare
There was a problem hiding this comment.
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.
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.
- 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>
a31735c to
9c3eb7b
Compare
| // 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" { |
There was a problem hiding this comment.
🟡 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.
| 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; | ||
| } |
There was a problem hiding this comment.
🟡 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.
There was a problem hiding this comment.
🟠 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 = |
There was a problem hiding this comment.
🟡 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`.
There was a problem hiding this comment.
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.
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.
- `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) { |
There was a problem hiding this comment.
🟡 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.
| ): boolean { | ||
| if (preferences === null) { | ||
| return true; | ||
| } |
There was a problem hiding this comment.
🟡 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.
| ): 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( |
There was a problem hiding this comment.
🟡 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.
There was a problem hiding this comment.
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.
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.
…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>
There was a problem hiding this comment.
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.
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.
| const secrets = yield* ServerSecretStore.ServerSecretStore; | ||
| const tokens = yield* CliTokenManager.CloudCliTokenManager; | ||
| const enabled = !flags.disable; | ||
| yield* secrets.set( |
There was a problem hiding this comment.
🟡 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 line189during sign-out, but the next sign-in can startregisterDeviceWithRelay()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 deviceregisteredwithout 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.
…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>
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>
There was a problem hiding this comment.
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.
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 linesYou can send follow-ups to the cloud agent here.
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>
There was a problem hiding this comment.
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.
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.
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>
There was a problem hiding this comment.
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.
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_atYou can send follow-ups to the cloud agent here.
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>
There was a problem hiding this comment.
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.
- Changed the priming gate from requiring liveActivitiesEnabled to be explicitly true to
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) { |
There was a problem hiding this comment.
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.
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) { |
There was a problem hiding this comment.
🟡 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) { |
There was a problem hiding this comment.
🟡 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.
| 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>
There was a problem hiding this comment.
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).
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]; | ||
| } |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit 2e02973. Configure here.



Summary
Testing
vp checkvp run typecheckvp testNote
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
bundleIdandapsEnvironment(sandbox for development builds), tracksregistered/pending/failedrelay 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 +actoolshell phase).Server / web T3 Connect splits managed tunnel vs publish agent activity:
publish_onlylink mode (manualendpoint, no tunnel),t3 connect publish/--publish-onlylink,managedTunnelActivein link state, and settings toggles that reconcile link mode and publish preference independently.Relay infra adds nullable
bundle_idandaps_environmentonrelay_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
bundleIdandapsEnvironmentfields to device registration, the delivery queue, and APNs request routing so each device can be addressed on the correct APNs topic and environment.chooseLiveActivityDeliveryto 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.getAgentActivitySnapshotrelay endpoint (mobile DPoP-gated) and a matchingManagedRelayClientmethod so the mobile app can prime a Live Activity on foreground if none exists.publish_onlyrelay link mode: linking without a managed Cloudflare tunnel usesproviderKind: "manual", omitsmanaged_tunnelsfrom proof scopes, deprovisions any existing tunnel, and is controllable viat3 connect publishCLI and a new Settings toggle.ApnsProviderTokensservice with quantized JWT reuse windows and an isolate-local cache; replaces inline JWT signing inApnsClient.pruneTerminalstep that removes terminal agent activity rows older than 30 minutes.statusForPhase('running')now returns'Connecting'instead of'Starting', changing visible status labels on existing Live Activities.Macroscope summarized 2e02973.