Improve live activity routing and diagnostics - #3685

Merged
juliusmarminge merged 26 commits into
mainfrom
t3code/live-activities-improvements
Jul 7, 2026
Merged

Improve live activity routing and diagnostics#3685
juliusmarminge merged 26 commits into
mainfrom
t3code/live-activities-improvements

Conversation

@juliusmarminge

@juliusmarmingejuliusmarminge commented Jul 4, 2026

Copy link
Copy Markdown
Member

Summary

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

Testing

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

Note

High Risk
Touches relay registration, APNs routing, cloud link modes, and Live Activity lifecycle (including removing push-to-start); misconfiguration or partial deploy could break mobile notifications or mis-route pushes.

Overview
Improves mobile agent awareness end-to-end: relay device registration now sends bundle ID and sandbox vs production APNs routing, tracks whether the relay actually accepted registration (settings toggles and alerts follow that, not just iOS permission), skips duplicate registrations via a persisted signature, and re-registers Live Activity tokens on foreground with short-window deduping. Push-to-start registration is removed in favor of arming a card when the user sends a task (and priming from a relay activity snapshot when idle), plus cleanup on cloud sign-out and a releaseAgentAwarenessRelayTokenProvider path so auth UI remounts do not wipe registration or end activities.

Agent Activity Live Activity UI is reworked (attention-first rows, phase tints aligned with web, deep links, branded T3 mark via new Expo config plugins and actool shell phase). expo-widgets gains frequentUpdates for higher update budget.

T3 Connect vs publishing split: environments can link in publish_only mode (manual endpoint, no Cloudflare tunnel) via CLI (connect link --publish-only, connect publish) and web settings (separate toggles reconciled together). Link proofs accept manual providers with activity-only scopes; server publish path defers transient tombstones and first completed snapshots before publishing.

Relay DB adds bundle_id and aps_environment on mobile devices (migration in diff).

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

Note

Add per-device APNs routing, JWT token caching, and publish-only link mode to Live Activity delivery

  • Stores bundle_id and aps_environment per device in the relay and uses them to route APNs pushes to the correct topic and environment per device rather than using global config.
  • Introduces ApnsProviderTokens service that caches APNs JWTs quantized to 45-minute windows, preventing TooManyProviderTokenUpdates errors; uses deterministic ES256 signing via @noble/curves.
  • Overhauls Live Activity delivery: activities are no longer remotely started; ends are suppressed within a grace window after arming; attention-phase transitions and newly-terminal rows can now trigger alert payloads with haptics; stale terminal aggregates suppress re-alerting.
  • Adds a publish_only cloud link mode in the CLI (t3 connect publish) and web settings UI, allowing agent activity publishing without a managed Cloudflare tunnel; linking with this mode uses providerKind: "manual" and deprovisions any existing tunnel allocation.
  • Reworks the AgentActivity widget to sort rows attention-first, apply per-phase tints from the web palette, deep-link to the most relevant thread, show a branded logo, and display a phase glyph in minimal presentation.
  • Exposes relay registration status (unknown/pending/registered/failed) to the settings UI so notification and Live Activity toggles only appear enabled when the device is actually registered.
  • Risk: makeAggregateState now requires a nowMs parameter and statusForPhase('starting') returns 'Connecting' instead of 'Starting', which are breaking changes for callers.

Macroscope summarized 6f0b32d.

@coderabbitai

coderabbitaiBot commented Jul 4, 2026

Copy link
Copy Markdown

Important

Review skipped

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

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: fe216c9a-1cd9-460a-a80d-6d90748a00b5

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

Use the checkbox below for a quick retry:

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

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

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

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Autofix Details

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

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

Create PR

Or push these changes by commenting:

@cursor push a97e0b6881
Preview (a97e0b6881)
diff --git a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts--- a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts+++ b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts@@ -69,6 +69,28 @@
]);
});
+ it("treats a non-2xx delivery status without a reason as a failure", () => {+ const rows = formatDeviceDiagnosticsRows(+ makeDevice({+ bundleId: "com.t3tools.t3code.preview",+ apsEnvironment: "production",+ hasPushToken: true,+ hasPushToStartToken: true,+ hasLiveActivityToken: true,+ lastDeliveryAt: "2026-06-05T01:02:59.566Z",+ lastDeliveryKind: "live_activity_end",+ lastDeliveryStatus: 400,+ lastDeliveryError: null,+ }),+ );++ expect(rows[4]).toEqual({+ label: "Last Delivery",+ value: "Delivery failed (400)",+ tone: "warn",+ });+ });+
it("reports healthy registrations with a successful delivery", () => {
const rows = formatDeviceDiagnosticsRows(
makeDevice({
diff --git a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts--- a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts+++ b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts@@ -73,14 +73,21 @@
});
}
+ // APNs can fail with a non-2xx status without a reason body, so a null+ // error alone does not mean the delivery succeeded.+ const deliveryFailed =+ diagnostics.lastDeliveryError !== null ||+ (diagnostics.lastDeliveryStatus !== null &&+ (diagnostics.lastDeliveryStatus < 200 || diagnostics.lastDeliveryStatus >= 300));+
if (diagnostics.lastDeliveryAt === null) {
rows.push({ label: "Last Delivery", value: "None yet", tone: "muted" });
- } else if (diagnostics.lastDeliveryError !== null) {+ } else if (deliveryFailed) {
const status =
diagnostics.lastDeliveryStatus === null ? "" : ` (${diagnostics.lastDeliveryStatus})`;
rows.push({
label: "Last Delivery",
- value: `${diagnostics.lastDeliveryError}${status}`,+ value: `${diagnostics.lastDeliveryError ?? "Delivery failed"}${status}`,
tone: "warn",
});
} else {
diff --git a/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/migration.sql b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/migration.sql
new file mode 100644
--- /dev/null+++ b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/migration.sql@@ -1,0 +1,3 @@+ALTER TABLE "relay_mobile_devices" ADD COLUMN "bundle_id" varchar(255);+--> statement-breakpoint+ALTER TABLE "relay_mobile_devices" ADD COLUMN "aps_environment" varchar(16);diff --git a/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/snapshot.json b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/snapshot.json
new file mode 100644
--- /dev/null+++ b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/snapshot.json@@ -1,0 +1,1479 @@+{+ "dialect": "postgres",+ "id": "e00cc7df-a31e-4b8b-b258-d04e7db7758a",+ "prevIds": ["385d476b-d4f6-48a3-99e6-0a95af4ee4e4"],+ "version": "8",+ "ddl": [+ {+ "isRlsEnabled": false,+ "name": "relay_agent_activity_rows",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_delivery_attempts",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_dpop_proofs",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_environment_credentials",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_environment_links",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_live_activities",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_managed_endpoint_allocations",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_mobile_devices",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_public_key",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "thread_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "jsonb",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "state_json",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "updated_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "varchar(36)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "user_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "thread_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "device_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "kind",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "source_job_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(16)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "token_suffix",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "integer",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "apns_status",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "apns_reason",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(128)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "apns_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "transport_error",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(128)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "thumbprint",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "jti",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "integer",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "iat",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "expires_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "credential_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_public_key",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "credential_hash",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "revoked_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "updated_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "user_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": "'T3 Environment'",+ "generated": null,+ "identity": null,+ "name": "environment_label",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_public_key",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "endpoint_http_base_url",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "endpoint_ws_base_url",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(32)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "endpoint_provider_kind",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "boolean",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": "true",+ "generated": null,+ "identity": null,+ "name": "notifications_enabled",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "boolean",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": "true",+ "generated": null,+ "identity": null,+ "name": "live_activities_enabled",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "boolean",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": "false",+ "generated": null,+ "identity": null,+ "name": "managed_tunnels_enabled",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_by_device_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "revoked_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "updated_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "user_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "device_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "activity_push_token",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "remote_start_queued_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "remote_started_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "ended_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "jsonb",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,
... diff truncated: showing 800 of 1681 lines

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highpersistence/schema.ts:27

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

Also found in 1 other location(s)

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

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/persistence/schema.ts around line 27:
The new `bundle_id` and `aps_environment` columns are added to the `relay_mobile_devices` schema, but no SQL migration is included to create them. After deploy, the code in `Devices.ts` immediately inserts and selects these columns, so PostgreSQL rejects every device registration and list query with `column "bundle_id" does not exist` (and `aps_environment` likewise). Add the corresponding `ALTER TABLE relay_mobile_devices ADD COLUMN ...` migration under `infra/relay/migrations/` so the columns exist before the new code runs.
Also found in 1 other location(s):
- infra/relay/src/agentActivity/LiveActivities.ts:201 -- The new `relayMobileDevices.bundleId` / `relayMobileDevices.apsEnvironment` columns are referenced by `listTargets` (`bundle_id`, `aps_environment`) and other relay code, but this PR does not add a matching SQL migration for `relay_mobile_devices`. After deploying the code to an existing database, any `listTargets` query will start failing with `column relay_mobile_devices.bundle_id does not exist` (and likewise for `aps_environment`), breaking live-activity target lookup until the schema is manually patched.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium

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

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/ApnsDeliveries.ts around line 506:
The stale-job check in `processSignedJob` only compares the token via `isCurrentSignedJobToken`, so a queued job created before the device registered `bundleId`/`apsEnvironment` (or before those values changed) still passes the staleness guard when the token is unchanged. The job is then sent with the stale or missing routing metadata from the signed payload instead of the device's current values, causing APNs to reject the delivery with `BadDeviceToken`/`DeviceTokenNotForTopic` even though the registration has already been corrected. Consider comparing `bundleId` and `apsEnvironment` against the current target in the staleness check so jobs with outdated routing are treated as stale.

@macroscopeapp

macroscopeappBot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

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

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumwidgets/AgentActivity.tsx:81

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/widgets/AgentActivity.tsx around line 81:
`widgetURL` is hardcoded to the `t3code://` scheme, but the app registers `t3code-dev` for development builds and `t3code-preview` for preview builds. On dev/preview builds the widget banner's tap URL uses a scheme the containing app does not register, so iOS will not open the app when the banner is tapped. Consider using a scheme that is registered in all build variants (or injecting the active scheme per-build).

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 MediumagentActivity/AgentActivityPublisher.ts:203

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/AgentActivityPublisher.ts around line 203:
`RUNNING_AGENT_ACTIVITY_ROW_TTL_MS` expires `running`/`starting` states after 2 hours based on `updatedAt`, but relay publishes are event-driven — a thread that stays in the same `running` phase for over 2 hours without a new event keeps its old `updatedAt`, so `isExpiredAgentActivityState` returns `true` and `makeAggregateState` filters it out. The aggregate then reports `activeCount: 0` (or `null`/terminal) even though the job is still running, hiding active work from clients. Consider raising the running TTL to match the waiting TTL, or filtering on a heartbeat/last-seen timestamp that is refreshed independently of phase-change events.

Comment threadapps/mobile/src/widgets/AgentActivity.tsx
Comment threadinfra/relay/src/agentActivity/Devices.ts Outdated
Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts
@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from 1d6309e to 39d9dcaCompareJuly 6, 2026 06:07
@github-actionsgithub-actionsBot added size:L 100-499 changed lines (additions + deletions). and removed size:XL 500-999 changed lines (additions + deletions). labels Jul 6, 2026
@cursor

This comment has been minimized.

@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from 39d9dca to 5225bdbCompareJuly 6, 2026 07:37
@github-actionsgithub-actionsBot added size:XXL 1,000+ changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Jul 6, 2026

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

Autofix Details

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

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

Create PR

Or push these changes by commenting:

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

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsettings/SettingsRouteScreen.tsx:438

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/settings/SettingsRouteScreen.tsx around line 438:
The `deviceRegistered` gate on the `Device Notifications` switch renders it `false` when relay registration fails, even though `notificationStatus` is `"enabled"`. Because the switch shows off, the user cannot toggle it to reach `handleDeviceNotificationsChange(false)`, which is the only path that opens iOS Settings to disable notifications. In the "permission granted, relay registration failed" case the switch misreports notifications as off and removes the in-app affordance for managing them. Consider gating only the Live Activity switch on `deviceRegistered`, or keeping the `Device Notifications` switch reflecting the actual iOS permission state so the user can still navigate to system Settings.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 HighagentActivity/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 effectSchema.Struct behavior) and recompute the HMAC over the reduced payload, so verifySignedApnsDeliveryJob returns ApnsDeliveryJobSignatureInvalid for every newly queued job carrying per-device routing. This breaks APNs delivery during rolling deploys when new producers share a queue with old consumers. Consider gating inclusion of these fields behind a flag, or otherwise ensuring the signed payload shape remains decodable by older schema versions, so signatures stay stable across the rollout.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/apnsDeliveryJobs.ts around line 247:
`makeApnsDeliveryJobPayload` includes `target.bundleId` and `target.apsEnvironment` in the payload, and `signatureForPayload` signs that full object. Older relay workers that decode with a previous `ApnsDeliveryJobPayload` schema strip the new keys on decode (default `effect` `Schema.Struct` behavior) and recompute the HMAC over the reduced payload, so `verifySignedApnsDeliveryJob` returns `ApnsDeliveryJobSignatureInvalid` for every newly queued job carrying per-device routing. This breaks APNs delivery during rolling deploys when new producers share a queue with old consumers. Consider gating inclusion of these fields behind a flag, or otherwise ensuring the signed payload shape remains decodable by older schema versions, so signatures stay stable across the rollout.

@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from 5225bdb to 5086c14CompareJuly 6, 2026 16:13

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium

infoPlist: {
NSAppTransportSecurity: {

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

 infoPlist: {
+ NSSupportsLiveActivitiesFrequentUpdates: true,
NSAppTransportSecurity: {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/app.config.ts around lines 96-97:
The `frequentUpdates: true` option under the `expo-widgets` plugin does not add the required `NSSupportsLiveActivitiesFrequentUpdates` key to `Info.plist`, so iOS keeps the standard Live Activity update budget and frequent updates are never enabled. Set `NSSupportsLiveActivitiesFrequentUpdates: true` in the `ios.infoPlist` block to actually grant the entitlement.

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

export interface AgentAwarenessRegistrationRecord {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumlib/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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highsettings/SettingsRouteScreen.tsx:441

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/settings/SettingsRouteScreen.tsx around line 441:
When `deviceRegistered` is `false` but `liveActivitiesEnabled` is `true` in storage, the `Live Activity Updates` switch renders off even though `liveActivityStatus` is `"enabled"`. Because `onValueChange` receives the next visual state (`true`), tapping the switch calls `linkEnvironments()` (the enable path) instead of persisting `enabled: false`, so the user cannot disable Live Activity updates from Settings until registration succeeds. Consider gating the disabled state or the alert on `deviceRegistered` rather than the switch `value`, so the toggle still reflects the saved preference and `onValueChange` fires with the correct next state.

Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

Autofix Details

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

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

Create PR

Or push these changes by commenting:

@cursor push 59517fdb7d
Preview (59517fdb7d)
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts@@ -186,7 +186,11 @@
refreshActiveLiveActivityRemoteRegistration(),
"active live activity registration after cloud sign-in failed",
);
- if (isExistingIdentity) {+ // An unchanged identity only skips re-registration when the previous attempt+ // actually reached the relay; a failed or stalled attempt must retry the next+ // time the auth effect re-runs, or the device stays unregistered until an+ // unrelated event (token rotation, environment link) happens to enqueue one.+ if (isExistingIdentity && registrationStatus === "registered") {
return;
}
enqueueDeviceRegistration({}, "device registration after cloud sign-in failed");
diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts--- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts+++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts@@ -119,6 +119,16 @@
terminalState: null,
nowMs: now.epochMilliseconds,
});
+ // A null aggregate is ambiguous while non-terminal rows still exist:+ // they may belong to a dead environment, or to a healthy long-running+ // agent whose meaningful state (and therefore updatedAt) has not changed+ // within the TTL. Sending in that case would end a possibly-healthy Live+ // Activity on every foreground, so replay only delivers when live rows+ // remain or the rows are truly gone (the thread ended and its end push+ // may have been lost).+ if (aggregate === null && activeStates.some((state) => !isTerminalPhase(state))) {+ return null;+ }
return yield* apnsDeliveries.sendForTarget({
target,
aggregate,

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

Comment threadinfra/relay/src/agentActivity/AgentActivityPublisher.ts
@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from 5086c14 to a31735cCompareJuly 6, 2026 17:03
Comment threadapps/mobile/src/features/settings/SettingsRouteScreen.tsx
Comment threadapps/server/src/cli/connect.ts

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

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

Autofix Details

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

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

Create PR

Or push these changes by commenting:

@cursor push d469c8569a
Preview (d469c8569a)
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts@@ -535,6 +535,48 @@
}).pipe(Effect.provide(relayTestLayer));
});
+ it.effect("keeps the registered status when a stale concurrent attempt fails", () => {+ const fetchMock = vi.fn((request: RequestInfo | URL) => {+ const url = request instanceof Request ? request.url : String(request);+ return Promise.resolve(+ Response.json(+ url.endsWith("/v1/client/dpop-token")+ ? {+ access_token: "relay-dpop-token",+ issued_token_type: "urn:ietf:params:oauth:token-type:access_token",+ token_type: "DPoP",+ expires_in: 300,+ scope: "mobile:registration",+ }+ : { ok: true },+ ),+ );+ });+ vi.stubGlobal("fetch", fetchMock);+ Constants.expoConfig!.extra = {+ relay: {+ url: "https://relay.example.test/",+ },+ };++ // Sign-in enqueues a registration attempt that stays parked in the+ // background queue while the direct refresh below runs.+ setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));++ return Effect.gen(function* () {+ yield* refreshAgentAwarenessRegistration();+ expect(getAgentAwarenessRegistrationStatus()).toBe("registered");++ // The queued sign-in attempt now fails; its stale failure must not+ // overwrite the registration the relay already accepted.+ vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockRejectedValueOnce(+ new Error("device id unavailable"),+ );+ yield* runBackgroundOperations();+ expect(getAgentAwarenessRegistrationStatus()).toBe("registered");+ }).pipe(Effect.provide(relayTestLayer));+ });+
it("clears registration status on cloud sign-out", () => {
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
setAgentAwarenessRelayTokenProvider(null);
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts@@ -74,6 +74,13 @@
let registrationStatus: AgentAwarenessRegistrationStatus = "unknown";
const registrationStatusListeners = new Set<() => void>();
+// Registration runs both through the background queue and directly via+// refreshAgentAwarenessRegistration, so attempts can overlap. Counting relay+// acceptances lets a failing attempt detect that a concurrent attempt already+// succeeded while it was in flight, so a stale failure cannot overwrite a+// registration the relay accepted.+let registrationSuccessCount = 0;+
function setRegistrationStatus(next: AgentAwarenessRegistrationStatus): void {
if (registrationStatus === next) {
return;
@@ -84,6 +91,18 @@
}
}
+function markRegistrationSucceeded(): void {+ registrationSuccessCount++;+ setRegistrationStatus("registered");+}++function markRegistrationFailed(successCountAtAttemptStart: number): void {+ if (registrationSuccessCount !== successCountAtAttemptStart) {+ return;+ }+ setRegistrationStatus("failed");+}+
export function getAgentAwarenessRegistrationStatus(): AgentAwarenessRegistrationStatus {
return registrationStatus;
}
@@ -316,7 +335,7 @@
catch: (cause) => cause,
}).pipe(Effect.orElseSucceed(() => null));
if (persisted && persisted.identity === identity && persisted.signature === signature) {
- setRegistrationStatus("registered");+ markRegistrationSucceeded();
logRegistrationDebug("relay device registration skipped; already registered for account", {
expectedGeneration,
});
@@ -331,7 +350,7 @@
clerkToken: token,
payload: body,
});
- setRegistrationStatus("registered");+ markRegistrationSucceeded();
yield* Effect.promise(() =>
saveAgentAwarenessRegistrationRecord({ identity, signature }).catch((error: unknown) => {
logRegistrationError("persist registration record failed", error);
@@ -466,11 +485,12 @@
};
activeDeviceRegistration = registration;
registration.operation = (async () => {
+ const successCountAtStart = registrationSuccessCount;
const result = await settleAsyncResult(() =>
runtime.runPromiseExit(registerDevice(next.input, generation)),
);
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
- setRegistrationStatus("failed");+ markRegistrationFailed(successCountAtStart);
logRegistrationError(next.context, squashAtomCommandFailure(result));
}
logRegistrationDebug("device registration finished", { generation });
@@ -675,14 +695,17 @@
never,
ManagedRelay.ManagedRelayClient
> {
- return registerDeviceForCurrentUser().pipe(- Effect.catch((error) =>- Effect.sync(() => {- setRegistrationStatus("failed");- logRegistrationError("device registration refresh failed", error);- }),- ),- );+ return Effect.suspend(() => {+ const successCountAtStart = registrationSuccessCount;+ return registerDeviceForCurrentUser().pipe(+ Effect.catch((error) =>+ Effect.sync(() => {+ markRegistrationFailed(successCountAtStart);+ logRegistrationError("device registration refresh failed", error);+ }),+ ),+ );+ });
}
export function __resetAgentAwarenessRemoteRegistrationForTest(): void {
@@ -703,6 +726,7 @@
activeDeviceRegistration = null;
pendingDeviceRegistration = null;
registrationStatus = "unknown";
+ registrationSuccessCount = 0;
registrationStatusListeners.clear();
}

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

Comment threadapps/mobile/src/features/agent-awareness/remoteRegistration.ts Outdated
- Per-device APNs routing (bundle id + environment) so preview/dev builds
receive pushes from the shared relay (+ migration for the new columns)
- Settings notification/Live Activity toggles reflect actual relay
registration success; cannot read as enabled when the device is not registered
- Persistent register-once: skip re-registering an unchanged payload for the
same account across launches; clear only on sign-out
- Headless publish + tunnel-free linking: `t3 connect publish` toggles agent
activity publishing and auto-establishes a publish-only (no managed tunnel)
link, and `t3 connect link --publish-only` links for publishing alone, so
activity flows to mobile clients that reach the environment out of band
(e.g. Tailscale) without T3 Connect. Relay accepts publish-only links with a
nominal endpoint; env proofs advertise the manual provider kind
- Foreground Live Activity refresh, stale/ghost-row hardening, dedup fixes
- Tighten widget deep linking and per-row rendering for active agents
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from a31735c to 9c3eb7bCompareJuly 6, 2026 17:24
// Development builds are Xcode-signed and receive sandbox APNs tokens;
// preview and production builds are distribution-signed and use production
// APNs. The relay routes each device's pushes accordingly.
export function resolveApsEnvironment(appVariant: unknown): "sandbox" | "production" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumagent-awareness/registrationPayload.ts:8

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/agent-awareness/registrationPayload.ts around line 8:
`resolveApsEnvironment` returns `"production"` for every value except the literal string `"development"`. Locally run `ios:preview` and `ios:prod` builds launched via `expo run:ios` are development-signed and receive sandbox APNs tokens, but this function classifies them as `"production"`. The relay then routes pushes to the production APNs gateway for a sandbox token, so notifications and Live Activities fail to deliver for those builds. Consider keying off the signing/provisioning environment (e.g. EAS build profile or `Configuration`/ entitlements) rather than the variant name, or document why locally-run `preview`/`prod` builds are expected to use production APNs.

Comment threadapps/server/src/cli/connect.ts
Comment threadpackages/contracts/src/environmentHttp.ts Outdated
Comment on lines 1745 to 1750
primaryCloudLinkState.refresh();
const refreshResult = await refreshRelayEnvironments();
if (refreshResult._tag === "Failure") {
if (!isAtomCommandInterrupted(refreshResult)) {
reportUpdateFailure(squashAtomCommandFailure(refreshResult));
}
setIsUpdating(false);
return;
if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) {
reportUpdateFailure(squashAtomCommandFailure(refreshResult));
return false;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsettings/ConnectionsSettings.tsx:1745

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

 primaryCloudLinkState.refresh();
+ return true;
const refreshResult = await refreshRelayEnvironments();
if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) {
reportUpdateFailure(squashAtomCommandFailure(refreshResult));
return false;
}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/settings/ConnectionsSettings.tsx around lines 1745-1750:
When `reconcileCloudState` completes a link/unlink and preference update successfully, the mutation is already committed and `primaryCloudLinkState.refresh()` has been called. If the follow-up `refreshRelayEnvironments()` call at line 1747 then fails, the function returns `false`, shows an error toast, and suppresses the success toast — so the user is told their T3 Connect change failed even though it was actually applied. Consider returning `true` before the relay refresh (or not reporting its failure as a mutation failure), so transient relay-discovery errors don't mask a successful mutation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highcloud/http.ts:369

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

 if (
!isSupportedLinkProviderKind(request) ||
(request.endpoint.providerKind === "cloudflare_tunnel" &&
!isAllowedEndpointOrigin({
origin: request.origin,
requestUrl,
}))
) {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/cloud/http.ts around lines 369-375:
`makeCloudLinkProof` calls `isAllowedEndpointOrigin` for every link proof, including manual `providerKind` requests. That helper only permits loopback hosts, so a manual or publish-only link from a non-loopback origin (e.g. Tailscale or directly exposed host) is rejected with `Invalid managed endpoint origin.`, blocking the out-of-band endpoint flow that `isSupportedLinkProviderKind` was added to support. Consider skipping the loopback origin check when `providerKind === "manual"`.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumwidgets/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`.

Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

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

  • ✅ Fixed: Grace blocks legitimate activity end
    • LiveActivities.register no longer resets remote_started_at when the same activity token is re-registered (foreground reconciliation), so the freshly-armed grace window measures from the true arming time and orphaned cards receive their live_activity_end once it expires.

Create PR

Or push these changes by commenting:

@cursor push 5ab40bb88e
Preview (5ab40bb88e)
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@@ -109,10 +109,12 @@
remoteStartedAt: null,
}),
]);
+ // The claim skips this device's own row: it must stay intact so the+ // upsert can tell a same-token re-registration apart from a fresh arm.
expect(updateConditions.map((condition) => dialect.sqlToQuery(condition))).toEqual([
{
- sql: '"relay_live_activities"."activity_push_token" = $1',- params: ["activity-push-token"],+ sql: '(("relay_live_activities"."activity_push_token" = $1) and ((("relay_live_activities"."user_id" <> $2) or ("relay_live_activities"."device_id" <> $3))))',+ params: ["activity-push-token", "user-2", "device-1"],
},
]);
expect(insertedValues).toEqual([
@@ -131,12 +133,20 @@
expect.objectContaining({
activityPushToken: "activity-push-token",
remoteStartQueuedAt: null,
- remoteStartedAt: expect.any(String),
endedAt: null,
lastAggregateJson: null,
lastLiveActivityDeliveryAt: null,
}),
);
+ // Re-registering the SAME token (foreground reconciliation) must not+ // restart the arming clock, or the end-on-empty grace window would+ // renew forever and orphaned cards would never receive their end.+ const remoteStartedAtSet = dialect.sqlToQuery(+ conflictConfigs[0]?.set?.remoteStartedAt as SQL,+ );+ expect(remoteStartedAtSet.sql.replace(/\s+/g, " ")).toBe(+ 'case when "relay_live_activities"."activity_push_token" = excluded.activity_push_token then coalesce("relay_live_activities"."remote_started_at", excluded.remote_started_at) else excluded.remote_started_at end',+ );
}).pipe(
Effect.provide(
LiveActivities.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))),
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@@ -13,7 +13,7 @@
import * as Function from "effect/Function";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
-import { and, eq, sql } from "drizzle-orm";+import { and, eq, ne, or, sql } from "drizzle-orm";
import * as RelayDb from "../db.ts";
import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts";
@@ -141,6 +141,10 @@
const updatedAt = DateTime.formatIso(yield* DateTime.now);
const registration = input.registration;
+ // Claim the token from any OTHER row that still holds it (a token+ // addresses exactly one activity). The device's own row is left+ // untouched so the upsert below can tell a same-token re-registration+ // apart from a fresh arm.
yield* db
.update(relayLiveActivities)
.set({
@@ -150,7 +154,15 @@
endedAt: updatedAt,
updatedAt,
})
- .where(eq(relayLiveActivities.activityPushToken, registration.activityPushToken));+ .where(+ and(+ eq(relayLiveActivities.activityPushToken, registration.activityPushToken),+ or(+ ne(relayLiveActivities.userId, input.userId),+ ne(relayLiveActivities.deviceId, registration.deviceId),+ ),+ ),+ );
yield* db
.insert(relayLiveActivities)
@@ -171,7 +183,17 @@
set: {
activityPushToken: registration.activityPushToken,
remoteStartQueuedAt: null,
- remoteStartedAt: updatedAt,+ // remote_started_at records when the CARD was armed, and the+ // end-on-empty grace window keys off it. Foreground+ // reconciliation re-registers the same token periodically;+ // refreshing the arming time there would perpetually renew the+ // grace and suppress the end an orphaned card is waiting for.+ // Only a new token (a new activity) restarts the clock.+ remoteStartedAt: sql`case+ when ${relayLiveActivities.activityPushToken} = excluded.activity_push_token+ then coalesce(${relayLiveActivities.remoteStartedAt}, excluded.remote_started_at)+ else excluded.remote_started_at+ end`,
endedAt: null,
lastAggregateJson: null,
lastLiveActivityDeliveryAt: null,

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

Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts
The stuck-at-Working card: the thread's publish stream showed running →
deleted with no completed publish in between, and the tombstone debounce
correctly confirmed the null five seconds later — the post-completion
state resolves to no phase PERSISTENTLY. Session teardown settles
still-running turns by session status, and that write can race
turn.completed, leaving latestTurn.state at "interrupted" for a turn
that actually finished. The awareness ladder mapped interrupted turns to
null, so quick finish-then-teardown threads were tombstoned instead of
published as completed: the row vanished, the empty aggregate fell into
the freshly-armed grace window, and the card froze on its last state
with no further publish to correct it.
completedAt survives the state-column race: a turn with a completion
timestamp finished, whatever the settling wrote. The ladder now projects
those as completed — the tombstone confirm publishes Done instead of
deleting the thread — while genuinely interrupted turns (no completedAt)
still resolve to null.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
// that has a completion timestamp finished, whatever the state column says.
// Without this, quick finish-then-teardown threads resolve to null
// persistently and get tombstoned instead of published as completed.
if (thread.latestTurn?.state === "interrupted" && thread.latestTurn.completedAt !== null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsrc/agentAwareness.ts:112

The new fallback at line 112 misclassifies cancelled runs as completed. Turns settled via cancellation paths (interrupt-requested or session ended with interrupted/stopped status) also get state: "interrupted" with a non-null completedAt, so the condition thread.latestTurn?.state === "interrupted" && thread.latestTurn.completedAt !== null matches them too. This causes resolveThreadAwarenessPhase to return "completed" for runs the user actually cancelled, so users see stopped runs as successfully finished. If the intent is only to cover the teardown race where a completed turn's state is overwritten by interrupted, the guard needs to distinguish that case from genuine cancellations — for example, by checking the session status or a separate completion marker. Otherwise, consider documenting why interrupted turns with a completedAt should be treated as completed.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/shared/src/agentAwareness.ts around line 112:
The new fallback at line 112 misclassifies cancelled runs as `completed`. Turns settled via cancellation paths (interrupt-requested or session ended with `interrupted`/`stopped` status) also get `state: "interrupted"` with a non-null `completedAt`, so the condition `thread.latestTurn?.state === "interrupted" && thread.latestTurn.completedAt !== null` matches them too. This causes `resolveThreadAwarenessPhase` to return `"completed"` for runs the user actually cancelled, so users see stopped runs as successfully finished. If the intent is only to cover the teardown race where a `completed` turn's state is overwritten by `interrupted`, the guard needs to distinguish that case from genuine cancellations — for example, by checking the session status or a separate completion marker. Otherwise, consider documenting why interrupted turns with a `completedAt` should be treated as completed.

Comment threadpackages/shared/src/agentAwareness.ts
The stuck-at-Working repro survives the completedAt ladder fix: the
confirmed tombstone five seconds after turn completion still resolves
null, so the settled shell holds some combination the static analysis
keeps missing. Instead of guessing another layer deep, the tombstone
paths now log the exact fields the phase ladder reads (session status,
latest turn id/state/completedAt, pendings) both when deferring and when
a confirmation actually publishes — one repro pins the writer.
Also collapses the deferral storm visible at thread birth (six confirm
fibers forked in 600ms): one pending confirmation per thread.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

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

  • ✅ Fixed: Pending confirm set can leak
    • Wrapped the check-add-fork sequence in a single uninterruptible region so a pendingTombstoneConfirms entry can only exist once the detached confirm fiber (whose ensuring finalizer owns the delete) is guaranteed to have been forked.

Create PR

Or push these changes by commenting:

@cursor push abb02eb729
Preview (abb02eb729)
diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts--- a/apps/server/src/relay/AgentAwarenessRelay.ts+++ b/apps/server/src/relay/AgentAwarenessRelay.ts@@ -432,20 +432,30 @@
// immediately deletes the thread from the lock-screen card mid-
// conversation. Defer, re-resolve, and only tombstone if it holds;
// genuinely deleted threads still propagate a few seconds later.
- if (pendingTombstoneConfirms.has(threadId)) {- return;- }- pendingTombstoneConfirms.add(threadId);- yield* Effect.logInfo("agent activity tombstone deferred pending confirmation", {- environmentId,- threadId,- reason: snapshot.reason,- shell: describeThreadShellForAwareness(thread),- });- yield* Effect.forkDetach(- confirmTombstoneLater(threadId).pipe(- Effect.ensuring(Effect.sync(() => pendingTombstoneConfirms.delete(threadId))),- ),+ // Check-add-fork must be atomic under interruption: a set entry without+ // a forked confirm fiber (whose `ensuring` owns the delete) would+ // suppress tombstone confirmation for this thread forever.+ yield* Effect.uninterruptible(+ Effect.suspend(() => {+ if (pendingTombstoneConfirms.has(threadId)) {+ return Effect.void;+ }+ pendingTombstoneConfirms.add(threadId);+ return Effect.logInfo("agent activity tombstone deferred pending confirmation", {+ environmentId,+ threadId,+ reason: snapshot.reason,+ shell: describeThreadShellForAwareness(thread),+ }).pipe(+ Effect.andThen(+ Effect.forkDetach(+ confirmTombstoneLater(threadId).pipe(+ Effect.ensuring(Effect.sync(() => pendingTombstoneConfirms.delete(threadId))),+ ),+ ),+ ),+ );+ }),
);
return;
}

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

Comment threadapps/server/src/relay/AgentAwarenessRelay.ts Outdated
Diagnostics from the stuck-at-Working repro finally exposed the data
hole: the confirmed tombstone's shell showed sessionStatus "ready" with
latestTurn entirely absent. Threads whose turns produce no checkpoint
never get a projection_turns row, and thread.session-set clears
latest_turn_id the moment the session settles (activeTurnId goes null) —
so "completed" is unrepresentable through turns for quick Q&A threads.
Their entire lifecycle rode on session status and pendings, and at
completion nothing remained on the awareness ladder: tombstone instead
of Done.
A live session at "ready"/"idle" with nothing pending and nothing
running now projects as completed. This intentionally diverges from the
web sidebar, which resolves the same null but renders it as "no pill" —
a fine default in a list, while the publisher's null means "remove from
the lock-screen card". (The sidebar's own Completed pill needs
latestTurn.completedAt and silently can't fire for these threads either;
persisting turn rows for checkpoint-less turns is the deeper future fix
for both surfaces.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment threadpackages/shared/src/agentAwareness.ts
The full lifecycle finally works end to end, with one wart: a duplicate
Done push notification fired one second after thread creation. Sessions
boot at "ready" before their first turn starts, so the new
ready-means-completed projection produced a momentary completed state at
birth — and since the freshly armed card's token wasn't registered yet,
it fell through to the notification channel. Server restarts had the
same shape at scale: the startup snapshot republished completed for
every recently finished thread, each queuing a push notification.
- Env server: completed as a thread's FIRST published state defers and
re-resolves like a tombstone — at birth the confirm sees running and
completed never publishes; genuinely at-rest threads still publish
five seconds later. Fresh completions (previous state was live) are
unaffected, keeping the buzz immediate.
- Relay: terminal push notifications only fire when the completion is
fresh (row updated within two minutes), so replays and restart
snapshots repaint state without ringing the device.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
if (!activity) {
return null;
}
if (activity.phase === "completed" || activity.phase === "failed") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 MediumagentActivity/ApnsDeliveries.ts:321

notificationForAggregate drops terminal notifications for push-notification-only users when activity.updatedAt is more than two minutes old. activity.updatedAt is the timestamp from the published aggregate state, not the time the notification was first delivered to this device. If relay publishing or replay is delayed by more than two minutes (e.g., after downtime or a late reconnect), the first completed/failed push notification for a notifications-only user is silently suppressed even though it was never delivered before. Consider gating the freshness check on the device's last-known delivery time (e.g., last_push_notification_delivery_at) rather than activity.updatedAt, so only devices that already received a recent terminal notification are suppressed.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/ApnsDeliveries.ts around line 321:
`notificationForAggregate` drops terminal notifications for push-notification-only users when `activity.updatedAt` is more than two minutes old. `activity.updatedAt` is the timestamp from the published aggregate state, not the time the notification was first delivered to this device. If relay publishing or replay is delayed by more than two minutes (e.g., after downtime or a late reconnect), the first `completed`/`failed` push notification for a notifications-only user is silently suppressed even though it was never delivered before. Consider gating the freshness check on the device's last-known delivery time (e.g., `last_push_notification_delivery_at`) rather than `activity.updatedAt`, so only devices that already received a recent terminal notification are suppressed.

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

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

  • ✅ Fixed: LA alerts skip terminal freshness
    • Threaded nowMs into alertForNewlyTerminal and alertForTerminalAggregate and applied the shared TERMINAL_NOTIFICATION_FRESHNESS_MS window via a new isFreshTerminalRow helper, so stale replayed completions no longer alert through Live Activity updates or end events.

Create PR

Or push these changes by commenting:

@cursor push c88cec1a49
Preview (c88cec1a49)
diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts--- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts+++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts@@ -1525,15 +1525,30 @@
activities: [{ ...aggregate.activities[0]!, phase: "completed" as const, status: "Done" }],
};
expect(
- ApnsDeliveries.alertForTerminalAggregate({ aggregate: terminalAggregate, preferences }),+ ApnsDeliveries.alertForTerminalAggregate({+ aggregate: terminalAggregate,+ preferences,+ nowMs: 0,+ }),
).toEqual({ title: "Thread", body: "Done: Project" });
expect(
ApnsDeliveries.alertForTerminalAggregate({
aggregate: terminalAggregate,
preferences: { ...preferences, notifyOnCompletion: false },
+ nowMs: 0,
}),
).toBeNull();
- expect(ApnsDeliveries.alertForTerminalAggregate({ aggregate: null, preferences })).toBeNull();+ expect(+ ApnsDeliveries.alertForTerminalAggregate({ aggregate: null, preferences, nowMs: 0 }),+ ).toBeNull();+ // A completion replayed long after the fact must not ring again.+ expect(+ ApnsDeliveries.alertForTerminalAggregate({+ aggregate: terminalAggregate,+ preferences,+ nowMs: 10 * 60 * 1_000,+ }),+ ).toBeNull();
});
it("alerts when a previously active thread finishes mid-flight", () => {
@@ -1552,6 +1567,7 @@
previousAggregate: aggregate,
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toEqual({ title: "Thread", body: "Done: Project" });
// The completion switch mutes it.
@@ -1560,6 +1576,7 @@
previousAggregate: aggregate,
nextAggregate: next,
preferences: { ...preferences, notifyOnCompletion: false },
+ nowMs: 0,
}),
).toBeNull();
// No baseline means no transition to ring on.
@@ -1568,6 +1585,7 @@
previousAggregate: null,
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toBeNull();
// A Done row that was already terminal (or absent) before stays silent.
@@ -1576,6 +1594,7 @@
previousAggregate: next,
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toBeNull();
expect(
@@ -1583,7 +1602,18 @@
previousAggregate: { ...aggregate, activities: [attentionRow] },
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toBeNull();
+ // A stale completion replayed after a restart (previous aggregate still+ // shows the thread as running) must not ring the device again.+ expect(+ ApnsDeliveries.alertForNewlyTerminal({+ previousAggregate: aggregate,+ nextAggregate: next,+ preferences,+ nowMs: 10 * 60 * 1_000,+ }),+ ).toBeNull();
});
});
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@@ -210,6 +210,20 @@
};
}
+// Completions replayed long after the fact (server restarts republish every+// recently-finished thread) must not ring the device again — on any channel:+// push notifications, Live Activity update alerts, and end alerts all honor+// the same freshness window.+const TERMINAL_NOTIFICATION_FRESHNESS_MS = 2 * 60 * 1_000;++function isFreshTerminalRow(row: { readonly updatedAt: string }, nowMs: number): boolean {+ const updatedAtMs = Option.match(DateTime.make(row.updatedAt), {+ onNone: () => null,+ onSome: (dt) => dt.epochMilliseconds,+ });+ return updatedAtMs !== null && nowMs - updatedAtMs <= TERMINAL_NOTIFICATION_FRESHNESS_MS;+}+
// Alert copy for an update whose aggregate contains threads that finished
// (Done/Failed) since the previously delivered aggregate — the mid-flight
// completion buzz while other agents keep the activity alive. Requires the
@@ -219,6 +233,7 @@
readonly previousAggregate: RelayAgentActivityAggregateState | null;
readonly nextAggregate: RelayAgentActivityAggregateState;
readonly preferences: RelayAgentAwarenessPreferences | null;
+ readonly nowMs: number;
}): ApnsLiveActivityAlert | null {
if (input.previousAggregate === null) {
return null;
@@ -230,6 +245,9 @@
if (row.phase !== "completed" && row.phase !== "failed") {
return false;
}
+ if (!isFreshTerminalRow(row, input.nowMs)) {+ return false;+ }
const previousPhase = previousPhases.get(row.threadId);
return (
previousPhase !== undefined &&
@@ -255,11 +273,15 @@
export function alertForTerminalAggregate(input: {
readonly aggregate: RelayAgentActivityAggregateState | null;
readonly preferences: RelayAgentAwarenessPreferences | null;
+ readonly nowMs: number;
}): ApnsLiveActivityAlert | null {
const row = input.aggregate?.activities[0];
if (!row || (row.phase !== "completed" && row.phase !== "failed")) {
return null;
}
+ if (!isFreshTerminalRow(row, input.nowMs)) {+ return null;+ }
if (!alertAllowedForPhase(input.preferences, row.phase)) {
return null;
}
@@ -298,10 +320,6 @@
);
}
-// Completions replayed long after the fact (server restarts republish every-// recently-finished thread) must not ring the device again.-const TERMINAL_NOTIFICATION_FRESHNESS_MS = 2 * 60 * 1_000;-
function notificationForAggregate(input: {
readonly target: LiveActivities.TargetRow;
readonly aggregate: RelayAgentActivityAggregateState | null;
@@ -318,14 +336,11 @@
if (!activity) {
return null;
}
- if (activity.phase === "completed" || activity.phase === "failed") {- const updatedAtMs = Option.match(DateTime.make(activity.updatedAt), {- onNone: () => null,- onSome: (dt) => dt.epochMilliseconds,- });- if (updatedAtMs === null || input.nowMs - updatedAtMs > TERMINAL_NOTIFICATION_FRESHNESS_MS) {- return null;- }+ if (+ (activity.phase === "completed" || activity.phase === "failed") &&+ !isFreshTerminalRow(activity, input.nowMs)+ ) {+ return null;
}
const enabled =
(activity.phase === "waiting_for_approval" && preferences.notifyOnApproval) ||
@@ -419,6 +434,7 @@
previousAggregate,
nextAggregate,
preferences,
+ nowMs: input.nowMs,
}),
}
: "suppressed";
@@ -1100,6 +1116,7 @@
: alertForTerminalAggregate({
aggregate: delivery.aggregate,
preferences: parsePreferences(input.target.preferences_json),
+ nowMs: input.nowMs,
})
: delivery.alert;
const result = yield* deliveryQueue.enqueueLiveActivity({

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

Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts
…A pref gates
Fixes the real findings from the PR review bots ahead of the TestFlight
build:
- Deferred publish confirmations (tombstones and first-state
completions) now re-enqueue through the same drainable worker as every
other publish instead of a detached fiber calling the publisher
directly, so a confirmed tombstone can never race an in-flight live
update; a deadline map replaces the pending set, which also guarantees
a single scheduled confirmation per thread and clears when the state
recovers. (cursor/macroscope High)
- Newly-terminal transitions bypass the 15s live-activity update
throttle: a completion landing in the same window as a new start keeps
activeCount unchanged and would previously be suppressed, silently
dropping the Done transition and its buzz. (macroscope High)
- The newly-terminal alert honors the same 2-minute freshness rule as
terminal push notifications, so replayed aggregates repaint without
ringing. (cursor Medium)
- Live Activity arming and priming treat an unset preference as enabled
(the toggle's default): fresh installs now prime, and arm-on-send
respects an explicit off. (cursor High/Medium)
Remaining findings triaged as stale (superseded designs), intended
behavior (replay painting recent Done, LA-disabled end retiring the
token), or accepted edge cases (grace extension on re-registration,
completion alerts beyond the 3-row display cap, ready-with-lastError
mapping to Done, cancelled turns showing Done).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

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

  • ✅ Fixed: Stale confirm deadline publishes tombstone
    • Cleared the thread's publishConfirmDeadlines entry in the unchanged-identity early-return path so a recovered projection cancels the deferred confirmation, preventing a later transient null state from confirming immediately against a stale expired deadline.

Create PR

Or push these changes by commenting:

@cursor push e02169b78e
Preview (e02169b78e)
diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts--- a/apps/server/src/relay/AgentAwarenessRelay.ts+++ b/apps/server/src/relay/AgentAwarenessRelay.ts@@ -410,6 +410,11 @@
const publishIdentity = agentAwarenessPublishIdentity(snapshot.state);
const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef);
if (publishedStateByThread.get(threadId) === publishIdentity) {
+ // The projection recovered to the last published state, so any deferred+ // confirmation (tombstone or first-state completion) is moot. Clear the+ // deadline so the next divergence gets a fresh deferral window instead+ // of confirming immediately against a stale, expired deadline.+ publishConfirmDeadlines.delete(threadId);
yield* Effect.logDebug("agent activity publish skipped; projected state unchanged", {
environmentId,
threadId,

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

Reviewed by Cursor Bugbot for commit 97ed442. Configure here.

Comment threadapps/server/src/relay/AgentAwarenessRelay.ts
…d state
A recovered projection exits at the unchanged-identity dedupe, which sits
above the confirmation block, so a deferred publish's deadline survived
recovery. A much later transient null would then find the deadline
already expired and publish its tombstone immediately, bypassing the
deferral window entirely. The dedupe path now clears any pending
deadline: the state is back at what was last published, so the deferred
confirmation is moot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@juliusmarmingejuliusmarminge added the 🚀 Mobile Continuous Deployment Trigger Expo preview build label Jul 7, 2026
Fixes the Effect Service Conventions check: the service builds a plain
object with no effectful acquisition, so wrapping it in Effect.sync just
to feed Layer.effect was the make-only-to-force-Layer.effect shape the
conventions call out.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

ghost commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

🚀 Expo continuous deployment is ready!

  • Project → t3-code
  • Platforms → android, ios
  • Scheme → t3code-preview
🤖 Android🍎 iOS
Fingerprint106610ae982be2d8aa28dda9217ff853daf9d30b4f388ce49fd16563ef62f563f42c0528f092d42b
Build DetailsBuild Permalink
DetailsDistribution: INTERNAL
Build profile: preview:dev
Runtime version: 106610ae982be2d8aa28dda9217ff853daf9d30b
App version: 0.1.0
Git commit: 74888084257320d42e7562c74996c949010bdf45
Build Permalink
DetailsDistribution: INTERNAL
Build profile: preview:dev
Runtime version: 4f388ce49fd16563ef62f563f42c0528f092d42b
App version: 0.1.0
Git commit: c622444d589c97adb591101ffca9161d785806b0
Update DetailsUpdate Permalink
DetailsBranch: pr-3685
Runtime version: 106610ae982be2d8aa28dda9217ff853daf9d30b
Git commit: 74888084257320d42e7562c74996c949010bdf45
Update Permalink
DetailsBranch: pr-3685
Runtime version: 4f388ce49fd16563ef62f563f42c0528f092d42b
Git commit: 74888084257320d42e7562c74996c949010bdf45
Update QR

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

Labels

🚀 Mobile Continuous DeploymentTrigger Expo preview buildsize:XXL1,000+ changed lines (additions + deletions).vouch:trustedPR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@juliusmarminge
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Improve live activity routing and diagnostics - #3685

Merged
juliusmarminge merged 26 commits into
mainfrom
t3code/live-activities-improvements
Jul 7, 2026
Merged

Improve live activity routing and diagnostics#3685
juliusmarminge merged 26 commits into
mainfrom
t3code/live-activities-improvements

Conversation

@juliusmarminge

@juliusmarmingejuliusmarminge commented Jul 4, 2026

Copy link
Copy Markdown
Member

Summary

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

Testing

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

Note

High Risk
Touches relay registration, APNs routing, cloud link modes, and Live Activity lifecycle (including removing push-to-start); misconfiguration or partial deploy could break mobile notifications or mis-route pushes.

Overview
Improves mobile agent awareness end-to-end: relay device registration now sends bundle ID and sandbox vs production APNs routing, tracks whether the relay actually accepted registration (settings toggles and alerts follow that, not just iOS permission), skips duplicate registrations via a persisted signature, and re-registers Live Activity tokens on foreground with short-window deduping. Push-to-start registration is removed in favor of arming a card when the user sends a task (and priming from a relay activity snapshot when idle), plus cleanup on cloud sign-out and a releaseAgentAwarenessRelayTokenProvider path so auth UI remounts do not wipe registration or end activities.

Agent Activity Live Activity UI is reworked (attention-first rows, phase tints aligned with web, deep links, branded T3 mark via new Expo config plugins and actool shell phase). expo-widgets gains frequentUpdates for higher update budget.

T3 Connect vs publishing split: environments can link in publish_only mode (manual endpoint, no Cloudflare tunnel) via CLI (connect link --publish-only, connect publish) and web settings (separate toggles reconciled together). Link proofs accept manual providers with activity-only scopes; server publish path defers transient tombstones and first completed snapshots before publishing.

Relay DB adds bundle_id and aps_environment on mobile devices (migration in diff).

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

Note

Add per-device APNs routing, JWT token caching, and publish-only link mode to Live Activity delivery

  • Stores bundle_id and aps_environment per device in the relay and uses them to route APNs pushes to the correct topic and environment per device rather than using global config.
  • Introduces ApnsProviderTokens service that caches APNs JWTs quantized to 45-minute windows, preventing TooManyProviderTokenUpdates errors; uses deterministic ES256 signing via @noble/curves.
  • Overhauls Live Activity delivery: activities are no longer remotely started; ends are suppressed within a grace window after arming; attention-phase transitions and newly-terminal rows can now trigger alert payloads with haptics; stale terminal aggregates suppress re-alerting.
  • Adds a publish_only cloud link mode in the CLI (t3 connect publish) and web settings UI, allowing agent activity publishing without a managed Cloudflare tunnel; linking with this mode uses providerKind: "manual" and deprovisions any existing tunnel allocation.
  • Reworks the AgentActivity widget to sort rows attention-first, apply per-phase tints from the web palette, deep-link to the most relevant thread, show a branded logo, and display a phase glyph in minimal presentation.
  • Exposes relay registration status (unknown/pending/registered/failed) to the settings UI so notification and Live Activity toggles only appear enabled when the device is actually registered.
  • Risk: makeAggregateState now requires a nowMs parameter and statusForPhase('starting') returns 'Connecting' instead of 'Starting', which are breaking changes for callers.

Macroscope summarized 6f0b32d.

@coderabbitai

coderabbitaiBot commented Jul 4, 2026

Copy link
Copy Markdown

Important

Review skipped

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

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: fe216c9a-1cd9-460a-a80d-6d90748a00b5

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

Use the checkbox below for a quick retry:

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

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

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

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Autofix Details

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

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

Create PR

Or push these changes by commenting:

@cursor push a97e0b6881
Preview (a97e0b6881)
diff --git a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts--- a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts+++ b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts@@ -69,6 +69,28 @@
]);
});
+ it("treats a non-2xx delivery status without a reason as a failure", () => {+ const rows = formatDeviceDiagnosticsRows(+ makeDevice({+ bundleId: "com.t3tools.t3code.preview",+ apsEnvironment: "production",+ hasPushToken: true,+ hasPushToStartToken: true,+ hasLiveActivityToken: true,+ lastDeliveryAt: "2026-06-05T01:02:59.566Z",+ lastDeliveryKind: "live_activity_end",+ lastDeliveryStatus: 400,+ lastDeliveryError: null,+ }),+ );++ expect(rows[4]).toEqual({+ label: "Last Delivery",+ value: "Delivery failed (400)",+ tone: "warn",+ });+ });+
it("reports healthy registrations with a successful delivery", () => {
const rows = formatDeviceDiagnosticsRows(
makeDevice({
diff --git a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts--- a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts+++ b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts@@ -73,14 +73,21 @@
});
}
+ // APNs can fail with a non-2xx status without a reason body, so a null+ // error alone does not mean the delivery succeeded.+ const deliveryFailed =+ diagnostics.lastDeliveryError !== null ||+ (diagnostics.lastDeliveryStatus !== null &&+ (diagnostics.lastDeliveryStatus < 200 || diagnostics.lastDeliveryStatus >= 300));+
if (diagnostics.lastDeliveryAt === null) {
rows.push({ label: "Last Delivery", value: "None yet", tone: "muted" });
- } else if (diagnostics.lastDeliveryError !== null) {+ } else if (deliveryFailed) {
const status =
diagnostics.lastDeliveryStatus === null ? "" : ` (${diagnostics.lastDeliveryStatus})`;
rows.push({
label: "Last Delivery",
- value: `${diagnostics.lastDeliveryError}${status}`,+ value: `${diagnostics.lastDeliveryError ?? "Delivery failed"}${status}`,
tone: "warn",
});
} else {
diff --git a/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/migration.sql b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/migration.sql
new file mode 100644
--- /dev/null+++ b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/migration.sql@@ -1,0 +1,3 @@+ALTER TABLE "relay_mobile_devices" ADD COLUMN "bundle_id" varchar(255);+--> statement-breakpoint+ALTER TABLE "relay_mobile_devices" ADD COLUMN "aps_environment" varchar(16);diff --git a/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/snapshot.json b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/snapshot.json
new file mode 100644
--- /dev/null+++ b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/snapshot.json@@ -1,0 +1,1479 @@+{+ "dialect": "postgres",+ "id": "e00cc7df-a31e-4b8b-b258-d04e7db7758a",+ "prevIds": ["385d476b-d4f6-48a3-99e6-0a95af4ee4e4"],+ "version": "8",+ "ddl": [+ {+ "isRlsEnabled": false,+ "name": "relay_agent_activity_rows",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_delivery_attempts",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_dpop_proofs",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_environment_credentials",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_environment_links",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_live_activities",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_managed_endpoint_allocations",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_mobile_devices",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_public_key",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "thread_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "jsonb",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "state_json",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "updated_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "varchar(36)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "user_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "thread_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "device_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "kind",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "source_job_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(16)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "token_suffix",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "integer",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "apns_status",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "apns_reason",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(128)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "apns_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "transport_error",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(128)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "thumbprint",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "jti",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "integer",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "iat",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "expires_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "credential_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_public_key",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "credential_hash",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "revoked_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "updated_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "user_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": "'T3 Environment'",+ "generated": null,+ "identity": null,+ "name": "environment_label",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_public_key",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "endpoint_http_base_url",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "endpoint_ws_base_url",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(32)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "endpoint_provider_kind",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "boolean",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": "true",+ "generated": null,+ "identity": null,+ "name": "notifications_enabled",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "boolean",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": "true",+ "generated": null,+ "identity": null,+ "name": "live_activities_enabled",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "boolean",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": "false",+ "generated": null,+ "identity": null,+ "name": "managed_tunnels_enabled",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_by_device_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "revoked_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "updated_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "user_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "device_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "activity_push_token",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "remote_start_queued_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "remote_started_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "ended_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "jsonb",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,
... diff truncated: showing 800 of 1681 lines

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highpersistence/schema.ts:27

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

Also found in 1 other location(s)

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

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/persistence/schema.ts around line 27:
The new `bundle_id` and `aps_environment` columns are added to the `relay_mobile_devices` schema, but no SQL migration is included to create them. After deploy, the code in `Devices.ts` immediately inserts and selects these columns, so PostgreSQL rejects every device registration and list query with `column "bundle_id" does not exist` (and `aps_environment` likewise). Add the corresponding `ALTER TABLE relay_mobile_devices ADD COLUMN ...` migration under `infra/relay/migrations/` so the columns exist before the new code runs.
Also found in 1 other location(s):
- infra/relay/src/agentActivity/LiveActivities.ts:201 -- The new `relayMobileDevices.bundleId` / `relayMobileDevices.apsEnvironment` columns are referenced by `listTargets` (`bundle_id`, `aps_environment`) and other relay code, but this PR does not add a matching SQL migration for `relay_mobile_devices`. After deploying the code to an existing database, any `listTargets` query will start failing with `column relay_mobile_devices.bundle_id does not exist` (and likewise for `aps_environment`), breaking live-activity target lookup until the schema is manually patched.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium

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

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/ApnsDeliveries.ts around line 506:
The stale-job check in `processSignedJob` only compares the token via `isCurrentSignedJobToken`, so a queued job created before the device registered `bundleId`/`apsEnvironment` (or before those values changed) still passes the staleness guard when the token is unchanged. The job is then sent with the stale or missing routing metadata from the signed payload instead of the device's current values, causing APNs to reject the delivery with `BadDeviceToken`/`DeviceTokenNotForTopic` even though the registration has already been corrected. Consider comparing `bundleId` and `apsEnvironment` against the current target in the staleness check so jobs with outdated routing are treated as stale.

@macroscopeapp

macroscopeappBot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

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

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumwidgets/AgentActivity.tsx:81

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/widgets/AgentActivity.tsx around line 81:
`widgetURL` is hardcoded to the `t3code://` scheme, but the app registers `t3code-dev` for development builds and `t3code-preview` for preview builds. On dev/preview builds the widget banner's tap URL uses a scheme the containing app does not register, so iOS will not open the app when the banner is tapped. Consider using a scheme that is registered in all build variants (or injecting the active scheme per-build).

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 MediumagentActivity/AgentActivityPublisher.ts:203

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/AgentActivityPublisher.ts around line 203:
`RUNNING_AGENT_ACTIVITY_ROW_TTL_MS` expires `running`/`starting` states after 2 hours based on `updatedAt`, but relay publishes are event-driven — a thread that stays in the same `running` phase for over 2 hours without a new event keeps its old `updatedAt`, so `isExpiredAgentActivityState` returns `true` and `makeAggregateState` filters it out. The aggregate then reports `activeCount: 0` (or `null`/terminal) even though the job is still running, hiding active work from clients. Consider raising the running TTL to match the waiting TTL, or filtering on a heartbeat/last-seen timestamp that is refreshed independently of phase-change events.

Comment threadapps/mobile/src/widgets/AgentActivity.tsx
Comment threadinfra/relay/src/agentActivity/Devices.ts Outdated
Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts
@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from 1d6309e to 39d9dcaCompareJuly 6, 2026 06:07
@github-actionsgithub-actionsBot added size:L 100-499 changed lines (additions + deletions). and removed size:XL 500-999 changed lines (additions + deletions). labels Jul 6, 2026
@cursor

This comment has been minimized.

@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from 39d9dca to 5225bdbCompareJuly 6, 2026 07:37
@github-actionsgithub-actionsBot added size:XXL 1,000+ changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Jul 6, 2026

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

Autofix Details

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

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

Create PR

Or push these changes by commenting:

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

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsettings/SettingsRouteScreen.tsx:438

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/settings/SettingsRouteScreen.tsx around line 438:
The `deviceRegistered` gate on the `Device Notifications` switch renders it `false` when relay registration fails, even though `notificationStatus` is `"enabled"`. Because the switch shows off, the user cannot toggle it to reach `handleDeviceNotificationsChange(false)`, which is the only path that opens iOS Settings to disable notifications. In the "permission granted, relay registration failed" case the switch misreports notifications as off and removes the in-app affordance for managing them. Consider gating only the Live Activity switch on `deviceRegistered`, or keeping the `Device Notifications` switch reflecting the actual iOS permission state so the user can still navigate to system Settings.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 HighagentActivity/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 effectSchema.Struct behavior) and recompute the HMAC over the reduced payload, so verifySignedApnsDeliveryJob returns ApnsDeliveryJobSignatureInvalid for every newly queued job carrying per-device routing. This breaks APNs delivery during rolling deploys when new producers share a queue with old consumers. Consider gating inclusion of these fields behind a flag, or otherwise ensuring the signed payload shape remains decodable by older schema versions, so signatures stay stable across the rollout.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/apnsDeliveryJobs.ts around line 247:
`makeApnsDeliveryJobPayload` includes `target.bundleId` and `target.apsEnvironment` in the payload, and `signatureForPayload` signs that full object. Older relay workers that decode with a previous `ApnsDeliveryJobPayload` schema strip the new keys on decode (default `effect` `Schema.Struct` behavior) and recompute the HMAC over the reduced payload, so `verifySignedApnsDeliveryJob` returns `ApnsDeliveryJobSignatureInvalid` for every newly queued job carrying per-device routing. This breaks APNs delivery during rolling deploys when new producers share a queue with old consumers. Consider gating inclusion of these fields behind a flag, or otherwise ensuring the signed payload shape remains decodable by older schema versions, so signatures stay stable across the rollout.

@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from 5225bdb to 5086c14CompareJuly 6, 2026 16:13

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium

infoPlist: {
NSAppTransportSecurity: {

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

 infoPlist: {
+ NSSupportsLiveActivitiesFrequentUpdates: true,
NSAppTransportSecurity: {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/app.config.ts around lines 96-97:
The `frequentUpdates: true` option under the `expo-widgets` plugin does not add the required `NSSupportsLiveActivitiesFrequentUpdates` key to `Info.plist`, so iOS keeps the standard Live Activity update budget and frequent updates are never enabled. Set `NSSupportsLiveActivitiesFrequentUpdates: true` in the `ios.infoPlist` block to actually grant the entitlement.

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

export interface AgentAwarenessRegistrationRecord {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumlib/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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highsettings/SettingsRouteScreen.tsx:441

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/settings/SettingsRouteScreen.tsx around line 441:
When `deviceRegistered` is `false` but `liveActivitiesEnabled` is `true` in storage, the `Live Activity Updates` switch renders off even though `liveActivityStatus` is `"enabled"`. Because `onValueChange` receives the next visual state (`true`), tapping the switch calls `linkEnvironments()` (the enable path) instead of persisting `enabled: false`, so the user cannot disable Live Activity updates from Settings until registration succeeds. Consider gating the disabled state or the alert on `deviceRegistered` rather than the switch `value`, so the toggle still reflects the saved preference and `onValueChange` fires with the correct next state.

Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

Autofix Details

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

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

Create PR

Or push these changes by commenting:

@cursor push 59517fdb7d
Preview (59517fdb7d)
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts@@ -186,7 +186,11 @@
refreshActiveLiveActivityRemoteRegistration(),
"active live activity registration after cloud sign-in failed",
);
- if (isExistingIdentity) {+ // An unchanged identity only skips re-registration when the previous attempt+ // actually reached the relay; a failed or stalled attempt must retry the next+ // time the auth effect re-runs, or the device stays unregistered until an+ // unrelated event (token rotation, environment link) happens to enqueue one.+ if (isExistingIdentity && registrationStatus === "registered") {
return;
}
enqueueDeviceRegistration({}, "device registration after cloud sign-in failed");
diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts--- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts+++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts@@ -119,6 +119,16 @@
terminalState: null,
nowMs: now.epochMilliseconds,
});
+ // A null aggregate is ambiguous while non-terminal rows still exist:+ // they may belong to a dead environment, or to a healthy long-running+ // agent whose meaningful state (and therefore updatedAt) has not changed+ // within the TTL. Sending in that case would end a possibly-healthy Live+ // Activity on every foreground, so replay only delivers when live rows+ // remain or the rows are truly gone (the thread ended and its end push+ // may have been lost).+ if (aggregate === null && activeStates.some((state) => !isTerminalPhase(state))) {+ return null;+ }
return yield* apnsDeliveries.sendForTarget({
target,
aggregate,

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

Comment threadinfra/relay/src/agentActivity/AgentActivityPublisher.ts
@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from 5086c14 to a31735cCompareJuly 6, 2026 17:03
Comment threadapps/mobile/src/features/settings/SettingsRouteScreen.tsx
Comment threadapps/server/src/cli/connect.ts

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

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

Autofix Details

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

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

Create PR

Or push these changes by commenting:

@cursor push d469c8569a
Preview (d469c8569a)
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts@@ -535,6 +535,48 @@
}).pipe(Effect.provide(relayTestLayer));
});
+ it.effect("keeps the registered status when a stale concurrent attempt fails", () => {+ const fetchMock = vi.fn((request: RequestInfo | URL) => {+ const url = request instanceof Request ? request.url : String(request);+ return Promise.resolve(+ Response.json(+ url.endsWith("/v1/client/dpop-token")+ ? {+ access_token: "relay-dpop-token",+ issued_token_type: "urn:ietf:params:oauth:token-type:access_token",+ token_type: "DPoP",+ expires_in: 300,+ scope: "mobile:registration",+ }+ : { ok: true },+ ),+ );+ });+ vi.stubGlobal("fetch", fetchMock);+ Constants.expoConfig!.extra = {+ relay: {+ url: "https://relay.example.test/",+ },+ };++ // Sign-in enqueues a registration attempt that stays parked in the+ // background queue while the direct refresh below runs.+ setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));++ return Effect.gen(function* () {+ yield* refreshAgentAwarenessRegistration();+ expect(getAgentAwarenessRegistrationStatus()).toBe("registered");++ // The queued sign-in attempt now fails; its stale failure must not+ // overwrite the registration the relay already accepted.+ vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockRejectedValueOnce(+ new Error("device id unavailable"),+ );+ yield* runBackgroundOperations();+ expect(getAgentAwarenessRegistrationStatus()).toBe("registered");+ }).pipe(Effect.provide(relayTestLayer));+ });+
it("clears registration status on cloud sign-out", () => {
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
setAgentAwarenessRelayTokenProvider(null);
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts@@ -74,6 +74,13 @@
let registrationStatus: AgentAwarenessRegistrationStatus = "unknown";
const registrationStatusListeners = new Set<() => void>();
+// Registration runs both through the background queue and directly via+// refreshAgentAwarenessRegistration, so attempts can overlap. Counting relay+// acceptances lets a failing attempt detect that a concurrent attempt already+// succeeded while it was in flight, so a stale failure cannot overwrite a+// registration the relay accepted.+let registrationSuccessCount = 0;+
function setRegistrationStatus(next: AgentAwarenessRegistrationStatus): void {
if (registrationStatus === next) {
return;
@@ -84,6 +91,18 @@
}
}
+function markRegistrationSucceeded(): void {+ registrationSuccessCount++;+ setRegistrationStatus("registered");+}++function markRegistrationFailed(successCountAtAttemptStart: number): void {+ if (registrationSuccessCount !== successCountAtAttemptStart) {+ return;+ }+ setRegistrationStatus("failed");+}+
export function getAgentAwarenessRegistrationStatus(): AgentAwarenessRegistrationStatus {
return registrationStatus;
}
@@ -316,7 +335,7 @@
catch: (cause) => cause,
}).pipe(Effect.orElseSucceed(() => null));
if (persisted && persisted.identity === identity && persisted.signature === signature) {
- setRegistrationStatus("registered");+ markRegistrationSucceeded();
logRegistrationDebug("relay device registration skipped; already registered for account", {
expectedGeneration,
});
@@ -331,7 +350,7 @@
clerkToken: token,
payload: body,
});
- setRegistrationStatus("registered");+ markRegistrationSucceeded();
yield* Effect.promise(() =>
saveAgentAwarenessRegistrationRecord({ identity, signature }).catch((error: unknown) => {
logRegistrationError("persist registration record failed", error);
@@ -466,11 +485,12 @@
};
activeDeviceRegistration = registration;
registration.operation = (async () => {
+ const successCountAtStart = registrationSuccessCount;
const result = await settleAsyncResult(() =>
runtime.runPromiseExit(registerDevice(next.input, generation)),
);
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
- setRegistrationStatus("failed");+ markRegistrationFailed(successCountAtStart);
logRegistrationError(next.context, squashAtomCommandFailure(result));
}
logRegistrationDebug("device registration finished", { generation });
@@ -675,14 +695,17 @@
never,
ManagedRelay.ManagedRelayClient
> {
- return registerDeviceForCurrentUser().pipe(- Effect.catch((error) =>- Effect.sync(() => {- setRegistrationStatus("failed");- logRegistrationError("device registration refresh failed", error);- }),- ),- );+ return Effect.suspend(() => {+ const successCountAtStart = registrationSuccessCount;+ return registerDeviceForCurrentUser().pipe(+ Effect.catch((error) =>+ Effect.sync(() => {+ markRegistrationFailed(successCountAtStart);+ logRegistrationError("device registration refresh failed", error);+ }),+ ),+ );+ });
}
export function __resetAgentAwarenessRemoteRegistrationForTest(): void {
@@ -703,6 +726,7 @@
activeDeviceRegistration = null;
pendingDeviceRegistration = null;
registrationStatus = "unknown";
+ registrationSuccessCount = 0;
registrationStatusListeners.clear();
}

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

Comment threadapps/mobile/src/features/agent-awareness/remoteRegistration.ts Outdated
- Per-device APNs routing (bundle id + environment) so preview/dev builds
receive pushes from the shared relay (+ migration for the new columns)
- Settings notification/Live Activity toggles reflect actual relay
registration success; cannot read as enabled when the device is not registered
- Persistent register-once: skip re-registering an unchanged payload for the
same account across launches; clear only on sign-out
- Headless publish + tunnel-free linking: `t3 connect publish` toggles agent
activity publishing and auto-establishes a publish-only (no managed tunnel)
link, and `t3 connect link --publish-only` links for publishing alone, so
activity flows to mobile clients that reach the environment out of band
(e.g. Tailscale) without T3 Connect. Relay accepts publish-only links with a
nominal endpoint; env proofs advertise the manual provider kind
- Foreground Live Activity refresh, stale/ghost-row hardening, dedup fixes
- Tighten widget deep linking and per-row rendering for active agents
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from a31735c to 9c3eb7bCompareJuly 6, 2026 17:24
// Development builds are Xcode-signed and receive sandbox APNs tokens;
// preview and production builds are distribution-signed and use production
// APNs. The relay routes each device's pushes accordingly.
export function resolveApsEnvironment(appVariant: unknown): "sandbox" | "production" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumagent-awareness/registrationPayload.ts:8

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/agent-awareness/registrationPayload.ts around line 8:
`resolveApsEnvironment` returns `"production"` for every value except the literal string `"development"`. Locally run `ios:preview` and `ios:prod` builds launched via `expo run:ios` are development-signed and receive sandbox APNs tokens, but this function classifies them as `"production"`. The relay then routes pushes to the production APNs gateway for a sandbox token, so notifications and Live Activities fail to deliver for those builds. Consider keying off the signing/provisioning environment (e.g. EAS build profile or `Configuration`/ entitlements) rather than the variant name, or document why locally-run `preview`/`prod` builds are expected to use production APNs.

Comment threadapps/server/src/cli/connect.ts
Comment threadpackages/contracts/src/environmentHttp.ts Outdated
Comment on lines 1745 to 1750
primaryCloudLinkState.refresh();
const refreshResult = await refreshRelayEnvironments();
if (refreshResult._tag === "Failure") {
if (!isAtomCommandInterrupted(refreshResult)) {
reportUpdateFailure(squashAtomCommandFailure(refreshResult));
}
setIsUpdating(false);
return;
if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) {
reportUpdateFailure(squashAtomCommandFailure(refreshResult));
return false;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsettings/ConnectionsSettings.tsx:1745

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

 primaryCloudLinkState.refresh();
+ return true;
const refreshResult = await refreshRelayEnvironments();
if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) {
reportUpdateFailure(squashAtomCommandFailure(refreshResult));
return false;
}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/settings/ConnectionsSettings.tsx around lines 1745-1750:
When `reconcileCloudState` completes a link/unlink and preference update successfully, the mutation is already committed and `primaryCloudLinkState.refresh()` has been called. If the follow-up `refreshRelayEnvironments()` call at line 1747 then fails, the function returns `false`, shows an error toast, and suppresses the success toast — so the user is told their T3 Connect change failed even though it was actually applied. Consider returning `true` before the relay refresh (or not reporting its failure as a mutation failure), so transient relay-discovery errors don't mask a successful mutation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highcloud/http.ts:369

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

 if (
!isSupportedLinkProviderKind(request) ||
(request.endpoint.providerKind === "cloudflare_tunnel" &&
!isAllowedEndpointOrigin({
origin: request.origin,
requestUrl,
}))
) {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/cloud/http.ts around lines 369-375:
`makeCloudLinkProof` calls `isAllowedEndpointOrigin` for every link proof, including manual `providerKind` requests. That helper only permits loopback hosts, so a manual or publish-only link from a non-loopback origin (e.g. Tailscale or directly exposed host) is rejected with `Invalid managed endpoint origin.`, blocking the out-of-band endpoint flow that `isSupportedLinkProviderKind` was added to support. Consider skipping the loopback origin check when `providerKind === "manual"`.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumwidgets/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`.

Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

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

  • ✅ Fixed: Grace blocks legitimate activity end
    • LiveActivities.register no longer resets remote_started_at when the same activity token is re-registered (foreground reconciliation), so the freshly-armed grace window measures from the true arming time and orphaned cards receive their live_activity_end once it expires.

Create PR

Or push these changes by commenting:

@cursor push 5ab40bb88e
Preview (5ab40bb88e)
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@@ -109,10 +109,12 @@
remoteStartedAt: null,
}),
]);
+ // The claim skips this device's own row: it must stay intact so the+ // upsert can tell a same-token re-registration apart from a fresh arm.
expect(updateConditions.map((condition) => dialect.sqlToQuery(condition))).toEqual([
{
- sql: '"relay_live_activities"."activity_push_token" = $1',- params: ["activity-push-token"],+ sql: '(("relay_live_activities"."activity_push_token" = $1) and ((("relay_live_activities"."user_id" <> $2) or ("relay_live_activities"."device_id" <> $3))))',+ params: ["activity-push-token", "user-2", "device-1"],
},
]);
expect(insertedValues).toEqual([
@@ -131,12 +133,20 @@
expect.objectContaining({
activityPushToken: "activity-push-token",
remoteStartQueuedAt: null,
- remoteStartedAt: expect.any(String),
endedAt: null,
lastAggregateJson: null,
lastLiveActivityDeliveryAt: null,
}),
);
+ // Re-registering the SAME token (foreground reconciliation) must not+ // restart the arming clock, or the end-on-empty grace window would+ // renew forever and orphaned cards would never receive their end.+ const remoteStartedAtSet = dialect.sqlToQuery(+ conflictConfigs[0]?.set?.remoteStartedAt as SQL,+ );+ expect(remoteStartedAtSet.sql.replace(/\s+/g, " ")).toBe(+ 'case when "relay_live_activities"."activity_push_token" = excluded.activity_push_token then coalesce("relay_live_activities"."remote_started_at", excluded.remote_started_at) else excluded.remote_started_at end',+ );
}).pipe(
Effect.provide(
LiveActivities.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))),
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@@ -13,7 +13,7 @@
import * as Function from "effect/Function";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
-import { and, eq, sql } from "drizzle-orm";+import { and, eq, ne, or, sql } from "drizzle-orm";
import * as RelayDb from "../db.ts";
import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts";
@@ -141,6 +141,10 @@
const updatedAt = DateTime.formatIso(yield* DateTime.now);
const registration = input.registration;
+ // Claim the token from any OTHER row that still holds it (a token+ // addresses exactly one activity). The device's own row is left+ // untouched so the upsert below can tell a same-token re-registration+ // apart from a fresh arm.
yield* db
.update(relayLiveActivities)
.set({
@@ -150,7 +154,15 @@
endedAt: updatedAt,
updatedAt,
})
- .where(eq(relayLiveActivities.activityPushToken, registration.activityPushToken));+ .where(+ and(+ eq(relayLiveActivities.activityPushToken, registration.activityPushToken),+ or(+ ne(relayLiveActivities.userId, input.userId),+ ne(relayLiveActivities.deviceId, registration.deviceId),+ ),+ ),+ );
yield* db
.insert(relayLiveActivities)
@@ -171,7 +183,17 @@
set: {
activityPushToken: registration.activityPushToken,
remoteStartQueuedAt: null,
- remoteStartedAt: updatedAt,+ // remote_started_at records when the CARD was armed, and the+ // end-on-empty grace window keys off it. Foreground+ // reconciliation re-registers the same token periodically;+ // refreshing the arming time there would perpetually renew the+ // grace and suppress the end an orphaned card is waiting for.+ // Only a new token (a new activity) restarts the clock.+ remoteStartedAt: sql`case+ when ${relayLiveActivities.activityPushToken} = excluded.activity_push_token+ then coalesce(${relayLiveActivities.remoteStartedAt}, excluded.remote_started_at)+ else excluded.remote_started_at+ end`,
endedAt: null,
lastAggregateJson: null,
lastLiveActivityDeliveryAt: null,

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

Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts
The stuck-at-Working card: the thread's publish stream showed running →
deleted with no completed publish in between, and the tombstone debounce
correctly confirmed the null five seconds later — the post-completion
state resolves to no phase PERSISTENTLY. Session teardown settles
still-running turns by session status, and that write can race
turn.completed, leaving latestTurn.state at "interrupted" for a turn
that actually finished. The awareness ladder mapped interrupted turns to
null, so quick finish-then-teardown threads were tombstoned instead of
published as completed: the row vanished, the empty aggregate fell into
the freshly-armed grace window, and the card froze on its last state
with no further publish to correct it.
completedAt survives the state-column race: a turn with a completion
timestamp finished, whatever the settling wrote. The ladder now projects
those as completed — the tombstone confirm publishes Done instead of
deleting the thread — while genuinely interrupted turns (no completedAt)
still resolve to null.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
// that has a completion timestamp finished, whatever the state column says.
// Without this, quick finish-then-teardown threads resolve to null
// persistently and get tombstoned instead of published as completed.
if (thread.latestTurn?.state === "interrupted" && thread.latestTurn.completedAt !== null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsrc/agentAwareness.ts:112

The new fallback at line 112 misclassifies cancelled runs as completed. Turns settled via cancellation paths (interrupt-requested or session ended with interrupted/stopped status) also get state: "interrupted" with a non-null completedAt, so the condition thread.latestTurn?.state === "interrupted" && thread.latestTurn.completedAt !== null matches them too. This causes resolveThreadAwarenessPhase to return "completed" for runs the user actually cancelled, so users see stopped runs as successfully finished. If the intent is only to cover the teardown race where a completed turn's state is overwritten by interrupted, the guard needs to distinguish that case from genuine cancellations — for example, by checking the session status or a separate completion marker. Otherwise, consider documenting why interrupted turns with a completedAt should be treated as completed.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/shared/src/agentAwareness.ts around line 112:
The new fallback at line 112 misclassifies cancelled runs as `completed`. Turns settled via cancellation paths (interrupt-requested or session ended with `interrupted`/`stopped` status) also get `state: "interrupted"` with a non-null `completedAt`, so the condition `thread.latestTurn?.state === "interrupted" && thread.latestTurn.completedAt !== null` matches them too. This causes `resolveThreadAwarenessPhase` to return `"completed"` for runs the user actually cancelled, so users see stopped runs as successfully finished. If the intent is only to cover the teardown race where a `completed` turn's state is overwritten by `interrupted`, the guard needs to distinguish that case from genuine cancellations — for example, by checking the session status or a separate completion marker. Otherwise, consider documenting why interrupted turns with a `completedAt` should be treated as completed.

Comment threadpackages/shared/src/agentAwareness.ts
The stuck-at-Working repro survives the completedAt ladder fix: the
confirmed tombstone five seconds after turn completion still resolves
null, so the settled shell holds some combination the static analysis
keeps missing. Instead of guessing another layer deep, the tombstone
paths now log the exact fields the phase ladder reads (session status,
latest turn id/state/completedAt, pendings) both when deferring and when
a confirmation actually publishes — one repro pins the writer.
Also collapses the deferral storm visible at thread birth (six confirm
fibers forked in 600ms): one pending confirmation per thread.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

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

  • ✅ Fixed: Pending confirm set can leak
    • Wrapped the check-add-fork sequence in a single uninterruptible region so a pendingTombstoneConfirms entry can only exist once the detached confirm fiber (whose ensuring finalizer owns the delete) is guaranteed to have been forked.

Create PR

Or push these changes by commenting:

@cursor push abb02eb729
Preview (abb02eb729)
diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts--- a/apps/server/src/relay/AgentAwarenessRelay.ts+++ b/apps/server/src/relay/AgentAwarenessRelay.ts@@ -432,20 +432,30 @@
// immediately deletes the thread from the lock-screen card mid-
// conversation. Defer, re-resolve, and only tombstone if it holds;
// genuinely deleted threads still propagate a few seconds later.
- if (pendingTombstoneConfirms.has(threadId)) {- return;- }- pendingTombstoneConfirms.add(threadId);- yield* Effect.logInfo("agent activity tombstone deferred pending confirmation", {- environmentId,- threadId,- reason: snapshot.reason,- shell: describeThreadShellForAwareness(thread),- });- yield* Effect.forkDetach(- confirmTombstoneLater(threadId).pipe(- Effect.ensuring(Effect.sync(() => pendingTombstoneConfirms.delete(threadId))),- ),+ // Check-add-fork must be atomic under interruption: a set entry without+ // a forked confirm fiber (whose `ensuring` owns the delete) would+ // suppress tombstone confirmation for this thread forever.+ yield* Effect.uninterruptible(+ Effect.suspend(() => {+ if (pendingTombstoneConfirms.has(threadId)) {+ return Effect.void;+ }+ pendingTombstoneConfirms.add(threadId);+ return Effect.logInfo("agent activity tombstone deferred pending confirmation", {+ environmentId,+ threadId,+ reason: snapshot.reason,+ shell: describeThreadShellForAwareness(thread),+ }).pipe(+ Effect.andThen(+ Effect.forkDetach(+ confirmTombstoneLater(threadId).pipe(+ Effect.ensuring(Effect.sync(() => pendingTombstoneConfirms.delete(threadId))),+ ),+ ),+ ),+ );+ }),
);
return;
}

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

Comment threadapps/server/src/relay/AgentAwarenessRelay.ts Outdated
Diagnostics from the stuck-at-Working repro finally exposed the data
hole: the confirmed tombstone's shell showed sessionStatus "ready" with
latestTurn entirely absent. Threads whose turns produce no checkpoint
never get a projection_turns row, and thread.session-set clears
latest_turn_id the moment the session settles (activeTurnId goes null) —
so "completed" is unrepresentable through turns for quick Q&A threads.
Their entire lifecycle rode on session status and pendings, and at
completion nothing remained on the awareness ladder: tombstone instead
of Done.
A live session at "ready"/"idle" with nothing pending and nothing
running now projects as completed. This intentionally diverges from the
web sidebar, which resolves the same null but renders it as "no pill" —
a fine default in a list, while the publisher's null means "remove from
the lock-screen card". (The sidebar's own Completed pill needs
latestTurn.completedAt and silently can't fire for these threads either;
persisting turn rows for checkpoint-less turns is the deeper future fix
for both surfaces.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment threadpackages/shared/src/agentAwareness.ts
The full lifecycle finally works end to end, with one wart: a duplicate
Done push notification fired one second after thread creation. Sessions
boot at "ready" before their first turn starts, so the new
ready-means-completed projection produced a momentary completed state at
birth — and since the freshly armed card's token wasn't registered yet,
it fell through to the notification channel. Server restarts had the
same shape at scale: the startup snapshot republished completed for
every recently finished thread, each queuing a push notification.
- Env server: completed as a thread's FIRST published state defers and
re-resolves like a tombstone — at birth the confirm sees running and
completed never publishes; genuinely at-rest threads still publish
five seconds later. Fresh completions (previous state was live) are
unaffected, keeping the buzz immediate.
- Relay: terminal push notifications only fire when the completion is
fresh (row updated within two minutes), so replays and restart
snapshots repaint state without ringing the device.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
if (!activity) {
return null;
}
if (activity.phase === "completed" || activity.phase === "failed") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 MediumagentActivity/ApnsDeliveries.ts:321

notificationForAggregate drops terminal notifications for push-notification-only users when activity.updatedAt is more than two minutes old. activity.updatedAt is the timestamp from the published aggregate state, not the time the notification was first delivered to this device. If relay publishing or replay is delayed by more than two minutes (e.g., after downtime or a late reconnect), the first completed/failed push notification for a notifications-only user is silently suppressed even though it was never delivered before. Consider gating the freshness check on the device's last-known delivery time (e.g., last_push_notification_delivery_at) rather than activity.updatedAt, so only devices that already received a recent terminal notification are suppressed.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/ApnsDeliveries.ts around line 321:
`notificationForAggregate` drops terminal notifications for push-notification-only users when `activity.updatedAt` is more than two minutes old. `activity.updatedAt` is the timestamp from the published aggregate state, not the time the notification was first delivered to this device. If relay publishing or replay is delayed by more than two minutes (e.g., after downtime or a late reconnect), the first `completed`/`failed` push notification for a notifications-only user is silently suppressed even though it was never delivered before. Consider gating the freshness check on the device's last-known delivery time (e.g., `last_push_notification_delivery_at`) rather than `activity.updatedAt`, so only devices that already received a recent terminal notification are suppressed.

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

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

  • ✅ Fixed: LA alerts skip terminal freshness
    • Threaded nowMs into alertForNewlyTerminal and alertForTerminalAggregate and applied the shared TERMINAL_NOTIFICATION_FRESHNESS_MS window via a new isFreshTerminalRow helper, so stale replayed completions no longer alert through Live Activity updates or end events.

Create PR

Or push these changes by commenting:

@cursor push c88cec1a49
Preview (c88cec1a49)
diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts--- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts+++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts@@ -1525,15 +1525,30 @@
activities: [{ ...aggregate.activities[0]!, phase: "completed" as const, status: "Done" }],
};
expect(
- ApnsDeliveries.alertForTerminalAggregate({ aggregate: terminalAggregate, preferences }),+ ApnsDeliveries.alertForTerminalAggregate({+ aggregate: terminalAggregate,+ preferences,+ nowMs: 0,+ }),
).toEqual({ title: "Thread", body: "Done: Project" });
expect(
ApnsDeliveries.alertForTerminalAggregate({
aggregate: terminalAggregate,
preferences: { ...preferences, notifyOnCompletion: false },
+ nowMs: 0,
}),
).toBeNull();
- expect(ApnsDeliveries.alertForTerminalAggregate({ aggregate: null, preferences })).toBeNull();+ expect(+ ApnsDeliveries.alertForTerminalAggregate({ aggregate: null, preferences, nowMs: 0 }),+ ).toBeNull();+ // A completion replayed long after the fact must not ring again.+ expect(+ ApnsDeliveries.alertForTerminalAggregate({+ aggregate: terminalAggregate,+ preferences,+ nowMs: 10 * 60 * 1_000,+ }),+ ).toBeNull();
});
it("alerts when a previously active thread finishes mid-flight", () => {
@@ -1552,6 +1567,7 @@
previousAggregate: aggregate,
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toEqual({ title: "Thread", body: "Done: Project" });
// The completion switch mutes it.
@@ -1560,6 +1576,7 @@
previousAggregate: aggregate,
nextAggregate: next,
preferences: { ...preferences, notifyOnCompletion: false },
+ nowMs: 0,
}),
).toBeNull();
// No baseline means no transition to ring on.
@@ -1568,6 +1585,7 @@
previousAggregate: null,
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toBeNull();
// A Done row that was already terminal (or absent) before stays silent.
@@ -1576,6 +1594,7 @@
previousAggregate: next,
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toBeNull();
expect(
@@ -1583,7 +1602,18 @@
previousAggregate: { ...aggregate, activities: [attentionRow] },
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toBeNull();
+ // A stale completion replayed after a restart (previous aggregate still+ // shows the thread as running) must not ring the device again.+ expect(+ ApnsDeliveries.alertForNewlyTerminal({+ previousAggregate: aggregate,+ nextAggregate: next,+ preferences,+ nowMs: 10 * 60 * 1_000,+ }),+ ).toBeNull();
});
});
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@@ -210,6 +210,20 @@
};
}
+// Completions replayed long after the fact (server restarts republish every+// recently-finished thread) must not ring the device again — on any channel:+// push notifications, Live Activity update alerts, and end alerts all honor+// the same freshness window.+const TERMINAL_NOTIFICATION_FRESHNESS_MS = 2 * 60 * 1_000;++function isFreshTerminalRow(row: { readonly updatedAt: string }, nowMs: number): boolean {+ const updatedAtMs = Option.match(DateTime.make(row.updatedAt), {+ onNone: () => null,+ onSome: (dt) => dt.epochMilliseconds,+ });+ return updatedAtMs !== null && nowMs - updatedAtMs <= TERMINAL_NOTIFICATION_FRESHNESS_MS;+}+
// Alert copy for an update whose aggregate contains threads that finished
// (Done/Failed) since the previously delivered aggregate — the mid-flight
// completion buzz while other agents keep the activity alive. Requires the
@@ -219,6 +233,7 @@
readonly previousAggregate: RelayAgentActivityAggregateState | null;
readonly nextAggregate: RelayAgentActivityAggregateState;
readonly preferences: RelayAgentAwarenessPreferences | null;
+ readonly nowMs: number;
}): ApnsLiveActivityAlert | null {
if (input.previousAggregate === null) {
return null;
@@ -230,6 +245,9 @@
if (row.phase !== "completed" && row.phase !== "failed") {
return false;
}
+ if (!isFreshTerminalRow(row, input.nowMs)) {+ return false;+ }
const previousPhase = previousPhases.get(row.threadId);
return (
previousPhase !== undefined &&
@@ -255,11 +273,15 @@
export function alertForTerminalAggregate(input: {
readonly aggregate: RelayAgentActivityAggregateState | null;
readonly preferences: RelayAgentAwarenessPreferences | null;
+ readonly nowMs: number;
}): ApnsLiveActivityAlert | null {
const row = input.aggregate?.activities[0];
if (!row || (row.phase !== "completed" && row.phase !== "failed")) {
return null;
}
+ if (!isFreshTerminalRow(row, input.nowMs)) {+ return null;+ }
if (!alertAllowedForPhase(input.preferences, row.phase)) {
return null;
}
@@ -298,10 +320,6 @@
);
}
-// Completions replayed long after the fact (server restarts republish every-// recently-finished thread) must not ring the device again.-const TERMINAL_NOTIFICATION_FRESHNESS_MS = 2 * 60 * 1_000;-
function notificationForAggregate(input: {
readonly target: LiveActivities.TargetRow;
readonly aggregate: RelayAgentActivityAggregateState | null;
@@ -318,14 +336,11 @@
if (!activity) {
return null;
}
- if (activity.phase === "completed" || activity.phase === "failed") {- const updatedAtMs = Option.match(DateTime.make(activity.updatedAt), {- onNone: () => null,- onSome: (dt) => dt.epochMilliseconds,- });- if (updatedAtMs === null || input.nowMs - updatedAtMs > TERMINAL_NOTIFICATION_FRESHNESS_MS) {- return null;- }+ if (+ (activity.phase === "completed" || activity.phase === "failed") &&+ !isFreshTerminalRow(activity, input.nowMs)+ ) {+ return null;
}
const enabled =
(activity.phase === "waiting_for_approval" && preferences.notifyOnApproval) ||
@@ -419,6 +434,7 @@
previousAggregate,
nextAggregate,
preferences,
+ nowMs: input.nowMs,
}),
}
: "suppressed";
@@ -1100,6 +1116,7 @@
: alertForTerminalAggregate({
aggregate: delivery.aggregate,
preferences: parsePreferences(input.target.preferences_json),
+ nowMs: input.nowMs,
})
: delivery.alert;
const result = yield* deliveryQueue.enqueueLiveActivity({

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

Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts
…A pref gates
Fixes the real findings from the PR review bots ahead of the TestFlight
build:
- Deferred publish confirmations (tombstones and first-state
completions) now re-enqueue through the same drainable worker as every
other publish instead of a detached fiber calling the publisher
directly, so a confirmed tombstone can never race an in-flight live
update; a deadline map replaces the pending set, which also guarantees
a single scheduled confirmation per thread and clears when the state
recovers. (cursor/macroscope High)
- Newly-terminal transitions bypass the 15s live-activity update
throttle: a completion landing in the same window as a new start keeps
activeCount unchanged and would previously be suppressed, silently
dropping the Done transition and its buzz. (macroscope High)
- The newly-terminal alert honors the same 2-minute freshness rule as
terminal push notifications, so replayed aggregates repaint without
ringing. (cursor Medium)
- Live Activity arming and priming treat an unset preference as enabled
(the toggle's default): fresh installs now prime, and arm-on-send
respects an explicit off. (cursor High/Medium)
Remaining findings triaged as stale (superseded designs), intended
behavior (replay painting recent Done, LA-disabled end retiring the
token), or accepted edge cases (grace extension on re-registration,
completion alerts beyond the 3-row display cap, ready-with-lastError
mapping to Done, cancelled turns showing Done).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

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

  • ✅ Fixed: Stale confirm deadline publishes tombstone
    • Cleared the thread's publishConfirmDeadlines entry in the unchanged-identity early-return path so a recovered projection cancels the deferred confirmation, preventing a later transient null state from confirming immediately against a stale expired deadline.

Create PR

Or push these changes by commenting:

@cursor push e02169b78e
Preview (e02169b78e)
diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts--- a/apps/server/src/relay/AgentAwarenessRelay.ts+++ b/apps/server/src/relay/AgentAwarenessRelay.ts@@ -410,6 +410,11 @@
const publishIdentity = agentAwarenessPublishIdentity(snapshot.state);
const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef);
if (publishedStateByThread.get(threadId) === publishIdentity) {
+ // The projection recovered to the last published state, so any deferred+ // confirmation (tombstone or first-state completion) is moot. Clear the+ // deadline so the next divergence gets a fresh deferral window instead+ // of confirming immediately against a stale, expired deadline.+ publishConfirmDeadlines.delete(threadId);
yield* Effect.logDebug("agent activity publish skipped; projected state unchanged", {
environmentId,
threadId,

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

Reviewed by Cursor Bugbot for commit 97ed442. Configure here.

Comment threadapps/server/src/relay/AgentAwarenessRelay.ts
…d state
A recovered projection exits at the unchanged-identity dedupe, which sits
above the confirmation block, so a deferred publish's deadline survived
recovery. A much later transient null would then find the deadline
already expired and publish its tombstone immediately, bypassing the
deferral window entirely. The dedupe path now clears any pending
deadline: the state is back at what was last published, so the deferred
confirmation is moot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@juliusmarmingejuliusmarminge added the 🚀 Mobile Continuous Deployment Trigger Expo preview build label Jul 7, 2026
Fixes the Effect Service Conventions check: the service builds a plain
object with no effectful acquisition, so wrapping it in Effect.sync just
to feed Layer.effect was the make-only-to-force-Layer.effect shape the
conventions call out.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

ghost commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

🚀 Expo continuous deployment is ready!

  • Project → t3-code
  • Platforms → android, ios
  • Scheme → t3code-preview
🤖 Android🍎 iOS
Fingerprint106610ae982be2d8aa28dda9217ff853daf9d30b4f388ce49fd16563ef62f563f42c0528f092d42b
Build DetailsBuild Permalink
DetailsDistribution: INTERNAL
Build profile: preview:dev
Runtime version: 106610ae982be2d8aa28dda9217ff853daf9d30b
App version: 0.1.0
Git commit: 74888084257320d42e7562c74996c949010bdf45
Build Permalink
DetailsDistribution: INTERNAL
Build profile: preview:dev
Runtime version: 4f388ce49fd16563ef62f563f42c0528f092d42b
App version: 0.1.0
Git commit: c622444d589c97adb591101ffca9161d785806b0
Update DetailsUpdate Permalink
DetailsBranch: pr-3685
Runtime version: 106610ae982be2d8aa28dda9217ff853daf9d30b
Git commit: 74888084257320d42e7562c74996c949010bdf45
Update Permalink
DetailsBranch: pr-3685
Runtime version: 4f388ce49fd16563ef62f563f42c0528f092d42b
Git commit: 74888084257320d42e7562c74996c949010bdf45
Update QR

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

Labels

🚀 Mobile Continuous DeploymentTrigger Expo preview buildsize:XXL1,000+ changed lines (additions + deletions).vouch:trustedPR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@juliusmarminge
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Improve live activity routing and diagnostics - #3685

Merged
juliusmarminge merged 26 commits into
mainfrom
t3code/live-activities-improvements
Jul 7, 2026
Merged

Improve live activity routing and diagnostics#3685
juliusmarminge merged 26 commits into
mainfrom
t3code/live-activities-improvements

Conversation

@juliusmarminge

@juliusmarmingejuliusmarminge commented Jul 4, 2026

Copy link
Copy Markdown
Member

Summary

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

Testing

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

Note

High Risk
Touches relay registration, APNs routing, cloud link modes, and Live Activity lifecycle (including removing push-to-start); misconfiguration or partial deploy could break mobile notifications or mis-route pushes.

Overview
Improves mobile agent awareness end-to-end: relay device registration now sends bundle ID and sandbox vs production APNs routing, tracks whether the relay actually accepted registration (settings toggles and alerts follow that, not just iOS permission), skips duplicate registrations via a persisted signature, and re-registers Live Activity tokens on foreground with short-window deduping. Push-to-start registration is removed in favor of arming a card when the user sends a task (and priming from a relay activity snapshot when idle), plus cleanup on cloud sign-out and a releaseAgentAwarenessRelayTokenProvider path so auth UI remounts do not wipe registration or end activities.

Agent Activity Live Activity UI is reworked (attention-first rows, phase tints aligned with web, deep links, branded T3 mark via new Expo config plugins and actool shell phase). expo-widgets gains frequentUpdates for higher update budget.

T3 Connect vs publishing split: environments can link in publish_only mode (manual endpoint, no Cloudflare tunnel) via CLI (connect link --publish-only, connect publish) and web settings (separate toggles reconciled together). Link proofs accept manual providers with activity-only scopes; server publish path defers transient tombstones and first completed snapshots before publishing.

Relay DB adds bundle_id and aps_environment on mobile devices (migration in diff).

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

Note

Add per-device APNs routing, JWT token caching, and publish-only link mode to Live Activity delivery

  • Stores bundle_id and aps_environment per device in the relay and uses them to route APNs pushes to the correct topic and environment per device rather than using global config.
  • Introduces ApnsProviderTokens service that caches APNs JWTs quantized to 45-minute windows, preventing TooManyProviderTokenUpdates errors; uses deterministic ES256 signing via @noble/curves.
  • Overhauls Live Activity delivery: activities are no longer remotely started; ends are suppressed within a grace window after arming; attention-phase transitions and newly-terminal rows can now trigger alert payloads with haptics; stale terminal aggregates suppress re-alerting.
  • Adds a publish_only cloud link mode in the CLI (t3 connect publish) and web settings UI, allowing agent activity publishing without a managed Cloudflare tunnel; linking with this mode uses providerKind: "manual" and deprovisions any existing tunnel allocation.
  • Reworks the AgentActivity widget to sort rows attention-first, apply per-phase tints from the web palette, deep-link to the most relevant thread, show a branded logo, and display a phase glyph in minimal presentation.
  • Exposes relay registration status (unknown/pending/registered/failed) to the settings UI so notification and Live Activity toggles only appear enabled when the device is actually registered.
  • Risk: makeAggregateState now requires a nowMs parameter and statusForPhase('starting') returns 'Connecting' instead of 'Starting', which are breaking changes for callers.

Macroscope summarized 6f0b32d.

@coderabbitai

coderabbitaiBot commented Jul 4, 2026

Copy link
Copy Markdown

Important

Review skipped

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

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: fe216c9a-1cd9-460a-a80d-6d90748a00b5

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

Use the checkbox below for a quick retry:

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

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

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

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Autofix Details

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

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

Create PR

Or push these changes by commenting:

@cursor push a97e0b6881
Preview (a97e0b6881)
diff --git a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts--- a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts+++ b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts@@ -69,6 +69,28 @@
]);
});
+ it("treats a non-2xx delivery status without a reason as a failure", () => {+ const rows = formatDeviceDiagnosticsRows(+ makeDevice({+ bundleId: "com.t3tools.t3code.preview",+ apsEnvironment: "production",+ hasPushToken: true,+ hasPushToStartToken: true,+ hasLiveActivityToken: true,+ lastDeliveryAt: "2026-06-05T01:02:59.566Z",+ lastDeliveryKind: "live_activity_end",+ lastDeliveryStatus: 400,+ lastDeliveryError: null,+ }),+ );++ expect(rows[4]).toEqual({+ label: "Last Delivery",+ value: "Delivery failed (400)",+ tone: "warn",+ });+ });+
it("reports healthy registrations with a successful delivery", () => {
const rows = formatDeviceDiagnosticsRows(
makeDevice({
diff --git a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts--- a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts+++ b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts@@ -73,14 +73,21 @@
});
}
+ // APNs can fail with a non-2xx status without a reason body, so a null+ // error alone does not mean the delivery succeeded.+ const deliveryFailed =+ diagnostics.lastDeliveryError !== null ||+ (diagnostics.lastDeliveryStatus !== null &&+ (diagnostics.lastDeliveryStatus < 200 || diagnostics.lastDeliveryStatus >= 300));+
if (diagnostics.lastDeliveryAt === null) {
rows.push({ label: "Last Delivery", value: "None yet", tone: "muted" });
- } else if (diagnostics.lastDeliveryError !== null) {+ } else if (deliveryFailed) {
const status =
diagnostics.lastDeliveryStatus === null ? "" : ` (${diagnostics.lastDeliveryStatus})`;
rows.push({
label: "Last Delivery",
- value: `${diagnostics.lastDeliveryError}${status}`,+ value: `${diagnostics.lastDeliveryError ?? "Delivery failed"}${status}`,
tone: "warn",
});
} else {
diff --git a/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/migration.sql b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/migration.sql
new file mode 100644
--- /dev/null+++ b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/migration.sql@@ -1,0 +1,3 @@+ALTER TABLE "relay_mobile_devices" ADD COLUMN "bundle_id" varchar(255);+--> statement-breakpoint+ALTER TABLE "relay_mobile_devices" ADD COLUMN "aps_environment" varchar(16);diff --git a/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/snapshot.json b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/snapshot.json
new file mode 100644
--- /dev/null+++ b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/snapshot.json@@ -1,0 +1,1479 @@+{+ "dialect": "postgres",+ "id": "e00cc7df-a31e-4b8b-b258-d04e7db7758a",+ "prevIds": ["385d476b-d4f6-48a3-99e6-0a95af4ee4e4"],+ "version": "8",+ "ddl": [+ {+ "isRlsEnabled": false,+ "name": "relay_agent_activity_rows",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_delivery_attempts",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_dpop_proofs",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_environment_credentials",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_environment_links",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_live_activities",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_managed_endpoint_allocations",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_mobile_devices",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_public_key",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "thread_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "jsonb",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "state_json",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "updated_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "varchar(36)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "user_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "thread_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "device_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "kind",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "source_job_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(16)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "token_suffix",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "integer",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "apns_status",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "apns_reason",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(128)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "apns_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "transport_error",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(128)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "thumbprint",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "jti",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "integer",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "iat",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "expires_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "credential_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_public_key",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "credential_hash",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "revoked_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "updated_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "user_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": "'T3 Environment'",+ "generated": null,+ "identity": null,+ "name": "environment_label",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_public_key",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "endpoint_http_base_url",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "endpoint_ws_base_url",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(32)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "endpoint_provider_kind",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "boolean",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": "true",+ "generated": null,+ "identity": null,+ "name": "notifications_enabled",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "boolean",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": "true",+ "generated": null,+ "identity": null,+ "name": "live_activities_enabled",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "boolean",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": "false",+ "generated": null,+ "identity": null,+ "name": "managed_tunnels_enabled",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_by_device_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "revoked_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "updated_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "user_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "device_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "activity_push_token",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "remote_start_queued_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "remote_started_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "ended_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "jsonb",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,
... diff truncated: showing 800 of 1681 lines

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highpersistence/schema.ts:27

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

Also found in 1 other location(s)

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

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/persistence/schema.ts around line 27:
The new `bundle_id` and `aps_environment` columns are added to the `relay_mobile_devices` schema, but no SQL migration is included to create them. After deploy, the code in `Devices.ts` immediately inserts and selects these columns, so PostgreSQL rejects every device registration and list query with `column "bundle_id" does not exist` (and `aps_environment` likewise). Add the corresponding `ALTER TABLE relay_mobile_devices ADD COLUMN ...` migration under `infra/relay/migrations/` so the columns exist before the new code runs.
Also found in 1 other location(s):
- infra/relay/src/agentActivity/LiveActivities.ts:201 -- The new `relayMobileDevices.bundleId` / `relayMobileDevices.apsEnvironment` columns are referenced by `listTargets` (`bundle_id`, `aps_environment`) and other relay code, but this PR does not add a matching SQL migration for `relay_mobile_devices`. After deploying the code to an existing database, any `listTargets` query will start failing with `column relay_mobile_devices.bundle_id does not exist` (and likewise for `aps_environment`), breaking live-activity target lookup until the schema is manually patched.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium

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

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/ApnsDeliveries.ts around line 506:
The stale-job check in `processSignedJob` only compares the token via `isCurrentSignedJobToken`, so a queued job created before the device registered `bundleId`/`apsEnvironment` (or before those values changed) still passes the staleness guard when the token is unchanged. The job is then sent with the stale or missing routing metadata from the signed payload instead of the device's current values, causing APNs to reject the delivery with `BadDeviceToken`/`DeviceTokenNotForTopic` even though the registration has already been corrected. Consider comparing `bundleId` and `apsEnvironment` against the current target in the staleness check so jobs with outdated routing are treated as stale.

@macroscopeapp

macroscopeappBot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

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

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumwidgets/AgentActivity.tsx:81

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/widgets/AgentActivity.tsx around line 81:
`widgetURL` is hardcoded to the `t3code://` scheme, but the app registers `t3code-dev` for development builds and `t3code-preview` for preview builds. On dev/preview builds the widget banner's tap URL uses a scheme the containing app does not register, so iOS will not open the app when the banner is tapped. Consider using a scheme that is registered in all build variants (or injecting the active scheme per-build).

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 MediumagentActivity/AgentActivityPublisher.ts:203

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/AgentActivityPublisher.ts around line 203:
`RUNNING_AGENT_ACTIVITY_ROW_TTL_MS` expires `running`/`starting` states after 2 hours based on `updatedAt`, but relay publishes are event-driven — a thread that stays in the same `running` phase for over 2 hours without a new event keeps its old `updatedAt`, so `isExpiredAgentActivityState` returns `true` and `makeAggregateState` filters it out. The aggregate then reports `activeCount: 0` (or `null`/terminal) even though the job is still running, hiding active work from clients. Consider raising the running TTL to match the waiting TTL, or filtering on a heartbeat/last-seen timestamp that is refreshed independently of phase-change events.

Comment threadapps/mobile/src/widgets/AgentActivity.tsx
Comment threadinfra/relay/src/agentActivity/Devices.ts Outdated
Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts
@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from 1d6309e to 39d9dcaCompareJuly 6, 2026 06:07
@github-actionsgithub-actionsBot added size:L 100-499 changed lines (additions + deletions). and removed size:XL 500-999 changed lines (additions + deletions). labels Jul 6, 2026
@cursor

This comment has been minimized.

@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from 39d9dca to 5225bdbCompareJuly 6, 2026 07:37
@github-actionsgithub-actionsBot added size:XXL 1,000+ changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Jul 6, 2026

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

Autofix Details

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

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

Create PR

Or push these changes by commenting:

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

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsettings/SettingsRouteScreen.tsx:438

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/settings/SettingsRouteScreen.tsx around line 438:
The `deviceRegistered` gate on the `Device Notifications` switch renders it `false` when relay registration fails, even though `notificationStatus` is `"enabled"`. Because the switch shows off, the user cannot toggle it to reach `handleDeviceNotificationsChange(false)`, which is the only path that opens iOS Settings to disable notifications. In the "permission granted, relay registration failed" case the switch misreports notifications as off and removes the in-app affordance for managing them. Consider gating only the Live Activity switch on `deviceRegistered`, or keeping the `Device Notifications` switch reflecting the actual iOS permission state so the user can still navigate to system Settings.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 HighagentActivity/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 effectSchema.Struct behavior) and recompute the HMAC over the reduced payload, so verifySignedApnsDeliveryJob returns ApnsDeliveryJobSignatureInvalid for every newly queued job carrying per-device routing. This breaks APNs delivery during rolling deploys when new producers share a queue with old consumers. Consider gating inclusion of these fields behind a flag, or otherwise ensuring the signed payload shape remains decodable by older schema versions, so signatures stay stable across the rollout.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/apnsDeliveryJobs.ts around line 247:
`makeApnsDeliveryJobPayload` includes `target.bundleId` and `target.apsEnvironment` in the payload, and `signatureForPayload` signs that full object. Older relay workers that decode with a previous `ApnsDeliveryJobPayload` schema strip the new keys on decode (default `effect` `Schema.Struct` behavior) and recompute the HMAC over the reduced payload, so `verifySignedApnsDeliveryJob` returns `ApnsDeliveryJobSignatureInvalid` for every newly queued job carrying per-device routing. This breaks APNs delivery during rolling deploys when new producers share a queue with old consumers. Consider gating inclusion of these fields behind a flag, or otherwise ensuring the signed payload shape remains decodable by older schema versions, so signatures stay stable across the rollout.

@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from 5225bdb to 5086c14CompareJuly 6, 2026 16:13

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium

infoPlist: {
NSAppTransportSecurity: {

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

 infoPlist: {
+ NSSupportsLiveActivitiesFrequentUpdates: true,
NSAppTransportSecurity: {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/app.config.ts around lines 96-97:
The `frequentUpdates: true` option under the `expo-widgets` plugin does not add the required `NSSupportsLiveActivitiesFrequentUpdates` key to `Info.plist`, so iOS keeps the standard Live Activity update budget and frequent updates are never enabled. Set `NSSupportsLiveActivitiesFrequentUpdates: true` in the `ios.infoPlist` block to actually grant the entitlement.

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

export interface AgentAwarenessRegistrationRecord {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumlib/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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highsettings/SettingsRouteScreen.tsx:441

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/settings/SettingsRouteScreen.tsx around line 441:
When `deviceRegistered` is `false` but `liveActivitiesEnabled` is `true` in storage, the `Live Activity Updates` switch renders off even though `liveActivityStatus` is `"enabled"`. Because `onValueChange` receives the next visual state (`true`), tapping the switch calls `linkEnvironments()` (the enable path) instead of persisting `enabled: false`, so the user cannot disable Live Activity updates from Settings until registration succeeds. Consider gating the disabled state or the alert on `deviceRegistered` rather than the switch `value`, so the toggle still reflects the saved preference and `onValueChange` fires with the correct next state.

Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

Autofix Details

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

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

Create PR

Or push these changes by commenting:

@cursor push 59517fdb7d
Preview (59517fdb7d)
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts@@ -186,7 +186,11 @@
refreshActiveLiveActivityRemoteRegistration(),
"active live activity registration after cloud sign-in failed",
);
- if (isExistingIdentity) {+ // An unchanged identity only skips re-registration when the previous attempt+ // actually reached the relay; a failed or stalled attempt must retry the next+ // time the auth effect re-runs, or the device stays unregistered until an+ // unrelated event (token rotation, environment link) happens to enqueue one.+ if (isExistingIdentity && registrationStatus === "registered") {
return;
}
enqueueDeviceRegistration({}, "device registration after cloud sign-in failed");
diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts--- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts+++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts@@ -119,6 +119,16 @@
terminalState: null,
nowMs: now.epochMilliseconds,
});
+ // A null aggregate is ambiguous while non-terminal rows still exist:+ // they may belong to a dead environment, or to a healthy long-running+ // agent whose meaningful state (and therefore updatedAt) has not changed+ // within the TTL. Sending in that case would end a possibly-healthy Live+ // Activity on every foreground, so replay only delivers when live rows+ // remain or the rows are truly gone (the thread ended and its end push+ // may have been lost).+ if (aggregate === null && activeStates.some((state) => !isTerminalPhase(state))) {+ return null;+ }
return yield* apnsDeliveries.sendForTarget({
target,
aggregate,

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

Comment threadinfra/relay/src/agentActivity/AgentActivityPublisher.ts
@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from 5086c14 to a31735cCompareJuly 6, 2026 17:03
Comment threadapps/mobile/src/features/settings/SettingsRouteScreen.tsx
Comment threadapps/server/src/cli/connect.ts

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

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

Autofix Details

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

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

Create PR

Or push these changes by commenting:

@cursor push d469c8569a
Preview (d469c8569a)
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts@@ -535,6 +535,48 @@
}).pipe(Effect.provide(relayTestLayer));
});
+ it.effect("keeps the registered status when a stale concurrent attempt fails", () => {+ const fetchMock = vi.fn((request: RequestInfo | URL) => {+ const url = request instanceof Request ? request.url : String(request);+ return Promise.resolve(+ Response.json(+ url.endsWith("/v1/client/dpop-token")+ ? {+ access_token: "relay-dpop-token",+ issued_token_type: "urn:ietf:params:oauth:token-type:access_token",+ token_type: "DPoP",+ expires_in: 300,+ scope: "mobile:registration",+ }+ : { ok: true },+ ),+ );+ });+ vi.stubGlobal("fetch", fetchMock);+ Constants.expoConfig!.extra = {+ relay: {+ url: "https://relay.example.test/",+ },+ };++ // Sign-in enqueues a registration attempt that stays parked in the+ // background queue while the direct refresh below runs.+ setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));++ return Effect.gen(function* () {+ yield* refreshAgentAwarenessRegistration();+ expect(getAgentAwarenessRegistrationStatus()).toBe("registered");++ // The queued sign-in attempt now fails; its stale failure must not+ // overwrite the registration the relay already accepted.+ vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockRejectedValueOnce(+ new Error("device id unavailable"),+ );+ yield* runBackgroundOperations();+ expect(getAgentAwarenessRegistrationStatus()).toBe("registered");+ }).pipe(Effect.provide(relayTestLayer));+ });+
it("clears registration status on cloud sign-out", () => {
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
setAgentAwarenessRelayTokenProvider(null);
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts@@ -74,6 +74,13 @@
let registrationStatus: AgentAwarenessRegistrationStatus = "unknown";
const registrationStatusListeners = new Set<() => void>();
+// Registration runs both through the background queue and directly via+// refreshAgentAwarenessRegistration, so attempts can overlap. Counting relay+// acceptances lets a failing attempt detect that a concurrent attempt already+// succeeded while it was in flight, so a stale failure cannot overwrite a+// registration the relay accepted.+let registrationSuccessCount = 0;+
function setRegistrationStatus(next: AgentAwarenessRegistrationStatus): void {
if (registrationStatus === next) {
return;
@@ -84,6 +91,18 @@
}
}
+function markRegistrationSucceeded(): void {+ registrationSuccessCount++;+ setRegistrationStatus("registered");+}++function markRegistrationFailed(successCountAtAttemptStart: number): void {+ if (registrationSuccessCount !== successCountAtAttemptStart) {+ return;+ }+ setRegistrationStatus("failed");+}+
export function getAgentAwarenessRegistrationStatus(): AgentAwarenessRegistrationStatus {
return registrationStatus;
}
@@ -316,7 +335,7 @@
catch: (cause) => cause,
}).pipe(Effect.orElseSucceed(() => null));
if (persisted && persisted.identity === identity && persisted.signature === signature) {
- setRegistrationStatus("registered");+ markRegistrationSucceeded();
logRegistrationDebug("relay device registration skipped; already registered for account", {
expectedGeneration,
});
@@ -331,7 +350,7 @@
clerkToken: token,
payload: body,
});
- setRegistrationStatus("registered");+ markRegistrationSucceeded();
yield* Effect.promise(() =>
saveAgentAwarenessRegistrationRecord({ identity, signature }).catch((error: unknown) => {
logRegistrationError("persist registration record failed", error);
@@ -466,11 +485,12 @@
};
activeDeviceRegistration = registration;
registration.operation = (async () => {
+ const successCountAtStart = registrationSuccessCount;
const result = await settleAsyncResult(() =>
runtime.runPromiseExit(registerDevice(next.input, generation)),
);
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
- setRegistrationStatus("failed");+ markRegistrationFailed(successCountAtStart);
logRegistrationError(next.context, squashAtomCommandFailure(result));
}
logRegistrationDebug("device registration finished", { generation });
@@ -675,14 +695,17 @@
never,
ManagedRelay.ManagedRelayClient
> {
- return registerDeviceForCurrentUser().pipe(- Effect.catch((error) =>- Effect.sync(() => {- setRegistrationStatus("failed");- logRegistrationError("device registration refresh failed", error);- }),- ),- );+ return Effect.suspend(() => {+ const successCountAtStart = registrationSuccessCount;+ return registerDeviceForCurrentUser().pipe(+ Effect.catch((error) =>+ Effect.sync(() => {+ markRegistrationFailed(successCountAtStart);+ logRegistrationError("device registration refresh failed", error);+ }),+ ),+ );+ });
}
export function __resetAgentAwarenessRemoteRegistrationForTest(): void {
@@ -703,6 +726,7 @@
activeDeviceRegistration = null;
pendingDeviceRegistration = null;
registrationStatus = "unknown";
+ registrationSuccessCount = 0;
registrationStatusListeners.clear();
}

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

Comment threadapps/mobile/src/features/agent-awareness/remoteRegistration.ts Outdated
- Per-device APNs routing (bundle id + environment) so preview/dev builds
receive pushes from the shared relay (+ migration for the new columns)
- Settings notification/Live Activity toggles reflect actual relay
registration success; cannot read as enabled when the device is not registered
- Persistent register-once: skip re-registering an unchanged payload for the
same account across launches; clear only on sign-out
- Headless publish + tunnel-free linking: `t3 connect publish` toggles agent
activity publishing and auto-establishes a publish-only (no managed tunnel)
link, and `t3 connect link --publish-only` links for publishing alone, so
activity flows to mobile clients that reach the environment out of band
(e.g. Tailscale) without T3 Connect. Relay accepts publish-only links with a
nominal endpoint; env proofs advertise the manual provider kind
- Foreground Live Activity refresh, stale/ghost-row hardening, dedup fixes
- Tighten widget deep linking and per-row rendering for active agents
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from a31735c to 9c3eb7bCompareJuly 6, 2026 17:24
// Development builds are Xcode-signed and receive sandbox APNs tokens;
// preview and production builds are distribution-signed and use production
// APNs. The relay routes each device's pushes accordingly.
export function resolveApsEnvironment(appVariant: unknown): "sandbox" | "production" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumagent-awareness/registrationPayload.ts:8

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/agent-awareness/registrationPayload.ts around line 8:
`resolveApsEnvironment` returns `"production"` for every value except the literal string `"development"`. Locally run `ios:preview` and `ios:prod` builds launched via `expo run:ios` are development-signed and receive sandbox APNs tokens, but this function classifies them as `"production"`. The relay then routes pushes to the production APNs gateway for a sandbox token, so notifications and Live Activities fail to deliver for those builds. Consider keying off the signing/provisioning environment (e.g. EAS build profile or `Configuration`/ entitlements) rather than the variant name, or document why locally-run `preview`/`prod` builds are expected to use production APNs.

Comment threadapps/server/src/cli/connect.ts
Comment threadpackages/contracts/src/environmentHttp.ts Outdated
Comment on lines 1745 to 1750
primaryCloudLinkState.refresh();
const refreshResult = await refreshRelayEnvironments();
if (refreshResult._tag === "Failure") {
if (!isAtomCommandInterrupted(refreshResult)) {
reportUpdateFailure(squashAtomCommandFailure(refreshResult));
}
setIsUpdating(false);
return;
if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) {
reportUpdateFailure(squashAtomCommandFailure(refreshResult));
return false;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsettings/ConnectionsSettings.tsx:1745

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

 primaryCloudLinkState.refresh();
+ return true;
const refreshResult = await refreshRelayEnvironments();
if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) {
reportUpdateFailure(squashAtomCommandFailure(refreshResult));
return false;
}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/settings/ConnectionsSettings.tsx around lines 1745-1750:
When `reconcileCloudState` completes a link/unlink and preference update successfully, the mutation is already committed and `primaryCloudLinkState.refresh()` has been called. If the follow-up `refreshRelayEnvironments()` call at line 1747 then fails, the function returns `false`, shows an error toast, and suppresses the success toast — so the user is told their T3 Connect change failed even though it was actually applied. Consider returning `true` before the relay refresh (or not reporting its failure as a mutation failure), so transient relay-discovery errors don't mask a successful mutation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highcloud/http.ts:369

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

 if (
!isSupportedLinkProviderKind(request) ||
(request.endpoint.providerKind === "cloudflare_tunnel" &&
!isAllowedEndpointOrigin({
origin: request.origin,
requestUrl,
}))
) {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/cloud/http.ts around lines 369-375:
`makeCloudLinkProof` calls `isAllowedEndpointOrigin` for every link proof, including manual `providerKind` requests. That helper only permits loopback hosts, so a manual or publish-only link from a non-loopback origin (e.g. Tailscale or directly exposed host) is rejected with `Invalid managed endpoint origin.`, blocking the out-of-band endpoint flow that `isSupportedLinkProviderKind` was added to support. Consider skipping the loopback origin check when `providerKind === "manual"`.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumwidgets/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`.

Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

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

  • ✅ Fixed: Grace blocks legitimate activity end
    • LiveActivities.register no longer resets remote_started_at when the same activity token is re-registered (foreground reconciliation), so the freshly-armed grace window measures from the true arming time and orphaned cards receive their live_activity_end once it expires.

Create PR

Or push these changes by commenting:

@cursor push 5ab40bb88e
Preview (5ab40bb88e)
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@@ -109,10 +109,12 @@
remoteStartedAt: null,
}),
]);
+ // The claim skips this device's own row: it must stay intact so the+ // upsert can tell a same-token re-registration apart from a fresh arm.
expect(updateConditions.map((condition) => dialect.sqlToQuery(condition))).toEqual([
{
- sql: '"relay_live_activities"."activity_push_token" = $1',- params: ["activity-push-token"],+ sql: '(("relay_live_activities"."activity_push_token" = $1) and ((("relay_live_activities"."user_id" <> $2) or ("relay_live_activities"."device_id" <> $3))))',+ params: ["activity-push-token", "user-2", "device-1"],
},
]);
expect(insertedValues).toEqual([
@@ -131,12 +133,20 @@
expect.objectContaining({
activityPushToken: "activity-push-token",
remoteStartQueuedAt: null,
- remoteStartedAt: expect.any(String),
endedAt: null,
lastAggregateJson: null,
lastLiveActivityDeliveryAt: null,
}),
);
+ // Re-registering the SAME token (foreground reconciliation) must not+ // restart the arming clock, or the end-on-empty grace window would+ // renew forever and orphaned cards would never receive their end.+ const remoteStartedAtSet = dialect.sqlToQuery(+ conflictConfigs[0]?.set?.remoteStartedAt as SQL,+ );+ expect(remoteStartedAtSet.sql.replace(/\s+/g, " ")).toBe(+ 'case when "relay_live_activities"."activity_push_token" = excluded.activity_push_token then coalesce("relay_live_activities"."remote_started_at", excluded.remote_started_at) else excluded.remote_started_at end',+ );
}).pipe(
Effect.provide(
LiveActivities.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))),
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@@ -13,7 +13,7 @@
import * as Function from "effect/Function";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
-import { and, eq, sql } from "drizzle-orm";+import { and, eq, ne, or, sql } from "drizzle-orm";
import * as RelayDb from "../db.ts";
import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts";
@@ -141,6 +141,10 @@
const updatedAt = DateTime.formatIso(yield* DateTime.now);
const registration = input.registration;
+ // Claim the token from any OTHER row that still holds it (a token+ // addresses exactly one activity). The device's own row is left+ // untouched so the upsert below can tell a same-token re-registration+ // apart from a fresh arm.
yield* db
.update(relayLiveActivities)
.set({
@@ -150,7 +154,15 @@
endedAt: updatedAt,
updatedAt,
})
- .where(eq(relayLiveActivities.activityPushToken, registration.activityPushToken));+ .where(+ and(+ eq(relayLiveActivities.activityPushToken, registration.activityPushToken),+ or(+ ne(relayLiveActivities.userId, input.userId),+ ne(relayLiveActivities.deviceId, registration.deviceId),+ ),+ ),+ );
yield* db
.insert(relayLiveActivities)
@@ -171,7 +183,17 @@
set: {
activityPushToken: registration.activityPushToken,
remoteStartQueuedAt: null,
- remoteStartedAt: updatedAt,+ // remote_started_at records when the CARD was armed, and the+ // end-on-empty grace window keys off it. Foreground+ // reconciliation re-registers the same token periodically;+ // refreshing the arming time there would perpetually renew the+ // grace and suppress the end an orphaned card is waiting for.+ // Only a new token (a new activity) restarts the clock.+ remoteStartedAt: sql`case+ when ${relayLiveActivities.activityPushToken} = excluded.activity_push_token+ then coalesce(${relayLiveActivities.remoteStartedAt}, excluded.remote_started_at)+ else excluded.remote_started_at+ end`,
endedAt: null,
lastAggregateJson: null,
lastLiveActivityDeliveryAt: null,

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

Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts
The stuck-at-Working card: the thread's publish stream showed running →
deleted with no completed publish in between, and the tombstone debounce
correctly confirmed the null five seconds later — the post-completion
state resolves to no phase PERSISTENTLY. Session teardown settles
still-running turns by session status, and that write can race
turn.completed, leaving latestTurn.state at "interrupted" for a turn
that actually finished. The awareness ladder mapped interrupted turns to
null, so quick finish-then-teardown threads were tombstoned instead of
published as completed: the row vanished, the empty aggregate fell into
the freshly-armed grace window, and the card froze on its last state
with no further publish to correct it.
completedAt survives the state-column race: a turn with a completion
timestamp finished, whatever the settling wrote. The ladder now projects
those as completed — the tombstone confirm publishes Done instead of
deleting the thread — while genuinely interrupted turns (no completedAt)
still resolve to null.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
// that has a completion timestamp finished, whatever the state column says.
// Without this, quick finish-then-teardown threads resolve to null
// persistently and get tombstoned instead of published as completed.
if (thread.latestTurn?.state === "interrupted" && thread.latestTurn.completedAt !== null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsrc/agentAwareness.ts:112

The new fallback at line 112 misclassifies cancelled runs as completed. Turns settled via cancellation paths (interrupt-requested or session ended with interrupted/stopped status) also get state: "interrupted" with a non-null completedAt, so the condition thread.latestTurn?.state === "interrupted" && thread.latestTurn.completedAt !== null matches them too. This causes resolveThreadAwarenessPhase to return "completed" for runs the user actually cancelled, so users see stopped runs as successfully finished. If the intent is only to cover the teardown race where a completed turn's state is overwritten by interrupted, the guard needs to distinguish that case from genuine cancellations — for example, by checking the session status or a separate completion marker. Otherwise, consider documenting why interrupted turns with a completedAt should be treated as completed.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/shared/src/agentAwareness.ts around line 112:
The new fallback at line 112 misclassifies cancelled runs as `completed`. Turns settled via cancellation paths (interrupt-requested or session ended with `interrupted`/`stopped` status) also get `state: "interrupted"` with a non-null `completedAt`, so the condition `thread.latestTurn?.state === "interrupted" && thread.latestTurn.completedAt !== null` matches them too. This causes `resolveThreadAwarenessPhase` to return `"completed"` for runs the user actually cancelled, so users see stopped runs as successfully finished. If the intent is only to cover the teardown race where a `completed` turn's state is overwritten by `interrupted`, the guard needs to distinguish that case from genuine cancellations — for example, by checking the session status or a separate completion marker. Otherwise, consider documenting why interrupted turns with a `completedAt` should be treated as completed.

Comment threadpackages/shared/src/agentAwareness.ts
The stuck-at-Working repro survives the completedAt ladder fix: the
confirmed tombstone five seconds after turn completion still resolves
null, so the settled shell holds some combination the static analysis
keeps missing. Instead of guessing another layer deep, the tombstone
paths now log the exact fields the phase ladder reads (session status,
latest turn id/state/completedAt, pendings) both when deferring and when
a confirmation actually publishes — one repro pins the writer.
Also collapses the deferral storm visible at thread birth (six confirm
fibers forked in 600ms): one pending confirmation per thread.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

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

  • ✅ Fixed: Pending confirm set can leak
    • Wrapped the check-add-fork sequence in a single uninterruptible region so a pendingTombstoneConfirms entry can only exist once the detached confirm fiber (whose ensuring finalizer owns the delete) is guaranteed to have been forked.

Create PR

Or push these changes by commenting:

@cursor push abb02eb729
Preview (abb02eb729)
diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts--- a/apps/server/src/relay/AgentAwarenessRelay.ts+++ b/apps/server/src/relay/AgentAwarenessRelay.ts@@ -432,20 +432,30 @@
// immediately deletes the thread from the lock-screen card mid-
// conversation. Defer, re-resolve, and only tombstone if it holds;
// genuinely deleted threads still propagate a few seconds later.
- if (pendingTombstoneConfirms.has(threadId)) {- return;- }- pendingTombstoneConfirms.add(threadId);- yield* Effect.logInfo("agent activity tombstone deferred pending confirmation", {- environmentId,- threadId,- reason: snapshot.reason,- shell: describeThreadShellForAwareness(thread),- });- yield* Effect.forkDetach(- confirmTombstoneLater(threadId).pipe(- Effect.ensuring(Effect.sync(() => pendingTombstoneConfirms.delete(threadId))),- ),+ // Check-add-fork must be atomic under interruption: a set entry without+ // a forked confirm fiber (whose `ensuring` owns the delete) would+ // suppress tombstone confirmation for this thread forever.+ yield* Effect.uninterruptible(+ Effect.suspend(() => {+ if (pendingTombstoneConfirms.has(threadId)) {+ return Effect.void;+ }+ pendingTombstoneConfirms.add(threadId);+ return Effect.logInfo("agent activity tombstone deferred pending confirmation", {+ environmentId,+ threadId,+ reason: snapshot.reason,+ shell: describeThreadShellForAwareness(thread),+ }).pipe(+ Effect.andThen(+ Effect.forkDetach(+ confirmTombstoneLater(threadId).pipe(+ Effect.ensuring(Effect.sync(() => pendingTombstoneConfirms.delete(threadId))),+ ),+ ),+ ),+ );+ }),
);
return;
}

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

Comment threadapps/server/src/relay/AgentAwarenessRelay.ts Outdated
Diagnostics from the stuck-at-Working repro finally exposed the data
hole: the confirmed tombstone's shell showed sessionStatus "ready" with
latestTurn entirely absent. Threads whose turns produce no checkpoint
never get a projection_turns row, and thread.session-set clears
latest_turn_id the moment the session settles (activeTurnId goes null) —
so "completed" is unrepresentable through turns for quick Q&A threads.
Their entire lifecycle rode on session status and pendings, and at
completion nothing remained on the awareness ladder: tombstone instead
of Done.
A live session at "ready"/"idle" with nothing pending and nothing
running now projects as completed. This intentionally diverges from the
web sidebar, which resolves the same null but renders it as "no pill" —
a fine default in a list, while the publisher's null means "remove from
the lock-screen card". (The sidebar's own Completed pill needs
latestTurn.completedAt and silently can't fire for these threads either;
persisting turn rows for checkpoint-less turns is the deeper future fix
for both surfaces.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment threadpackages/shared/src/agentAwareness.ts
The full lifecycle finally works end to end, with one wart: a duplicate
Done push notification fired one second after thread creation. Sessions
boot at "ready" before their first turn starts, so the new
ready-means-completed projection produced a momentary completed state at
birth — and since the freshly armed card's token wasn't registered yet,
it fell through to the notification channel. Server restarts had the
same shape at scale: the startup snapshot republished completed for
every recently finished thread, each queuing a push notification.
- Env server: completed as a thread's FIRST published state defers and
re-resolves like a tombstone — at birth the confirm sees running and
completed never publishes; genuinely at-rest threads still publish
five seconds later. Fresh completions (previous state was live) are
unaffected, keeping the buzz immediate.
- Relay: terminal push notifications only fire when the completion is
fresh (row updated within two minutes), so replays and restart
snapshots repaint state without ringing the device.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
if (!activity) {
return null;
}
if (activity.phase === "completed" || activity.phase === "failed") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 MediumagentActivity/ApnsDeliveries.ts:321

notificationForAggregate drops terminal notifications for push-notification-only users when activity.updatedAt is more than two minutes old. activity.updatedAt is the timestamp from the published aggregate state, not the time the notification was first delivered to this device. If relay publishing or replay is delayed by more than two minutes (e.g., after downtime or a late reconnect), the first completed/failed push notification for a notifications-only user is silently suppressed even though it was never delivered before. Consider gating the freshness check on the device's last-known delivery time (e.g., last_push_notification_delivery_at) rather than activity.updatedAt, so only devices that already received a recent terminal notification are suppressed.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/ApnsDeliveries.ts around line 321:
`notificationForAggregate` drops terminal notifications for push-notification-only users when `activity.updatedAt` is more than two minutes old. `activity.updatedAt` is the timestamp from the published aggregate state, not the time the notification was first delivered to this device. If relay publishing or replay is delayed by more than two minutes (e.g., after downtime or a late reconnect), the first `completed`/`failed` push notification for a notifications-only user is silently suppressed even though it was never delivered before. Consider gating the freshness check on the device's last-known delivery time (e.g., `last_push_notification_delivery_at`) rather than `activity.updatedAt`, so only devices that already received a recent terminal notification are suppressed.

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

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

  • ✅ Fixed: LA alerts skip terminal freshness
    • Threaded nowMs into alertForNewlyTerminal and alertForTerminalAggregate and applied the shared TERMINAL_NOTIFICATION_FRESHNESS_MS window via a new isFreshTerminalRow helper, so stale replayed completions no longer alert through Live Activity updates or end events.

Create PR

Or push these changes by commenting:

@cursor push c88cec1a49
Preview (c88cec1a49)
diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts--- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts+++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts@@ -1525,15 +1525,30 @@
activities: [{ ...aggregate.activities[0]!, phase: "completed" as const, status: "Done" }],
};
expect(
- ApnsDeliveries.alertForTerminalAggregate({ aggregate: terminalAggregate, preferences }),+ ApnsDeliveries.alertForTerminalAggregate({+ aggregate: terminalAggregate,+ preferences,+ nowMs: 0,+ }),
).toEqual({ title: "Thread", body: "Done: Project" });
expect(
ApnsDeliveries.alertForTerminalAggregate({
aggregate: terminalAggregate,
preferences: { ...preferences, notifyOnCompletion: false },
+ nowMs: 0,
}),
).toBeNull();
- expect(ApnsDeliveries.alertForTerminalAggregate({ aggregate: null, preferences })).toBeNull();+ expect(+ ApnsDeliveries.alertForTerminalAggregate({ aggregate: null, preferences, nowMs: 0 }),+ ).toBeNull();+ // A completion replayed long after the fact must not ring again.+ expect(+ ApnsDeliveries.alertForTerminalAggregate({+ aggregate: terminalAggregate,+ preferences,+ nowMs: 10 * 60 * 1_000,+ }),+ ).toBeNull();
});
it("alerts when a previously active thread finishes mid-flight", () => {
@@ -1552,6 +1567,7 @@
previousAggregate: aggregate,
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toEqual({ title: "Thread", body: "Done: Project" });
// The completion switch mutes it.
@@ -1560,6 +1576,7 @@
previousAggregate: aggregate,
nextAggregate: next,
preferences: { ...preferences, notifyOnCompletion: false },
+ nowMs: 0,
}),
).toBeNull();
// No baseline means no transition to ring on.
@@ -1568,6 +1585,7 @@
previousAggregate: null,
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toBeNull();
// A Done row that was already terminal (or absent) before stays silent.
@@ -1576,6 +1594,7 @@
previousAggregate: next,
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toBeNull();
expect(
@@ -1583,7 +1602,18 @@
previousAggregate: { ...aggregate, activities: [attentionRow] },
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toBeNull();
+ // A stale completion replayed after a restart (previous aggregate still+ // shows the thread as running) must not ring the device again.+ expect(+ ApnsDeliveries.alertForNewlyTerminal({+ previousAggregate: aggregate,+ nextAggregate: next,+ preferences,+ nowMs: 10 * 60 * 1_000,+ }),+ ).toBeNull();
});
});
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@@ -210,6 +210,20 @@
};
}
+// Completions replayed long after the fact (server restarts republish every+// recently-finished thread) must not ring the device again — on any channel:+// push notifications, Live Activity update alerts, and end alerts all honor+// the same freshness window.+const TERMINAL_NOTIFICATION_FRESHNESS_MS = 2 * 60 * 1_000;++function isFreshTerminalRow(row: { readonly updatedAt: string }, nowMs: number): boolean {+ const updatedAtMs = Option.match(DateTime.make(row.updatedAt), {+ onNone: () => null,+ onSome: (dt) => dt.epochMilliseconds,+ });+ return updatedAtMs !== null && nowMs - updatedAtMs <= TERMINAL_NOTIFICATION_FRESHNESS_MS;+}+
// Alert copy for an update whose aggregate contains threads that finished
// (Done/Failed) since the previously delivered aggregate — the mid-flight
// completion buzz while other agents keep the activity alive. Requires the
@@ -219,6 +233,7 @@
readonly previousAggregate: RelayAgentActivityAggregateState | null;
readonly nextAggregate: RelayAgentActivityAggregateState;
readonly preferences: RelayAgentAwarenessPreferences | null;
+ readonly nowMs: number;
}): ApnsLiveActivityAlert | null {
if (input.previousAggregate === null) {
return null;
@@ -230,6 +245,9 @@
if (row.phase !== "completed" && row.phase !== "failed") {
return false;
}
+ if (!isFreshTerminalRow(row, input.nowMs)) {+ return false;+ }
const previousPhase = previousPhases.get(row.threadId);
return (
previousPhase !== undefined &&
@@ -255,11 +273,15 @@
export function alertForTerminalAggregate(input: {
readonly aggregate: RelayAgentActivityAggregateState | null;
readonly preferences: RelayAgentAwarenessPreferences | null;
+ readonly nowMs: number;
}): ApnsLiveActivityAlert | null {
const row = input.aggregate?.activities[0];
if (!row || (row.phase !== "completed" && row.phase !== "failed")) {
return null;
}
+ if (!isFreshTerminalRow(row, input.nowMs)) {+ return null;+ }
if (!alertAllowedForPhase(input.preferences, row.phase)) {
return null;
}
@@ -298,10 +320,6 @@
);
}
-// Completions replayed long after the fact (server restarts republish every-// recently-finished thread) must not ring the device again.-const TERMINAL_NOTIFICATION_FRESHNESS_MS = 2 * 60 * 1_000;-
function notificationForAggregate(input: {
readonly target: LiveActivities.TargetRow;
readonly aggregate: RelayAgentActivityAggregateState | null;
@@ -318,14 +336,11 @@
if (!activity) {
return null;
}
- if (activity.phase === "completed" || activity.phase === "failed") {- const updatedAtMs = Option.match(DateTime.make(activity.updatedAt), {- onNone: () => null,- onSome: (dt) => dt.epochMilliseconds,- });- if (updatedAtMs === null || input.nowMs - updatedAtMs > TERMINAL_NOTIFICATION_FRESHNESS_MS) {- return null;- }+ if (+ (activity.phase === "completed" || activity.phase === "failed") &&+ !isFreshTerminalRow(activity, input.nowMs)+ ) {+ return null;
}
const enabled =
(activity.phase === "waiting_for_approval" && preferences.notifyOnApproval) ||
@@ -419,6 +434,7 @@
previousAggregate,
nextAggregate,
preferences,
+ nowMs: input.nowMs,
}),
}
: "suppressed";
@@ -1100,6 +1116,7 @@
: alertForTerminalAggregate({
aggregate: delivery.aggregate,
preferences: parsePreferences(input.target.preferences_json),
+ nowMs: input.nowMs,
})
: delivery.alert;
const result = yield* deliveryQueue.enqueueLiveActivity({

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

Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts
…A pref gates
Fixes the real findings from the PR review bots ahead of the TestFlight
build:
- Deferred publish confirmations (tombstones and first-state
completions) now re-enqueue through the same drainable worker as every
other publish instead of a detached fiber calling the publisher
directly, so a confirmed tombstone can never race an in-flight live
update; a deadline map replaces the pending set, which also guarantees
a single scheduled confirmation per thread and clears when the state
recovers. (cursor/macroscope High)
- Newly-terminal transitions bypass the 15s live-activity update
throttle: a completion landing in the same window as a new start keeps
activeCount unchanged and would previously be suppressed, silently
dropping the Done transition and its buzz. (macroscope High)
- The newly-terminal alert honors the same 2-minute freshness rule as
terminal push notifications, so replayed aggregates repaint without
ringing. (cursor Medium)
- Live Activity arming and priming treat an unset preference as enabled
(the toggle's default): fresh installs now prime, and arm-on-send
respects an explicit off. (cursor High/Medium)
Remaining findings triaged as stale (superseded designs), intended
behavior (replay painting recent Done, LA-disabled end retiring the
token), or accepted edge cases (grace extension on re-registration,
completion alerts beyond the 3-row display cap, ready-with-lastError
mapping to Done, cancelled turns showing Done).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

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

  • ✅ Fixed: Stale confirm deadline publishes tombstone
    • Cleared the thread's publishConfirmDeadlines entry in the unchanged-identity early-return path so a recovered projection cancels the deferred confirmation, preventing a later transient null state from confirming immediately against a stale expired deadline.

Create PR

Or push these changes by commenting:

@cursor push e02169b78e
Preview (e02169b78e)
diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts--- a/apps/server/src/relay/AgentAwarenessRelay.ts+++ b/apps/server/src/relay/AgentAwarenessRelay.ts@@ -410,6 +410,11 @@
const publishIdentity = agentAwarenessPublishIdentity(snapshot.state);
const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef);
if (publishedStateByThread.get(threadId) === publishIdentity) {
+ // The projection recovered to the last published state, so any deferred+ // confirmation (tombstone or first-state completion) is moot. Clear the+ // deadline so the next divergence gets a fresh deferral window instead+ // of confirming immediately against a stale, expired deadline.+ publishConfirmDeadlines.delete(threadId);
yield* Effect.logDebug("agent activity publish skipped; projected state unchanged", {
environmentId,
threadId,

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

Reviewed by Cursor Bugbot for commit 97ed442. Configure here.

Comment threadapps/server/src/relay/AgentAwarenessRelay.ts
…d state
A recovered projection exits at the unchanged-identity dedupe, which sits
above the confirmation block, so a deferred publish's deadline survived
recovery. A much later transient null would then find the deadline
already expired and publish its tombstone immediately, bypassing the
deferral window entirely. The dedupe path now clears any pending
deadline: the state is back at what was last published, so the deferred
confirmation is moot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@juliusmarmingejuliusmarminge added the 🚀 Mobile Continuous Deployment Trigger Expo preview build label Jul 7, 2026
Fixes the Effect Service Conventions check: the service builds a plain
object with no effectful acquisition, so wrapping it in Effect.sync just
to feed Layer.effect was the make-only-to-force-Layer.effect shape the
conventions call out.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

ghost commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

🚀 Expo continuous deployment is ready!

  • Project → t3-code
  • Platforms → android, ios
  • Scheme → t3code-preview
🤖 Android🍎 iOS
Fingerprint106610ae982be2d8aa28dda9217ff853daf9d30b4f388ce49fd16563ef62f563f42c0528f092d42b
Build DetailsBuild Permalink
DetailsDistribution: INTERNAL
Build profile: preview:dev
Runtime version: 106610ae982be2d8aa28dda9217ff853daf9d30b
App version: 0.1.0
Git commit: 74888084257320d42e7562c74996c949010bdf45
Build Permalink
DetailsDistribution: INTERNAL
Build profile: preview:dev
Runtime version: 4f388ce49fd16563ef62f563f42c0528f092d42b
App version: 0.1.0
Git commit: c622444d589c97adb591101ffca9161d785806b0
Update DetailsUpdate Permalink
DetailsBranch: pr-3685
Runtime version: 106610ae982be2d8aa28dda9217ff853daf9d30b
Git commit: 74888084257320d42e7562c74996c949010bdf45
Update Permalink
DetailsBranch: pr-3685
Runtime version: 4f388ce49fd16563ef62f563f42c0528f092d42b
Git commit: 74888084257320d42e7562c74996c949010bdf45
Update QR

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

Labels

🚀 Mobile Continuous DeploymentTrigger Expo preview buildsize:XXL1,000+ changed lines (additions + deletions).vouch:trustedPR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@juliusmarminge
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Improve live activity routing and diagnostics - #3685

Merged
juliusmarminge merged 26 commits into
mainfrom
t3code/live-activities-improvements
Jul 7, 2026
Merged

Improve live activity routing and diagnostics#3685
juliusmarminge merged 26 commits into
mainfrom
t3code/live-activities-improvements

Conversation

@juliusmarminge

@juliusmarmingejuliusmarminge commented Jul 4, 2026

Copy link
Copy Markdown
Member

Summary

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

Testing

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

Note

High Risk
Touches relay registration, APNs routing, cloud link modes, and Live Activity lifecycle (including removing push-to-start); misconfiguration or partial deploy could break mobile notifications or mis-route pushes.

Overview
Improves mobile agent awareness end-to-end: relay device registration now sends bundle ID and sandbox vs production APNs routing, tracks whether the relay actually accepted registration (settings toggles and alerts follow that, not just iOS permission), skips duplicate registrations via a persisted signature, and re-registers Live Activity tokens on foreground with short-window deduping. Push-to-start registration is removed in favor of arming a card when the user sends a task (and priming from a relay activity snapshot when idle), plus cleanup on cloud sign-out and a releaseAgentAwarenessRelayTokenProvider path so auth UI remounts do not wipe registration or end activities.

Agent Activity Live Activity UI is reworked (attention-first rows, phase tints aligned with web, deep links, branded T3 mark via new Expo config plugins and actool shell phase). expo-widgets gains frequentUpdates for higher update budget.

T3 Connect vs publishing split: environments can link in publish_only mode (manual endpoint, no Cloudflare tunnel) via CLI (connect link --publish-only, connect publish) and web settings (separate toggles reconciled together). Link proofs accept manual providers with activity-only scopes; server publish path defers transient tombstones and first completed snapshots before publishing.

Relay DB adds bundle_id and aps_environment on mobile devices (migration in diff).

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

Note

Add per-device APNs routing, JWT token caching, and publish-only link mode to Live Activity delivery

  • Stores bundle_id and aps_environment per device in the relay and uses them to route APNs pushes to the correct topic and environment per device rather than using global config.
  • Introduces ApnsProviderTokens service that caches APNs JWTs quantized to 45-minute windows, preventing TooManyProviderTokenUpdates errors; uses deterministic ES256 signing via @noble/curves.
  • Overhauls Live Activity delivery: activities are no longer remotely started; ends are suppressed within a grace window after arming; attention-phase transitions and newly-terminal rows can now trigger alert payloads with haptics; stale terminal aggregates suppress re-alerting.
  • Adds a publish_only cloud link mode in the CLI (t3 connect publish) and web settings UI, allowing agent activity publishing without a managed Cloudflare tunnel; linking with this mode uses providerKind: "manual" and deprovisions any existing tunnel allocation.
  • Reworks the AgentActivity widget to sort rows attention-first, apply per-phase tints from the web palette, deep-link to the most relevant thread, show a branded logo, and display a phase glyph in minimal presentation.
  • Exposes relay registration status (unknown/pending/registered/failed) to the settings UI so notification and Live Activity toggles only appear enabled when the device is actually registered.
  • Risk: makeAggregateState now requires a nowMs parameter and statusForPhase('starting') returns 'Connecting' instead of 'Starting', which are breaking changes for callers.

Macroscope summarized 6f0b32d.

@coderabbitai

coderabbitaiBot commented Jul 4, 2026

Copy link
Copy Markdown

Important

Review skipped

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

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: fe216c9a-1cd9-460a-a80d-6d90748a00b5

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

Use the checkbox below for a quick retry:

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

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

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

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Autofix Details

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

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

Create PR

Or push these changes by commenting:

@cursor push a97e0b6881
Preview (a97e0b6881)
diff --git a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts--- a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts+++ b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts@@ -69,6 +69,28 @@
]);
});
+ it("treats a non-2xx delivery status without a reason as a failure", () => {+ const rows = formatDeviceDiagnosticsRows(+ makeDevice({+ bundleId: "com.t3tools.t3code.preview",+ apsEnvironment: "production",+ hasPushToken: true,+ hasPushToStartToken: true,+ hasLiveActivityToken: true,+ lastDeliveryAt: "2026-06-05T01:02:59.566Z",+ lastDeliveryKind: "live_activity_end",+ lastDeliveryStatus: 400,+ lastDeliveryError: null,+ }),+ );++ expect(rows[4]).toEqual({+ label: "Last Delivery",+ value: "Delivery failed (400)",+ tone: "warn",+ });+ });+
it("reports healthy registrations with a successful delivery", () => {
const rows = formatDeviceDiagnosticsRows(
makeDevice({
diff --git a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts--- a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts+++ b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts@@ -73,14 +73,21 @@
});
}
+ // APNs can fail with a non-2xx status without a reason body, so a null+ // error alone does not mean the delivery succeeded.+ const deliveryFailed =+ diagnostics.lastDeliveryError !== null ||+ (diagnostics.lastDeliveryStatus !== null &&+ (diagnostics.lastDeliveryStatus < 200 || diagnostics.lastDeliveryStatus >= 300));+
if (diagnostics.lastDeliveryAt === null) {
rows.push({ label: "Last Delivery", value: "None yet", tone: "muted" });
- } else if (diagnostics.lastDeliveryError !== null) {+ } else if (deliveryFailed) {
const status =
diagnostics.lastDeliveryStatus === null ? "" : ` (${diagnostics.lastDeliveryStatus})`;
rows.push({
label: "Last Delivery",
- value: `${diagnostics.lastDeliveryError}${status}`,+ value: `${diagnostics.lastDeliveryError ?? "Delivery failed"}${status}`,
tone: "warn",
});
} else {
diff --git a/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/migration.sql b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/migration.sql
new file mode 100644
--- /dev/null+++ b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/migration.sql@@ -1,0 +1,3 @@+ALTER TABLE "relay_mobile_devices" ADD COLUMN "bundle_id" varchar(255);+--> statement-breakpoint+ALTER TABLE "relay_mobile_devices" ADD COLUMN "aps_environment" varchar(16);diff --git a/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/snapshot.json b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/snapshot.json
new file mode 100644
--- /dev/null+++ b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/snapshot.json@@ -1,0 +1,1479 @@+{+ "dialect": "postgres",+ "id": "e00cc7df-a31e-4b8b-b258-d04e7db7758a",+ "prevIds": ["385d476b-d4f6-48a3-99e6-0a95af4ee4e4"],+ "version": "8",+ "ddl": [+ {+ "isRlsEnabled": false,+ "name": "relay_agent_activity_rows",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_delivery_attempts",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_dpop_proofs",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_environment_credentials",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_environment_links",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_live_activities",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_managed_endpoint_allocations",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_mobile_devices",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_public_key",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "thread_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "jsonb",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "state_json",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "updated_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "varchar(36)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "user_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "thread_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "device_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "kind",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "source_job_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(16)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "token_suffix",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "integer",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "apns_status",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "apns_reason",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(128)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "apns_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "transport_error",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(128)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "thumbprint",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "jti",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "integer",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "iat",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "expires_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "credential_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_public_key",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "credential_hash",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "revoked_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "updated_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "user_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": "'T3 Environment'",+ "generated": null,+ "identity": null,+ "name": "environment_label",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_public_key",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "endpoint_http_base_url",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "endpoint_ws_base_url",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(32)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "endpoint_provider_kind",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "boolean",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": "true",+ "generated": null,+ "identity": null,+ "name": "notifications_enabled",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "boolean",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": "true",+ "generated": null,+ "identity": null,+ "name": "live_activities_enabled",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "boolean",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": "false",+ "generated": null,+ "identity": null,+ "name": "managed_tunnels_enabled",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_by_device_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "revoked_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "updated_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "user_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "device_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "activity_push_token",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "remote_start_queued_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "remote_started_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "ended_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "jsonb",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,
... diff truncated: showing 800 of 1681 lines

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highpersistence/schema.ts:27

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

Also found in 1 other location(s)

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

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/persistence/schema.ts around line 27:
The new `bundle_id` and `aps_environment` columns are added to the `relay_mobile_devices` schema, but no SQL migration is included to create them. After deploy, the code in `Devices.ts` immediately inserts and selects these columns, so PostgreSQL rejects every device registration and list query with `column "bundle_id" does not exist` (and `aps_environment` likewise). Add the corresponding `ALTER TABLE relay_mobile_devices ADD COLUMN ...` migration under `infra/relay/migrations/` so the columns exist before the new code runs.
Also found in 1 other location(s):
- infra/relay/src/agentActivity/LiveActivities.ts:201 -- The new `relayMobileDevices.bundleId` / `relayMobileDevices.apsEnvironment` columns are referenced by `listTargets` (`bundle_id`, `aps_environment`) and other relay code, but this PR does not add a matching SQL migration for `relay_mobile_devices`. After deploying the code to an existing database, any `listTargets` query will start failing with `column relay_mobile_devices.bundle_id does not exist` (and likewise for `aps_environment`), breaking live-activity target lookup until the schema is manually patched.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium

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

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/ApnsDeliveries.ts around line 506:
The stale-job check in `processSignedJob` only compares the token via `isCurrentSignedJobToken`, so a queued job created before the device registered `bundleId`/`apsEnvironment` (or before those values changed) still passes the staleness guard when the token is unchanged. The job is then sent with the stale or missing routing metadata from the signed payload instead of the device's current values, causing APNs to reject the delivery with `BadDeviceToken`/`DeviceTokenNotForTopic` even though the registration has already been corrected. Consider comparing `bundleId` and `apsEnvironment` against the current target in the staleness check so jobs with outdated routing are treated as stale.

@macroscopeapp

macroscopeappBot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

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

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumwidgets/AgentActivity.tsx:81

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/widgets/AgentActivity.tsx around line 81:
`widgetURL` is hardcoded to the `t3code://` scheme, but the app registers `t3code-dev` for development builds and `t3code-preview` for preview builds. On dev/preview builds the widget banner's tap URL uses a scheme the containing app does not register, so iOS will not open the app when the banner is tapped. Consider using a scheme that is registered in all build variants (or injecting the active scheme per-build).

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 MediumagentActivity/AgentActivityPublisher.ts:203

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/AgentActivityPublisher.ts around line 203:
`RUNNING_AGENT_ACTIVITY_ROW_TTL_MS` expires `running`/`starting` states after 2 hours based on `updatedAt`, but relay publishes are event-driven — a thread that stays in the same `running` phase for over 2 hours without a new event keeps its old `updatedAt`, so `isExpiredAgentActivityState` returns `true` and `makeAggregateState` filters it out. The aggregate then reports `activeCount: 0` (or `null`/terminal) even though the job is still running, hiding active work from clients. Consider raising the running TTL to match the waiting TTL, or filtering on a heartbeat/last-seen timestamp that is refreshed independently of phase-change events.

Comment threadapps/mobile/src/widgets/AgentActivity.tsx
Comment threadinfra/relay/src/agentActivity/Devices.ts Outdated
Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts
@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from 1d6309e to 39d9dcaCompareJuly 6, 2026 06:07
@github-actionsgithub-actionsBot added size:L 100-499 changed lines (additions + deletions). and removed size:XL 500-999 changed lines (additions + deletions). labels Jul 6, 2026
@cursor

This comment has been minimized.

@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from 39d9dca to 5225bdbCompareJuly 6, 2026 07:37
@github-actionsgithub-actionsBot added size:XXL 1,000+ changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Jul 6, 2026

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

Autofix Details

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

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

Create PR

Or push these changes by commenting:

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

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsettings/SettingsRouteScreen.tsx:438

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/settings/SettingsRouteScreen.tsx around line 438:
The `deviceRegistered` gate on the `Device Notifications` switch renders it `false` when relay registration fails, even though `notificationStatus` is `"enabled"`. Because the switch shows off, the user cannot toggle it to reach `handleDeviceNotificationsChange(false)`, which is the only path that opens iOS Settings to disable notifications. In the "permission granted, relay registration failed" case the switch misreports notifications as off and removes the in-app affordance for managing them. Consider gating only the Live Activity switch on `deviceRegistered`, or keeping the `Device Notifications` switch reflecting the actual iOS permission state so the user can still navigate to system Settings.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 HighagentActivity/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 effectSchema.Struct behavior) and recompute the HMAC over the reduced payload, so verifySignedApnsDeliveryJob returns ApnsDeliveryJobSignatureInvalid for every newly queued job carrying per-device routing. This breaks APNs delivery during rolling deploys when new producers share a queue with old consumers. Consider gating inclusion of these fields behind a flag, or otherwise ensuring the signed payload shape remains decodable by older schema versions, so signatures stay stable across the rollout.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/apnsDeliveryJobs.ts around line 247:
`makeApnsDeliveryJobPayload` includes `target.bundleId` and `target.apsEnvironment` in the payload, and `signatureForPayload` signs that full object. Older relay workers that decode with a previous `ApnsDeliveryJobPayload` schema strip the new keys on decode (default `effect` `Schema.Struct` behavior) and recompute the HMAC over the reduced payload, so `verifySignedApnsDeliveryJob` returns `ApnsDeliveryJobSignatureInvalid` for every newly queued job carrying per-device routing. This breaks APNs delivery during rolling deploys when new producers share a queue with old consumers. Consider gating inclusion of these fields behind a flag, or otherwise ensuring the signed payload shape remains decodable by older schema versions, so signatures stay stable across the rollout.

@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from 5225bdb to 5086c14CompareJuly 6, 2026 16:13

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium

infoPlist: {
NSAppTransportSecurity: {

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

 infoPlist: {
+ NSSupportsLiveActivitiesFrequentUpdates: true,
NSAppTransportSecurity: {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/app.config.ts around lines 96-97:
The `frequentUpdates: true` option under the `expo-widgets` plugin does not add the required `NSSupportsLiveActivitiesFrequentUpdates` key to `Info.plist`, so iOS keeps the standard Live Activity update budget and frequent updates are never enabled. Set `NSSupportsLiveActivitiesFrequentUpdates: true` in the `ios.infoPlist` block to actually grant the entitlement.

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

export interface AgentAwarenessRegistrationRecord {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumlib/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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highsettings/SettingsRouteScreen.tsx:441

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/settings/SettingsRouteScreen.tsx around line 441:
When `deviceRegistered` is `false` but `liveActivitiesEnabled` is `true` in storage, the `Live Activity Updates` switch renders off even though `liveActivityStatus` is `"enabled"`. Because `onValueChange` receives the next visual state (`true`), tapping the switch calls `linkEnvironments()` (the enable path) instead of persisting `enabled: false`, so the user cannot disable Live Activity updates from Settings until registration succeeds. Consider gating the disabled state or the alert on `deviceRegistered` rather than the switch `value`, so the toggle still reflects the saved preference and `onValueChange` fires with the correct next state.

Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

Autofix Details

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

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

Create PR

Or push these changes by commenting:

@cursor push 59517fdb7d
Preview (59517fdb7d)
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts@@ -186,7 +186,11 @@
refreshActiveLiveActivityRemoteRegistration(),
"active live activity registration after cloud sign-in failed",
);
- if (isExistingIdentity) {+ // An unchanged identity only skips re-registration when the previous attempt+ // actually reached the relay; a failed or stalled attempt must retry the next+ // time the auth effect re-runs, or the device stays unregistered until an+ // unrelated event (token rotation, environment link) happens to enqueue one.+ if (isExistingIdentity && registrationStatus === "registered") {
return;
}
enqueueDeviceRegistration({}, "device registration after cloud sign-in failed");
diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts--- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts+++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts@@ -119,6 +119,16 @@
terminalState: null,
nowMs: now.epochMilliseconds,
});
+ // A null aggregate is ambiguous while non-terminal rows still exist:+ // they may belong to a dead environment, or to a healthy long-running+ // agent whose meaningful state (and therefore updatedAt) has not changed+ // within the TTL. Sending in that case would end a possibly-healthy Live+ // Activity on every foreground, so replay only delivers when live rows+ // remain or the rows are truly gone (the thread ended and its end push+ // may have been lost).+ if (aggregate === null && activeStates.some((state) => !isTerminalPhase(state))) {+ return null;+ }
return yield* apnsDeliveries.sendForTarget({
target,
aggregate,

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

Comment threadinfra/relay/src/agentActivity/AgentActivityPublisher.ts
@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from 5086c14 to a31735cCompareJuly 6, 2026 17:03
Comment threadapps/mobile/src/features/settings/SettingsRouteScreen.tsx
Comment threadapps/server/src/cli/connect.ts

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

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

Autofix Details

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

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

Create PR

Or push these changes by commenting:

@cursor push d469c8569a
Preview (d469c8569a)
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts@@ -535,6 +535,48 @@
}).pipe(Effect.provide(relayTestLayer));
});
+ it.effect("keeps the registered status when a stale concurrent attempt fails", () => {+ const fetchMock = vi.fn((request: RequestInfo | URL) => {+ const url = request instanceof Request ? request.url : String(request);+ return Promise.resolve(+ Response.json(+ url.endsWith("/v1/client/dpop-token")+ ? {+ access_token: "relay-dpop-token",+ issued_token_type: "urn:ietf:params:oauth:token-type:access_token",+ token_type: "DPoP",+ expires_in: 300,+ scope: "mobile:registration",+ }+ : { ok: true },+ ),+ );+ });+ vi.stubGlobal("fetch", fetchMock);+ Constants.expoConfig!.extra = {+ relay: {+ url: "https://relay.example.test/",+ },+ };++ // Sign-in enqueues a registration attempt that stays parked in the+ // background queue while the direct refresh below runs.+ setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));++ return Effect.gen(function* () {+ yield* refreshAgentAwarenessRegistration();+ expect(getAgentAwarenessRegistrationStatus()).toBe("registered");++ // The queued sign-in attempt now fails; its stale failure must not+ // overwrite the registration the relay already accepted.+ vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockRejectedValueOnce(+ new Error("device id unavailable"),+ );+ yield* runBackgroundOperations();+ expect(getAgentAwarenessRegistrationStatus()).toBe("registered");+ }).pipe(Effect.provide(relayTestLayer));+ });+
it("clears registration status on cloud sign-out", () => {
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
setAgentAwarenessRelayTokenProvider(null);
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts@@ -74,6 +74,13 @@
let registrationStatus: AgentAwarenessRegistrationStatus = "unknown";
const registrationStatusListeners = new Set<() => void>();
+// Registration runs both through the background queue and directly via+// refreshAgentAwarenessRegistration, so attempts can overlap. Counting relay+// acceptances lets a failing attempt detect that a concurrent attempt already+// succeeded while it was in flight, so a stale failure cannot overwrite a+// registration the relay accepted.+let registrationSuccessCount = 0;+
function setRegistrationStatus(next: AgentAwarenessRegistrationStatus): void {
if (registrationStatus === next) {
return;
@@ -84,6 +91,18 @@
}
}
+function markRegistrationSucceeded(): void {+ registrationSuccessCount++;+ setRegistrationStatus("registered");+}++function markRegistrationFailed(successCountAtAttemptStart: number): void {+ if (registrationSuccessCount !== successCountAtAttemptStart) {+ return;+ }+ setRegistrationStatus("failed");+}+
export function getAgentAwarenessRegistrationStatus(): AgentAwarenessRegistrationStatus {
return registrationStatus;
}
@@ -316,7 +335,7 @@
catch: (cause) => cause,
}).pipe(Effect.orElseSucceed(() => null));
if (persisted && persisted.identity === identity && persisted.signature === signature) {
- setRegistrationStatus("registered");+ markRegistrationSucceeded();
logRegistrationDebug("relay device registration skipped; already registered for account", {
expectedGeneration,
});
@@ -331,7 +350,7 @@
clerkToken: token,
payload: body,
});
- setRegistrationStatus("registered");+ markRegistrationSucceeded();
yield* Effect.promise(() =>
saveAgentAwarenessRegistrationRecord({ identity, signature }).catch((error: unknown) => {
logRegistrationError("persist registration record failed", error);
@@ -466,11 +485,12 @@
};
activeDeviceRegistration = registration;
registration.operation = (async () => {
+ const successCountAtStart = registrationSuccessCount;
const result = await settleAsyncResult(() =>
runtime.runPromiseExit(registerDevice(next.input, generation)),
);
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
- setRegistrationStatus("failed");+ markRegistrationFailed(successCountAtStart);
logRegistrationError(next.context, squashAtomCommandFailure(result));
}
logRegistrationDebug("device registration finished", { generation });
@@ -675,14 +695,17 @@
never,
ManagedRelay.ManagedRelayClient
> {
- return registerDeviceForCurrentUser().pipe(- Effect.catch((error) =>- Effect.sync(() => {- setRegistrationStatus("failed");- logRegistrationError("device registration refresh failed", error);- }),- ),- );+ return Effect.suspend(() => {+ const successCountAtStart = registrationSuccessCount;+ return registerDeviceForCurrentUser().pipe(+ Effect.catch((error) =>+ Effect.sync(() => {+ markRegistrationFailed(successCountAtStart);+ logRegistrationError("device registration refresh failed", error);+ }),+ ),+ );+ });
}
export function __resetAgentAwarenessRemoteRegistrationForTest(): void {
@@ -703,6 +726,7 @@
activeDeviceRegistration = null;
pendingDeviceRegistration = null;
registrationStatus = "unknown";
+ registrationSuccessCount = 0;
registrationStatusListeners.clear();
}

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

Comment threadapps/mobile/src/features/agent-awareness/remoteRegistration.ts Outdated
- Per-device APNs routing (bundle id + environment) so preview/dev builds
receive pushes from the shared relay (+ migration for the new columns)
- Settings notification/Live Activity toggles reflect actual relay
registration success; cannot read as enabled when the device is not registered
- Persistent register-once: skip re-registering an unchanged payload for the
same account across launches; clear only on sign-out
- Headless publish + tunnel-free linking: `t3 connect publish` toggles agent
activity publishing and auto-establishes a publish-only (no managed tunnel)
link, and `t3 connect link --publish-only` links for publishing alone, so
activity flows to mobile clients that reach the environment out of band
(e.g. Tailscale) without T3 Connect. Relay accepts publish-only links with a
nominal endpoint; env proofs advertise the manual provider kind
- Foreground Live Activity refresh, stale/ghost-row hardening, dedup fixes
- Tighten widget deep linking and per-row rendering for active agents
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from a31735c to 9c3eb7bCompareJuly 6, 2026 17:24
// Development builds are Xcode-signed and receive sandbox APNs tokens;
// preview and production builds are distribution-signed and use production
// APNs. The relay routes each device's pushes accordingly.
export function resolveApsEnvironment(appVariant: unknown): "sandbox" | "production" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumagent-awareness/registrationPayload.ts:8

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/agent-awareness/registrationPayload.ts around line 8:
`resolveApsEnvironment` returns `"production"` for every value except the literal string `"development"`. Locally run `ios:preview` and `ios:prod` builds launched via `expo run:ios` are development-signed and receive sandbox APNs tokens, but this function classifies them as `"production"`. The relay then routes pushes to the production APNs gateway for a sandbox token, so notifications and Live Activities fail to deliver for those builds. Consider keying off the signing/provisioning environment (e.g. EAS build profile or `Configuration`/ entitlements) rather than the variant name, or document why locally-run `preview`/`prod` builds are expected to use production APNs.

Comment threadapps/server/src/cli/connect.ts
Comment threadpackages/contracts/src/environmentHttp.ts Outdated
Comment on lines 1745 to 1750
primaryCloudLinkState.refresh();
const refreshResult = await refreshRelayEnvironments();
if (refreshResult._tag === "Failure") {
if (!isAtomCommandInterrupted(refreshResult)) {
reportUpdateFailure(squashAtomCommandFailure(refreshResult));
}
setIsUpdating(false);
return;
if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) {
reportUpdateFailure(squashAtomCommandFailure(refreshResult));
return false;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsettings/ConnectionsSettings.tsx:1745

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

 primaryCloudLinkState.refresh();
+ return true;
const refreshResult = await refreshRelayEnvironments();
if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) {
reportUpdateFailure(squashAtomCommandFailure(refreshResult));
return false;
}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/settings/ConnectionsSettings.tsx around lines 1745-1750:
When `reconcileCloudState` completes a link/unlink and preference update successfully, the mutation is already committed and `primaryCloudLinkState.refresh()` has been called. If the follow-up `refreshRelayEnvironments()` call at line 1747 then fails, the function returns `false`, shows an error toast, and suppresses the success toast — so the user is told their T3 Connect change failed even though it was actually applied. Consider returning `true` before the relay refresh (or not reporting its failure as a mutation failure), so transient relay-discovery errors don't mask a successful mutation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highcloud/http.ts:369

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

 if (
!isSupportedLinkProviderKind(request) ||
(request.endpoint.providerKind === "cloudflare_tunnel" &&
!isAllowedEndpointOrigin({
origin: request.origin,
requestUrl,
}))
) {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/cloud/http.ts around lines 369-375:
`makeCloudLinkProof` calls `isAllowedEndpointOrigin` for every link proof, including manual `providerKind` requests. That helper only permits loopback hosts, so a manual or publish-only link from a non-loopback origin (e.g. Tailscale or directly exposed host) is rejected with `Invalid managed endpoint origin.`, blocking the out-of-band endpoint flow that `isSupportedLinkProviderKind` was added to support. Consider skipping the loopback origin check when `providerKind === "manual"`.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumwidgets/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`.

Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

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

  • ✅ Fixed: Grace blocks legitimate activity end
    • LiveActivities.register no longer resets remote_started_at when the same activity token is re-registered (foreground reconciliation), so the freshly-armed grace window measures from the true arming time and orphaned cards receive their live_activity_end once it expires.

Create PR

Or push these changes by commenting:

@cursor push 5ab40bb88e
Preview (5ab40bb88e)
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@@ -109,10 +109,12 @@
remoteStartedAt: null,
}),
]);
+ // The claim skips this device's own row: it must stay intact so the+ // upsert can tell a same-token re-registration apart from a fresh arm.
expect(updateConditions.map((condition) => dialect.sqlToQuery(condition))).toEqual([
{
- sql: '"relay_live_activities"."activity_push_token" = $1',- params: ["activity-push-token"],+ sql: '(("relay_live_activities"."activity_push_token" = $1) and ((("relay_live_activities"."user_id" <> $2) or ("relay_live_activities"."device_id" <> $3))))',+ params: ["activity-push-token", "user-2", "device-1"],
},
]);
expect(insertedValues).toEqual([
@@ -131,12 +133,20 @@
expect.objectContaining({
activityPushToken: "activity-push-token",
remoteStartQueuedAt: null,
- remoteStartedAt: expect.any(String),
endedAt: null,
lastAggregateJson: null,
lastLiveActivityDeliveryAt: null,
}),
);
+ // Re-registering the SAME token (foreground reconciliation) must not+ // restart the arming clock, or the end-on-empty grace window would+ // renew forever and orphaned cards would never receive their end.+ const remoteStartedAtSet = dialect.sqlToQuery(+ conflictConfigs[0]?.set?.remoteStartedAt as SQL,+ );+ expect(remoteStartedAtSet.sql.replace(/\s+/g, " ")).toBe(+ 'case when "relay_live_activities"."activity_push_token" = excluded.activity_push_token then coalesce("relay_live_activities"."remote_started_at", excluded.remote_started_at) else excluded.remote_started_at end',+ );
}).pipe(
Effect.provide(
LiveActivities.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))),
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@@ -13,7 +13,7 @@
import * as Function from "effect/Function";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
-import { and, eq, sql } from "drizzle-orm";+import { and, eq, ne, or, sql } from "drizzle-orm";
import * as RelayDb from "../db.ts";
import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts";
@@ -141,6 +141,10 @@
const updatedAt = DateTime.formatIso(yield* DateTime.now);
const registration = input.registration;
+ // Claim the token from any OTHER row that still holds it (a token+ // addresses exactly one activity). The device's own row is left+ // untouched so the upsert below can tell a same-token re-registration+ // apart from a fresh arm.
yield* db
.update(relayLiveActivities)
.set({
@@ -150,7 +154,15 @@
endedAt: updatedAt,
updatedAt,
})
- .where(eq(relayLiveActivities.activityPushToken, registration.activityPushToken));+ .where(+ and(+ eq(relayLiveActivities.activityPushToken, registration.activityPushToken),+ or(+ ne(relayLiveActivities.userId, input.userId),+ ne(relayLiveActivities.deviceId, registration.deviceId),+ ),+ ),+ );
yield* db
.insert(relayLiveActivities)
@@ -171,7 +183,17 @@
set: {
activityPushToken: registration.activityPushToken,
remoteStartQueuedAt: null,
- remoteStartedAt: updatedAt,+ // remote_started_at records when the CARD was armed, and the+ // end-on-empty grace window keys off it. Foreground+ // reconciliation re-registers the same token periodically;+ // refreshing the arming time there would perpetually renew the+ // grace and suppress the end an orphaned card is waiting for.+ // Only a new token (a new activity) restarts the clock.+ remoteStartedAt: sql`case+ when ${relayLiveActivities.activityPushToken} = excluded.activity_push_token+ then coalesce(${relayLiveActivities.remoteStartedAt}, excluded.remote_started_at)+ else excluded.remote_started_at+ end`,
endedAt: null,
lastAggregateJson: null,
lastLiveActivityDeliveryAt: null,

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

Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts
The stuck-at-Working card: the thread's publish stream showed running →
deleted with no completed publish in between, and the tombstone debounce
correctly confirmed the null five seconds later — the post-completion
state resolves to no phase PERSISTENTLY. Session teardown settles
still-running turns by session status, and that write can race
turn.completed, leaving latestTurn.state at "interrupted" for a turn
that actually finished. The awareness ladder mapped interrupted turns to
null, so quick finish-then-teardown threads were tombstoned instead of
published as completed: the row vanished, the empty aggregate fell into
the freshly-armed grace window, and the card froze on its last state
with no further publish to correct it.
completedAt survives the state-column race: a turn with a completion
timestamp finished, whatever the settling wrote. The ladder now projects
those as completed — the tombstone confirm publishes Done instead of
deleting the thread — while genuinely interrupted turns (no completedAt)
still resolve to null.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
// that has a completion timestamp finished, whatever the state column says.
// Without this, quick finish-then-teardown threads resolve to null
// persistently and get tombstoned instead of published as completed.
if (thread.latestTurn?.state === "interrupted" && thread.latestTurn.completedAt !== null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsrc/agentAwareness.ts:112

The new fallback at line 112 misclassifies cancelled runs as completed. Turns settled via cancellation paths (interrupt-requested or session ended with interrupted/stopped status) also get state: "interrupted" with a non-null completedAt, so the condition thread.latestTurn?.state === "interrupted" && thread.latestTurn.completedAt !== null matches them too. This causes resolveThreadAwarenessPhase to return "completed" for runs the user actually cancelled, so users see stopped runs as successfully finished. If the intent is only to cover the teardown race where a completed turn's state is overwritten by interrupted, the guard needs to distinguish that case from genuine cancellations — for example, by checking the session status or a separate completion marker. Otherwise, consider documenting why interrupted turns with a completedAt should be treated as completed.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/shared/src/agentAwareness.ts around line 112:
The new fallback at line 112 misclassifies cancelled runs as `completed`. Turns settled via cancellation paths (interrupt-requested or session ended with `interrupted`/`stopped` status) also get `state: "interrupted"` with a non-null `completedAt`, so the condition `thread.latestTurn?.state === "interrupted" && thread.latestTurn.completedAt !== null` matches them too. This causes `resolveThreadAwarenessPhase` to return `"completed"` for runs the user actually cancelled, so users see stopped runs as successfully finished. If the intent is only to cover the teardown race where a `completed` turn's state is overwritten by `interrupted`, the guard needs to distinguish that case from genuine cancellations — for example, by checking the session status or a separate completion marker. Otherwise, consider documenting why interrupted turns with a `completedAt` should be treated as completed.

Comment threadpackages/shared/src/agentAwareness.ts
The stuck-at-Working repro survives the completedAt ladder fix: the
confirmed tombstone five seconds after turn completion still resolves
null, so the settled shell holds some combination the static analysis
keeps missing. Instead of guessing another layer deep, the tombstone
paths now log the exact fields the phase ladder reads (session status,
latest turn id/state/completedAt, pendings) both when deferring and when
a confirmation actually publishes — one repro pins the writer.
Also collapses the deferral storm visible at thread birth (six confirm
fibers forked in 600ms): one pending confirmation per thread.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

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

  • ✅ Fixed: Pending confirm set can leak
    • Wrapped the check-add-fork sequence in a single uninterruptible region so a pendingTombstoneConfirms entry can only exist once the detached confirm fiber (whose ensuring finalizer owns the delete) is guaranteed to have been forked.

Create PR

Or push these changes by commenting:

@cursor push abb02eb729
Preview (abb02eb729)
diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts--- a/apps/server/src/relay/AgentAwarenessRelay.ts+++ b/apps/server/src/relay/AgentAwarenessRelay.ts@@ -432,20 +432,30 @@
// immediately deletes the thread from the lock-screen card mid-
// conversation. Defer, re-resolve, and only tombstone if it holds;
// genuinely deleted threads still propagate a few seconds later.
- if (pendingTombstoneConfirms.has(threadId)) {- return;- }- pendingTombstoneConfirms.add(threadId);- yield* Effect.logInfo("agent activity tombstone deferred pending confirmation", {- environmentId,- threadId,- reason: snapshot.reason,- shell: describeThreadShellForAwareness(thread),- });- yield* Effect.forkDetach(- confirmTombstoneLater(threadId).pipe(- Effect.ensuring(Effect.sync(() => pendingTombstoneConfirms.delete(threadId))),- ),+ // Check-add-fork must be atomic under interruption: a set entry without+ // a forked confirm fiber (whose `ensuring` owns the delete) would+ // suppress tombstone confirmation for this thread forever.+ yield* Effect.uninterruptible(+ Effect.suspend(() => {+ if (pendingTombstoneConfirms.has(threadId)) {+ return Effect.void;+ }+ pendingTombstoneConfirms.add(threadId);+ return Effect.logInfo("agent activity tombstone deferred pending confirmation", {+ environmentId,+ threadId,+ reason: snapshot.reason,+ shell: describeThreadShellForAwareness(thread),+ }).pipe(+ Effect.andThen(+ Effect.forkDetach(+ confirmTombstoneLater(threadId).pipe(+ Effect.ensuring(Effect.sync(() => pendingTombstoneConfirms.delete(threadId))),+ ),+ ),+ ),+ );+ }),
);
return;
}

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

Comment threadapps/server/src/relay/AgentAwarenessRelay.ts Outdated
Diagnostics from the stuck-at-Working repro finally exposed the data
hole: the confirmed tombstone's shell showed sessionStatus "ready" with
latestTurn entirely absent. Threads whose turns produce no checkpoint
never get a projection_turns row, and thread.session-set clears
latest_turn_id the moment the session settles (activeTurnId goes null) —
so "completed" is unrepresentable through turns for quick Q&A threads.
Their entire lifecycle rode on session status and pendings, and at
completion nothing remained on the awareness ladder: tombstone instead
of Done.
A live session at "ready"/"idle" with nothing pending and nothing
running now projects as completed. This intentionally diverges from the
web sidebar, which resolves the same null but renders it as "no pill" —
a fine default in a list, while the publisher's null means "remove from
the lock-screen card". (The sidebar's own Completed pill needs
latestTurn.completedAt and silently can't fire for these threads either;
persisting turn rows for checkpoint-less turns is the deeper future fix
for both surfaces.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment threadpackages/shared/src/agentAwareness.ts
The full lifecycle finally works end to end, with one wart: a duplicate
Done push notification fired one second after thread creation. Sessions
boot at "ready" before their first turn starts, so the new
ready-means-completed projection produced a momentary completed state at
birth — and since the freshly armed card's token wasn't registered yet,
it fell through to the notification channel. Server restarts had the
same shape at scale: the startup snapshot republished completed for
every recently finished thread, each queuing a push notification.
- Env server: completed as a thread's FIRST published state defers and
re-resolves like a tombstone — at birth the confirm sees running and
completed never publishes; genuinely at-rest threads still publish
five seconds later. Fresh completions (previous state was live) are
unaffected, keeping the buzz immediate.
- Relay: terminal push notifications only fire when the completion is
fresh (row updated within two minutes), so replays and restart
snapshots repaint state without ringing the device.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
if (!activity) {
return null;
}
if (activity.phase === "completed" || activity.phase === "failed") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 MediumagentActivity/ApnsDeliveries.ts:321

notificationForAggregate drops terminal notifications for push-notification-only users when activity.updatedAt is more than two minutes old. activity.updatedAt is the timestamp from the published aggregate state, not the time the notification was first delivered to this device. If relay publishing or replay is delayed by more than two minutes (e.g., after downtime or a late reconnect), the first completed/failed push notification for a notifications-only user is silently suppressed even though it was never delivered before. Consider gating the freshness check on the device's last-known delivery time (e.g., last_push_notification_delivery_at) rather than activity.updatedAt, so only devices that already received a recent terminal notification are suppressed.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/ApnsDeliveries.ts around line 321:
`notificationForAggregate` drops terminal notifications for push-notification-only users when `activity.updatedAt` is more than two minutes old. `activity.updatedAt` is the timestamp from the published aggregate state, not the time the notification was first delivered to this device. If relay publishing or replay is delayed by more than two minutes (e.g., after downtime or a late reconnect), the first `completed`/`failed` push notification for a notifications-only user is silently suppressed even though it was never delivered before. Consider gating the freshness check on the device's last-known delivery time (e.g., `last_push_notification_delivery_at`) rather than `activity.updatedAt`, so only devices that already received a recent terminal notification are suppressed.

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

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

  • ✅ Fixed: LA alerts skip terminal freshness
    • Threaded nowMs into alertForNewlyTerminal and alertForTerminalAggregate and applied the shared TERMINAL_NOTIFICATION_FRESHNESS_MS window via a new isFreshTerminalRow helper, so stale replayed completions no longer alert through Live Activity updates or end events.

Create PR

Or push these changes by commenting:

@cursor push c88cec1a49
Preview (c88cec1a49)
diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts--- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts+++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts@@ -1525,15 +1525,30 @@
activities: [{ ...aggregate.activities[0]!, phase: "completed" as const, status: "Done" }],
};
expect(
- ApnsDeliveries.alertForTerminalAggregate({ aggregate: terminalAggregate, preferences }),+ ApnsDeliveries.alertForTerminalAggregate({+ aggregate: terminalAggregate,+ preferences,+ nowMs: 0,+ }),
).toEqual({ title: "Thread", body: "Done: Project" });
expect(
ApnsDeliveries.alertForTerminalAggregate({
aggregate: terminalAggregate,
preferences: { ...preferences, notifyOnCompletion: false },
+ nowMs: 0,
}),
).toBeNull();
- expect(ApnsDeliveries.alertForTerminalAggregate({ aggregate: null, preferences })).toBeNull();+ expect(+ ApnsDeliveries.alertForTerminalAggregate({ aggregate: null, preferences, nowMs: 0 }),+ ).toBeNull();+ // A completion replayed long after the fact must not ring again.+ expect(+ ApnsDeliveries.alertForTerminalAggregate({+ aggregate: terminalAggregate,+ preferences,+ nowMs: 10 * 60 * 1_000,+ }),+ ).toBeNull();
});
it("alerts when a previously active thread finishes mid-flight", () => {
@@ -1552,6 +1567,7 @@
previousAggregate: aggregate,
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toEqual({ title: "Thread", body: "Done: Project" });
// The completion switch mutes it.
@@ -1560,6 +1576,7 @@
previousAggregate: aggregate,
nextAggregate: next,
preferences: { ...preferences, notifyOnCompletion: false },
+ nowMs: 0,
}),
).toBeNull();
// No baseline means no transition to ring on.
@@ -1568,6 +1585,7 @@
previousAggregate: null,
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toBeNull();
// A Done row that was already terminal (or absent) before stays silent.
@@ -1576,6 +1594,7 @@
previousAggregate: next,
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toBeNull();
expect(
@@ -1583,7 +1602,18 @@
previousAggregate: { ...aggregate, activities: [attentionRow] },
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toBeNull();
+ // A stale completion replayed after a restart (previous aggregate still+ // shows the thread as running) must not ring the device again.+ expect(+ ApnsDeliveries.alertForNewlyTerminal({+ previousAggregate: aggregate,+ nextAggregate: next,+ preferences,+ nowMs: 10 * 60 * 1_000,+ }),+ ).toBeNull();
});
});
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@@ -210,6 +210,20 @@
};
}
+// Completions replayed long after the fact (server restarts republish every+// recently-finished thread) must not ring the device again — on any channel:+// push notifications, Live Activity update alerts, and end alerts all honor+// the same freshness window.+const TERMINAL_NOTIFICATION_FRESHNESS_MS = 2 * 60 * 1_000;++function isFreshTerminalRow(row: { readonly updatedAt: string }, nowMs: number): boolean {+ const updatedAtMs = Option.match(DateTime.make(row.updatedAt), {+ onNone: () => null,+ onSome: (dt) => dt.epochMilliseconds,+ });+ return updatedAtMs !== null && nowMs - updatedAtMs <= TERMINAL_NOTIFICATION_FRESHNESS_MS;+}+
// Alert copy for an update whose aggregate contains threads that finished
// (Done/Failed) since the previously delivered aggregate — the mid-flight
// completion buzz while other agents keep the activity alive. Requires the
@@ -219,6 +233,7 @@
readonly previousAggregate: RelayAgentActivityAggregateState | null;
readonly nextAggregate: RelayAgentActivityAggregateState;
readonly preferences: RelayAgentAwarenessPreferences | null;
+ readonly nowMs: number;
}): ApnsLiveActivityAlert | null {
if (input.previousAggregate === null) {
return null;
@@ -230,6 +245,9 @@
if (row.phase !== "completed" && row.phase !== "failed") {
return false;
}
+ if (!isFreshTerminalRow(row, input.nowMs)) {+ return false;+ }
const previousPhase = previousPhases.get(row.threadId);
return (
previousPhase !== undefined &&
@@ -255,11 +273,15 @@
export function alertForTerminalAggregate(input: {
readonly aggregate: RelayAgentActivityAggregateState | null;
readonly preferences: RelayAgentAwarenessPreferences | null;
+ readonly nowMs: number;
}): ApnsLiveActivityAlert | null {
const row = input.aggregate?.activities[0];
if (!row || (row.phase !== "completed" && row.phase !== "failed")) {
return null;
}
+ if (!isFreshTerminalRow(row, input.nowMs)) {+ return null;+ }
if (!alertAllowedForPhase(input.preferences, row.phase)) {
return null;
}
@@ -298,10 +320,6 @@
);
}
-// Completions replayed long after the fact (server restarts republish every-// recently-finished thread) must not ring the device again.-const TERMINAL_NOTIFICATION_FRESHNESS_MS = 2 * 60 * 1_000;-
function notificationForAggregate(input: {
readonly target: LiveActivities.TargetRow;
readonly aggregate: RelayAgentActivityAggregateState | null;
@@ -318,14 +336,11 @@
if (!activity) {
return null;
}
- if (activity.phase === "completed" || activity.phase === "failed") {- const updatedAtMs = Option.match(DateTime.make(activity.updatedAt), {- onNone: () => null,- onSome: (dt) => dt.epochMilliseconds,- });- if (updatedAtMs === null || input.nowMs - updatedAtMs > TERMINAL_NOTIFICATION_FRESHNESS_MS) {- return null;- }+ if (+ (activity.phase === "completed" || activity.phase === "failed") &&+ !isFreshTerminalRow(activity, input.nowMs)+ ) {+ return null;
}
const enabled =
(activity.phase === "waiting_for_approval" && preferences.notifyOnApproval) ||
@@ -419,6 +434,7 @@
previousAggregate,
nextAggregate,
preferences,
+ nowMs: input.nowMs,
}),
}
: "suppressed";
@@ -1100,6 +1116,7 @@
: alertForTerminalAggregate({
aggregate: delivery.aggregate,
preferences: parsePreferences(input.target.preferences_json),
+ nowMs: input.nowMs,
})
: delivery.alert;
const result = yield* deliveryQueue.enqueueLiveActivity({

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

Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts
…A pref gates
Fixes the real findings from the PR review bots ahead of the TestFlight
build:
- Deferred publish confirmations (tombstones and first-state
completions) now re-enqueue through the same drainable worker as every
other publish instead of a detached fiber calling the publisher
directly, so a confirmed tombstone can never race an in-flight live
update; a deadline map replaces the pending set, which also guarantees
a single scheduled confirmation per thread and clears when the state
recovers. (cursor/macroscope High)
- Newly-terminal transitions bypass the 15s live-activity update
throttle: a completion landing in the same window as a new start keeps
activeCount unchanged and would previously be suppressed, silently
dropping the Done transition and its buzz. (macroscope High)
- The newly-terminal alert honors the same 2-minute freshness rule as
terminal push notifications, so replayed aggregates repaint without
ringing. (cursor Medium)
- Live Activity arming and priming treat an unset preference as enabled
(the toggle's default): fresh installs now prime, and arm-on-send
respects an explicit off. (cursor High/Medium)
Remaining findings triaged as stale (superseded designs), intended
behavior (replay painting recent Done, LA-disabled end retiring the
token), or accepted edge cases (grace extension on re-registration,
completion alerts beyond the 3-row display cap, ready-with-lastError
mapping to Done, cancelled turns showing Done).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

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

  • ✅ Fixed: Stale confirm deadline publishes tombstone
    • Cleared the thread's publishConfirmDeadlines entry in the unchanged-identity early-return path so a recovered projection cancels the deferred confirmation, preventing a later transient null state from confirming immediately against a stale expired deadline.

Create PR

Or push these changes by commenting:

@cursor push e02169b78e
Preview (e02169b78e)
diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts--- a/apps/server/src/relay/AgentAwarenessRelay.ts+++ b/apps/server/src/relay/AgentAwarenessRelay.ts@@ -410,6 +410,11 @@
const publishIdentity = agentAwarenessPublishIdentity(snapshot.state);
const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef);
if (publishedStateByThread.get(threadId) === publishIdentity) {
+ // The projection recovered to the last published state, so any deferred+ // confirmation (tombstone or first-state completion) is moot. Clear the+ // deadline so the next divergence gets a fresh deferral window instead+ // of confirming immediately against a stale, expired deadline.+ publishConfirmDeadlines.delete(threadId);
yield* Effect.logDebug("agent activity publish skipped; projected state unchanged", {
environmentId,
threadId,

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

Reviewed by Cursor Bugbot for commit 97ed442. Configure here.

Comment threadapps/server/src/relay/AgentAwarenessRelay.ts
…d state
A recovered projection exits at the unchanged-identity dedupe, which sits
above the confirmation block, so a deferred publish's deadline survived
recovery. A much later transient null would then find the deadline
already expired and publish its tombstone immediately, bypassing the
deferral window entirely. The dedupe path now clears any pending
deadline: the state is back at what was last published, so the deferred
confirmation is moot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@juliusmarmingejuliusmarminge added the 🚀 Mobile Continuous Deployment Trigger Expo preview build label Jul 7, 2026
Fixes the Effect Service Conventions check: the service builds a plain
object with no effectful acquisition, so wrapping it in Effect.sync just
to feed Layer.effect was the make-only-to-force-Layer.effect shape the
conventions call out.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

ghost commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

🚀 Expo continuous deployment is ready!

  • Project → t3-code
  • Platforms → android, ios
  • Scheme → t3code-preview
🤖 Android🍎 iOS
Fingerprint106610ae982be2d8aa28dda9217ff853daf9d30b4f388ce49fd16563ef62f563f42c0528f092d42b
Build DetailsBuild Permalink
DetailsDistribution: INTERNAL
Build profile: preview:dev
Runtime version: 106610ae982be2d8aa28dda9217ff853daf9d30b
App version: 0.1.0
Git commit: 74888084257320d42e7562c74996c949010bdf45
Build Permalink
DetailsDistribution: INTERNAL
Build profile: preview:dev
Runtime version: 4f388ce49fd16563ef62f563f42c0528f092d42b
App version: 0.1.0
Git commit: c622444d589c97adb591101ffca9161d785806b0
Update DetailsUpdate Permalink
DetailsBranch: pr-3685
Runtime version: 106610ae982be2d8aa28dda9217ff853daf9d30b
Git commit: 74888084257320d42e7562c74996c949010bdf45
Update Permalink
DetailsBranch: pr-3685
Runtime version: 4f388ce49fd16563ef62f563f42c0528f092d42b
Git commit: 74888084257320d42e7562c74996c949010bdf45
Update QR

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

Labels

🚀 Mobile Continuous DeploymentTrigger Expo preview buildsize:XXL1,000+ changed lines (additions + deletions).vouch:trustedPR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@juliusmarminge
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Improve live activity routing and diagnostics - #3685

Merged
juliusmarminge merged 26 commits into
mainfrom
t3code/live-activities-improvements
Jul 7, 2026
Merged

Improve live activity routing and diagnostics#3685
juliusmarminge merged 26 commits into
mainfrom
t3code/live-activities-improvements

Conversation

@juliusmarminge

@juliusmarmingejuliusmarminge commented Jul 4, 2026

Copy link
Copy Markdown
Member

Summary

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

Testing

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

Note

High Risk
Touches relay registration, APNs routing, cloud link modes, and Live Activity lifecycle (including removing push-to-start); misconfiguration or partial deploy could break mobile notifications or mis-route pushes.

Overview
Improves mobile agent awareness end-to-end: relay device registration now sends bundle ID and sandbox vs production APNs routing, tracks whether the relay actually accepted registration (settings toggles and alerts follow that, not just iOS permission), skips duplicate registrations via a persisted signature, and re-registers Live Activity tokens on foreground with short-window deduping. Push-to-start registration is removed in favor of arming a card when the user sends a task (and priming from a relay activity snapshot when idle), plus cleanup on cloud sign-out and a releaseAgentAwarenessRelayTokenProvider path so auth UI remounts do not wipe registration or end activities.

Agent Activity Live Activity UI is reworked (attention-first rows, phase tints aligned with web, deep links, branded T3 mark via new Expo config plugins and actool shell phase). expo-widgets gains frequentUpdates for higher update budget.

T3 Connect vs publishing split: environments can link in publish_only mode (manual endpoint, no Cloudflare tunnel) via CLI (connect link --publish-only, connect publish) and web settings (separate toggles reconciled together). Link proofs accept manual providers with activity-only scopes; server publish path defers transient tombstones and first completed snapshots before publishing.

Relay DB adds bundle_id and aps_environment on mobile devices (migration in diff).

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

Note

Add per-device APNs routing, JWT token caching, and publish-only link mode to Live Activity delivery

  • Stores bundle_id and aps_environment per device in the relay and uses them to route APNs pushes to the correct topic and environment per device rather than using global config.
  • Introduces ApnsProviderTokens service that caches APNs JWTs quantized to 45-minute windows, preventing TooManyProviderTokenUpdates errors; uses deterministic ES256 signing via @noble/curves.
  • Overhauls Live Activity delivery: activities are no longer remotely started; ends are suppressed within a grace window after arming; attention-phase transitions and newly-terminal rows can now trigger alert payloads with haptics; stale terminal aggregates suppress re-alerting.
  • Adds a publish_only cloud link mode in the CLI (t3 connect publish) and web settings UI, allowing agent activity publishing without a managed Cloudflare tunnel; linking with this mode uses providerKind: "manual" and deprovisions any existing tunnel allocation.
  • Reworks the AgentActivity widget to sort rows attention-first, apply per-phase tints from the web palette, deep-link to the most relevant thread, show a branded logo, and display a phase glyph in minimal presentation.
  • Exposes relay registration status (unknown/pending/registered/failed) to the settings UI so notification and Live Activity toggles only appear enabled when the device is actually registered.
  • Risk: makeAggregateState now requires a nowMs parameter and statusForPhase('starting') returns 'Connecting' instead of 'Starting', which are breaking changes for callers.

Macroscope summarized 6f0b32d.

@coderabbitai

coderabbitaiBot commented Jul 4, 2026

Copy link
Copy Markdown

Important

Review skipped

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

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: fe216c9a-1cd9-460a-a80d-6d90748a00b5

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

Use the checkbox below for a quick retry:

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

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

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

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Autofix Details

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

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

Create PR

Or push these changes by commenting:

@cursor push a97e0b6881
Preview (a97e0b6881)
diff --git a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts--- a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts+++ b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts@@ -69,6 +69,28 @@
]);
});
+ it("treats a non-2xx delivery status without a reason as a failure", () => {+ const rows = formatDeviceDiagnosticsRows(+ makeDevice({+ bundleId: "com.t3tools.t3code.preview",+ apsEnvironment: "production",+ hasPushToken: true,+ hasPushToStartToken: true,+ hasLiveActivityToken: true,+ lastDeliveryAt: "2026-06-05T01:02:59.566Z",+ lastDeliveryKind: "live_activity_end",+ lastDeliveryStatus: 400,+ lastDeliveryError: null,+ }),+ );++ expect(rows[4]).toEqual({+ label: "Last Delivery",+ value: "Delivery failed (400)",+ tone: "warn",+ });+ });+
it("reports healthy registrations with a successful delivery", () => {
const rows = formatDeviceDiagnosticsRows(
makeDevice({
diff --git a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts--- a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts+++ b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts@@ -73,14 +73,21 @@
});
}
+ // APNs can fail with a non-2xx status without a reason body, so a null+ // error alone does not mean the delivery succeeded.+ const deliveryFailed =+ diagnostics.lastDeliveryError !== null ||+ (diagnostics.lastDeliveryStatus !== null &&+ (diagnostics.lastDeliveryStatus < 200 || diagnostics.lastDeliveryStatus >= 300));+
if (diagnostics.lastDeliveryAt === null) {
rows.push({ label: "Last Delivery", value: "None yet", tone: "muted" });
- } else if (diagnostics.lastDeliveryError !== null) {+ } else if (deliveryFailed) {
const status =
diagnostics.lastDeliveryStatus === null ? "" : ` (${diagnostics.lastDeliveryStatus})`;
rows.push({
label: "Last Delivery",
- value: `${diagnostics.lastDeliveryError}${status}`,+ value: `${diagnostics.lastDeliveryError ?? "Delivery failed"}${status}`,
tone: "warn",
});
} else {
diff --git a/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/migration.sql b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/migration.sql
new file mode 100644
--- /dev/null+++ b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/migration.sql@@ -1,0 +1,3 @@+ALTER TABLE "relay_mobile_devices" ADD COLUMN "bundle_id" varchar(255);+--> statement-breakpoint+ALTER TABLE "relay_mobile_devices" ADD COLUMN "aps_environment" varchar(16);diff --git a/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/snapshot.json b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/snapshot.json
new file mode 100644
--- /dev/null+++ b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/snapshot.json@@ -1,0 +1,1479 @@+{+ "dialect": "postgres",+ "id": "e00cc7df-a31e-4b8b-b258-d04e7db7758a",+ "prevIds": ["385d476b-d4f6-48a3-99e6-0a95af4ee4e4"],+ "version": "8",+ "ddl": [+ {+ "isRlsEnabled": false,+ "name": "relay_agent_activity_rows",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_delivery_attempts",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_dpop_proofs",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_environment_credentials",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_environment_links",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_live_activities",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_managed_endpoint_allocations",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_mobile_devices",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_public_key",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "thread_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "jsonb",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "state_json",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "updated_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "varchar(36)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "user_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "thread_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "device_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "kind",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "source_job_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(16)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "token_suffix",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "integer",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "apns_status",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "apns_reason",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(128)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "apns_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "transport_error",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(128)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "thumbprint",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "jti",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "integer",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "iat",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "expires_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "credential_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_public_key",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "credential_hash",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "revoked_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "updated_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "user_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": "'T3 Environment'",+ "generated": null,+ "identity": null,+ "name": "environment_label",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_public_key",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "endpoint_http_base_url",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "endpoint_ws_base_url",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(32)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "endpoint_provider_kind",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "boolean",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": "true",+ "generated": null,+ "identity": null,+ "name": "notifications_enabled",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "boolean",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": "true",+ "generated": null,+ "identity": null,+ "name": "live_activities_enabled",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "boolean",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": "false",+ "generated": null,+ "identity": null,+ "name": "managed_tunnels_enabled",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_by_device_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "revoked_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "updated_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "user_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "device_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "activity_push_token",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "remote_start_queued_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "remote_started_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "ended_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "jsonb",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,
... diff truncated: showing 800 of 1681 lines

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highpersistence/schema.ts:27

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

Also found in 1 other location(s)

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

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/persistence/schema.ts around line 27:
The new `bundle_id` and `aps_environment` columns are added to the `relay_mobile_devices` schema, but no SQL migration is included to create them. After deploy, the code in `Devices.ts` immediately inserts and selects these columns, so PostgreSQL rejects every device registration and list query with `column "bundle_id" does not exist` (and `aps_environment` likewise). Add the corresponding `ALTER TABLE relay_mobile_devices ADD COLUMN ...` migration under `infra/relay/migrations/` so the columns exist before the new code runs.
Also found in 1 other location(s):
- infra/relay/src/agentActivity/LiveActivities.ts:201 -- The new `relayMobileDevices.bundleId` / `relayMobileDevices.apsEnvironment` columns are referenced by `listTargets` (`bundle_id`, `aps_environment`) and other relay code, but this PR does not add a matching SQL migration for `relay_mobile_devices`. After deploying the code to an existing database, any `listTargets` query will start failing with `column relay_mobile_devices.bundle_id does not exist` (and likewise for `aps_environment`), breaking live-activity target lookup until the schema is manually patched.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium

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

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/ApnsDeliveries.ts around line 506:
The stale-job check in `processSignedJob` only compares the token via `isCurrentSignedJobToken`, so a queued job created before the device registered `bundleId`/`apsEnvironment` (or before those values changed) still passes the staleness guard when the token is unchanged. The job is then sent with the stale or missing routing metadata from the signed payload instead of the device's current values, causing APNs to reject the delivery with `BadDeviceToken`/`DeviceTokenNotForTopic` even though the registration has already been corrected. Consider comparing `bundleId` and `apsEnvironment` against the current target in the staleness check so jobs with outdated routing are treated as stale.

@macroscopeapp

macroscopeappBot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

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

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumwidgets/AgentActivity.tsx:81

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/widgets/AgentActivity.tsx around line 81:
`widgetURL` is hardcoded to the `t3code://` scheme, but the app registers `t3code-dev` for development builds and `t3code-preview` for preview builds. On dev/preview builds the widget banner's tap URL uses a scheme the containing app does not register, so iOS will not open the app when the banner is tapped. Consider using a scheme that is registered in all build variants (or injecting the active scheme per-build).

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 MediumagentActivity/AgentActivityPublisher.ts:203

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/AgentActivityPublisher.ts around line 203:
`RUNNING_AGENT_ACTIVITY_ROW_TTL_MS` expires `running`/`starting` states after 2 hours based on `updatedAt`, but relay publishes are event-driven — a thread that stays in the same `running` phase for over 2 hours without a new event keeps its old `updatedAt`, so `isExpiredAgentActivityState` returns `true` and `makeAggregateState` filters it out. The aggregate then reports `activeCount: 0` (or `null`/terminal) even though the job is still running, hiding active work from clients. Consider raising the running TTL to match the waiting TTL, or filtering on a heartbeat/last-seen timestamp that is refreshed independently of phase-change events.

Comment threadapps/mobile/src/widgets/AgentActivity.tsx
Comment threadinfra/relay/src/agentActivity/Devices.ts Outdated
Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts
@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from 1d6309e to 39d9dcaCompareJuly 6, 2026 06:07
@github-actionsgithub-actionsBot added size:L 100-499 changed lines (additions + deletions). and removed size:XL 500-999 changed lines (additions + deletions). labels Jul 6, 2026
@cursor

This comment has been minimized.

@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from 39d9dca to 5225bdbCompareJuly 6, 2026 07:37
@github-actionsgithub-actionsBot added size:XXL 1,000+ changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Jul 6, 2026

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

Autofix Details

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

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

Create PR

Or push these changes by commenting:

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

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsettings/SettingsRouteScreen.tsx:438

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/settings/SettingsRouteScreen.tsx around line 438:
The `deviceRegistered` gate on the `Device Notifications` switch renders it `false` when relay registration fails, even though `notificationStatus` is `"enabled"`. Because the switch shows off, the user cannot toggle it to reach `handleDeviceNotificationsChange(false)`, which is the only path that opens iOS Settings to disable notifications. In the "permission granted, relay registration failed" case the switch misreports notifications as off and removes the in-app affordance for managing them. Consider gating only the Live Activity switch on `deviceRegistered`, or keeping the `Device Notifications` switch reflecting the actual iOS permission state so the user can still navigate to system Settings.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 HighagentActivity/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 effectSchema.Struct behavior) and recompute the HMAC over the reduced payload, so verifySignedApnsDeliveryJob returns ApnsDeliveryJobSignatureInvalid for every newly queued job carrying per-device routing. This breaks APNs delivery during rolling deploys when new producers share a queue with old consumers. Consider gating inclusion of these fields behind a flag, or otherwise ensuring the signed payload shape remains decodable by older schema versions, so signatures stay stable across the rollout.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/apnsDeliveryJobs.ts around line 247:
`makeApnsDeliveryJobPayload` includes `target.bundleId` and `target.apsEnvironment` in the payload, and `signatureForPayload` signs that full object. Older relay workers that decode with a previous `ApnsDeliveryJobPayload` schema strip the new keys on decode (default `effect` `Schema.Struct` behavior) and recompute the HMAC over the reduced payload, so `verifySignedApnsDeliveryJob` returns `ApnsDeliveryJobSignatureInvalid` for every newly queued job carrying per-device routing. This breaks APNs delivery during rolling deploys when new producers share a queue with old consumers. Consider gating inclusion of these fields behind a flag, or otherwise ensuring the signed payload shape remains decodable by older schema versions, so signatures stay stable across the rollout.

@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from 5225bdb to 5086c14CompareJuly 6, 2026 16:13

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium

infoPlist: {
NSAppTransportSecurity: {

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

 infoPlist: {
+ NSSupportsLiveActivitiesFrequentUpdates: true,
NSAppTransportSecurity: {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/app.config.ts around lines 96-97:
The `frequentUpdates: true` option under the `expo-widgets` plugin does not add the required `NSSupportsLiveActivitiesFrequentUpdates` key to `Info.plist`, so iOS keeps the standard Live Activity update budget and frequent updates are never enabled. Set `NSSupportsLiveActivitiesFrequentUpdates: true` in the `ios.infoPlist` block to actually grant the entitlement.

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

export interface AgentAwarenessRegistrationRecord {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumlib/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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highsettings/SettingsRouteScreen.tsx:441

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/settings/SettingsRouteScreen.tsx around line 441:
When `deviceRegistered` is `false` but `liveActivitiesEnabled` is `true` in storage, the `Live Activity Updates` switch renders off even though `liveActivityStatus` is `"enabled"`. Because `onValueChange` receives the next visual state (`true`), tapping the switch calls `linkEnvironments()` (the enable path) instead of persisting `enabled: false`, so the user cannot disable Live Activity updates from Settings until registration succeeds. Consider gating the disabled state or the alert on `deviceRegistered` rather than the switch `value`, so the toggle still reflects the saved preference and `onValueChange` fires with the correct next state.

Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

Autofix Details

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

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

Create PR

Or push these changes by commenting:

@cursor push 59517fdb7d
Preview (59517fdb7d)
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts@@ -186,7 +186,11 @@
refreshActiveLiveActivityRemoteRegistration(),
"active live activity registration after cloud sign-in failed",
);
- if (isExistingIdentity) {+ // An unchanged identity only skips re-registration when the previous attempt+ // actually reached the relay; a failed or stalled attempt must retry the next+ // time the auth effect re-runs, or the device stays unregistered until an+ // unrelated event (token rotation, environment link) happens to enqueue one.+ if (isExistingIdentity && registrationStatus === "registered") {
return;
}
enqueueDeviceRegistration({}, "device registration after cloud sign-in failed");
diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts--- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts+++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts@@ -119,6 +119,16 @@
terminalState: null,
nowMs: now.epochMilliseconds,
});
+ // A null aggregate is ambiguous while non-terminal rows still exist:+ // they may belong to a dead environment, or to a healthy long-running+ // agent whose meaningful state (and therefore updatedAt) has not changed+ // within the TTL. Sending in that case would end a possibly-healthy Live+ // Activity on every foreground, so replay only delivers when live rows+ // remain or the rows are truly gone (the thread ended and its end push+ // may have been lost).+ if (aggregate === null && activeStates.some((state) => !isTerminalPhase(state))) {+ return null;+ }
return yield* apnsDeliveries.sendForTarget({
target,
aggregate,

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

Comment threadinfra/relay/src/agentActivity/AgentActivityPublisher.ts
@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from 5086c14 to a31735cCompareJuly 6, 2026 17:03
Comment threadapps/mobile/src/features/settings/SettingsRouteScreen.tsx
Comment threadapps/server/src/cli/connect.ts

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

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

Autofix Details

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

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

Create PR

Or push these changes by commenting:

@cursor push d469c8569a
Preview (d469c8569a)
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts@@ -535,6 +535,48 @@
}).pipe(Effect.provide(relayTestLayer));
});
+ it.effect("keeps the registered status when a stale concurrent attempt fails", () => {+ const fetchMock = vi.fn((request: RequestInfo | URL) => {+ const url = request instanceof Request ? request.url : String(request);+ return Promise.resolve(+ Response.json(+ url.endsWith("/v1/client/dpop-token")+ ? {+ access_token: "relay-dpop-token",+ issued_token_type: "urn:ietf:params:oauth:token-type:access_token",+ token_type: "DPoP",+ expires_in: 300,+ scope: "mobile:registration",+ }+ : { ok: true },+ ),+ );+ });+ vi.stubGlobal("fetch", fetchMock);+ Constants.expoConfig!.extra = {+ relay: {+ url: "https://relay.example.test/",+ },+ };++ // Sign-in enqueues a registration attempt that stays parked in the+ // background queue while the direct refresh below runs.+ setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));++ return Effect.gen(function* () {+ yield* refreshAgentAwarenessRegistration();+ expect(getAgentAwarenessRegistrationStatus()).toBe("registered");++ // The queued sign-in attempt now fails; its stale failure must not+ // overwrite the registration the relay already accepted.+ vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockRejectedValueOnce(+ new Error("device id unavailable"),+ );+ yield* runBackgroundOperations();+ expect(getAgentAwarenessRegistrationStatus()).toBe("registered");+ }).pipe(Effect.provide(relayTestLayer));+ });+
it("clears registration status on cloud sign-out", () => {
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
setAgentAwarenessRelayTokenProvider(null);
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts@@ -74,6 +74,13 @@
let registrationStatus: AgentAwarenessRegistrationStatus = "unknown";
const registrationStatusListeners = new Set<() => void>();
+// Registration runs both through the background queue and directly via+// refreshAgentAwarenessRegistration, so attempts can overlap. Counting relay+// acceptances lets a failing attempt detect that a concurrent attempt already+// succeeded while it was in flight, so a stale failure cannot overwrite a+// registration the relay accepted.+let registrationSuccessCount = 0;+
function setRegistrationStatus(next: AgentAwarenessRegistrationStatus): void {
if (registrationStatus === next) {
return;
@@ -84,6 +91,18 @@
}
}
+function markRegistrationSucceeded(): void {+ registrationSuccessCount++;+ setRegistrationStatus("registered");+}++function markRegistrationFailed(successCountAtAttemptStart: number): void {+ if (registrationSuccessCount !== successCountAtAttemptStart) {+ return;+ }+ setRegistrationStatus("failed");+}+
export function getAgentAwarenessRegistrationStatus(): AgentAwarenessRegistrationStatus {
return registrationStatus;
}
@@ -316,7 +335,7 @@
catch: (cause) => cause,
}).pipe(Effect.orElseSucceed(() => null));
if (persisted && persisted.identity === identity && persisted.signature === signature) {
- setRegistrationStatus("registered");+ markRegistrationSucceeded();
logRegistrationDebug("relay device registration skipped; already registered for account", {
expectedGeneration,
});
@@ -331,7 +350,7 @@
clerkToken: token,
payload: body,
});
- setRegistrationStatus("registered");+ markRegistrationSucceeded();
yield* Effect.promise(() =>
saveAgentAwarenessRegistrationRecord({ identity, signature }).catch((error: unknown) => {
logRegistrationError("persist registration record failed", error);
@@ -466,11 +485,12 @@
};
activeDeviceRegistration = registration;
registration.operation = (async () => {
+ const successCountAtStart = registrationSuccessCount;
const result = await settleAsyncResult(() =>
runtime.runPromiseExit(registerDevice(next.input, generation)),
);
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
- setRegistrationStatus("failed");+ markRegistrationFailed(successCountAtStart);
logRegistrationError(next.context, squashAtomCommandFailure(result));
}
logRegistrationDebug("device registration finished", { generation });
@@ -675,14 +695,17 @@
never,
ManagedRelay.ManagedRelayClient
> {
- return registerDeviceForCurrentUser().pipe(- Effect.catch((error) =>- Effect.sync(() => {- setRegistrationStatus("failed");- logRegistrationError("device registration refresh failed", error);- }),- ),- );+ return Effect.suspend(() => {+ const successCountAtStart = registrationSuccessCount;+ return registerDeviceForCurrentUser().pipe(+ Effect.catch((error) =>+ Effect.sync(() => {+ markRegistrationFailed(successCountAtStart);+ logRegistrationError("device registration refresh failed", error);+ }),+ ),+ );+ });
}
export function __resetAgentAwarenessRemoteRegistrationForTest(): void {
@@ -703,6 +726,7 @@
activeDeviceRegistration = null;
pendingDeviceRegistration = null;
registrationStatus = "unknown";
+ registrationSuccessCount = 0;
registrationStatusListeners.clear();
}

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

Comment threadapps/mobile/src/features/agent-awareness/remoteRegistration.ts Outdated
- Per-device APNs routing (bundle id + environment) so preview/dev builds
receive pushes from the shared relay (+ migration for the new columns)
- Settings notification/Live Activity toggles reflect actual relay
registration success; cannot read as enabled when the device is not registered
- Persistent register-once: skip re-registering an unchanged payload for the
same account across launches; clear only on sign-out
- Headless publish + tunnel-free linking: `t3 connect publish` toggles agent
activity publishing and auto-establishes a publish-only (no managed tunnel)
link, and `t3 connect link --publish-only` links for publishing alone, so
activity flows to mobile clients that reach the environment out of band
(e.g. Tailscale) without T3 Connect. Relay accepts publish-only links with a
nominal endpoint; env proofs advertise the manual provider kind
- Foreground Live Activity refresh, stale/ghost-row hardening, dedup fixes
- Tighten widget deep linking and per-row rendering for active agents
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from a31735c to 9c3eb7bCompareJuly 6, 2026 17:24
// Development builds are Xcode-signed and receive sandbox APNs tokens;
// preview and production builds are distribution-signed and use production
// APNs. The relay routes each device's pushes accordingly.
export function resolveApsEnvironment(appVariant: unknown): "sandbox" | "production" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumagent-awareness/registrationPayload.ts:8

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/agent-awareness/registrationPayload.ts around line 8:
`resolveApsEnvironment` returns `"production"` for every value except the literal string `"development"`. Locally run `ios:preview` and `ios:prod` builds launched via `expo run:ios` are development-signed and receive sandbox APNs tokens, but this function classifies them as `"production"`. The relay then routes pushes to the production APNs gateway for a sandbox token, so notifications and Live Activities fail to deliver for those builds. Consider keying off the signing/provisioning environment (e.g. EAS build profile or `Configuration`/ entitlements) rather than the variant name, or document why locally-run `preview`/`prod` builds are expected to use production APNs.

Comment threadapps/server/src/cli/connect.ts
Comment threadpackages/contracts/src/environmentHttp.ts Outdated
Comment on lines 1745 to 1750
primaryCloudLinkState.refresh();
const refreshResult = await refreshRelayEnvironments();
if (refreshResult._tag === "Failure") {
if (!isAtomCommandInterrupted(refreshResult)) {
reportUpdateFailure(squashAtomCommandFailure(refreshResult));
}
setIsUpdating(false);
return;
if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) {
reportUpdateFailure(squashAtomCommandFailure(refreshResult));
return false;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsettings/ConnectionsSettings.tsx:1745

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

 primaryCloudLinkState.refresh();
+ return true;
const refreshResult = await refreshRelayEnvironments();
if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) {
reportUpdateFailure(squashAtomCommandFailure(refreshResult));
return false;
}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/settings/ConnectionsSettings.tsx around lines 1745-1750:
When `reconcileCloudState` completes a link/unlink and preference update successfully, the mutation is already committed and `primaryCloudLinkState.refresh()` has been called. If the follow-up `refreshRelayEnvironments()` call at line 1747 then fails, the function returns `false`, shows an error toast, and suppresses the success toast — so the user is told their T3 Connect change failed even though it was actually applied. Consider returning `true` before the relay refresh (or not reporting its failure as a mutation failure), so transient relay-discovery errors don't mask a successful mutation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highcloud/http.ts:369

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

 if (
!isSupportedLinkProviderKind(request) ||
(request.endpoint.providerKind === "cloudflare_tunnel" &&
!isAllowedEndpointOrigin({
origin: request.origin,
requestUrl,
}))
) {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/cloud/http.ts around lines 369-375:
`makeCloudLinkProof` calls `isAllowedEndpointOrigin` for every link proof, including manual `providerKind` requests. That helper only permits loopback hosts, so a manual or publish-only link from a non-loopback origin (e.g. Tailscale or directly exposed host) is rejected with `Invalid managed endpoint origin.`, blocking the out-of-band endpoint flow that `isSupportedLinkProviderKind` was added to support. Consider skipping the loopback origin check when `providerKind === "manual"`.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumwidgets/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`.

Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

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

  • ✅ Fixed: Grace blocks legitimate activity end
    • LiveActivities.register no longer resets remote_started_at when the same activity token is re-registered (foreground reconciliation), so the freshly-armed grace window measures from the true arming time and orphaned cards receive their live_activity_end once it expires.

Create PR

Or push these changes by commenting:

@cursor push 5ab40bb88e
Preview (5ab40bb88e)
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@@ -109,10 +109,12 @@
remoteStartedAt: null,
}),
]);
+ // The claim skips this device's own row: it must stay intact so the+ // upsert can tell a same-token re-registration apart from a fresh arm.
expect(updateConditions.map((condition) => dialect.sqlToQuery(condition))).toEqual([
{
- sql: '"relay_live_activities"."activity_push_token" = $1',- params: ["activity-push-token"],+ sql: '(("relay_live_activities"."activity_push_token" = $1) and ((("relay_live_activities"."user_id" <> $2) or ("relay_live_activities"."device_id" <> $3))))',+ params: ["activity-push-token", "user-2", "device-1"],
},
]);
expect(insertedValues).toEqual([
@@ -131,12 +133,20 @@
expect.objectContaining({
activityPushToken: "activity-push-token",
remoteStartQueuedAt: null,
- remoteStartedAt: expect.any(String),
endedAt: null,
lastAggregateJson: null,
lastLiveActivityDeliveryAt: null,
}),
);
+ // Re-registering the SAME token (foreground reconciliation) must not+ // restart the arming clock, or the end-on-empty grace window would+ // renew forever and orphaned cards would never receive their end.+ const remoteStartedAtSet = dialect.sqlToQuery(+ conflictConfigs[0]?.set?.remoteStartedAt as SQL,+ );+ expect(remoteStartedAtSet.sql.replace(/\s+/g, " ")).toBe(+ 'case when "relay_live_activities"."activity_push_token" = excluded.activity_push_token then coalesce("relay_live_activities"."remote_started_at", excluded.remote_started_at) else excluded.remote_started_at end',+ );
}).pipe(
Effect.provide(
LiveActivities.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))),
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@@ -13,7 +13,7 @@
import * as Function from "effect/Function";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
-import { and, eq, sql } from "drizzle-orm";+import { and, eq, ne, or, sql } from "drizzle-orm";
import * as RelayDb from "../db.ts";
import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts";
@@ -141,6 +141,10 @@
const updatedAt = DateTime.formatIso(yield* DateTime.now);
const registration = input.registration;
+ // Claim the token from any OTHER row that still holds it (a token+ // addresses exactly one activity). The device's own row is left+ // untouched so the upsert below can tell a same-token re-registration+ // apart from a fresh arm.
yield* db
.update(relayLiveActivities)
.set({
@@ -150,7 +154,15 @@
endedAt: updatedAt,
updatedAt,
})
- .where(eq(relayLiveActivities.activityPushToken, registration.activityPushToken));+ .where(+ and(+ eq(relayLiveActivities.activityPushToken, registration.activityPushToken),+ or(+ ne(relayLiveActivities.userId, input.userId),+ ne(relayLiveActivities.deviceId, registration.deviceId),+ ),+ ),+ );
yield* db
.insert(relayLiveActivities)
@@ -171,7 +183,17 @@
set: {
activityPushToken: registration.activityPushToken,
remoteStartQueuedAt: null,
- remoteStartedAt: updatedAt,+ // remote_started_at records when the CARD was armed, and the+ // end-on-empty grace window keys off it. Foreground+ // reconciliation re-registers the same token periodically;+ // refreshing the arming time there would perpetually renew the+ // grace and suppress the end an orphaned card is waiting for.+ // Only a new token (a new activity) restarts the clock.+ remoteStartedAt: sql`case+ when ${relayLiveActivities.activityPushToken} = excluded.activity_push_token+ then coalesce(${relayLiveActivities.remoteStartedAt}, excluded.remote_started_at)+ else excluded.remote_started_at+ end`,
endedAt: null,
lastAggregateJson: null,
lastLiveActivityDeliveryAt: null,

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

Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts
The stuck-at-Working card: the thread's publish stream showed running →
deleted with no completed publish in between, and the tombstone debounce
correctly confirmed the null five seconds later — the post-completion
state resolves to no phase PERSISTENTLY. Session teardown settles
still-running turns by session status, and that write can race
turn.completed, leaving latestTurn.state at "interrupted" for a turn
that actually finished. The awareness ladder mapped interrupted turns to
null, so quick finish-then-teardown threads were tombstoned instead of
published as completed: the row vanished, the empty aggregate fell into
the freshly-armed grace window, and the card froze on its last state
with no further publish to correct it.
completedAt survives the state-column race: a turn with a completion
timestamp finished, whatever the settling wrote. The ladder now projects
those as completed — the tombstone confirm publishes Done instead of
deleting the thread — while genuinely interrupted turns (no completedAt)
still resolve to null.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
// that has a completion timestamp finished, whatever the state column says.
// Without this, quick finish-then-teardown threads resolve to null
// persistently and get tombstoned instead of published as completed.
if (thread.latestTurn?.state === "interrupted" && thread.latestTurn.completedAt !== null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsrc/agentAwareness.ts:112

The new fallback at line 112 misclassifies cancelled runs as completed. Turns settled via cancellation paths (interrupt-requested or session ended with interrupted/stopped status) also get state: "interrupted" with a non-null completedAt, so the condition thread.latestTurn?.state === "interrupted" && thread.latestTurn.completedAt !== null matches them too. This causes resolveThreadAwarenessPhase to return "completed" for runs the user actually cancelled, so users see stopped runs as successfully finished. If the intent is only to cover the teardown race where a completed turn's state is overwritten by interrupted, the guard needs to distinguish that case from genuine cancellations — for example, by checking the session status or a separate completion marker. Otherwise, consider documenting why interrupted turns with a completedAt should be treated as completed.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/shared/src/agentAwareness.ts around line 112:
The new fallback at line 112 misclassifies cancelled runs as `completed`. Turns settled via cancellation paths (interrupt-requested or session ended with `interrupted`/`stopped` status) also get `state: "interrupted"` with a non-null `completedAt`, so the condition `thread.latestTurn?.state === "interrupted" && thread.latestTurn.completedAt !== null` matches them too. This causes `resolveThreadAwarenessPhase` to return `"completed"` for runs the user actually cancelled, so users see stopped runs as successfully finished. If the intent is only to cover the teardown race where a `completed` turn's state is overwritten by `interrupted`, the guard needs to distinguish that case from genuine cancellations — for example, by checking the session status or a separate completion marker. Otherwise, consider documenting why interrupted turns with a `completedAt` should be treated as completed.

Comment threadpackages/shared/src/agentAwareness.ts
The stuck-at-Working repro survives the completedAt ladder fix: the
confirmed tombstone five seconds after turn completion still resolves
null, so the settled shell holds some combination the static analysis
keeps missing. Instead of guessing another layer deep, the tombstone
paths now log the exact fields the phase ladder reads (session status,
latest turn id/state/completedAt, pendings) both when deferring and when
a confirmation actually publishes — one repro pins the writer.
Also collapses the deferral storm visible at thread birth (six confirm
fibers forked in 600ms): one pending confirmation per thread.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

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

  • ✅ Fixed: Pending confirm set can leak
    • Wrapped the check-add-fork sequence in a single uninterruptible region so a pendingTombstoneConfirms entry can only exist once the detached confirm fiber (whose ensuring finalizer owns the delete) is guaranteed to have been forked.

Create PR

Or push these changes by commenting:

@cursor push abb02eb729
Preview (abb02eb729)
diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts--- a/apps/server/src/relay/AgentAwarenessRelay.ts+++ b/apps/server/src/relay/AgentAwarenessRelay.ts@@ -432,20 +432,30 @@
// immediately deletes the thread from the lock-screen card mid-
// conversation. Defer, re-resolve, and only tombstone if it holds;
// genuinely deleted threads still propagate a few seconds later.
- if (pendingTombstoneConfirms.has(threadId)) {- return;- }- pendingTombstoneConfirms.add(threadId);- yield* Effect.logInfo("agent activity tombstone deferred pending confirmation", {- environmentId,- threadId,- reason: snapshot.reason,- shell: describeThreadShellForAwareness(thread),- });- yield* Effect.forkDetach(- confirmTombstoneLater(threadId).pipe(- Effect.ensuring(Effect.sync(() => pendingTombstoneConfirms.delete(threadId))),- ),+ // Check-add-fork must be atomic under interruption: a set entry without+ // a forked confirm fiber (whose `ensuring` owns the delete) would+ // suppress tombstone confirmation for this thread forever.+ yield* Effect.uninterruptible(+ Effect.suspend(() => {+ if (pendingTombstoneConfirms.has(threadId)) {+ return Effect.void;+ }+ pendingTombstoneConfirms.add(threadId);+ return Effect.logInfo("agent activity tombstone deferred pending confirmation", {+ environmentId,+ threadId,+ reason: snapshot.reason,+ shell: describeThreadShellForAwareness(thread),+ }).pipe(+ Effect.andThen(+ Effect.forkDetach(+ confirmTombstoneLater(threadId).pipe(+ Effect.ensuring(Effect.sync(() => pendingTombstoneConfirms.delete(threadId))),+ ),+ ),+ ),+ );+ }),
);
return;
}

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

Comment threadapps/server/src/relay/AgentAwarenessRelay.ts Outdated
Diagnostics from the stuck-at-Working repro finally exposed the data
hole: the confirmed tombstone's shell showed sessionStatus "ready" with
latestTurn entirely absent. Threads whose turns produce no checkpoint
never get a projection_turns row, and thread.session-set clears
latest_turn_id the moment the session settles (activeTurnId goes null) —
so "completed" is unrepresentable through turns for quick Q&A threads.
Their entire lifecycle rode on session status and pendings, and at
completion nothing remained on the awareness ladder: tombstone instead
of Done.
A live session at "ready"/"idle" with nothing pending and nothing
running now projects as completed. This intentionally diverges from the
web sidebar, which resolves the same null but renders it as "no pill" —
a fine default in a list, while the publisher's null means "remove from
the lock-screen card". (The sidebar's own Completed pill needs
latestTurn.completedAt and silently can't fire for these threads either;
persisting turn rows for checkpoint-less turns is the deeper future fix
for both surfaces.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment threadpackages/shared/src/agentAwareness.ts
The full lifecycle finally works end to end, with one wart: a duplicate
Done push notification fired one second after thread creation. Sessions
boot at "ready" before their first turn starts, so the new
ready-means-completed projection produced a momentary completed state at
birth — and since the freshly armed card's token wasn't registered yet,
it fell through to the notification channel. Server restarts had the
same shape at scale: the startup snapshot republished completed for
every recently finished thread, each queuing a push notification.
- Env server: completed as a thread's FIRST published state defers and
re-resolves like a tombstone — at birth the confirm sees running and
completed never publishes; genuinely at-rest threads still publish
five seconds later. Fresh completions (previous state was live) are
unaffected, keeping the buzz immediate.
- Relay: terminal push notifications only fire when the completion is
fresh (row updated within two minutes), so replays and restart
snapshots repaint state without ringing the device.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
if (!activity) {
return null;
}
if (activity.phase === "completed" || activity.phase === "failed") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 MediumagentActivity/ApnsDeliveries.ts:321

notificationForAggregate drops terminal notifications for push-notification-only users when activity.updatedAt is more than two minutes old. activity.updatedAt is the timestamp from the published aggregate state, not the time the notification was first delivered to this device. If relay publishing or replay is delayed by more than two minutes (e.g., after downtime or a late reconnect), the first completed/failed push notification for a notifications-only user is silently suppressed even though it was never delivered before. Consider gating the freshness check on the device's last-known delivery time (e.g., last_push_notification_delivery_at) rather than activity.updatedAt, so only devices that already received a recent terminal notification are suppressed.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/ApnsDeliveries.ts around line 321:
`notificationForAggregate` drops terminal notifications for push-notification-only users when `activity.updatedAt` is more than two minutes old. `activity.updatedAt` is the timestamp from the published aggregate state, not the time the notification was first delivered to this device. If relay publishing or replay is delayed by more than two minutes (e.g., after downtime or a late reconnect), the first `completed`/`failed` push notification for a notifications-only user is silently suppressed even though it was never delivered before. Consider gating the freshness check on the device's last-known delivery time (e.g., `last_push_notification_delivery_at`) rather than `activity.updatedAt`, so only devices that already received a recent terminal notification are suppressed.

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

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

  • ✅ Fixed: LA alerts skip terminal freshness
    • Threaded nowMs into alertForNewlyTerminal and alertForTerminalAggregate and applied the shared TERMINAL_NOTIFICATION_FRESHNESS_MS window via a new isFreshTerminalRow helper, so stale replayed completions no longer alert through Live Activity updates or end events.

Create PR

Or push these changes by commenting:

@cursor push c88cec1a49
Preview (c88cec1a49)
diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts--- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts+++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts@@ -1525,15 +1525,30 @@
activities: [{ ...aggregate.activities[0]!, phase: "completed" as const, status: "Done" }],
};
expect(
- ApnsDeliveries.alertForTerminalAggregate({ aggregate: terminalAggregate, preferences }),+ ApnsDeliveries.alertForTerminalAggregate({+ aggregate: terminalAggregate,+ preferences,+ nowMs: 0,+ }),
).toEqual({ title: "Thread", body: "Done: Project" });
expect(
ApnsDeliveries.alertForTerminalAggregate({
aggregate: terminalAggregate,
preferences: { ...preferences, notifyOnCompletion: false },
+ nowMs: 0,
}),
).toBeNull();
- expect(ApnsDeliveries.alertForTerminalAggregate({ aggregate: null, preferences })).toBeNull();+ expect(+ ApnsDeliveries.alertForTerminalAggregate({ aggregate: null, preferences, nowMs: 0 }),+ ).toBeNull();+ // A completion replayed long after the fact must not ring again.+ expect(+ ApnsDeliveries.alertForTerminalAggregate({+ aggregate: terminalAggregate,+ preferences,+ nowMs: 10 * 60 * 1_000,+ }),+ ).toBeNull();
});
it("alerts when a previously active thread finishes mid-flight", () => {
@@ -1552,6 +1567,7 @@
previousAggregate: aggregate,
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toEqual({ title: "Thread", body: "Done: Project" });
// The completion switch mutes it.
@@ -1560,6 +1576,7 @@
previousAggregate: aggregate,
nextAggregate: next,
preferences: { ...preferences, notifyOnCompletion: false },
+ nowMs: 0,
}),
).toBeNull();
// No baseline means no transition to ring on.
@@ -1568,6 +1585,7 @@
previousAggregate: null,
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toBeNull();
// A Done row that was already terminal (or absent) before stays silent.
@@ -1576,6 +1594,7 @@
previousAggregate: next,
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toBeNull();
expect(
@@ -1583,7 +1602,18 @@
previousAggregate: { ...aggregate, activities: [attentionRow] },
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toBeNull();
+ // A stale completion replayed after a restart (previous aggregate still+ // shows the thread as running) must not ring the device again.+ expect(+ ApnsDeliveries.alertForNewlyTerminal({+ previousAggregate: aggregate,+ nextAggregate: next,+ preferences,+ nowMs: 10 * 60 * 1_000,+ }),+ ).toBeNull();
});
});
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@@ -210,6 +210,20 @@
};
}
+// Completions replayed long after the fact (server restarts republish every+// recently-finished thread) must not ring the device again — on any channel:+// push notifications, Live Activity update alerts, and end alerts all honor+// the same freshness window.+const TERMINAL_NOTIFICATION_FRESHNESS_MS = 2 * 60 * 1_000;++function isFreshTerminalRow(row: { readonly updatedAt: string }, nowMs: number): boolean {+ const updatedAtMs = Option.match(DateTime.make(row.updatedAt), {+ onNone: () => null,+ onSome: (dt) => dt.epochMilliseconds,+ });+ return updatedAtMs !== null && nowMs - updatedAtMs <= TERMINAL_NOTIFICATION_FRESHNESS_MS;+}+
// Alert copy for an update whose aggregate contains threads that finished
// (Done/Failed) since the previously delivered aggregate — the mid-flight
// completion buzz while other agents keep the activity alive. Requires the
@@ -219,6 +233,7 @@
readonly previousAggregate: RelayAgentActivityAggregateState | null;
readonly nextAggregate: RelayAgentActivityAggregateState;
readonly preferences: RelayAgentAwarenessPreferences | null;
+ readonly nowMs: number;
}): ApnsLiveActivityAlert | null {
if (input.previousAggregate === null) {
return null;
@@ -230,6 +245,9 @@
if (row.phase !== "completed" && row.phase !== "failed") {
return false;
}
+ if (!isFreshTerminalRow(row, input.nowMs)) {+ return false;+ }
const previousPhase = previousPhases.get(row.threadId);
return (
previousPhase !== undefined &&
@@ -255,11 +273,15 @@
export function alertForTerminalAggregate(input: {
readonly aggregate: RelayAgentActivityAggregateState | null;
readonly preferences: RelayAgentAwarenessPreferences | null;
+ readonly nowMs: number;
}): ApnsLiveActivityAlert | null {
const row = input.aggregate?.activities[0];
if (!row || (row.phase !== "completed" && row.phase !== "failed")) {
return null;
}
+ if (!isFreshTerminalRow(row, input.nowMs)) {+ return null;+ }
if (!alertAllowedForPhase(input.preferences, row.phase)) {
return null;
}
@@ -298,10 +320,6 @@
);
}
-// Completions replayed long after the fact (server restarts republish every-// recently-finished thread) must not ring the device again.-const TERMINAL_NOTIFICATION_FRESHNESS_MS = 2 * 60 * 1_000;-
function notificationForAggregate(input: {
readonly target: LiveActivities.TargetRow;
readonly aggregate: RelayAgentActivityAggregateState | null;
@@ -318,14 +336,11 @@
if (!activity) {
return null;
}
- if (activity.phase === "completed" || activity.phase === "failed") {- const updatedAtMs = Option.match(DateTime.make(activity.updatedAt), {- onNone: () => null,- onSome: (dt) => dt.epochMilliseconds,- });- if (updatedAtMs === null || input.nowMs - updatedAtMs > TERMINAL_NOTIFICATION_FRESHNESS_MS) {- return null;- }+ if (+ (activity.phase === "completed" || activity.phase === "failed") &&+ !isFreshTerminalRow(activity, input.nowMs)+ ) {+ return null;
}
const enabled =
(activity.phase === "waiting_for_approval" && preferences.notifyOnApproval) ||
@@ -419,6 +434,7 @@
previousAggregate,
nextAggregate,
preferences,
+ nowMs: input.nowMs,
}),
}
: "suppressed";
@@ -1100,6 +1116,7 @@
: alertForTerminalAggregate({
aggregate: delivery.aggregate,
preferences: parsePreferences(input.target.preferences_json),
+ nowMs: input.nowMs,
})
: delivery.alert;
const result = yield* deliveryQueue.enqueueLiveActivity({

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

Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts
…A pref gates
Fixes the real findings from the PR review bots ahead of the TestFlight
build:
- Deferred publish confirmations (tombstones and first-state
completions) now re-enqueue through the same drainable worker as every
other publish instead of a detached fiber calling the publisher
directly, so a confirmed tombstone can never race an in-flight live
update; a deadline map replaces the pending set, which also guarantees
a single scheduled confirmation per thread and clears when the state
recovers. (cursor/macroscope High)
- Newly-terminal transitions bypass the 15s live-activity update
throttle: a completion landing in the same window as a new start keeps
activeCount unchanged and would previously be suppressed, silently
dropping the Done transition and its buzz. (macroscope High)
- The newly-terminal alert honors the same 2-minute freshness rule as
terminal push notifications, so replayed aggregates repaint without
ringing. (cursor Medium)
- Live Activity arming and priming treat an unset preference as enabled
(the toggle's default): fresh installs now prime, and arm-on-send
respects an explicit off. (cursor High/Medium)
Remaining findings triaged as stale (superseded designs), intended
behavior (replay painting recent Done, LA-disabled end retiring the
token), or accepted edge cases (grace extension on re-registration,
completion alerts beyond the 3-row display cap, ready-with-lastError
mapping to Done, cancelled turns showing Done).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

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

  • ✅ Fixed: Stale confirm deadline publishes tombstone
    • Cleared the thread's publishConfirmDeadlines entry in the unchanged-identity early-return path so a recovered projection cancels the deferred confirmation, preventing a later transient null state from confirming immediately against a stale expired deadline.

Create PR

Or push these changes by commenting:

@cursor push e02169b78e
Preview (e02169b78e)
diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts--- a/apps/server/src/relay/AgentAwarenessRelay.ts+++ b/apps/server/src/relay/AgentAwarenessRelay.ts@@ -410,6 +410,11 @@
const publishIdentity = agentAwarenessPublishIdentity(snapshot.state);
const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef);
if (publishedStateByThread.get(threadId) === publishIdentity) {
+ // The projection recovered to the last published state, so any deferred+ // confirmation (tombstone or first-state completion) is moot. Clear the+ // deadline so the next divergence gets a fresh deferral window instead+ // of confirming immediately against a stale, expired deadline.+ publishConfirmDeadlines.delete(threadId);
yield* Effect.logDebug("agent activity publish skipped; projected state unchanged", {
environmentId,
threadId,

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

Reviewed by Cursor Bugbot for commit 97ed442. Configure here.

Comment threadapps/server/src/relay/AgentAwarenessRelay.ts
…d state
A recovered projection exits at the unchanged-identity dedupe, which sits
above the confirmation block, so a deferred publish's deadline survived
recovery. A much later transient null would then find the deadline
already expired and publish its tombstone immediately, bypassing the
deferral window entirely. The dedupe path now clears any pending
deadline: the state is back at what was last published, so the deferred
confirmation is moot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@juliusmarmingejuliusmarminge added the 🚀 Mobile Continuous Deployment Trigger Expo preview build label Jul 7, 2026
Fixes the Effect Service Conventions check: the service builds a plain
object with no effectful acquisition, so wrapping it in Effect.sync just
to feed Layer.effect was the make-only-to-force-Layer.effect shape the
conventions call out.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

ghost commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

🚀 Expo continuous deployment is ready!

  • Project → t3-code
  • Platforms → android, ios
  • Scheme → t3code-preview
🤖 Android🍎 iOS
Fingerprint106610ae982be2d8aa28dda9217ff853daf9d30b4f388ce49fd16563ef62f563f42c0528f092d42b
Build DetailsBuild Permalink
DetailsDistribution: INTERNAL
Build profile: preview:dev
Runtime version: 106610ae982be2d8aa28dda9217ff853daf9d30b
App version: 0.1.0
Git commit: 74888084257320d42e7562c74996c949010bdf45
Build Permalink
DetailsDistribution: INTERNAL
Build profile: preview:dev
Runtime version: 4f388ce49fd16563ef62f563f42c0528f092d42b
App version: 0.1.0
Git commit: c622444d589c97adb591101ffca9161d785806b0
Update DetailsUpdate Permalink
DetailsBranch: pr-3685
Runtime version: 106610ae982be2d8aa28dda9217ff853daf9d30b
Git commit: 74888084257320d42e7562c74996c949010bdf45
Update Permalink
DetailsBranch: pr-3685
Runtime version: 4f388ce49fd16563ef62f563f42c0528f092d42b
Git commit: 74888084257320d42e7562c74996c949010bdf45
Update QR

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

Labels

🚀 Mobile Continuous DeploymentTrigger Expo preview buildsize:XXL1,000+ changed lines (additions + deletions).vouch:trustedPR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@juliusmarminge
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Improve live activity routing and diagnostics - #3685

Merged
juliusmarminge merged 26 commits into
mainfrom
t3code/live-activities-improvements
Jul 7, 2026
Merged

Improve live activity routing and diagnostics#3685
juliusmarminge merged 26 commits into
mainfrom
t3code/live-activities-improvements

Conversation

@juliusmarminge

@juliusmarmingejuliusmarminge commented Jul 4, 2026

Copy link
Copy Markdown
Member

Summary

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

Testing

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

Note

High Risk
Touches relay registration, APNs routing, cloud link modes, and Live Activity lifecycle (including removing push-to-start); misconfiguration or partial deploy could break mobile notifications or mis-route pushes.

Overview
Improves mobile agent awareness end-to-end: relay device registration now sends bundle ID and sandbox vs production APNs routing, tracks whether the relay actually accepted registration (settings toggles and alerts follow that, not just iOS permission), skips duplicate registrations via a persisted signature, and re-registers Live Activity tokens on foreground with short-window deduping. Push-to-start registration is removed in favor of arming a card when the user sends a task (and priming from a relay activity snapshot when idle), plus cleanup on cloud sign-out and a releaseAgentAwarenessRelayTokenProvider path so auth UI remounts do not wipe registration or end activities.

Agent Activity Live Activity UI is reworked (attention-first rows, phase tints aligned with web, deep links, branded T3 mark via new Expo config plugins and actool shell phase). expo-widgets gains frequentUpdates for higher update budget.

T3 Connect vs publishing split: environments can link in publish_only mode (manual endpoint, no Cloudflare tunnel) via CLI (connect link --publish-only, connect publish) and web settings (separate toggles reconciled together). Link proofs accept manual providers with activity-only scopes; server publish path defers transient tombstones and first completed snapshots before publishing.

Relay DB adds bundle_id and aps_environment on mobile devices (migration in diff).

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

Note

Add per-device APNs routing, JWT token caching, and publish-only link mode to Live Activity delivery

  • Stores bundle_id and aps_environment per device in the relay and uses them to route APNs pushes to the correct topic and environment per device rather than using global config.
  • Introduces ApnsProviderTokens service that caches APNs JWTs quantized to 45-minute windows, preventing TooManyProviderTokenUpdates errors; uses deterministic ES256 signing via @noble/curves.
  • Overhauls Live Activity delivery: activities are no longer remotely started; ends are suppressed within a grace window after arming; attention-phase transitions and newly-terminal rows can now trigger alert payloads with haptics; stale terminal aggregates suppress re-alerting.
  • Adds a publish_only cloud link mode in the CLI (t3 connect publish) and web settings UI, allowing agent activity publishing without a managed Cloudflare tunnel; linking with this mode uses providerKind: "manual" and deprovisions any existing tunnel allocation.
  • Reworks the AgentActivity widget to sort rows attention-first, apply per-phase tints from the web palette, deep-link to the most relevant thread, show a branded logo, and display a phase glyph in minimal presentation.
  • Exposes relay registration status (unknown/pending/registered/failed) to the settings UI so notification and Live Activity toggles only appear enabled when the device is actually registered.
  • Risk: makeAggregateState now requires a nowMs parameter and statusForPhase('starting') returns 'Connecting' instead of 'Starting', which are breaking changes for callers.

Macroscope summarized 6f0b32d.

@coderabbitai

coderabbitaiBot commented Jul 4, 2026

Copy link
Copy Markdown

Important

Review skipped

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

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: fe216c9a-1cd9-460a-a80d-6d90748a00b5

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

Use the checkbox below for a quick retry:

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

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

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

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Autofix Details

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

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

Create PR

Or push these changes by commenting:

@cursor push a97e0b6881
Preview (a97e0b6881)
diff --git a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts--- a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts+++ b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts@@ -69,6 +69,28 @@
]);
});
+ it("treats a non-2xx delivery status without a reason as a failure", () => {+ const rows = formatDeviceDiagnosticsRows(+ makeDevice({+ bundleId: "com.t3tools.t3code.preview",+ apsEnvironment: "production",+ hasPushToken: true,+ hasPushToStartToken: true,+ hasLiveActivityToken: true,+ lastDeliveryAt: "2026-06-05T01:02:59.566Z",+ lastDeliveryKind: "live_activity_end",+ lastDeliveryStatus: 400,+ lastDeliveryError: null,+ }),+ );++ expect(rows[4]).toEqual({+ label: "Last Delivery",+ value: "Delivery failed (400)",+ tone: "warn",+ });+ });+
it("reports healthy registrations with a successful delivery", () => {
const rows = formatDeviceDiagnosticsRows(
makeDevice({
diff --git a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts--- a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts+++ b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts@@ -73,14 +73,21 @@
});
}
+ // APNs can fail with a non-2xx status without a reason body, so a null+ // error alone does not mean the delivery succeeded.+ const deliveryFailed =+ diagnostics.lastDeliveryError !== null ||+ (diagnostics.lastDeliveryStatus !== null &&+ (diagnostics.lastDeliveryStatus < 200 || diagnostics.lastDeliveryStatus >= 300));+
if (diagnostics.lastDeliveryAt === null) {
rows.push({ label: "Last Delivery", value: "None yet", tone: "muted" });
- } else if (diagnostics.lastDeliveryError !== null) {+ } else if (deliveryFailed) {
const status =
diagnostics.lastDeliveryStatus === null ? "" : ` (${diagnostics.lastDeliveryStatus})`;
rows.push({
label: "Last Delivery",
- value: `${diagnostics.lastDeliveryError}${status}`,+ value: `${diagnostics.lastDeliveryError ?? "Delivery failed"}${status}`,
tone: "warn",
});
} else {
diff --git a/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/migration.sql b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/migration.sql
new file mode 100644
--- /dev/null+++ b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/migration.sql@@ -1,0 +1,3 @@+ALTER TABLE "relay_mobile_devices" ADD COLUMN "bundle_id" varchar(255);+--> statement-breakpoint+ALTER TABLE "relay_mobile_devices" ADD COLUMN "aps_environment" varchar(16);diff --git a/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/snapshot.json b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/snapshot.json
new file mode 100644
--- /dev/null+++ b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/snapshot.json@@ -1,0 +1,1479 @@+{+ "dialect": "postgres",+ "id": "e00cc7df-a31e-4b8b-b258-d04e7db7758a",+ "prevIds": ["385d476b-d4f6-48a3-99e6-0a95af4ee4e4"],+ "version": "8",+ "ddl": [+ {+ "isRlsEnabled": false,+ "name": "relay_agent_activity_rows",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_delivery_attempts",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_dpop_proofs",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_environment_credentials",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_environment_links",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_live_activities",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_managed_endpoint_allocations",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_mobile_devices",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_public_key",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "thread_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "jsonb",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "state_json",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "updated_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "varchar(36)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "user_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "thread_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "device_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "kind",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "source_job_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(16)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "token_suffix",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "integer",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "apns_status",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "apns_reason",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(128)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "apns_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "transport_error",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(128)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "thumbprint",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "jti",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "integer",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "iat",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "expires_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "credential_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_public_key",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "credential_hash",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "revoked_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "updated_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "user_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": "'T3 Environment'",+ "generated": null,+ "identity": null,+ "name": "environment_label",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_public_key",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "endpoint_http_base_url",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "endpoint_ws_base_url",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(32)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "endpoint_provider_kind",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "boolean",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": "true",+ "generated": null,+ "identity": null,+ "name": "notifications_enabled",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "boolean",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": "true",+ "generated": null,+ "identity": null,+ "name": "live_activities_enabled",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "boolean",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": "false",+ "generated": null,+ "identity": null,+ "name": "managed_tunnels_enabled",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_by_device_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "revoked_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "updated_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "user_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "device_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "activity_push_token",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "remote_start_queued_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "remote_started_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "ended_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "jsonb",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,
... diff truncated: showing 800 of 1681 lines

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highpersistence/schema.ts:27

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

Also found in 1 other location(s)

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

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/persistence/schema.ts around line 27:
The new `bundle_id` and `aps_environment` columns are added to the `relay_mobile_devices` schema, but no SQL migration is included to create them. After deploy, the code in `Devices.ts` immediately inserts and selects these columns, so PostgreSQL rejects every device registration and list query with `column "bundle_id" does not exist` (and `aps_environment` likewise). Add the corresponding `ALTER TABLE relay_mobile_devices ADD COLUMN ...` migration under `infra/relay/migrations/` so the columns exist before the new code runs.
Also found in 1 other location(s):
- infra/relay/src/agentActivity/LiveActivities.ts:201 -- The new `relayMobileDevices.bundleId` / `relayMobileDevices.apsEnvironment` columns are referenced by `listTargets` (`bundle_id`, `aps_environment`) and other relay code, but this PR does not add a matching SQL migration for `relay_mobile_devices`. After deploying the code to an existing database, any `listTargets` query will start failing with `column relay_mobile_devices.bundle_id does not exist` (and likewise for `aps_environment`), breaking live-activity target lookup until the schema is manually patched.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium

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

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/ApnsDeliveries.ts around line 506:
The stale-job check in `processSignedJob` only compares the token via `isCurrentSignedJobToken`, so a queued job created before the device registered `bundleId`/`apsEnvironment` (or before those values changed) still passes the staleness guard when the token is unchanged. The job is then sent with the stale or missing routing metadata from the signed payload instead of the device's current values, causing APNs to reject the delivery with `BadDeviceToken`/`DeviceTokenNotForTopic` even though the registration has already been corrected. Consider comparing `bundleId` and `apsEnvironment` against the current target in the staleness check so jobs with outdated routing are treated as stale.

@macroscopeapp

macroscopeappBot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

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

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumwidgets/AgentActivity.tsx:81

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/widgets/AgentActivity.tsx around line 81:
`widgetURL` is hardcoded to the `t3code://` scheme, but the app registers `t3code-dev` for development builds and `t3code-preview` for preview builds. On dev/preview builds the widget banner's tap URL uses a scheme the containing app does not register, so iOS will not open the app when the banner is tapped. Consider using a scheme that is registered in all build variants (or injecting the active scheme per-build).

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 MediumagentActivity/AgentActivityPublisher.ts:203

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/AgentActivityPublisher.ts around line 203:
`RUNNING_AGENT_ACTIVITY_ROW_TTL_MS` expires `running`/`starting` states after 2 hours based on `updatedAt`, but relay publishes are event-driven — a thread that stays in the same `running` phase for over 2 hours without a new event keeps its old `updatedAt`, so `isExpiredAgentActivityState` returns `true` and `makeAggregateState` filters it out. The aggregate then reports `activeCount: 0` (or `null`/terminal) even though the job is still running, hiding active work from clients. Consider raising the running TTL to match the waiting TTL, or filtering on a heartbeat/last-seen timestamp that is refreshed independently of phase-change events.

Comment threadapps/mobile/src/widgets/AgentActivity.tsx
Comment threadinfra/relay/src/agentActivity/Devices.ts Outdated
Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts
@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from 1d6309e to 39d9dcaCompareJuly 6, 2026 06:07
@github-actionsgithub-actionsBot added size:L 100-499 changed lines (additions + deletions). and removed size:XL 500-999 changed lines (additions + deletions). labels Jul 6, 2026
@cursor

This comment has been minimized.

@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from 39d9dca to 5225bdbCompareJuly 6, 2026 07:37
@github-actionsgithub-actionsBot added size:XXL 1,000+ changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Jul 6, 2026

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

Autofix Details

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

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

Create PR

Or push these changes by commenting:

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

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsettings/SettingsRouteScreen.tsx:438

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/settings/SettingsRouteScreen.tsx around line 438:
The `deviceRegistered` gate on the `Device Notifications` switch renders it `false` when relay registration fails, even though `notificationStatus` is `"enabled"`. Because the switch shows off, the user cannot toggle it to reach `handleDeviceNotificationsChange(false)`, which is the only path that opens iOS Settings to disable notifications. In the "permission granted, relay registration failed" case the switch misreports notifications as off and removes the in-app affordance for managing them. Consider gating only the Live Activity switch on `deviceRegistered`, or keeping the `Device Notifications` switch reflecting the actual iOS permission state so the user can still navigate to system Settings.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 HighagentActivity/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 effectSchema.Struct behavior) and recompute the HMAC over the reduced payload, so verifySignedApnsDeliveryJob returns ApnsDeliveryJobSignatureInvalid for every newly queued job carrying per-device routing. This breaks APNs delivery during rolling deploys when new producers share a queue with old consumers. Consider gating inclusion of these fields behind a flag, or otherwise ensuring the signed payload shape remains decodable by older schema versions, so signatures stay stable across the rollout.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/apnsDeliveryJobs.ts around line 247:
`makeApnsDeliveryJobPayload` includes `target.bundleId` and `target.apsEnvironment` in the payload, and `signatureForPayload` signs that full object. Older relay workers that decode with a previous `ApnsDeliveryJobPayload` schema strip the new keys on decode (default `effect` `Schema.Struct` behavior) and recompute the HMAC over the reduced payload, so `verifySignedApnsDeliveryJob` returns `ApnsDeliveryJobSignatureInvalid` for every newly queued job carrying per-device routing. This breaks APNs delivery during rolling deploys when new producers share a queue with old consumers. Consider gating inclusion of these fields behind a flag, or otherwise ensuring the signed payload shape remains decodable by older schema versions, so signatures stay stable across the rollout.

@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from 5225bdb to 5086c14CompareJuly 6, 2026 16:13

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium

infoPlist: {
NSAppTransportSecurity: {

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

 infoPlist: {
+ NSSupportsLiveActivitiesFrequentUpdates: true,
NSAppTransportSecurity: {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/app.config.ts around lines 96-97:
The `frequentUpdates: true` option under the `expo-widgets` plugin does not add the required `NSSupportsLiveActivitiesFrequentUpdates` key to `Info.plist`, so iOS keeps the standard Live Activity update budget and frequent updates are never enabled. Set `NSSupportsLiveActivitiesFrequentUpdates: true` in the `ios.infoPlist` block to actually grant the entitlement.

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

export interface AgentAwarenessRegistrationRecord {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumlib/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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highsettings/SettingsRouteScreen.tsx:441

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/settings/SettingsRouteScreen.tsx around line 441:
When `deviceRegistered` is `false` but `liveActivitiesEnabled` is `true` in storage, the `Live Activity Updates` switch renders off even though `liveActivityStatus` is `"enabled"`. Because `onValueChange` receives the next visual state (`true`), tapping the switch calls `linkEnvironments()` (the enable path) instead of persisting `enabled: false`, so the user cannot disable Live Activity updates from Settings until registration succeeds. Consider gating the disabled state or the alert on `deviceRegistered` rather than the switch `value`, so the toggle still reflects the saved preference and `onValueChange` fires with the correct next state.

Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

Autofix Details

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

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

Create PR

Or push these changes by commenting:

@cursor push 59517fdb7d
Preview (59517fdb7d)
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts@@ -186,7 +186,11 @@
refreshActiveLiveActivityRemoteRegistration(),
"active live activity registration after cloud sign-in failed",
);
- if (isExistingIdentity) {+ // An unchanged identity only skips re-registration when the previous attempt+ // actually reached the relay; a failed or stalled attempt must retry the next+ // time the auth effect re-runs, or the device stays unregistered until an+ // unrelated event (token rotation, environment link) happens to enqueue one.+ if (isExistingIdentity && registrationStatus === "registered") {
return;
}
enqueueDeviceRegistration({}, "device registration after cloud sign-in failed");
diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts--- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts+++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts@@ -119,6 +119,16 @@
terminalState: null,
nowMs: now.epochMilliseconds,
});
+ // A null aggregate is ambiguous while non-terminal rows still exist:+ // they may belong to a dead environment, or to a healthy long-running+ // agent whose meaningful state (and therefore updatedAt) has not changed+ // within the TTL. Sending in that case would end a possibly-healthy Live+ // Activity on every foreground, so replay only delivers when live rows+ // remain or the rows are truly gone (the thread ended and its end push+ // may have been lost).+ if (aggregate === null && activeStates.some((state) => !isTerminalPhase(state))) {+ return null;+ }
return yield* apnsDeliveries.sendForTarget({
target,
aggregate,

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

Comment threadinfra/relay/src/agentActivity/AgentActivityPublisher.ts
@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from 5086c14 to a31735cCompareJuly 6, 2026 17:03
Comment threadapps/mobile/src/features/settings/SettingsRouteScreen.tsx
Comment threadapps/server/src/cli/connect.ts

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

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

Autofix Details

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

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

Create PR

Or push these changes by commenting:

@cursor push d469c8569a
Preview (d469c8569a)
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts@@ -535,6 +535,48 @@
}).pipe(Effect.provide(relayTestLayer));
});
+ it.effect("keeps the registered status when a stale concurrent attempt fails", () => {+ const fetchMock = vi.fn((request: RequestInfo | URL) => {+ const url = request instanceof Request ? request.url : String(request);+ return Promise.resolve(+ Response.json(+ url.endsWith("/v1/client/dpop-token")+ ? {+ access_token: "relay-dpop-token",+ issued_token_type: "urn:ietf:params:oauth:token-type:access_token",+ token_type: "DPoP",+ expires_in: 300,+ scope: "mobile:registration",+ }+ : { ok: true },+ ),+ );+ });+ vi.stubGlobal("fetch", fetchMock);+ Constants.expoConfig!.extra = {+ relay: {+ url: "https://relay.example.test/",+ },+ };++ // Sign-in enqueues a registration attempt that stays parked in the+ // background queue while the direct refresh below runs.+ setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));++ return Effect.gen(function* () {+ yield* refreshAgentAwarenessRegistration();+ expect(getAgentAwarenessRegistrationStatus()).toBe("registered");++ // The queued sign-in attempt now fails; its stale failure must not+ // overwrite the registration the relay already accepted.+ vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockRejectedValueOnce(+ new Error("device id unavailable"),+ );+ yield* runBackgroundOperations();+ expect(getAgentAwarenessRegistrationStatus()).toBe("registered");+ }).pipe(Effect.provide(relayTestLayer));+ });+
it("clears registration status on cloud sign-out", () => {
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
setAgentAwarenessRelayTokenProvider(null);
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts@@ -74,6 +74,13 @@
let registrationStatus: AgentAwarenessRegistrationStatus = "unknown";
const registrationStatusListeners = new Set<() => void>();
+// Registration runs both through the background queue and directly via+// refreshAgentAwarenessRegistration, so attempts can overlap. Counting relay+// acceptances lets a failing attempt detect that a concurrent attempt already+// succeeded while it was in flight, so a stale failure cannot overwrite a+// registration the relay accepted.+let registrationSuccessCount = 0;+
function setRegistrationStatus(next: AgentAwarenessRegistrationStatus): void {
if (registrationStatus === next) {
return;
@@ -84,6 +91,18 @@
}
}
+function markRegistrationSucceeded(): void {+ registrationSuccessCount++;+ setRegistrationStatus("registered");+}++function markRegistrationFailed(successCountAtAttemptStart: number): void {+ if (registrationSuccessCount !== successCountAtAttemptStart) {+ return;+ }+ setRegistrationStatus("failed");+}+
export function getAgentAwarenessRegistrationStatus(): AgentAwarenessRegistrationStatus {
return registrationStatus;
}
@@ -316,7 +335,7 @@
catch: (cause) => cause,
}).pipe(Effect.orElseSucceed(() => null));
if (persisted && persisted.identity === identity && persisted.signature === signature) {
- setRegistrationStatus("registered");+ markRegistrationSucceeded();
logRegistrationDebug("relay device registration skipped; already registered for account", {
expectedGeneration,
});
@@ -331,7 +350,7 @@
clerkToken: token,
payload: body,
});
- setRegistrationStatus("registered");+ markRegistrationSucceeded();
yield* Effect.promise(() =>
saveAgentAwarenessRegistrationRecord({ identity, signature }).catch((error: unknown) => {
logRegistrationError("persist registration record failed", error);
@@ -466,11 +485,12 @@
};
activeDeviceRegistration = registration;
registration.operation = (async () => {
+ const successCountAtStart = registrationSuccessCount;
const result = await settleAsyncResult(() =>
runtime.runPromiseExit(registerDevice(next.input, generation)),
);
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
- setRegistrationStatus("failed");+ markRegistrationFailed(successCountAtStart);
logRegistrationError(next.context, squashAtomCommandFailure(result));
}
logRegistrationDebug("device registration finished", { generation });
@@ -675,14 +695,17 @@
never,
ManagedRelay.ManagedRelayClient
> {
- return registerDeviceForCurrentUser().pipe(- Effect.catch((error) =>- Effect.sync(() => {- setRegistrationStatus("failed");- logRegistrationError("device registration refresh failed", error);- }),- ),- );+ return Effect.suspend(() => {+ const successCountAtStart = registrationSuccessCount;+ return registerDeviceForCurrentUser().pipe(+ Effect.catch((error) =>+ Effect.sync(() => {+ markRegistrationFailed(successCountAtStart);+ logRegistrationError("device registration refresh failed", error);+ }),+ ),+ );+ });
}
export function __resetAgentAwarenessRemoteRegistrationForTest(): void {
@@ -703,6 +726,7 @@
activeDeviceRegistration = null;
pendingDeviceRegistration = null;
registrationStatus = "unknown";
+ registrationSuccessCount = 0;
registrationStatusListeners.clear();
}

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

Comment threadapps/mobile/src/features/agent-awareness/remoteRegistration.ts Outdated
- Per-device APNs routing (bundle id + environment) so preview/dev builds
receive pushes from the shared relay (+ migration for the new columns)
- Settings notification/Live Activity toggles reflect actual relay
registration success; cannot read as enabled when the device is not registered
- Persistent register-once: skip re-registering an unchanged payload for the
same account across launches; clear only on sign-out
- Headless publish + tunnel-free linking: `t3 connect publish` toggles agent
activity publishing and auto-establishes a publish-only (no managed tunnel)
link, and `t3 connect link --publish-only` links for publishing alone, so
activity flows to mobile clients that reach the environment out of band
(e.g. Tailscale) without T3 Connect. Relay accepts publish-only links with a
nominal endpoint; env proofs advertise the manual provider kind
- Foreground Live Activity refresh, stale/ghost-row hardening, dedup fixes
- Tighten widget deep linking and per-row rendering for active agents
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from a31735c to 9c3eb7bCompareJuly 6, 2026 17:24
// Development builds are Xcode-signed and receive sandbox APNs tokens;
// preview and production builds are distribution-signed and use production
// APNs. The relay routes each device's pushes accordingly.
export function resolveApsEnvironment(appVariant: unknown): "sandbox" | "production" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumagent-awareness/registrationPayload.ts:8

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/agent-awareness/registrationPayload.ts around line 8:
`resolveApsEnvironment` returns `"production"` for every value except the literal string `"development"`. Locally run `ios:preview` and `ios:prod` builds launched via `expo run:ios` are development-signed and receive sandbox APNs tokens, but this function classifies them as `"production"`. The relay then routes pushes to the production APNs gateway for a sandbox token, so notifications and Live Activities fail to deliver for those builds. Consider keying off the signing/provisioning environment (e.g. EAS build profile or `Configuration`/ entitlements) rather than the variant name, or document why locally-run `preview`/`prod` builds are expected to use production APNs.

Comment threadapps/server/src/cli/connect.ts
Comment threadpackages/contracts/src/environmentHttp.ts Outdated
Comment on lines 1745 to 1750
primaryCloudLinkState.refresh();
const refreshResult = await refreshRelayEnvironments();
if (refreshResult._tag === "Failure") {
if (!isAtomCommandInterrupted(refreshResult)) {
reportUpdateFailure(squashAtomCommandFailure(refreshResult));
}
setIsUpdating(false);
return;
if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) {
reportUpdateFailure(squashAtomCommandFailure(refreshResult));
return false;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsettings/ConnectionsSettings.tsx:1745

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

 primaryCloudLinkState.refresh();
+ return true;
const refreshResult = await refreshRelayEnvironments();
if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) {
reportUpdateFailure(squashAtomCommandFailure(refreshResult));
return false;
}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/settings/ConnectionsSettings.tsx around lines 1745-1750:
When `reconcileCloudState` completes a link/unlink and preference update successfully, the mutation is already committed and `primaryCloudLinkState.refresh()` has been called. If the follow-up `refreshRelayEnvironments()` call at line 1747 then fails, the function returns `false`, shows an error toast, and suppresses the success toast — so the user is told their T3 Connect change failed even though it was actually applied. Consider returning `true` before the relay refresh (or not reporting its failure as a mutation failure), so transient relay-discovery errors don't mask a successful mutation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highcloud/http.ts:369

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

 if (
!isSupportedLinkProviderKind(request) ||
(request.endpoint.providerKind === "cloudflare_tunnel" &&
!isAllowedEndpointOrigin({
origin: request.origin,
requestUrl,
}))
) {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/cloud/http.ts around lines 369-375:
`makeCloudLinkProof` calls `isAllowedEndpointOrigin` for every link proof, including manual `providerKind` requests. That helper only permits loopback hosts, so a manual or publish-only link from a non-loopback origin (e.g. Tailscale or directly exposed host) is rejected with `Invalid managed endpoint origin.`, blocking the out-of-band endpoint flow that `isSupportedLinkProviderKind` was added to support. Consider skipping the loopback origin check when `providerKind === "manual"`.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumwidgets/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`.

Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

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

  • ✅ Fixed: Grace blocks legitimate activity end
    • LiveActivities.register no longer resets remote_started_at when the same activity token is re-registered (foreground reconciliation), so the freshly-armed grace window measures from the true arming time and orphaned cards receive their live_activity_end once it expires.

Create PR

Or push these changes by commenting:

@cursor push 5ab40bb88e
Preview (5ab40bb88e)
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@@ -109,10 +109,12 @@
remoteStartedAt: null,
}),
]);
+ // The claim skips this device's own row: it must stay intact so the+ // upsert can tell a same-token re-registration apart from a fresh arm.
expect(updateConditions.map((condition) => dialect.sqlToQuery(condition))).toEqual([
{
- sql: '"relay_live_activities"."activity_push_token" = $1',- params: ["activity-push-token"],+ sql: '(("relay_live_activities"."activity_push_token" = $1) and ((("relay_live_activities"."user_id" <> $2) or ("relay_live_activities"."device_id" <> $3))))',+ params: ["activity-push-token", "user-2", "device-1"],
},
]);
expect(insertedValues).toEqual([
@@ -131,12 +133,20 @@
expect.objectContaining({
activityPushToken: "activity-push-token",
remoteStartQueuedAt: null,
- remoteStartedAt: expect.any(String),
endedAt: null,
lastAggregateJson: null,
lastLiveActivityDeliveryAt: null,
}),
);
+ // Re-registering the SAME token (foreground reconciliation) must not+ // restart the arming clock, or the end-on-empty grace window would+ // renew forever and orphaned cards would never receive their end.+ const remoteStartedAtSet = dialect.sqlToQuery(+ conflictConfigs[0]?.set?.remoteStartedAt as SQL,+ );+ expect(remoteStartedAtSet.sql.replace(/\s+/g, " ")).toBe(+ 'case when "relay_live_activities"."activity_push_token" = excluded.activity_push_token then coalesce("relay_live_activities"."remote_started_at", excluded.remote_started_at) else excluded.remote_started_at end',+ );
}).pipe(
Effect.provide(
LiveActivities.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))),
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@@ -13,7 +13,7 @@
import * as Function from "effect/Function";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
-import { and, eq, sql } from "drizzle-orm";+import { and, eq, ne, or, sql } from "drizzle-orm";
import * as RelayDb from "../db.ts";
import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts";
@@ -141,6 +141,10 @@
const updatedAt = DateTime.formatIso(yield* DateTime.now);
const registration = input.registration;
+ // Claim the token from any OTHER row that still holds it (a token+ // addresses exactly one activity). The device's own row is left+ // untouched so the upsert below can tell a same-token re-registration+ // apart from a fresh arm.
yield* db
.update(relayLiveActivities)
.set({
@@ -150,7 +154,15 @@
endedAt: updatedAt,
updatedAt,
})
- .where(eq(relayLiveActivities.activityPushToken, registration.activityPushToken));+ .where(+ and(+ eq(relayLiveActivities.activityPushToken, registration.activityPushToken),+ or(+ ne(relayLiveActivities.userId, input.userId),+ ne(relayLiveActivities.deviceId, registration.deviceId),+ ),+ ),+ );
yield* db
.insert(relayLiveActivities)
@@ -171,7 +183,17 @@
set: {
activityPushToken: registration.activityPushToken,
remoteStartQueuedAt: null,
- remoteStartedAt: updatedAt,+ // remote_started_at records when the CARD was armed, and the+ // end-on-empty grace window keys off it. Foreground+ // reconciliation re-registers the same token periodically;+ // refreshing the arming time there would perpetually renew the+ // grace and suppress the end an orphaned card is waiting for.+ // Only a new token (a new activity) restarts the clock.+ remoteStartedAt: sql`case+ when ${relayLiveActivities.activityPushToken} = excluded.activity_push_token+ then coalesce(${relayLiveActivities.remoteStartedAt}, excluded.remote_started_at)+ else excluded.remote_started_at+ end`,
endedAt: null,
lastAggregateJson: null,
lastLiveActivityDeliveryAt: null,

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

Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts
The stuck-at-Working card: the thread's publish stream showed running →
deleted with no completed publish in between, and the tombstone debounce
correctly confirmed the null five seconds later — the post-completion
state resolves to no phase PERSISTENTLY. Session teardown settles
still-running turns by session status, and that write can race
turn.completed, leaving latestTurn.state at "interrupted" for a turn
that actually finished. The awareness ladder mapped interrupted turns to
null, so quick finish-then-teardown threads were tombstoned instead of
published as completed: the row vanished, the empty aggregate fell into
the freshly-armed grace window, and the card froze on its last state
with no further publish to correct it.
completedAt survives the state-column race: a turn with a completion
timestamp finished, whatever the settling wrote. The ladder now projects
those as completed — the tombstone confirm publishes Done instead of
deleting the thread — while genuinely interrupted turns (no completedAt)
still resolve to null.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
// that has a completion timestamp finished, whatever the state column says.
// Without this, quick finish-then-teardown threads resolve to null
// persistently and get tombstoned instead of published as completed.
if (thread.latestTurn?.state === "interrupted" && thread.latestTurn.completedAt !== null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsrc/agentAwareness.ts:112

The new fallback at line 112 misclassifies cancelled runs as completed. Turns settled via cancellation paths (interrupt-requested or session ended with interrupted/stopped status) also get state: "interrupted" with a non-null completedAt, so the condition thread.latestTurn?.state === "interrupted" && thread.latestTurn.completedAt !== null matches them too. This causes resolveThreadAwarenessPhase to return "completed" for runs the user actually cancelled, so users see stopped runs as successfully finished. If the intent is only to cover the teardown race where a completed turn's state is overwritten by interrupted, the guard needs to distinguish that case from genuine cancellations — for example, by checking the session status or a separate completion marker. Otherwise, consider documenting why interrupted turns with a completedAt should be treated as completed.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/shared/src/agentAwareness.ts around line 112:
The new fallback at line 112 misclassifies cancelled runs as `completed`. Turns settled via cancellation paths (interrupt-requested or session ended with `interrupted`/`stopped` status) also get `state: "interrupted"` with a non-null `completedAt`, so the condition `thread.latestTurn?.state === "interrupted" && thread.latestTurn.completedAt !== null` matches them too. This causes `resolveThreadAwarenessPhase` to return `"completed"` for runs the user actually cancelled, so users see stopped runs as successfully finished. If the intent is only to cover the teardown race where a `completed` turn's state is overwritten by `interrupted`, the guard needs to distinguish that case from genuine cancellations — for example, by checking the session status or a separate completion marker. Otherwise, consider documenting why interrupted turns with a `completedAt` should be treated as completed.

Comment threadpackages/shared/src/agentAwareness.ts
The stuck-at-Working repro survives the completedAt ladder fix: the
confirmed tombstone five seconds after turn completion still resolves
null, so the settled shell holds some combination the static analysis
keeps missing. Instead of guessing another layer deep, the tombstone
paths now log the exact fields the phase ladder reads (session status,
latest turn id/state/completedAt, pendings) both when deferring and when
a confirmation actually publishes — one repro pins the writer.
Also collapses the deferral storm visible at thread birth (six confirm
fibers forked in 600ms): one pending confirmation per thread.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

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

  • ✅ Fixed: Pending confirm set can leak
    • Wrapped the check-add-fork sequence in a single uninterruptible region so a pendingTombstoneConfirms entry can only exist once the detached confirm fiber (whose ensuring finalizer owns the delete) is guaranteed to have been forked.

Create PR

Or push these changes by commenting:

@cursor push abb02eb729
Preview (abb02eb729)
diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts--- a/apps/server/src/relay/AgentAwarenessRelay.ts+++ b/apps/server/src/relay/AgentAwarenessRelay.ts@@ -432,20 +432,30 @@
// immediately deletes the thread from the lock-screen card mid-
// conversation. Defer, re-resolve, and only tombstone if it holds;
// genuinely deleted threads still propagate a few seconds later.
- if (pendingTombstoneConfirms.has(threadId)) {- return;- }- pendingTombstoneConfirms.add(threadId);- yield* Effect.logInfo("agent activity tombstone deferred pending confirmation", {- environmentId,- threadId,- reason: snapshot.reason,- shell: describeThreadShellForAwareness(thread),- });- yield* Effect.forkDetach(- confirmTombstoneLater(threadId).pipe(- Effect.ensuring(Effect.sync(() => pendingTombstoneConfirms.delete(threadId))),- ),+ // Check-add-fork must be atomic under interruption: a set entry without+ // a forked confirm fiber (whose `ensuring` owns the delete) would+ // suppress tombstone confirmation for this thread forever.+ yield* Effect.uninterruptible(+ Effect.suspend(() => {+ if (pendingTombstoneConfirms.has(threadId)) {+ return Effect.void;+ }+ pendingTombstoneConfirms.add(threadId);+ return Effect.logInfo("agent activity tombstone deferred pending confirmation", {+ environmentId,+ threadId,+ reason: snapshot.reason,+ shell: describeThreadShellForAwareness(thread),+ }).pipe(+ Effect.andThen(+ Effect.forkDetach(+ confirmTombstoneLater(threadId).pipe(+ Effect.ensuring(Effect.sync(() => pendingTombstoneConfirms.delete(threadId))),+ ),+ ),+ ),+ );+ }),
);
return;
}

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

Comment threadapps/server/src/relay/AgentAwarenessRelay.ts Outdated
Diagnostics from the stuck-at-Working repro finally exposed the data
hole: the confirmed tombstone's shell showed sessionStatus "ready" with
latestTurn entirely absent. Threads whose turns produce no checkpoint
never get a projection_turns row, and thread.session-set clears
latest_turn_id the moment the session settles (activeTurnId goes null) —
so "completed" is unrepresentable through turns for quick Q&A threads.
Their entire lifecycle rode on session status and pendings, and at
completion nothing remained on the awareness ladder: tombstone instead
of Done.
A live session at "ready"/"idle" with nothing pending and nothing
running now projects as completed. This intentionally diverges from the
web sidebar, which resolves the same null but renders it as "no pill" —
a fine default in a list, while the publisher's null means "remove from
the lock-screen card". (The sidebar's own Completed pill needs
latestTurn.completedAt and silently can't fire for these threads either;
persisting turn rows for checkpoint-less turns is the deeper future fix
for both surfaces.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment threadpackages/shared/src/agentAwareness.ts
The full lifecycle finally works end to end, with one wart: a duplicate
Done push notification fired one second after thread creation. Sessions
boot at "ready" before their first turn starts, so the new
ready-means-completed projection produced a momentary completed state at
birth — and since the freshly armed card's token wasn't registered yet,
it fell through to the notification channel. Server restarts had the
same shape at scale: the startup snapshot republished completed for
every recently finished thread, each queuing a push notification.
- Env server: completed as a thread's FIRST published state defers and
re-resolves like a tombstone — at birth the confirm sees running and
completed never publishes; genuinely at-rest threads still publish
five seconds later. Fresh completions (previous state was live) are
unaffected, keeping the buzz immediate.
- Relay: terminal push notifications only fire when the completion is
fresh (row updated within two minutes), so replays and restart
snapshots repaint state without ringing the device.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
if (!activity) {
return null;
}
if (activity.phase === "completed" || activity.phase === "failed") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 MediumagentActivity/ApnsDeliveries.ts:321

notificationForAggregate drops terminal notifications for push-notification-only users when activity.updatedAt is more than two minutes old. activity.updatedAt is the timestamp from the published aggregate state, not the time the notification was first delivered to this device. If relay publishing or replay is delayed by more than two minutes (e.g., after downtime or a late reconnect), the first completed/failed push notification for a notifications-only user is silently suppressed even though it was never delivered before. Consider gating the freshness check on the device's last-known delivery time (e.g., last_push_notification_delivery_at) rather than activity.updatedAt, so only devices that already received a recent terminal notification are suppressed.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/ApnsDeliveries.ts around line 321:
`notificationForAggregate` drops terminal notifications for push-notification-only users when `activity.updatedAt` is more than two minutes old. `activity.updatedAt` is the timestamp from the published aggregate state, not the time the notification was first delivered to this device. If relay publishing or replay is delayed by more than two minutes (e.g., after downtime or a late reconnect), the first `completed`/`failed` push notification for a notifications-only user is silently suppressed even though it was never delivered before. Consider gating the freshness check on the device's last-known delivery time (e.g., `last_push_notification_delivery_at`) rather than `activity.updatedAt`, so only devices that already received a recent terminal notification are suppressed.

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

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

  • ✅ Fixed: LA alerts skip terminal freshness
    • Threaded nowMs into alertForNewlyTerminal and alertForTerminalAggregate and applied the shared TERMINAL_NOTIFICATION_FRESHNESS_MS window via a new isFreshTerminalRow helper, so stale replayed completions no longer alert through Live Activity updates or end events.

Create PR

Or push these changes by commenting:

@cursor push c88cec1a49
Preview (c88cec1a49)
diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts--- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts+++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts@@ -1525,15 +1525,30 @@
activities: [{ ...aggregate.activities[0]!, phase: "completed" as const, status: "Done" }],
};
expect(
- ApnsDeliveries.alertForTerminalAggregate({ aggregate: terminalAggregate, preferences }),+ ApnsDeliveries.alertForTerminalAggregate({+ aggregate: terminalAggregate,+ preferences,+ nowMs: 0,+ }),
).toEqual({ title: "Thread", body: "Done: Project" });
expect(
ApnsDeliveries.alertForTerminalAggregate({
aggregate: terminalAggregate,
preferences: { ...preferences, notifyOnCompletion: false },
+ nowMs: 0,
}),
).toBeNull();
- expect(ApnsDeliveries.alertForTerminalAggregate({ aggregate: null, preferences })).toBeNull();+ expect(+ ApnsDeliveries.alertForTerminalAggregate({ aggregate: null, preferences, nowMs: 0 }),+ ).toBeNull();+ // A completion replayed long after the fact must not ring again.+ expect(+ ApnsDeliveries.alertForTerminalAggregate({+ aggregate: terminalAggregate,+ preferences,+ nowMs: 10 * 60 * 1_000,+ }),+ ).toBeNull();
});
it("alerts when a previously active thread finishes mid-flight", () => {
@@ -1552,6 +1567,7 @@
previousAggregate: aggregate,
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toEqual({ title: "Thread", body: "Done: Project" });
// The completion switch mutes it.
@@ -1560,6 +1576,7 @@
previousAggregate: aggregate,
nextAggregate: next,
preferences: { ...preferences, notifyOnCompletion: false },
+ nowMs: 0,
}),
).toBeNull();
// No baseline means no transition to ring on.
@@ -1568,6 +1585,7 @@
previousAggregate: null,
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toBeNull();
// A Done row that was already terminal (or absent) before stays silent.
@@ -1576,6 +1594,7 @@
previousAggregate: next,
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toBeNull();
expect(
@@ -1583,7 +1602,18 @@
previousAggregate: { ...aggregate, activities: [attentionRow] },
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toBeNull();
+ // A stale completion replayed after a restart (previous aggregate still+ // shows the thread as running) must not ring the device again.+ expect(+ ApnsDeliveries.alertForNewlyTerminal({+ previousAggregate: aggregate,+ nextAggregate: next,+ preferences,+ nowMs: 10 * 60 * 1_000,+ }),+ ).toBeNull();
});
});
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@@ -210,6 +210,20 @@
};
}
+// Completions replayed long after the fact (server restarts republish every+// recently-finished thread) must not ring the device again — on any channel:+// push notifications, Live Activity update alerts, and end alerts all honor+// the same freshness window.+const TERMINAL_NOTIFICATION_FRESHNESS_MS = 2 * 60 * 1_000;++function isFreshTerminalRow(row: { readonly updatedAt: string }, nowMs: number): boolean {+ const updatedAtMs = Option.match(DateTime.make(row.updatedAt), {+ onNone: () => null,+ onSome: (dt) => dt.epochMilliseconds,+ });+ return updatedAtMs !== null && nowMs - updatedAtMs <= TERMINAL_NOTIFICATION_FRESHNESS_MS;+}+
// Alert copy for an update whose aggregate contains threads that finished
// (Done/Failed) since the previously delivered aggregate — the mid-flight
// completion buzz while other agents keep the activity alive. Requires the
@@ -219,6 +233,7 @@
readonly previousAggregate: RelayAgentActivityAggregateState | null;
readonly nextAggregate: RelayAgentActivityAggregateState;
readonly preferences: RelayAgentAwarenessPreferences | null;
+ readonly nowMs: number;
}): ApnsLiveActivityAlert | null {
if (input.previousAggregate === null) {
return null;
@@ -230,6 +245,9 @@
if (row.phase !== "completed" && row.phase !== "failed") {
return false;
}
+ if (!isFreshTerminalRow(row, input.nowMs)) {+ return false;+ }
const previousPhase = previousPhases.get(row.threadId);
return (
previousPhase !== undefined &&
@@ -255,11 +273,15 @@
export function alertForTerminalAggregate(input: {
readonly aggregate: RelayAgentActivityAggregateState | null;
readonly preferences: RelayAgentAwarenessPreferences | null;
+ readonly nowMs: number;
}): ApnsLiveActivityAlert | null {
const row = input.aggregate?.activities[0];
if (!row || (row.phase !== "completed" && row.phase !== "failed")) {
return null;
}
+ if (!isFreshTerminalRow(row, input.nowMs)) {+ return null;+ }
if (!alertAllowedForPhase(input.preferences, row.phase)) {
return null;
}
@@ -298,10 +320,6 @@
);
}
-// Completions replayed long after the fact (server restarts republish every-// recently-finished thread) must not ring the device again.-const TERMINAL_NOTIFICATION_FRESHNESS_MS = 2 * 60 * 1_000;-
function notificationForAggregate(input: {
readonly target: LiveActivities.TargetRow;
readonly aggregate: RelayAgentActivityAggregateState | null;
@@ -318,14 +336,11 @@
if (!activity) {
return null;
}
- if (activity.phase === "completed" || activity.phase === "failed") {- const updatedAtMs = Option.match(DateTime.make(activity.updatedAt), {- onNone: () => null,- onSome: (dt) => dt.epochMilliseconds,- });- if (updatedAtMs === null || input.nowMs - updatedAtMs > TERMINAL_NOTIFICATION_FRESHNESS_MS) {- return null;- }+ if (+ (activity.phase === "completed" || activity.phase === "failed") &&+ !isFreshTerminalRow(activity, input.nowMs)+ ) {+ return null;
}
const enabled =
(activity.phase === "waiting_for_approval" && preferences.notifyOnApproval) ||
@@ -419,6 +434,7 @@
previousAggregate,
nextAggregate,
preferences,
+ nowMs: input.nowMs,
}),
}
: "suppressed";
@@ -1100,6 +1116,7 @@
: alertForTerminalAggregate({
aggregate: delivery.aggregate,
preferences: parsePreferences(input.target.preferences_json),
+ nowMs: input.nowMs,
})
: delivery.alert;
const result = yield* deliveryQueue.enqueueLiveActivity({

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

Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts
…A pref gates
Fixes the real findings from the PR review bots ahead of the TestFlight
build:
- Deferred publish confirmations (tombstones and first-state
completions) now re-enqueue through the same drainable worker as every
other publish instead of a detached fiber calling the publisher
directly, so a confirmed tombstone can never race an in-flight live
update; a deadline map replaces the pending set, which also guarantees
a single scheduled confirmation per thread and clears when the state
recovers. (cursor/macroscope High)
- Newly-terminal transitions bypass the 15s live-activity update
throttle: a completion landing in the same window as a new start keeps
activeCount unchanged and would previously be suppressed, silently
dropping the Done transition and its buzz. (macroscope High)
- The newly-terminal alert honors the same 2-minute freshness rule as
terminal push notifications, so replayed aggregates repaint without
ringing. (cursor Medium)
- Live Activity arming and priming treat an unset preference as enabled
(the toggle's default): fresh installs now prime, and arm-on-send
respects an explicit off. (cursor High/Medium)
Remaining findings triaged as stale (superseded designs), intended
behavior (replay painting recent Done, LA-disabled end retiring the
token), or accepted edge cases (grace extension on re-registration,
completion alerts beyond the 3-row display cap, ready-with-lastError
mapping to Done, cancelled turns showing Done).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

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

  • ✅ Fixed: Stale confirm deadline publishes tombstone
    • Cleared the thread's publishConfirmDeadlines entry in the unchanged-identity early-return path so a recovered projection cancels the deferred confirmation, preventing a later transient null state from confirming immediately against a stale expired deadline.

Create PR

Or push these changes by commenting:

@cursor push e02169b78e
Preview (e02169b78e)
diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts--- a/apps/server/src/relay/AgentAwarenessRelay.ts+++ b/apps/server/src/relay/AgentAwarenessRelay.ts@@ -410,6 +410,11 @@
const publishIdentity = agentAwarenessPublishIdentity(snapshot.state);
const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef);
if (publishedStateByThread.get(threadId) === publishIdentity) {
+ // The projection recovered to the last published state, so any deferred+ // confirmation (tombstone or first-state completion) is moot. Clear the+ // deadline so the next divergence gets a fresh deferral window instead+ // of confirming immediately against a stale, expired deadline.+ publishConfirmDeadlines.delete(threadId);
yield* Effect.logDebug("agent activity publish skipped; projected state unchanged", {
environmentId,
threadId,

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

Reviewed by Cursor Bugbot for commit 97ed442. Configure here.

Comment threadapps/server/src/relay/AgentAwarenessRelay.ts
…d state
A recovered projection exits at the unchanged-identity dedupe, which sits
above the confirmation block, so a deferred publish's deadline survived
recovery. A much later transient null would then find the deadline
already expired and publish its tombstone immediately, bypassing the
deferral window entirely. The dedupe path now clears any pending
deadline: the state is back at what was last published, so the deferred
confirmation is moot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@juliusmarmingejuliusmarminge added the 🚀 Mobile Continuous Deployment Trigger Expo preview build label Jul 7, 2026
Fixes the Effect Service Conventions check: the service builds a plain
object with no effectful acquisition, so wrapping it in Effect.sync just
to feed Layer.effect was the make-only-to-force-Layer.effect shape the
conventions call out.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

ghost commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

🚀 Expo continuous deployment is ready!

  • Project → t3-code
  • Platforms → android, ios
  • Scheme → t3code-preview
🤖 Android🍎 iOS
Fingerprint106610ae982be2d8aa28dda9217ff853daf9d30b4f388ce49fd16563ef62f563f42c0528f092d42b
Build DetailsBuild Permalink
DetailsDistribution: INTERNAL
Build profile: preview:dev
Runtime version: 106610ae982be2d8aa28dda9217ff853daf9d30b
App version: 0.1.0
Git commit: 74888084257320d42e7562c74996c949010bdf45
Build Permalink
DetailsDistribution: INTERNAL
Build profile: preview:dev
Runtime version: 4f388ce49fd16563ef62f563f42c0528f092d42b
App version: 0.1.0
Git commit: c622444d589c97adb591101ffca9161d785806b0
Update DetailsUpdate Permalink
DetailsBranch: pr-3685
Runtime version: 106610ae982be2d8aa28dda9217ff853daf9d30b
Git commit: 74888084257320d42e7562c74996c949010bdf45
Update Permalink
DetailsBranch: pr-3685
Runtime version: 4f388ce49fd16563ef62f563f42c0528f092d42b
Git commit: 74888084257320d42e7562c74996c949010bdf45
Update QR

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

Labels

🚀 Mobile Continuous DeploymentTrigger Expo preview buildsize:XXL1,000+ changed lines (additions + deletions).vouch:trustedPR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@juliusmarminge
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Improve live activity routing and diagnostics - #3685

Merged
juliusmarminge merged 26 commits into
mainfrom
t3code/live-activities-improvements
Jul 7, 2026
Merged

Improve live activity routing and diagnostics#3685
juliusmarminge merged 26 commits into
mainfrom
t3code/live-activities-improvements

Conversation

@juliusmarminge

@juliusmarmingejuliusmarminge commented Jul 4, 2026

Copy link
Copy Markdown
Member

Summary

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

Testing

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

Note

High Risk
Touches relay registration, APNs routing, cloud link modes, and Live Activity lifecycle (including removing push-to-start); misconfiguration or partial deploy could break mobile notifications or mis-route pushes.

Overview
Improves mobile agent awareness end-to-end: relay device registration now sends bundle ID and sandbox vs production APNs routing, tracks whether the relay actually accepted registration (settings toggles and alerts follow that, not just iOS permission), skips duplicate registrations via a persisted signature, and re-registers Live Activity tokens on foreground with short-window deduping. Push-to-start registration is removed in favor of arming a card when the user sends a task (and priming from a relay activity snapshot when idle), plus cleanup on cloud sign-out and a releaseAgentAwarenessRelayTokenProvider path so auth UI remounts do not wipe registration or end activities.

Agent Activity Live Activity UI is reworked (attention-first rows, phase tints aligned with web, deep links, branded T3 mark via new Expo config plugins and actool shell phase). expo-widgets gains frequentUpdates for higher update budget.

T3 Connect vs publishing split: environments can link in publish_only mode (manual endpoint, no Cloudflare tunnel) via CLI (connect link --publish-only, connect publish) and web settings (separate toggles reconciled together). Link proofs accept manual providers with activity-only scopes; server publish path defers transient tombstones and first completed snapshots before publishing.

Relay DB adds bundle_id and aps_environment on mobile devices (migration in diff).

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

Note

Add per-device APNs routing, JWT token caching, and publish-only link mode to Live Activity delivery

  • Stores bundle_id and aps_environment per device in the relay and uses them to route APNs pushes to the correct topic and environment per device rather than using global config.
  • Introduces ApnsProviderTokens service that caches APNs JWTs quantized to 45-minute windows, preventing TooManyProviderTokenUpdates errors; uses deterministic ES256 signing via @noble/curves.
  • Overhauls Live Activity delivery: activities are no longer remotely started; ends are suppressed within a grace window after arming; attention-phase transitions and newly-terminal rows can now trigger alert payloads with haptics; stale terminal aggregates suppress re-alerting.
  • Adds a publish_only cloud link mode in the CLI (t3 connect publish) and web settings UI, allowing agent activity publishing without a managed Cloudflare tunnel; linking with this mode uses providerKind: "manual" and deprovisions any existing tunnel allocation.
  • Reworks the AgentActivity widget to sort rows attention-first, apply per-phase tints from the web palette, deep-link to the most relevant thread, show a branded logo, and display a phase glyph in minimal presentation.
  • Exposes relay registration status (unknown/pending/registered/failed) to the settings UI so notification and Live Activity toggles only appear enabled when the device is actually registered.
  • Risk: makeAggregateState now requires a nowMs parameter and statusForPhase('starting') returns 'Connecting' instead of 'Starting', which are breaking changes for callers.

Macroscope summarized 6f0b32d.

@coderabbitai

coderabbitaiBot commented Jul 4, 2026

Copy link
Copy Markdown

Important

Review skipped

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

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: fe216c9a-1cd9-460a-a80d-6d90748a00b5

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

Use the checkbox below for a quick retry:

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

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

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

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Autofix Details

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

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

Create PR

Or push these changes by commenting:

@cursor push a97e0b6881
Preview (a97e0b6881)
diff --git a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts--- a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts+++ b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts@@ -69,6 +69,28 @@
]);
});
+ it("treats a non-2xx delivery status without a reason as a failure", () => {+ const rows = formatDeviceDiagnosticsRows(+ makeDevice({+ bundleId: "com.t3tools.t3code.preview",+ apsEnvironment: "production",+ hasPushToken: true,+ hasPushToStartToken: true,+ hasLiveActivityToken: true,+ lastDeliveryAt: "2026-06-05T01:02:59.566Z",+ lastDeliveryKind: "live_activity_end",+ lastDeliveryStatus: 400,+ lastDeliveryError: null,+ }),+ );++ expect(rows[4]).toEqual({+ label: "Last Delivery",+ value: "Delivery failed (400)",+ tone: "warn",+ });+ });+
it("reports healthy registrations with a successful delivery", () => {
const rows = formatDeviceDiagnosticsRows(
makeDevice({
diff --git a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts--- a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts+++ b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts@@ -73,14 +73,21 @@
});
}
+ // APNs can fail with a non-2xx status without a reason body, so a null+ // error alone does not mean the delivery succeeded.+ const deliveryFailed =+ diagnostics.lastDeliveryError !== null ||+ (diagnostics.lastDeliveryStatus !== null &&+ (diagnostics.lastDeliveryStatus < 200 || diagnostics.lastDeliveryStatus >= 300));+
if (diagnostics.lastDeliveryAt === null) {
rows.push({ label: "Last Delivery", value: "None yet", tone: "muted" });
- } else if (diagnostics.lastDeliveryError !== null) {+ } else if (deliveryFailed) {
const status =
diagnostics.lastDeliveryStatus === null ? "" : ` (${diagnostics.lastDeliveryStatus})`;
rows.push({
label: "Last Delivery",
- value: `${diagnostics.lastDeliveryError}${status}`,+ value: `${diagnostics.lastDeliveryError ?? "Delivery failed"}${status}`,
tone: "warn",
});
} else {
diff --git a/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/migration.sql b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/migration.sql
new file mode 100644
--- /dev/null+++ b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/migration.sql@@ -1,0 +1,3 @@+ALTER TABLE "relay_mobile_devices" ADD COLUMN "bundle_id" varchar(255);+--> statement-breakpoint+ALTER TABLE "relay_mobile_devices" ADD COLUMN "aps_environment" varchar(16);diff --git a/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/snapshot.json b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/snapshot.json
new file mode 100644
--- /dev/null+++ b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/snapshot.json@@ -1,0 +1,1479 @@+{+ "dialect": "postgres",+ "id": "e00cc7df-a31e-4b8b-b258-d04e7db7758a",+ "prevIds": ["385d476b-d4f6-48a3-99e6-0a95af4ee4e4"],+ "version": "8",+ "ddl": [+ {+ "isRlsEnabled": false,+ "name": "relay_agent_activity_rows",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_delivery_attempts",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_dpop_proofs",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_environment_credentials",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_environment_links",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_live_activities",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_managed_endpoint_allocations",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_mobile_devices",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_public_key",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "thread_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "jsonb",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "state_json",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "updated_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "varchar(36)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "user_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "thread_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "device_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "kind",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "source_job_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(16)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "token_suffix",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "integer",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "apns_status",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "apns_reason",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(128)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "apns_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "transport_error",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(128)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "thumbprint",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "jti",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "integer",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "iat",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "expires_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "credential_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_public_key",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "credential_hash",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "revoked_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "updated_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "user_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": "'T3 Environment'",+ "generated": null,+ "identity": null,+ "name": "environment_label",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_public_key",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "endpoint_http_base_url",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "endpoint_ws_base_url",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(32)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "endpoint_provider_kind",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "boolean",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": "true",+ "generated": null,+ "identity": null,+ "name": "notifications_enabled",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "boolean",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": "true",+ "generated": null,+ "identity": null,+ "name": "live_activities_enabled",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "boolean",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": "false",+ "generated": null,+ "identity": null,+ "name": "managed_tunnels_enabled",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_by_device_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "revoked_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "updated_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "user_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "device_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "activity_push_token",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "remote_start_queued_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "remote_started_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "ended_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "jsonb",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,
... diff truncated: showing 800 of 1681 lines

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highpersistence/schema.ts:27

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

Also found in 1 other location(s)

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

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/persistence/schema.ts around line 27:
The new `bundle_id` and `aps_environment` columns are added to the `relay_mobile_devices` schema, but no SQL migration is included to create them. After deploy, the code in `Devices.ts` immediately inserts and selects these columns, so PostgreSQL rejects every device registration and list query with `column "bundle_id" does not exist` (and `aps_environment` likewise). Add the corresponding `ALTER TABLE relay_mobile_devices ADD COLUMN ...` migration under `infra/relay/migrations/` so the columns exist before the new code runs.
Also found in 1 other location(s):
- infra/relay/src/agentActivity/LiveActivities.ts:201 -- The new `relayMobileDevices.bundleId` / `relayMobileDevices.apsEnvironment` columns are referenced by `listTargets` (`bundle_id`, `aps_environment`) and other relay code, but this PR does not add a matching SQL migration for `relay_mobile_devices`. After deploying the code to an existing database, any `listTargets` query will start failing with `column relay_mobile_devices.bundle_id does not exist` (and likewise for `aps_environment`), breaking live-activity target lookup until the schema is manually patched.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium

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

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/ApnsDeliveries.ts around line 506:
The stale-job check in `processSignedJob` only compares the token via `isCurrentSignedJobToken`, so a queued job created before the device registered `bundleId`/`apsEnvironment` (or before those values changed) still passes the staleness guard when the token is unchanged. The job is then sent with the stale or missing routing metadata from the signed payload instead of the device's current values, causing APNs to reject the delivery with `BadDeviceToken`/`DeviceTokenNotForTopic` even though the registration has already been corrected. Consider comparing `bundleId` and `apsEnvironment` against the current target in the staleness check so jobs with outdated routing are treated as stale.

@macroscopeapp

macroscopeappBot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

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

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumwidgets/AgentActivity.tsx:81

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/widgets/AgentActivity.tsx around line 81:
`widgetURL` is hardcoded to the `t3code://` scheme, but the app registers `t3code-dev` for development builds and `t3code-preview` for preview builds. On dev/preview builds the widget banner's tap URL uses a scheme the containing app does not register, so iOS will not open the app when the banner is tapped. Consider using a scheme that is registered in all build variants (or injecting the active scheme per-build).

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 MediumagentActivity/AgentActivityPublisher.ts:203

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/AgentActivityPublisher.ts around line 203:
`RUNNING_AGENT_ACTIVITY_ROW_TTL_MS` expires `running`/`starting` states after 2 hours based on `updatedAt`, but relay publishes are event-driven — a thread that stays in the same `running` phase for over 2 hours without a new event keeps its old `updatedAt`, so `isExpiredAgentActivityState` returns `true` and `makeAggregateState` filters it out. The aggregate then reports `activeCount: 0` (or `null`/terminal) even though the job is still running, hiding active work from clients. Consider raising the running TTL to match the waiting TTL, or filtering on a heartbeat/last-seen timestamp that is refreshed independently of phase-change events.

Comment threadapps/mobile/src/widgets/AgentActivity.tsx
Comment threadinfra/relay/src/agentActivity/Devices.ts Outdated
Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts
@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from 1d6309e to 39d9dcaCompareJuly 6, 2026 06:07
@github-actionsgithub-actionsBot added size:L 100-499 changed lines (additions + deletions). and removed size:XL 500-999 changed lines (additions + deletions). labels Jul 6, 2026
@cursor

This comment has been minimized.

@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from 39d9dca to 5225bdbCompareJuly 6, 2026 07:37
@github-actionsgithub-actionsBot added size:XXL 1,000+ changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Jul 6, 2026

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

Autofix Details

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

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

Create PR

Or push these changes by commenting:

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

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsettings/SettingsRouteScreen.tsx:438

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/settings/SettingsRouteScreen.tsx around line 438:
The `deviceRegistered` gate on the `Device Notifications` switch renders it `false` when relay registration fails, even though `notificationStatus` is `"enabled"`. Because the switch shows off, the user cannot toggle it to reach `handleDeviceNotificationsChange(false)`, which is the only path that opens iOS Settings to disable notifications. In the "permission granted, relay registration failed" case the switch misreports notifications as off and removes the in-app affordance for managing them. Consider gating only the Live Activity switch on `deviceRegistered`, or keeping the `Device Notifications` switch reflecting the actual iOS permission state so the user can still navigate to system Settings.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 HighagentActivity/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 effectSchema.Struct behavior) and recompute the HMAC over the reduced payload, so verifySignedApnsDeliveryJob returns ApnsDeliveryJobSignatureInvalid for every newly queued job carrying per-device routing. This breaks APNs delivery during rolling deploys when new producers share a queue with old consumers. Consider gating inclusion of these fields behind a flag, or otherwise ensuring the signed payload shape remains decodable by older schema versions, so signatures stay stable across the rollout.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/apnsDeliveryJobs.ts around line 247:
`makeApnsDeliveryJobPayload` includes `target.bundleId` and `target.apsEnvironment` in the payload, and `signatureForPayload` signs that full object. Older relay workers that decode with a previous `ApnsDeliveryJobPayload` schema strip the new keys on decode (default `effect` `Schema.Struct` behavior) and recompute the HMAC over the reduced payload, so `verifySignedApnsDeliveryJob` returns `ApnsDeliveryJobSignatureInvalid` for every newly queued job carrying per-device routing. This breaks APNs delivery during rolling deploys when new producers share a queue with old consumers. Consider gating inclusion of these fields behind a flag, or otherwise ensuring the signed payload shape remains decodable by older schema versions, so signatures stay stable across the rollout.

@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from 5225bdb to 5086c14CompareJuly 6, 2026 16:13

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium

infoPlist: {
NSAppTransportSecurity: {

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

 infoPlist: {
+ NSSupportsLiveActivitiesFrequentUpdates: true,
NSAppTransportSecurity: {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/app.config.ts around lines 96-97:
The `frequentUpdates: true` option under the `expo-widgets` plugin does not add the required `NSSupportsLiveActivitiesFrequentUpdates` key to `Info.plist`, so iOS keeps the standard Live Activity update budget and frequent updates are never enabled. Set `NSSupportsLiveActivitiesFrequentUpdates: true` in the `ios.infoPlist` block to actually grant the entitlement.

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

export interface AgentAwarenessRegistrationRecord {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumlib/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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highsettings/SettingsRouteScreen.tsx:441

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/settings/SettingsRouteScreen.tsx around line 441:
When `deviceRegistered` is `false` but `liveActivitiesEnabled` is `true` in storage, the `Live Activity Updates` switch renders off even though `liveActivityStatus` is `"enabled"`. Because `onValueChange` receives the next visual state (`true`), tapping the switch calls `linkEnvironments()` (the enable path) instead of persisting `enabled: false`, so the user cannot disable Live Activity updates from Settings until registration succeeds. Consider gating the disabled state or the alert on `deviceRegistered` rather than the switch `value`, so the toggle still reflects the saved preference and `onValueChange` fires with the correct next state.

Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

Autofix Details

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

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

Create PR

Or push these changes by commenting:

@cursor push 59517fdb7d
Preview (59517fdb7d)
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts@@ -186,7 +186,11 @@
refreshActiveLiveActivityRemoteRegistration(),
"active live activity registration after cloud sign-in failed",
);
- if (isExistingIdentity) {+ // An unchanged identity only skips re-registration when the previous attempt+ // actually reached the relay; a failed or stalled attempt must retry the next+ // time the auth effect re-runs, or the device stays unregistered until an+ // unrelated event (token rotation, environment link) happens to enqueue one.+ if (isExistingIdentity && registrationStatus === "registered") {
return;
}
enqueueDeviceRegistration({}, "device registration after cloud sign-in failed");
diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts--- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts+++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts@@ -119,6 +119,16 @@
terminalState: null,
nowMs: now.epochMilliseconds,
});
+ // A null aggregate is ambiguous while non-terminal rows still exist:+ // they may belong to a dead environment, or to a healthy long-running+ // agent whose meaningful state (and therefore updatedAt) has not changed+ // within the TTL. Sending in that case would end a possibly-healthy Live+ // Activity on every foreground, so replay only delivers when live rows+ // remain or the rows are truly gone (the thread ended and its end push+ // may have been lost).+ if (aggregate === null && activeStates.some((state) => !isTerminalPhase(state))) {+ return null;+ }
return yield* apnsDeliveries.sendForTarget({
target,
aggregate,

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

Comment threadinfra/relay/src/agentActivity/AgentActivityPublisher.ts
@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from 5086c14 to a31735cCompareJuly 6, 2026 17:03
Comment threadapps/mobile/src/features/settings/SettingsRouteScreen.tsx
Comment threadapps/server/src/cli/connect.ts

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

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

Autofix Details

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

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

Create PR

Or push these changes by commenting:

@cursor push d469c8569a
Preview (d469c8569a)
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts@@ -535,6 +535,48 @@
}).pipe(Effect.provide(relayTestLayer));
});
+ it.effect("keeps the registered status when a stale concurrent attempt fails", () => {+ const fetchMock = vi.fn((request: RequestInfo | URL) => {+ const url = request instanceof Request ? request.url : String(request);+ return Promise.resolve(+ Response.json(+ url.endsWith("/v1/client/dpop-token")+ ? {+ access_token: "relay-dpop-token",+ issued_token_type: "urn:ietf:params:oauth:token-type:access_token",+ token_type: "DPoP",+ expires_in: 300,+ scope: "mobile:registration",+ }+ : { ok: true },+ ),+ );+ });+ vi.stubGlobal("fetch", fetchMock);+ Constants.expoConfig!.extra = {+ relay: {+ url: "https://relay.example.test/",+ },+ };++ // Sign-in enqueues a registration attempt that stays parked in the+ // background queue while the direct refresh below runs.+ setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));++ return Effect.gen(function* () {+ yield* refreshAgentAwarenessRegistration();+ expect(getAgentAwarenessRegistrationStatus()).toBe("registered");++ // The queued sign-in attempt now fails; its stale failure must not+ // overwrite the registration the relay already accepted.+ vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockRejectedValueOnce(+ new Error("device id unavailable"),+ );+ yield* runBackgroundOperations();+ expect(getAgentAwarenessRegistrationStatus()).toBe("registered");+ }).pipe(Effect.provide(relayTestLayer));+ });+
it("clears registration status on cloud sign-out", () => {
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
setAgentAwarenessRelayTokenProvider(null);
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts@@ -74,6 +74,13 @@
let registrationStatus: AgentAwarenessRegistrationStatus = "unknown";
const registrationStatusListeners = new Set<() => void>();
+// Registration runs both through the background queue and directly via+// refreshAgentAwarenessRegistration, so attempts can overlap. Counting relay+// acceptances lets a failing attempt detect that a concurrent attempt already+// succeeded while it was in flight, so a stale failure cannot overwrite a+// registration the relay accepted.+let registrationSuccessCount = 0;+
function setRegistrationStatus(next: AgentAwarenessRegistrationStatus): void {
if (registrationStatus === next) {
return;
@@ -84,6 +91,18 @@
}
}
+function markRegistrationSucceeded(): void {+ registrationSuccessCount++;+ setRegistrationStatus("registered");+}++function markRegistrationFailed(successCountAtAttemptStart: number): void {+ if (registrationSuccessCount !== successCountAtAttemptStart) {+ return;+ }+ setRegistrationStatus("failed");+}+
export function getAgentAwarenessRegistrationStatus(): AgentAwarenessRegistrationStatus {
return registrationStatus;
}
@@ -316,7 +335,7 @@
catch: (cause) => cause,
}).pipe(Effect.orElseSucceed(() => null));
if (persisted && persisted.identity === identity && persisted.signature === signature) {
- setRegistrationStatus("registered");+ markRegistrationSucceeded();
logRegistrationDebug("relay device registration skipped; already registered for account", {
expectedGeneration,
});
@@ -331,7 +350,7 @@
clerkToken: token,
payload: body,
});
- setRegistrationStatus("registered");+ markRegistrationSucceeded();
yield* Effect.promise(() =>
saveAgentAwarenessRegistrationRecord({ identity, signature }).catch((error: unknown) => {
logRegistrationError("persist registration record failed", error);
@@ -466,11 +485,12 @@
};
activeDeviceRegistration = registration;
registration.operation = (async () => {
+ const successCountAtStart = registrationSuccessCount;
const result = await settleAsyncResult(() =>
runtime.runPromiseExit(registerDevice(next.input, generation)),
);
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
- setRegistrationStatus("failed");+ markRegistrationFailed(successCountAtStart);
logRegistrationError(next.context, squashAtomCommandFailure(result));
}
logRegistrationDebug("device registration finished", { generation });
@@ -675,14 +695,17 @@
never,
ManagedRelay.ManagedRelayClient
> {
- return registerDeviceForCurrentUser().pipe(- Effect.catch((error) =>- Effect.sync(() => {- setRegistrationStatus("failed");- logRegistrationError("device registration refresh failed", error);- }),- ),- );+ return Effect.suspend(() => {+ const successCountAtStart = registrationSuccessCount;+ return registerDeviceForCurrentUser().pipe(+ Effect.catch((error) =>+ Effect.sync(() => {+ markRegistrationFailed(successCountAtStart);+ logRegistrationError("device registration refresh failed", error);+ }),+ ),+ );+ });
}
export function __resetAgentAwarenessRemoteRegistrationForTest(): void {
@@ -703,6 +726,7 @@
activeDeviceRegistration = null;
pendingDeviceRegistration = null;
registrationStatus = "unknown";
+ registrationSuccessCount = 0;
registrationStatusListeners.clear();
}

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

Comment threadapps/mobile/src/features/agent-awareness/remoteRegistration.ts Outdated
- Per-device APNs routing (bundle id + environment) so preview/dev builds
receive pushes from the shared relay (+ migration for the new columns)
- Settings notification/Live Activity toggles reflect actual relay
registration success; cannot read as enabled when the device is not registered
- Persistent register-once: skip re-registering an unchanged payload for the
same account across launches; clear only on sign-out
- Headless publish + tunnel-free linking: `t3 connect publish` toggles agent
activity publishing and auto-establishes a publish-only (no managed tunnel)
link, and `t3 connect link --publish-only` links for publishing alone, so
activity flows to mobile clients that reach the environment out of band
(e.g. Tailscale) without T3 Connect. Relay accepts publish-only links with a
nominal endpoint; env proofs advertise the manual provider kind
- Foreground Live Activity refresh, stale/ghost-row hardening, dedup fixes
- Tighten widget deep linking and per-row rendering for active agents
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from a31735c to 9c3eb7bCompareJuly 6, 2026 17:24
// Development builds are Xcode-signed and receive sandbox APNs tokens;
// preview and production builds are distribution-signed and use production
// APNs. The relay routes each device's pushes accordingly.
export function resolveApsEnvironment(appVariant: unknown): "sandbox" | "production" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumagent-awareness/registrationPayload.ts:8

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/agent-awareness/registrationPayload.ts around line 8:
`resolveApsEnvironment` returns `"production"` for every value except the literal string `"development"`. Locally run `ios:preview` and `ios:prod` builds launched via `expo run:ios` are development-signed and receive sandbox APNs tokens, but this function classifies them as `"production"`. The relay then routes pushes to the production APNs gateway for a sandbox token, so notifications and Live Activities fail to deliver for those builds. Consider keying off the signing/provisioning environment (e.g. EAS build profile or `Configuration`/ entitlements) rather than the variant name, or document why locally-run `preview`/`prod` builds are expected to use production APNs.

Comment threadapps/server/src/cli/connect.ts
Comment threadpackages/contracts/src/environmentHttp.ts Outdated
Comment on lines 1745 to 1750
primaryCloudLinkState.refresh();
const refreshResult = await refreshRelayEnvironments();
if (refreshResult._tag === "Failure") {
if (!isAtomCommandInterrupted(refreshResult)) {
reportUpdateFailure(squashAtomCommandFailure(refreshResult));
}
setIsUpdating(false);
return;
if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) {
reportUpdateFailure(squashAtomCommandFailure(refreshResult));
return false;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsettings/ConnectionsSettings.tsx:1745

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

 primaryCloudLinkState.refresh();
+ return true;
const refreshResult = await refreshRelayEnvironments();
if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) {
reportUpdateFailure(squashAtomCommandFailure(refreshResult));
return false;
}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/settings/ConnectionsSettings.tsx around lines 1745-1750:
When `reconcileCloudState` completes a link/unlink and preference update successfully, the mutation is already committed and `primaryCloudLinkState.refresh()` has been called. If the follow-up `refreshRelayEnvironments()` call at line 1747 then fails, the function returns `false`, shows an error toast, and suppresses the success toast — so the user is told their T3 Connect change failed even though it was actually applied. Consider returning `true` before the relay refresh (or not reporting its failure as a mutation failure), so transient relay-discovery errors don't mask a successful mutation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highcloud/http.ts:369

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

 if (
!isSupportedLinkProviderKind(request) ||
(request.endpoint.providerKind === "cloudflare_tunnel" &&
!isAllowedEndpointOrigin({
origin: request.origin,
requestUrl,
}))
) {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/cloud/http.ts around lines 369-375:
`makeCloudLinkProof` calls `isAllowedEndpointOrigin` for every link proof, including manual `providerKind` requests. That helper only permits loopback hosts, so a manual or publish-only link from a non-loopback origin (e.g. Tailscale or directly exposed host) is rejected with `Invalid managed endpoint origin.`, blocking the out-of-band endpoint flow that `isSupportedLinkProviderKind` was added to support. Consider skipping the loopback origin check when `providerKind === "manual"`.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumwidgets/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`.

Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

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

  • ✅ Fixed: Grace blocks legitimate activity end
    • LiveActivities.register no longer resets remote_started_at when the same activity token is re-registered (foreground reconciliation), so the freshly-armed grace window measures from the true arming time and orphaned cards receive their live_activity_end once it expires.

Create PR

Or push these changes by commenting:

@cursor push 5ab40bb88e
Preview (5ab40bb88e)
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@@ -109,10 +109,12 @@
remoteStartedAt: null,
}),
]);
+ // The claim skips this device's own row: it must stay intact so the+ // upsert can tell a same-token re-registration apart from a fresh arm.
expect(updateConditions.map((condition) => dialect.sqlToQuery(condition))).toEqual([
{
- sql: '"relay_live_activities"."activity_push_token" = $1',- params: ["activity-push-token"],+ sql: '(("relay_live_activities"."activity_push_token" = $1) and ((("relay_live_activities"."user_id" <> $2) or ("relay_live_activities"."device_id" <> $3))))',+ params: ["activity-push-token", "user-2", "device-1"],
},
]);
expect(insertedValues).toEqual([
@@ -131,12 +133,20 @@
expect.objectContaining({
activityPushToken: "activity-push-token",
remoteStartQueuedAt: null,
- remoteStartedAt: expect.any(String),
endedAt: null,
lastAggregateJson: null,
lastLiveActivityDeliveryAt: null,
}),
);
+ // Re-registering the SAME token (foreground reconciliation) must not+ // restart the arming clock, or the end-on-empty grace window would+ // renew forever and orphaned cards would never receive their end.+ const remoteStartedAtSet = dialect.sqlToQuery(+ conflictConfigs[0]?.set?.remoteStartedAt as SQL,+ );+ expect(remoteStartedAtSet.sql.replace(/\s+/g, " ")).toBe(+ 'case when "relay_live_activities"."activity_push_token" = excluded.activity_push_token then coalesce("relay_live_activities"."remote_started_at", excluded.remote_started_at) else excluded.remote_started_at end',+ );
}).pipe(
Effect.provide(
LiveActivities.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))),
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@@ -13,7 +13,7 @@
import * as Function from "effect/Function";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
-import { and, eq, sql } from "drizzle-orm";+import { and, eq, ne, or, sql } from "drizzle-orm";
import * as RelayDb from "../db.ts";
import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts";
@@ -141,6 +141,10 @@
const updatedAt = DateTime.formatIso(yield* DateTime.now);
const registration = input.registration;
+ // Claim the token from any OTHER row that still holds it (a token+ // addresses exactly one activity). The device's own row is left+ // untouched so the upsert below can tell a same-token re-registration+ // apart from a fresh arm.
yield* db
.update(relayLiveActivities)
.set({
@@ -150,7 +154,15 @@
endedAt: updatedAt,
updatedAt,
})
- .where(eq(relayLiveActivities.activityPushToken, registration.activityPushToken));+ .where(+ and(+ eq(relayLiveActivities.activityPushToken, registration.activityPushToken),+ or(+ ne(relayLiveActivities.userId, input.userId),+ ne(relayLiveActivities.deviceId, registration.deviceId),+ ),+ ),+ );
yield* db
.insert(relayLiveActivities)
@@ -171,7 +183,17 @@
set: {
activityPushToken: registration.activityPushToken,
remoteStartQueuedAt: null,
- remoteStartedAt: updatedAt,+ // remote_started_at records when the CARD was armed, and the+ // end-on-empty grace window keys off it. Foreground+ // reconciliation re-registers the same token periodically;+ // refreshing the arming time there would perpetually renew the+ // grace and suppress the end an orphaned card is waiting for.+ // Only a new token (a new activity) restarts the clock.+ remoteStartedAt: sql`case+ when ${relayLiveActivities.activityPushToken} = excluded.activity_push_token+ then coalesce(${relayLiveActivities.remoteStartedAt}, excluded.remote_started_at)+ else excluded.remote_started_at+ end`,
endedAt: null,
lastAggregateJson: null,
lastLiveActivityDeliveryAt: null,

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

Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts
The stuck-at-Working card: the thread's publish stream showed running →
deleted with no completed publish in between, and the tombstone debounce
correctly confirmed the null five seconds later — the post-completion
state resolves to no phase PERSISTENTLY. Session teardown settles
still-running turns by session status, and that write can race
turn.completed, leaving latestTurn.state at "interrupted" for a turn
that actually finished. The awareness ladder mapped interrupted turns to
null, so quick finish-then-teardown threads were tombstoned instead of
published as completed: the row vanished, the empty aggregate fell into
the freshly-armed grace window, and the card froze on its last state
with no further publish to correct it.
completedAt survives the state-column race: a turn with a completion
timestamp finished, whatever the settling wrote. The ladder now projects
those as completed — the tombstone confirm publishes Done instead of
deleting the thread — while genuinely interrupted turns (no completedAt)
still resolve to null.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
// that has a completion timestamp finished, whatever the state column says.
// Without this, quick finish-then-teardown threads resolve to null
// persistently and get tombstoned instead of published as completed.
if (thread.latestTurn?.state === "interrupted" && thread.latestTurn.completedAt !== null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsrc/agentAwareness.ts:112

The new fallback at line 112 misclassifies cancelled runs as completed. Turns settled via cancellation paths (interrupt-requested or session ended with interrupted/stopped status) also get state: "interrupted" with a non-null completedAt, so the condition thread.latestTurn?.state === "interrupted" && thread.latestTurn.completedAt !== null matches them too. This causes resolveThreadAwarenessPhase to return "completed" for runs the user actually cancelled, so users see stopped runs as successfully finished. If the intent is only to cover the teardown race where a completed turn's state is overwritten by interrupted, the guard needs to distinguish that case from genuine cancellations — for example, by checking the session status or a separate completion marker. Otherwise, consider documenting why interrupted turns with a completedAt should be treated as completed.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/shared/src/agentAwareness.ts around line 112:
The new fallback at line 112 misclassifies cancelled runs as `completed`. Turns settled via cancellation paths (interrupt-requested or session ended with `interrupted`/`stopped` status) also get `state: "interrupted"` with a non-null `completedAt`, so the condition `thread.latestTurn?.state === "interrupted" && thread.latestTurn.completedAt !== null` matches them too. This causes `resolveThreadAwarenessPhase` to return `"completed"` for runs the user actually cancelled, so users see stopped runs as successfully finished. If the intent is only to cover the teardown race where a `completed` turn's state is overwritten by `interrupted`, the guard needs to distinguish that case from genuine cancellations — for example, by checking the session status or a separate completion marker. Otherwise, consider documenting why interrupted turns with a `completedAt` should be treated as completed.

Comment threadpackages/shared/src/agentAwareness.ts
The stuck-at-Working repro survives the completedAt ladder fix: the
confirmed tombstone five seconds after turn completion still resolves
null, so the settled shell holds some combination the static analysis
keeps missing. Instead of guessing another layer deep, the tombstone
paths now log the exact fields the phase ladder reads (session status,
latest turn id/state/completedAt, pendings) both when deferring and when
a confirmation actually publishes — one repro pins the writer.
Also collapses the deferral storm visible at thread birth (six confirm
fibers forked in 600ms): one pending confirmation per thread.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

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

  • ✅ Fixed: Pending confirm set can leak
    • Wrapped the check-add-fork sequence in a single uninterruptible region so a pendingTombstoneConfirms entry can only exist once the detached confirm fiber (whose ensuring finalizer owns the delete) is guaranteed to have been forked.

Create PR

Or push these changes by commenting:

@cursor push abb02eb729
Preview (abb02eb729)
diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts--- a/apps/server/src/relay/AgentAwarenessRelay.ts+++ b/apps/server/src/relay/AgentAwarenessRelay.ts@@ -432,20 +432,30 @@
// immediately deletes the thread from the lock-screen card mid-
// conversation. Defer, re-resolve, and only tombstone if it holds;
// genuinely deleted threads still propagate a few seconds later.
- if (pendingTombstoneConfirms.has(threadId)) {- return;- }- pendingTombstoneConfirms.add(threadId);- yield* Effect.logInfo("agent activity tombstone deferred pending confirmation", {- environmentId,- threadId,- reason: snapshot.reason,- shell: describeThreadShellForAwareness(thread),- });- yield* Effect.forkDetach(- confirmTombstoneLater(threadId).pipe(- Effect.ensuring(Effect.sync(() => pendingTombstoneConfirms.delete(threadId))),- ),+ // Check-add-fork must be atomic under interruption: a set entry without+ // a forked confirm fiber (whose `ensuring` owns the delete) would+ // suppress tombstone confirmation for this thread forever.+ yield* Effect.uninterruptible(+ Effect.suspend(() => {+ if (pendingTombstoneConfirms.has(threadId)) {+ return Effect.void;+ }+ pendingTombstoneConfirms.add(threadId);+ return Effect.logInfo("agent activity tombstone deferred pending confirmation", {+ environmentId,+ threadId,+ reason: snapshot.reason,+ shell: describeThreadShellForAwareness(thread),+ }).pipe(+ Effect.andThen(+ Effect.forkDetach(+ confirmTombstoneLater(threadId).pipe(+ Effect.ensuring(Effect.sync(() => pendingTombstoneConfirms.delete(threadId))),+ ),+ ),+ ),+ );+ }),
);
return;
}

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

Comment threadapps/server/src/relay/AgentAwarenessRelay.ts Outdated
Diagnostics from the stuck-at-Working repro finally exposed the data
hole: the confirmed tombstone's shell showed sessionStatus "ready" with
latestTurn entirely absent. Threads whose turns produce no checkpoint
never get a projection_turns row, and thread.session-set clears
latest_turn_id the moment the session settles (activeTurnId goes null) —
so "completed" is unrepresentable through turns for quick Q&A threads.
Their entire lifecycle rode on session status and pendings, and at
completion nothing remained on the awareness ladder: tombstone instead
of Done.
A live session at "ready"/"idle" with nothing pending and nothing
running now projects as completed. This intentionally diverges from the
web sidebar, which resolves the same null but renders it as "no pill" —
a fine default in a list, while the publisher's null means "remove from
the lock-screen card". (The sidebar's own Completed pill needs
latestTurn.completedAt and silently can't fire for these threads either;
persisting turn rows for checkpoint-less turns is the deeper future fix
for both surfaces.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment threadpackages/shared/src/agentAwareness.ts
The full lifecycle finally works end to end, with one wart: a duplicate
Done push notification fired one second after thread creation. Sessions
boot at "ready" before their first turn starts, so the new
ready-means-completed projection produced a momentary completed state at
birth — and since the freshly armed card's token wasn't registered yet,
it fell through to the notification channel. Server restarts had the
same shape at scale: the startup snapshot republished completed for
every recently finished thread, each queuing a push notification.
- Env server: completed as a thread's FIRST published state defers and
re-resolves like a tombstone — at birth the confirm sees running and
completed never publishes; genuinely at-rest threads still publish
five seconds later. Fresh completions (previous state was live) are
unaffected, keeping the buzz immediate.
- Relay: terminal push notifications only fire when the completion is
fresh (row updated within two minutes), so replays and restart
snapshots repaint state without ringing the device.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
if (!activity) {
return null;
}
if (activity.phase === "completed" || activity.phase === "failed") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 MediumagentActivity/ApnsDeliveries.ts:321

notificationForAggregate drops terminal notifications for push-notification-only users when activity.updatedAt is more than two minutes old. activity.updatedAt is the timestamp from the published aggregate state, not the time the notification was first delivered to this device. If relay publishing or replay is delayed by more than two minutes (e.g., after downtime or a late reconnect), the first completed/failed push notification for a notifications-only user is silently suppressed even though it was never delivered before. Consider gating the freshness check on the device's last-known delivery time (e.g., last_push_notification_delivery_at) rather than activity.updatedAt, so only devices that already received a recent terminal notification are suppressed.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/ApnsDeliveries.ts around line 321:
`notificationForAggregate` drops terminal notifications for push-notification-only users when `activity.updatedAt` is more than two minutes old. `activity.updatedAt` is the timestamp from the published aggregate state, not the time the notification was first delivered to this device. If relay publishing or replay is delayed by more than two minutes (e.g., after downtime or a late reconnect), the first `completed`/`failed` push notification for a notifications-only user is silently suppressed even though it was never delivered before. Consider gating the freshness check on the device's last-known delivery time (e.g., `last_push_notification_delivery_at`) rather than `activity.updatedAt`, so only devices that already received a recent terminal notification are suppressed.

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

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

  • ✅ Fixed: LA alerts skip terminal freshness
    • Threaded nowMs into alertForNewlyTerminal and alertForTerminalAggregate and applied the shared TERMINAL_NOTIFICATION_FRESHNESS_MS window via a new isFreshTerminalRow helper, so stale replayed completions no longer alert through Live Activity updates or end events.

Create PR

Or push these changes by commenting:

@cursor push c88cec1a49
Preview (c88cec1a49)
diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts--- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts+++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts@@ -1525,15 +1525,30 @@
activities: [{ ...aggregate.activities[0]!, phase: "completed" as const, status: "Done" }],
};
expect(
- ApnsDeliveries.alertForTerminalAggregate({ aggregate: terminalAggregate, preferences }),+ ApnsDeliveries.alertForTerminalAggregate({+ aggregate: terminalAggregate,+ preferences,+ nowMs: 0,+ }),
).toEqual({ title: "Thread", body: "Done: Project" });
expect(
ApnsDeliveries.alertForTerminalAggregate({
aggregate: terminalAggregate,
preferences: { ...preferences, notifyOnCompletion: false },
+ nowMs: 0,
}),
).toBeNull();
- expect(ApnsDeliveries.alertForTerminalAggregate({ aggregate: null, preferences })).toBeNull();+ expect(+ ApnsDeliveries.alertForTerminalAggregate({ aggregate: null, preferences, nowMs: 0 }),+ ).toBeNull();+ // A completion replayed long after the fact must not ring again.+ expect(+ ApnsDeliveries.alertForTerminalAggregate({+ aggregate: terminalAggregate,+ preferences,+ nowMs: 10 * 60 * 1_000,+ }),+ ).toBeNull();
});
it("alerts when a previously active thread finishes mid-flight", () => {
@@ -1552,6 +1567,7 @@
previousAggregate: aggregate,
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toEqual({ title: "Thread", body: "Done: Project" });
// The completion switch mutes it.
@@ -1560,6 +1576,7 @@
previousAggregate: aggregate,
nextAggregate: next,
preferences: { ...preferences, notifyOnCompletion: false },
+ nowMs: 0,
}),
).toBeNull();
// No baseline means no transition to ring on.
@@ -1568,6 +1585,7 @@
previousAggregate: null,
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toBeNull();
// A Done row that was already terminal (or absent) before stays silent.
@@ -1576,6 +1594,7 @@
previousAggregate: next,
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toBeNull();
expect(
@@ -1583,7 +1602,18 @@
previousAggregate: { ...aggregate, activities: [attentionRow] },
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toBeNull();
+ // A stale completion replayed after a restart (previous aggregate still+ // shows the thread as running) must not ring the device again.+ expect(+ ApnsDeliveries.alertForNewlyTerminal({+ previousAggregate: aggregate,+ nextAggregate: next,+ preferences,+ nowMs: 10 * 60 * 1_000,+ }),+ ).toBeNull();
});
});
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@@ -210,6 +210,20 @@
};
}
+// Completions replayed long after the fact (server restarts republish every+// recently-finished thread) must not ring the device again — on any channel:+// push notifications, Live Activity update alerts, and end alerts all honor+// the same freshness window.+const TERMINAL_NOTIFICATION_FRESHNESS_MS = 2 * 60 * 1_000;++function isFreshTerminalRow(row: { readonly updatedAt: string }, nowMs: number): boolean {+ const updatedAtMs = Option.match(DateTime.make(row.updatedAt), {+ onNone: () => null,+ onSome: (dt) => dt.epochMilliseconds,+ });+ return updatedAtMs !== null && nowMs - updatedAtMs <= TERMINAL_NOTIFICATION_FRESHNESS_MS;+}+
// Alert copy for an update whose aggregate contains threads that finished
// (Done/Failed) since the previously delivered aggregate — the mid-flight
// completion buzz while other agents keep the activity alive. Requires the
@@ -219,6 +233,7 @@
readonly previousAggregate: RelayAgentActivityAggregateState | null;
readonly nextAggregate: RelayAgentActivityAggregateState;
readonly preferences: RelayAgentAwarenessPreferences | null;
+ readonly nowMs: number;
}): ApnsLiveActivityAlert | null {
if (input.previousAggregate === null) {
return null;
@@ -230,6 +245,9 @@
if (row.phase !== "completed" && row.phase !== "failed") {
return false;
}
+ if (!isFreshTerminalRow(row, input.nowMs)) {+ return false;+ }
const previousPhase = previousPhases.get(row.threadId);
return (
previousPhase !== undefined &&
@@ -255,11 +273,15 @@
export function alertForTerminalAggregate(input: {
readonly aggregate: RelayAgentActivityAggregateState | null;
readonly preferences: RelayAgentAwarenessPreferences | null;
+ readonly nowMs: number;
}): ApnsLiveActivityAlert | null {
const row = input.aggregate?.activities[0];
if (!row || (row.phase !== "completed" && row.phase !== "failed")) {
return null;
}
+ if (!isFreshTerminalRow(row, input.nowMs)) {+ return null;+ }
if (!alertAllowedForPhase(input.preferences, row.phase)) {
return null;
}
@@ -298,10 +320,6 @@
);
}
-// Completions replayed long after the fact (server restarts republish every-// recently-finished thread) must not ring the device again.-const TERMINAL_NOTIFICATION_FRESHNESS_MS = 2 * 60 * 1_000;-
function notificationForAggregate(input: {
readonly target: LiveActivities.TargetRow;
readonly aggregate: RelayAgentActivityAggregateState | null;
@@ -318,14 +336,11 @@
if (!activity) {
return null;
}
- if (activity.phase === "completed" || activity.phase === "failed") {- const updatedAtMs = Option.match(DateTime.make(activity.updatedAt), {- onNone: () => null,- onSome: (dt) => dt.epochMilliseconds,- });- if (updatedAtMs === null || input.nowMs - updatedAtMs > TERMINAL_NOTIFICATION_FRESHNESS_MS) {- return null;- }+ if (+ (activity.phase === "completed" || activity.phase === "failed") &&+ !isFreshTerminalRow(activity, input.nowMs)+ ) {+ return null;
}
const enabled =
(activity.phase === "waiting_for_approval" && preferences.notifyOnApproval) ||
@@ -419,6 +434,7 @@
previousAggregate,
nextAggregate,
preferences,
+ nowMs: input.nowMs,
}),
}
: "suppressed";
@@ -1100,6 +1116,7 @@
: alertForTerminalAggregate({
aggregate: delivery.aggregate,
preferences: parsePreferences(input.target.preferences_json),
+ nowMs: input.nowMs,
})
: delivery.alert;
const result = yield* deliveryQueue.enqueueLiveActivity({

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

Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts
…A pref gates
Fixes the real findings from the PR review bots ahead of the TestFlight
build:
- Deferred publish confirmations (tombstones and first-state
completions) now re-enqueue through the same drainable worker as every
other publish instead of a detached fiber calling the publisher
directly, so a confirmed tombstone can never race an in-flight live
update; a deadline map replaces the pending set, which also guarantees
a single scheduled confirmation per thread and clears when the state
recovers. (cursor/macroscope High)
- Newly-terminal transitions bypass the 15s live-activity update
throttle: a completion landing in the same window as a new start keeps
activeCount unchanged and would previously be suppressed, silently
dropping the Done transition and its buzz. (macroscope High)
- The newly-terminal alert honors the same 2-minute freshness rule as
terminal push notifications, so replayed aggregates repaint without
ringing. (cursor Medium)
- Live Activity arming and priming treat an unset preference as enabled
(the toggle's default): fresh installs now prime, and arm-on-send
respects an explicit off. (cursor High/Medium)
Remaining findings triaged as stale (superseded designs), intended
behavior (replay painting recent Done, LA-disabled end retiring the
token), or accepted edge cases (grace extension on re-registration,
completion alerts beyond the 3-row display cap, ready-with-lastError
mapping to Done, cancelled turns showing Done).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

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

  • ✅ Fixed: Stale confirm deadline publishes tombstone
    • Cleared the thread's publishConfirmDeadlines entry in the unchanged-identity early-return path so a recovered projection cancels the deferred confirmation, preventing a later transient null state from confirming immediately against a stale expired deadline.

Create PR

Or push these changes by commenting:

@cursor push e02169b78e
Preview (e02169b78e)
diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts--- a/apps/server/src/relay/AgentAwarenessRelay.ts+++ b/apps/server/src/relay/AgentAwarenessRelay.ts@@ -410,6 +410,11 @@
const publishIdentity = agentAwarenessPublishIdentity(snapshot.state);
const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef);
if (publishedStateByThread.get(threadId) === publishIdentity) {
+ // The projection recovered to the last published state, so any deferred+ // confirmation (tombstone or first-state completion) is moot. Clear the+ // deadline so the next divergence gets a fresh deferral window instead+ // of confirming immediately against a stale, expired deadline.+ publishConfirmDeadlines.delete(threadId);
yield* Effect.logDebug("agent activity publish skipped; projected state unchanged", {
environmentId,
threadId,

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

Reviewed by Cursor Bugbot for commit 97ed442. Configure here.

Comment threadapps/server/src/relay/AgentAwarenessRelay.ts
…d state
A recovered projection exits at the unchanged-identity dedupe, which sits
above the confirmation block, so a deferred publish's deadline survived
recovery. A much later transient null would then find the deadline
already expired and publish its tombstone immediately, bypassing the
deferral window entirely. The dedupe path now clears any pending
deadline: the state is back at what was last published, so the deferred
confirmation is moot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@juliusmarmingejuliusmarminge added the 🚀 Mobile Continuous Deployment Trigger Expo preview build label Jul 7, 2026
Fixes the Effect Service Conventions check: the service builds a plain
object with no effectful acquisition, so wrapping it in Effect.sync just
to feed Layer.effect was the make-only-to-force-Layer.effect shape the
conventions call out.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

ghost commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

🚀 Expo continuous deployment is ready!

  • Project → t3-code
  • Platforms → android, ios
  • Scheme → t3code-preview
🤖 Android🍎 iOS
Fingerprint106610ae982be2d8aa28dda9217ff853daf9d30b4f388ce49fd16563ef62f563f42c0528f092d42b
Build DetailsBuild Permalink
DetailsDistribution: INTERNAL
Build profile: preview:dev
Runtime version: 106610ae982be2d8aa28dda9217ff853daf9d30b
App version: 0.1.0
Git commit: 74888084257320d42e7562c74996c949010bdf45
Build Permalink
DetailsDistribution: INTERNAL
Build profile: preview:dev
Runtime version: 4f388ce49fd16563ef62f563f42c0528f092d42b
App version: 0.1.0
Git commit: c622444d589c97adb591101ffca9161d785806b0
Update DetailsUpdate Permalink
DetailsBranch: pr-3685
Runtime version: 106610ae982be2d8aa28dda9217ff853daf9d30b
Git commit: 74888084257320d42e7562c74996c949010bdf45
Update Permalink
DetailsBranch: pr-3685
Runtime version: 4f388ce49fd16563ef62f563f42c0528f092d42b
Git commit: 74888084257320d42e7562c74996c949010bdf45
Update QR

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

Labels

🚀 Mobile Continuous DeploymentTrigger Expo preview buildsize:XXL1,000+ changed lines (additions + deletions).vouch:trustedPR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@juliusmarminge
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Improve live activity routing and diagnostics - #3685

Merged
juliusmarminge merged 26 commits into
mainfrom
t3code/live-activities-improvements
Jul 7, 2026
Merged

Improve live activity routing and diagnostics#3685
juliusmarminge merged 26 commits into
mainfrom
t3code/live-activities-improvements

Conversation

@juliusmarminge

@juliusmarmingejuliusmarminge commented Jul 4, 2026

Copy link
Copy Markdown
Member

Summary

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

Testing

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

Note

High Risk
Touches relay registration, APNs routing, cloud link modes, and Live Activity lifecycle (including removing push-to-start); misconfiguration or partial deploy could break mobile notifications or mis-route pushes.

Overview
Improves mobile agent awareness end-to-end: relay device registration now sends bundle ID and sandbox vs production APNs routing, tracks whether the relay actually accepted registration (settings toggles and alerts follow that, not just iOS permission), skips duplicate registrations via a persisted signature, and re-registers Live Activity tokens on foreground with short-window deduping. Push-to-start registration is removed in favor of arming a card when the user sends a task (and priming from a relay activity snapshot when idle), plus cleanup on cloud sign-out and a releaseAgentAwarenessRelayTokenProvider path so auth UI remounts do not wipe registration or end activities.

Agent Activity Live Activity UI is reworked (attention-first rows, phase tints aligned with web, deep links, branded T3 mark via new Expo config plugins and actool shell phase). expo-widgets gains frequentUpdates for higher update budget.

T3 Connect vs publishing split: environments can link in publish_only mode (manual endpoint, no Cloudflare tunnel) via CLI (connect link --publish-only, connect publish) and web settings (separate toggles reconciled together). Link proofs accept manual providers with activity-only scopes; server publish path defers transient tombstones and first completed snapshots before publishing.

Relay DB adds bundle_id and aps_environment on mobile devices (migration in diff).

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

Note

Add per-device APNs routing, JWT token caching, and publish-only link mode to Live Activity delivery

  • Stores bundle_id and aps_environment per device in the relay and uses them to route APNs pushes to the correct topic and environment per device rather than using global config.
  • Introduces ApnsProviderTokens service that caches APNs JWTs quantized to 45-minute windows, preventing TooManyProviderTokenUpdates errors; uses deterministic ES256 signing via @noble/curves.
  • Overhauls Live Activity delivery: activities are no longer remotely started; ends are suppressed within a grace window after arming; attention-phase transitions and newly-terminal rows can now trigger alert payloads with haptics; stale terminal aggregates suppress re-alerting.
  • Adds a publish_only cloud link mode in the CLI (t3 connect publish) and web settings UI, allowing agent activity publishing without a managed Cloudflare tunnel; linking with this mode uses providerKind: "manual" and deprovisions any existing tunnel allocation.
  • Reworks the AgentActivity widget to sort rows attention-first, apply per-phase tints from the web palette, deep-link to the most relevant thread, show a branded logo, and display a phase glyph in minimal presentation.
  • Exposes relay registration status (unknown/pending/registered/failed) to the settings UI so notification and Live Activity toggles only appear enabled when the device is actually registered.
  • Risk: makeAggregateState now requires a nowMs parameter and statusForPhase('starting') returns 'Connecting' instead of 'Starting', which are breaking changes for callers.

Macroscope summarized 6f0b32d.

@coderabbitai

coderabbitaiBot commented Jul 4, 2026

Copy link
Copy Markdown

Important

Review skipped

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

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: fe216c9a-1cd9-460a-a80d-6d90748a00b5

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

Use the checkbox below for a quick retry:

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

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

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

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Autofix Details

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

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

Create PR

Or push these changes by commenting:

@cursor push a97e0b6881
Preview (a97e0b6881)
diff --git a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts--- a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts+++ b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts@@ -69,6 +69,28 @@
]);
});
+ it("treats a non-2xx delivery status without a reason as a failure", () => {+ const rows = formatDeviceDiagnosticsRows(+ makeDevice({+ bundleId: "com.t3tools.t3code.preview",+ apsEnvironment: "production",+ hasPushToken: true,+ hasPushToStartToken: true,+ hasLiveActivityToken: true,+ lastDeliveryAt: "2026-06-05T01:02:59.566Z",+ lastDeliveryKind: "live_activity_end",+ lastDeliveryStatus: 400,+ lastDeliveryError: null,+ }),+ );++ expect(rows[4]).toEqual({+ label: "Last Delivery",+ value: "Delivery failed (400)",+ tone: "warn",+ });+ });+
it("reports healthy registrations with a successful delivery", () => {
const rows = formatDeviceDiagnosticsRows(
makeDevice({
diff --git a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts--- a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts+++ b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts@@ -73,14 +73,21 @@
});
}
+ // APNs can fail with a non-2xx status without a reason body, so a null+ // error alone does not mean the delivery succeeded.+ const deliveryFailed =+ diagnostics.lastDeliveryError !== null ||+ (diagnostics.lastDeliveryStatus !== null &&+ (diagnostics.lastDeliveryStatus < 200 || diagnostics.lastDeliveryStatus >= 300));+
if (diagnostics.lastDeliveryAt === null) {
rows.push({ label: "Last Delivery", value: "None yet", tone: "muted" });
- } else if (diagnostics.lastDeliveryError !== null) {+ } else if (deliveryFailed) {
const status =
diagnostics.lastDeliveryStatus === null ? "" : ` (${diagnostics.lastDeliveryStatus})`;
rows.push({
label: "Last Delivery",
- value: `${diagnostics.lastDeliveryError}${status}`,+ value: `${diagnostics.lastDeliveryError ?? "Delivery failed"}${status}`,
tone: "warn",
});
} else {
diff --git a/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/migration.sql b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/migration.sql
new file mode 100644
--- /dev/null+++ b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/migration.sql@@ -1,0 +1,3 @@+ALTER TABLE "relay_mobile_devices" ADD COLUMN "bundle_id" varchar(255);+--> statement-breakpoint+ALTER TABLE "relay_mobile_devices" ADD COLUMN "aps_environment" varchar(16);diff --git a/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/snapshot.json b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/snapshot.json
new file mode 100644
--- /dev/null+++ b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/snapshot.json@@ -1,0 +1,1479 @@+{+ "dialect": "postgres",+ "id": "e00cc7df-a31e-4b8b-b258-d04e7db7758a",+ "prevIds": ["385d476b-d4f6-48a3-99e6-0a95af4ee4e4"],+ "version": "8",+ "ddl": [+ {+ "isRlsEnabled": false,+ "name": "relay_agent_activity_rows",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_delivery_attempts",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_dpop_proofs",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_environment_credentials",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_environment_links",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_live_activities",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_managed_endpoint_allocations",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "isRlsEnabled": false,+ "name": "relay_mobile_devices",+ "entityType": "tables",+ "schema": "public"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_public_key",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "thread_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "jsonb",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "state_json",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "updated_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_agent_activity_rows"+ },+ {+ "type": "varchar(36)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "user_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "thread_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "device_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "kind",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "source_job_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(16)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "token_suffix",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "integer",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "apns_status",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "apns_reason",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(128)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "apns_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "transport_error",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_delivery_attempts"+ },+ {+ "type": "varchar(128)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "thumbprint",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "jti",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "integer",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "iat",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "expires_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_dpop_proofs"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "credential_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_public_key",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "credential_hash",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "revoked_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "updated_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_credentials"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "user_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": "'T3 Environment'",+ "generated": null,+ "identity": null,+ "name": "environment_label",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "environment_public_key",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "endpoint_http_base_url",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "endpoint_ws_base_url",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(32)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "endpoint_provider_kind",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "boolean",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": "true",+ "generated": null,+ "identity": null,+ "name": "notifications_enabled",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "boolean",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": "true",+ "generated": null,+ "identity": null,+ "name": "live_activities_enabled",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "boolean",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": "false",+ "generated": null,+ "identity": null,+ "name": "managed_tunnels_enabled",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(191)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_by_device_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "revoked_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "created_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "updated_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_environment_links"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "user_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "varchar(255)",+ "typeSchema": null,+ "notNull": true,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "device_id",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "text",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "activity_push_token",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "remote_start_queued_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "remote_started_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "varchar(64)",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,+ "name": "ended_at",+ "entityType": "columns",+ "schema": "public",+ "table": "relay_live_activities"+ },+ {+ "type": "jsonb",+ "typeSchema": null,+ "notNull": false,+ "dimensions": 0,+ "default": null,+ "generated": null,+ "identity": null,
... diff truncated: showing 800 of 1681 lines

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highpersistence/schema.ts:27

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

Also found in 1 other location(s)

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

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/persistence/schema.ts around line 27:
The new `bundle_id` and `aps_environment` columns are added to the `relay_mobile_devices` schema, but no SQL migration is included to create them. After deploy, the code in `Devices.ts` immediately inserts and selects these columns, so PostgreSQL rejects every device registration and list query with `column "bundle_id" does not exist` (and `aps_environment` likewise). Add the corresponding `ALTER TABLE relay_mobile_devices ADD COLUMN ...` migration under `infra/relay/migrations/` so the columns exist before the new code runs.
Also found in 1 other location(s):
- infra/relay/src/agentActivity/LiveActivities.ts:201 -- The new `relayMobileDevices.bundleId` / `relayMobileDevices.apsEnvironment` columns are referenced by `listTargets` (`bundle_id`, `aps_environment`) and other relay code, but this PR does not add a matching SQL migration for `relay_mobile_devices`. After deploying the code to an existing database, any `listTargets` query will start failing with `column relay_mobile_devices.bundle_id does not exist` (and likewise for `aps_environment`), breaking live-activity target lookup until the schema is manually patched.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium

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

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/ApnsDeliveries.ts around line 506:
The stale-job check in `processSignedJob` only compares the token via `isCurrentSignedJobToken`, so a queued job created before the device registered `bundleId`/`apsEnvironment` (or before those values changed) still passes the staleness guard when the token is unchanged. The job is then sent with the stale or missing routing metadata from the signed payload instead of the device's current values, causing APNs to reject the delivery with `BadDeviceToken`/`DeviceTokenNotForTopic` even though the registration has already been corrected. Consider comparing `bundleId` and `apsEnvironment` against the current target in the staleness check so jobs with outdated routing are treated as stale.

@macroscopeapp

macroscopeappBot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

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

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumwidgets/AgentActivity.tsx:81

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/widgets/AgentActivity.tsx around line 81:
`widgetURL` is hardcoded to the `t3code://` scheme, but the app registers `t3code-dev` for development builds and `t3code-preview` for preview builds. On dev/preview builds the widget banner's tap URL uses a scheme the containing app does not register, so iOS will not open the app when the banner is tapped. Consider using a scheme that is registered in all build variants (or injecting the active scheme per-build).

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 MediumagentActivity/AgentActivityPublisher.ts:203

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/AgentActivityPublisher.ts around line 203:
`RUNNING_AGENT_ACTIVITY_ROW_TTL_MS` expires `running`/`starting` states after 2 hours based on `updatedAt`, but relay publishes are event-driven — a thread that stays in the same `running` phase for over 2 hours without a new event keeps its old `updatedAt`, so `isExpiredAgentActivityState` returns `true` and `makeAggregateState` filters it out. The aggregate then reports `activeCount: 0` (or `null`/terminal) even though the job is still running, hiding active work from clients. Consider raising the running TTL to match the waiting TTL, or filtering on a heartbeat/last-seen timestamp that is refreshed independently of phase-change events.

Comment threadapps/mobile/src/widgets/AgentActivity.tsx
Comment threadinfra/relay/src/agentActivity/Devices.ts Outdated
Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts
@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from 1d6309e to 39d9dcaCompareJuly 6, 2026 06:07
@github-actionsgithub-actionsBot added size:L 100-499 changed lines (additions + deletions). and removed size:XL 500-999 changed lines (additions + deletions). labels Jul 6, 2026
@cursor

This comment has been minimized.

@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from 39d9dca to 5225bdbCompareJuly 6, 2026 07:37
@github-actionsgithub-actionsBot added size:XXL 1,000+ changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Jul 6, 2026

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

Autofix Details

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

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

Create PR

Or push these changes by commenting:

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

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsettings/SettingsRouteScreen.tsx:438

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/settings/SettingsRouteScreen.tsx around line 438:
The `deviceRegistered` gate on the `Device Notifications` switch renders it `false` when relay registration fails, even though `notificationStatus` is `"enabled"`. Because the switch shows off, the user cannot toggle it to reach `handleDeviceNotificationsChange(false)`, which is the only path that opens iOS Settings to disable notifications. In the "permission granted, relay registration failed" case the switch misreports notifications as off and removes the in-app affordance for managing them. Consider gating only the Live Activity switch on `deviceRegistered`, or keeping the `Device Notifications` switch reflecting the actual iOS permission state so the user can still navigate to system Settings.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 HighagentActivity/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 effectSchema.Struct behavior) and recompute the HMAC over the reduced payload, so verifySignedApnsDeliveryJob returns ApnsDeliveryJobSignatureInvalid for every newly queued job carrying per-device routing. This breaks APNs delivery during rolling deploys when new producers share a queue with old consumers. Consider gating inclusion of these fields behind a flag, or otherwise ensuring the signed payload shape remains decodable by older schema versions, so signatures stay stable across the rollout.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/apnsDeliveryJobs.ts around line 247:
`makeApnsDeliveryJobPayload` includes `target.bundleId` and `target.apsEnvironment` in the payload, and `signatureForPayload` signs that full object. Older relay workers that decode with a previous `ApnsDeliveryJobPayload` schema strip the new keys on decode (default `effect` `Schema.Struct` behavior) and recompute the HMAC over the reduced payload, so `verifySignedApnsDeliveryJob` returns `ApnsDeliveryJobSignatureInvalid` for every newly queued job carrying per-device routing. This breaks APNs delivery during rolling deploys when new producers share a queue with old consumers. Consider gating inclusion of these fields behind a flag, or otherwise ensuring the signed payload shape remains decodable by older schema versions, so signatures stay stable across the rollout.

@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from 5225bdb to 5086c14CompareJuly 6, 2026 16:13

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium

infoPlist: {
NSAppTransportSecurity: {

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

 infoPlist: {
+ NSSupportsLiveActivitiesFrequentUpdates: true,
NSAppTransportSecurity: {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/app.config.ts around lines 96-97:
The `frequentUpdates: true` option under the `expo-widgets` plugin does not add the required `NSSupportsLiveActivitiesFrequentUpdates` key to `Info.plist`, so iOS keeps the standard Live Activity update budget and frequent updates are never enabled. Set `NSSupportsLiveActivitiesFrequentUpdates: true` in the `ios.infoPlist` block to actually grant the entitlement.

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

export interface AgentAwarenessRegistrationRecord {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumlib/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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highsettings/SettingsRouteScreen.tsx:441

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/settings/SettingsRouteScreen.tsx around line 441:
When `deviceRegistered` is `false` but `liveActivitiesEnabled` is `true` in storage, the `Live Activity Updates` switch renders off even though `liveActivityStatus` is `"enabled"`. Because `onValueChange` receives the next visual state (`true`), tapping the switch calls `linkEnvironments()` (the enable path) instead of persisting `enabled: false`, so the user cannot disable Live Activity updates from Settings until registration succeeds. Consider gating the disabled state or the alert on `deviceRegistered` rather than the switch `value`, so the toggle still reflects the saved preference and `onValueChange` fires with the correct next state.

Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

Autofix Details

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

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

Create PR

Or push these changes by commenting:

@cursor push 59517fdb7d
Preview (59517fdb7d)
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts@@ -186,7 +186,11 @@
refreshActiveLiveActivityRemoteRegistration(),
"active live activity registration after cloud sign-in failed",
);
- if (isExistingIdentity) {+ // An unchanged identity only skips re-registration when the previous attempt+ // actually reached the relay; a failed or stalled attempt must retry the next+ // time the auth effect re-runs, or the device stays unregistered until an+ // unrelated event (token rotation, environment link) happens to enqueue one.+ if (isExistingIdentity && registrationStatus === "registered") {
return;
}
enqueueDeviceRegistration({}, "device registration after cloud sign-in failed");
diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts--- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts+++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts@@ -119,6 +119,16 @@
terminalState: null,
nowMs: now.epochMilliseconds,
});
+ // A null aggregate is ambiguous while non-terminal rows still exist:+ // they may belong to a dead environment, or to a healthy long-running+ // agent whose meaningful state (and therefore updatedAt) has not changed+ // within the TTL. Sending in that case would end a possibly-healthy Live+ // Activity on every foreground, so replay only delivers when live rows+ // remain or the rows are truly gone (the thread ended and its end push+ // may have been lost).+ if (aggregate === null && activeStates.some((state) => !isTerminalPhase(state))) {+ return null;+ }
return yield* apnsDeliveries.sendForTarget({
target,
aggregate,

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

Comment threadinfra/relay/src/agentActivity/AgentActivityPublisher.ts
@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from 5086c14 to a31735cCompareJuly 6, 2026 17:03
Comment threadapps/mobile/src/features/settings/SettingsRouteScreen.tsx
Comment threadapps/server/src/cli/connect.ts

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

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

Autofix Details

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

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

Create PR

Or push these changes by commenting:

@cursor push d469c8569a
Preview (d469c8569a)
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts@@ -535,6 +535,48 @@
}).pipe(Effect.provide(relayTestLayer));
});
+ it.effect("keeps the registered status when a stale concurrent attempt fails", () => {+ const fetchMock = vi.fn((request: RequestInfo | URL) => {+ const url = request instanceof Request ? request.url : String(request);+ return Promise.resolve(+ Response.json(+ url.endsWith("/v1/client/dpop-token")+ ? {+ access_token: "relay-dpop-token",+ issued_token_type: "urn:ietf:params:oauth:token-type:access_token",+ token_type: "DPoP",+ expires_in: 300,+ scope: "mobile:registration",+ }+ : { ok: true },+ ),+ );+ });+ vi.stubGlobal("fetch", fetchMock);+ Constants.expoConfig!.extra = {+ relay: {+ url: "https://relay.example.test/",+ },+ };++ // Sign-in enqueues a registration attempt that stays parked in the+ // background queue while the direct refresh below runs.+ setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));++ return Effect.gen(function* () {+ yield* refreshAgentAwarenessRegistration();+ expect(getAgentAwarenessRegistrationStatus()).toBe("registered");++ // The queued sign-in attempt now fails; its stale failure must not+ // overwrite the registration the relay already accepted.+ vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockRejectedValueOnce(+ new Error("device id unavailable"),+ );+ yield* runBackgroundOperations();+ expect(getAgentAwarenessRegistrationStatus()).toBe("registered");+ }).pipe(Effect.provide(relayTestLayer));+ });+
it("clears registration status on cloud sign-out", () => {
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
setAgentAwarenessRelayTokenProvider(null);
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts@@ -74,6 +74,13 @@
let registrationStatus: AgentAwarenessRegistrationStatus = "unknown";
const registrationStatusListeners = new Set<() => void>();
+// Registration runs both through the background queue and directly via+// refreshAgentAwarenessRegistration, so attempts can overlap. Counting relay+// acceptances lets a failing attempt detect that a concurrent attempt already+// succeeded while it was in flight, so a stale failure cannot overwrite a+// registration the relay accepted.+let registrationSuccessCount = 0;+
function setRegistrationStatus(next: AgentAwarenessRegistrationStatus): void {
if (registrationStatus === next) {
return;
@@ -84,6 +91,18 @@
}
}
+function markRegistrationSucceeded(): void {+ registrationSuccessCount++;+ setRegistrationStatus("registered");+}++function markRegistrationFailed(successCountAtAttemptStart: number): void {+ if (registrationSuccessCount !== successCountAtAttemptStart) {+ return;+ }+ setRegistrationStatus("failed");+}+
export function getAgentAwarenessRegistrationStatus(): AgentAwarenessRegistrationStatus {
return registrationStatus;
}
@@ -316,7 +335,7 @@
catch: (cause) => cause,
}).pipe(Effect.orElseSucceed(() => null));
if (persisted && persisted.identity === identity && persisted.signature === signature) {
- setRegistrationStatus("registered");+ markRegistrationSucceeded();
logRegistrationDebug("relay device registration skipped; already registered for account", {
expectedGeneration,
});
@@ -331,7 +350,7 @@
clerkToken: token,
payload: body,
});
- setRegistrationStatus("registered");+ markRegistrationSucceeded();
yield* Effect.promise(() =>
saveAgentAwarenessRegistrationRecord({ identity, signature }).catch((error: unknown) => {
logRegistrationError("persist registration record failed", error);
@@ -466,11 +485,12 @@
};
activeDeviceRegistration = registration;
registration.operation = (async () => {
+ const successCountAtStart = registrationSuccessCount;
const result = await settleAsyncResult(() =>
runtime.runPromiseExit(registerDevice(next.input, generation)),
);
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
- setRegistrationStatus("failed");+ markRegistrationFailed(successCountAtStart);
logRegistrationError(next.context, squashAtomCommandFailure(result));
}
logRegistrationDebug("device registration finished", { generation });
@@ -675,14 +695,17 @@
never,
ManagedRelay.ManagedRelayClient
> {
- return registerDeviceForCurrentUser().pipe(- Effect.catch((error) =>- Effect.sync(() => {- setRegistrationStatus("failed");- logRegistrationError("device registration refresh failed", error);- }),- ),- );+ return Effect.suspend(() => {+ const successCountAtStart = registrationSuccessCount;+ return registerDeviceForCurrentUser().pipe(+ Effect.catch((error) =>+ Effect.sync(() => {+ markRegistrationFailed(successCountAtStart);+ logRegistrationError("device registration refresh failed", error);+ }),+ ),+ );+ });
}
export function __resetAgentAwarenessRemoteRegistrationForTest(): void {
@@ -703,6 +726,7 @@
activeDeviceRegistration = null;
pendingDeviceRegistration = null;
registrationStatus = "unknown";
+ registrationSuccessCount = 0;
registrationStatusListeners.clear();
}

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

Comment threadapps/mobile/src/features/agent-awareness/remoteRegistration.ts Outdated
- Per-device APNs routing (bundle id + environment) so preview/dev builds
receive pushes from the shared relay (+ migration for the new columns)
- Settings notification/Live Activity toggles reflect actual relay
registration success; cannot read as enabled when the device is not registered
- Persistent register-once: skip re-registering an unchanged payload for the
same account across launches; clear only on sign-out
- Headless publish + tunnel-free linking: `t3 connect publish` toggles agent
activity publishing and auto-establishes a publish-only (no managed tunnel)
link, and `t3 connect link --publish-only` links for publishing alone, so
activity flows to mobile clients that reach the environment out of band
(e.g. Tailscale) without T3 Connect. Relay accepts publish-only links with a
nominal endpoint; env proofs advertise the manual provider kind
- Foreground Live Activity refresh, stale/ghost-row hardening, dedup fixes
- Tighten widget deep linking and per-row rendering for active agents
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@juliusmarminge
juliusmarmingeforce-pushed the t3code/live-activities-improvements branch from a31735c to 9c3eb7bCompareJuly 6, 2026 17:24
// Development builds are Xcode-signed and receive sandbox APNs tokens;
// preview and production builds are distribution-signed and use production
// APNs. The relay routes each device's pushes accordingly.
export function resolveApsEnvironment(appVariant: unknown): "sandbox" | "production" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumagent-awareness/registrationPayload.ts:8

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

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/agent-awareness/registrationPayload.ts around line 8:
`resolveApsEnvironment` returns `"production"` for every value except the literal string `"development"`. Locally run `ios:preview` and `ios:prod` builds launched via `expo run:ios` are development-signed and receive sandbox APNs tokens, but this function classifies them as `"production"`. The relay then routes pushes to the production APNs gateway for a sandbox token, so notifications and Live Activities fail to deliver for those builds. Consider keying off the signing/provisioning environment (e.g. EAS build profile or `Configuration`/ entitlements) rather than the variant name, or document why locally-run `preview`/`prod` builds are expected to use production APNs.

Comment threadapps/server/src/cli/connect.ts
Comment threadpackages/contracts/src/environmentHttp.ts Outdated
Comment on lines 1745 to 1750
primaryCloudLinkState.refresh();
const refreshResult = await refreshRelayEnvironments();
if (refreshResult._tag === "Failure") {
if (!isAtomCommandInterrupted(refreshResult)) {
reportUpdateFailure(squashAtomCommandFailure(refreshResult));
}
setIsUpdating(false);
return;
if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) {
reportUpdateFailure(squashAtomCommandFailure(refreshResult));
return false;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsettings/ConnectionsSettings.tsx:1745

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

 primaryCloudLinkState.refresh();
+ return true;
const refreshResult = await refreshRelayEnvironments();
if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) {
reportUpdateFailure(squashAtomCommandFailure(refreshResult));
return false;
}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/settings/ConnectionsSettings.tsx around lines 1745-1750:
When `reconcileCloudState` completes a link/unlink and preference update successfully, the mutation is already committed and `primaryCloudLinkState.refresh()` has been called. If the follow-up `refreshRelayEnvironments()` call at line 1747 then fails, the function returns `false`, shows an error toast, and suppresses the success toast — so the user is told their T3 Connect change failed even though it was actually applied. Consider returning `true` before the relay refresh (or not reporting its failure as a mutation failure), so transient relay-discovery errors don't mask a successful mutation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Highcloud/http.ts:369

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

 if (
!isSupportedLinkProviderKind(request) ||
(request.endpoint.providerKind === "cloudflare_tunnel" &&
!isAllowedEndpointOrigin({
origin: request.origin,
requestUrl,
}))
) {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/cloud/http.ts around lines 369-375:
`makeCloudLinkProof` calls `isAllowedEndpointOrigin` for every link proof, including manual `providerKind` requests. That helper only permits loopback hosts, so a manual or publish-only link from a non-loopback origin (e.g. Tailscale or directly exposed host) is rejected with `Invalid managed endpoint origin.`, blocking the out-of-band endpoint flow that `isSupportedLinkProviderKind` was added to support. Consider skipping the loopback origin check when `providerKind === "manual"`.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumwidgets/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`.

Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

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

  • ✅ Fixed: Grace blocks legitimate activity end
    • LiveActivities.register no longer resets remote_started_at when the same activity token is re-registered (foreground reconciliation), so the freshly-armed grace window measures from the true arming time and orphaned cards receive their live_activity_end once it expires.

Create PR

Or push these changes by commenting:

@cursor push 5ab40bb88e
Preview (5ab40bb88e)
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@@ -109,10 +109,12 @@
remoteStartedAt: null,
}),
]);
+ // The claim skips this device's own row: it must stay intact so the+ // upsert can tell a same-token re-registration apart from a fresh arm.
expect(updateConditions.map((condition) => dialect.sqlToQuery(condition))).toEqual([
{
- sql: '"relay_live_activities"."activity_push_token" = $1',- params: ["activity-push-token"],+ sql: '(("relay_live_activities"."activity_push_token" = $1) and ((("relay_live_activities"."user_id" <> $2) or ("relay_live_activities"."device_id" <> $3))))',+ params: ["activity-push-token", "user-2", "device-1"],
},
]);
expect(insertedValues).toEqual([
@@ -131,12 +133,20 @@
expect.objectContaining({
activityPushToken: "activity-push-token",
remoteStartQueuedAt: null,
- remoteStartedAt: expect.any(String),
endedAt: null,
lastAggregateJson: null,
lastLiveActivityDeliveryAt: null,
}),
);
+ // Re-registering the SAME token (foreground reconciliation) must not+ // restart the arming clock, or the end-on-empty grace window would+ // renew forever and orphaned cards would never receive their end.+ const remoteStartedAtSet = dialect.sqlToQuery(+ conflictConfigs[0]?.set?.remoteStartedAt as SQL,+ );+ expect(remoteStartedAtSet.sql.replace(/\s+/g, " ")).toBe(+ 'case when "relay_live_activities"."activity_push_token" = excluded.activity_push_token then coalesce("relay_live_activities"."remote_started_at", excluded.remote_started_at) else excluded.remote_started_at end',+ );
}).pipe(
Effect.provide(
LiveActivities.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))),
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@@ -13,7 +13,7 @@
import * as Function from "effect/Function";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
-import { and, eq, sql } from "drizzle-orm";+import { and, eq, ne, or, sql } from "drizzle-orm";
import * as RelayDb from "../db.ts";
import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts";
@@ -141,6 +141,10 @@
const updatedAt = DateTime.formatIso(yield* DateTime.now);
const registration = input.registration;
+ // Claim the token from any OTHER row that still holds it (a token+ // addresses exactly one activity). The device's own row is left+ // untouched so the upsert below can tell a same-token re-registration+ // apart from a fresh arm.
yield* db
.update(relayLiveActivities)
.set({
@@ -150,7 +154,15 @@
endedAt: updatedAt,
updatedAt,
})
- .where(eq(relayLiveActivities.activityPushToken, registration.activityPushToken));+ .where(+ and(+ eq(relayLiveActivities.activityPushToken, registration.activityPushToken),+ or(+ ne(relayLiveActivities.userId, input.userId),+ ne(relayLiveActivities.deviceId, registration.deviceId),+ ),+ ),+ );
yield* db
.insert(relayLiveActivities)
@@ -171,7 +183,17 @@
set: {
activityPushToken: registration.activityPushToken,
remoteStartQueuedAt: null,
- remoteStartedAt: updatedAt,+ // remote_started_at records when the CARD was armed, and the+ // end-on-empty grace window keys off it. Foreground+ // reconciliation re-registers the same token periodically;+ // refreshing the arming time there would perpetually renew the+ // grace and suppress the end an orphaned card is waiting for.+ // Only a new token (a new activity) restarts the clock.+ remoteStartedAt: sql`case+ when ${relayLiveActivities.activityPushToken} = excluded.activity_push_token+ then coalesce(${relayLiveActivities.remoteStartedAt}, excluded.remote_started_at)+ else excluded.remote_started_at+ end`,
endedAt: null,
lastAggregateJson: null,
lastLiveActivityDeliveryAt: null,

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

Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts
The stuck-at-Working card: the thread's publish stream showed running →
deleted with no completed publish in between, and the tombstone debounce
correctly confirmed the null five seconds later — the post-completion
state resolves to no phase PERSISTENTLY. Session teardown settles
still-running turns by session status, and that write can race
turn.completed, leaving latestTurn.state at "interrupted" for a turn
that actually finished. The awareness ladder mapped interrupted turns to
null, so quick finish-then-teardown threads were tombstoned instead of
published as completed: the row vanished, the empty aggregate fell into
the freshly-armed grace window, and the card froze on its last state
with no further publish to correct it.
completedAt survives the state-column race: a turn with a completion
timestamp finished, whatever the settling wrote. The ladder now projects
those as completed — the tombstone confirm publishes Done instead of
deleting the thread — while genuinely interrupted turns (no completedAt)
still resolve to null.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
// that has a completion timestamp finished, whatever the state column says.
// Without this, quick finish-then-teardown threads resolve to null
// persistently and get tombstoned instead of published as completed.
if (thread.latestTurn?.state === "interrupted" && thread.latestTurn.completedAt !== null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Mediumsrc/agentAwareness.ts:112

The new fallback at line 112 misclassifies cancelled runs as completed. Turns settled via cancellation paths (interrupt-requested or session ended with interrupted/stopped status) also get state: "interrupted" with a non-null completedAt, so the condition thread.latestTurn?.state === "interrupted" && thread.latestTurn.completedAt !== null matches them too. This causes resolveThreadAwarenessPhase to return "completed" for runs the user actually cancelled, so users see stopped runs as successfully finished. If the intent is only to cover the teardown race where a completed turn's state is overwritten by interrupted, the guard needs to distinguish that case from genuine cancellations — for example, by checking the session status or a separate completion marker. Otherwise, consider documenting why interrupted turns with a completedAt should be treated as completed.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/shared/src/agentAwareness.ts around line 112:
The new fallback at line 112 misclassifies cancelled runs as `completed`. Turns settled via cancellation paths (interrupt-requested or session ended with `interrupted`/`stopped` status) also get `state: "interrupted"` with a non-null `completedAt`, so the condition `thread.latestTurn?.state === "interrupted" && thread.latestTurn.completedAt !== null` matches them too. This causes `resolveThreadAwarenessPhase` to return `"completed"` for runs the user actually cancelled, so users see stopped runs as successfully finished. If the intent is only to cover the teardown race where a `completed` turn's state is overwritten by `interrupted`, the guard needs to distinguish that case from genuine cancellations — for example, by checking the session status or a separate completion marker. Otherwise, consider documenting why interrupted turns with a `completedAt` should be treated as completed.

Comment threadpackages/shared/src/agentAwareness.ts
The stuck-at-Working repro survives the completedAt ladder fix: the
confirmed tombstone five seconds after turn completion still resolves
null, so the settled shell holds some combination the static analysis
keeps missing. Instead of guessing another layer deep, the tombstone
paths now log the exact fields the phase ladder reads (session status,
latest turn id/state/completedAt, pendings) both when deferring and when
a confirmation actually publishes — one repro pins the writer.
Also collapses the deferral storm visible at thread birth (six confirm
fibers forked in 600ms): one pending confirmation per thread.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

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

  • ✅ Fixed: Pending confirm set can leak
    • Wrapped the check-add-fork sequence in a single uninterruptible region so a pendingTombstoneConfirms entry can only exist once the detached confirm fiber (whose ensuring finalizer owns the delete) is guaranteed to have been forked.

Create PR

Or push these changes by commenting:

@cursor push abb02eb729
Preview (abb02eb729)
diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts--- a/apps/server/src/relay/AgentAwarenessRelay.ts+++ b/apps/server/src/relay/AgentAwarenessRelay.ts@@ -432,20 +432,30 @@
// immediately deletes the thread from the lock-screen card mid-
// conversation. Defer, re-resolve, and only tombstone if it holds;
// genuinely deleted threads still propagate a few seconds later.
- if (pendingTombstoneConfirms.has(threadId)) {- return;- }- pendingTombstoneConfirms.add(threadId);- yield* Effect.logInfo("agent activity tombstone deferred pending confirmation", {- environmentId,- threadId,- reason: snapshot.reason,- shell: describeThreadShellForAwareness(thread),- });- yield* Effect.forkDetach(- confirmTombstoneLater(threadId).pipe(- Effect.ensuring(Effect.sync(() => pendingTombstoneConfirms.delete(threadId))),- ),+ // Check-add-fork must be atomic under interruption: a set entry without+ // a forked confirm fiber (whose `ensuring` owns the delete) would+ // suppress tombstone confirmation for this thread forever.+ yield* Effect.uninterruptible(+ Effect.suspend(() => {+ if (pendingTombstoneConfirms.has(threadId)) {+ return Effect.void;+ }+ pendingTombstoneConfirms.add(threadId);+ return Effect.logInfo("agent activity tombstone deferred pending confirmation", {+ environmentId,+ threadId,+ reason: snapshot.reason,+ shell: describeThreadShellForAwareness(thread),+ }).pipe(+ Effect.andThen(+ Effect.forkDetach(+ confirmTombstoneLater(threadId).pipe(+ Effect.ensuring(Effect.sync(() => pendingTombstoneConfirms.delete(threadId))),+ ),+ ),+ ),+ );+ }),
);
return;
}

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

Comment threadapps/server/src/relay/AgentAwarenessRelay.ts Outdated
Diagnostics from the stuck-at-Working repro finally exposed the data
hole: the confirmed tombstone's shell showed sessionStatus "ready" with
latestTurn entirely absent. Threads whose turns produce no checkpoint
never get a projection_turns row, and thread.session-set clears
latest_turn_id the moment the session settles (activeTurnId goes null) —
so "completed" is unrepresentable through turns for quick Q&A threads.
Their entire lifecycle rode on session status and pendings, and at
completion nothing remained on the awareness ladder: tombstone instead
of Done.
A live session at "ready"/"idle" with nothing pending and nothing
running now projects as completed. This intentionally diverges from the
web sidebar, which resolves the same null but renders it as "no pill" —
a fine default in a list, while the publisher's null means "remove from
the lock-screen card". (The sidebar's own Completed pill needs
latestTurn.completedAt and silently can't fire for these threads either;
persisting turn rows for checkpoint-less turns is the deeper future fix
for both surfaces.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment threadpackages/shared/src/agentAwareness.ts
The full lifecycle finally works end to end, with one wart: a duplicate
Done push notification fired one second after thread creation. Sessions
boot at "ready" before their first turn starts, so the new
ready-means-completed projection produced a momentary completed state at
birth — and since the freshly armed card's token wasn't registered yet,
it fell through to the notification channel. Server restarts had the
same shape at scale: the startup snapshot republished completed for
every recently finished thread, each queuing a push notification.
- Env server: completed as a thread's FIRST published state defers and
re-resolves like a tombstone — at birth the confirm sees running and
completed never publishes; genuinely at-rest threads still publish
five seconds later. Fresh completions (previous state was live) are
unaffected, keeping the buzz immediate.
- Relay: terminal push notifications only fire when the completion is
fresh (row updated within two minutes), so replays and restart
snapshots repaint state without ringing the device.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
if (!activity) {
return null;
}
if (activity.phase === "completed" || activity.phase === "failed") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 MediumagentActivity/ApnsDeliveries.ts:321

notificationForAggregate drops terminal notifications for push-notification-only users when activity.updatedAt is more than two minutes old. activity.updatedAt is the timestamp from the published aggregate state, not the time the notification was first delivered to this device. If relay publishing or replay is delayed by more than two minutes (e.g., after downtime or a late reconnect), the first completed/failed push notification for a notifications-only user is silently suppressed even though it was never delivered before. Consider gating the freshness check on the device's last-known delivery time (e.g., last_push_notification_delivery_at) rather than activity.updatedAt, so only devices that already received a recent terminal notification are suppressed.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/ApnsDeliveries.ts around line 321:
`notificationForAggregate` drops terminal notifications for push-notification-only users when `activity.updatedAt` is more than two minutes old. `activity.updatedAt` is the timestamp from the published aggregate state, not the time the notification was first delivered to this device. If relay publishing or replay is delayed by more than two minutes (e.g., after downtime or a late reconnect), the first `completed`/`failed` push notification for a notifications-only user is silently suppressed even though it was never delivered before. Consider gating the freshness check on the device's last-known delivery time (e.g., `last_push_notification_delivery_at`) rather than `activity.updatedAt`, so only devices that already received a recent terminal notification are suppressed.

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

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

  • ✅ Fixed: LA alerts skip terminal freshness
    • Threaded nowMs into alertForNewlyTerminal and alertForTerminalAggregate and applied the shared TERMINAL_NOTIFICATION_FRESHNESS_MS window via a new isFreshTerminalRow helper, so stale replayed completions no longer alert through Live Activity updates or end events.

Create PR

Or push these changes by commenting:

@cursor push c88cec1a49
Preview (c88cec1a49)
diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts--- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts+++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts@@ -1525,15 +1525,30 @@
activities: [{ ...aggregate.activities[0]!, phase: "completed" as const, status: "Done" }],
};
expect(
- ApnsDeliveries.alertForTerminalAggregate({ aggregate: terminalAggregate, preferences }),+ ApnsDeliveries.alertForTerminalAggregate({+ aggregate: terminalAggregate,+ preferences,+ nowMs: 0,+ }),
).toEqual({ title: "Thread", body: "Done: Project" });
expect(
ApnsDeliveries.alertForTerminalAggregate({
aggregate: terminalAggregate,
preferences: { ...preferences, notifyOnCompletion: false },
+ nowMs: 0,
}),
).toBeNull();
- expect(ApnsDeliveries.alertForTerminalAggregate({ aggregate: null, preferences })).toBeNull();+ expect(+ ApnsDeliveries.alertForTerminalAggregate({ aggregate: null, preferences, nowMs: 0 }),+ ).toBeNull();+ // A completion replayed long after the fact must not ring again.+ expect(+ ApnsDeliveries.alertForTerminalAggregate({+ aggregate: terminalAggregate,+ preferences,+ nowMs: 10 * 60 * 1_000,+ }),+ ).toBeNull();
});
it("alerts when a previously active thread finishes mid-flight", () => {
@@ -1552,6 +1567,7 @@
previousAggregate: aggregate,
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toEqual({ title: "Thread", body: "Done: Project" });
// The completion switch mutes it.
@@ -1560,6 +1576,7 @@
previousAggregate: aggregate,
nextAggregate: next,
preferences: { ...preferences, notifyOnCompletion: false },
+ nowMs: 0,
}),
).toBeNull();
// No baseline means no transition to ring on.
@@ -1568,6 +1585,7 @@
previousAggregate: null,
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toBeNull();
// A Done row that was already terminal (or absent) before stays silent.
@@ -1576,6 +1594,7 @@
previousAggregate: next,
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toBeNull();
expect(
@@ -1583,7 +1602,18 @@
previousAggregate: { ...aggregate, activities: [attentionRow] },
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toBeNull();
+ // A stale completion replayed after a restart (previous aggregate still+ // shows the thread as running) must not ring the device again.+ expect(+ ApnsDeliveries.alertForNewlyTerminal({+ previousAggregate: aggregate,+ nextAggregate: next,+ preferences,+ nowMs: 10 * 60 * 1_000,+ }),+ ).toBeNull();
});
});
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@@ -210,6 +210,20 @@
};
}
+// Completions replayed long after the fact (server restarts republish every+// recently-finished thread) must not ring the device again — on any channel:+// push notifications, Live Activity update alerts, and end alerts all honor+// the same freshness window.+const TERMINAL_NOTIFICATION_FRESHNESS_MS = 2 * 60 * 1_000;++function isFreshTerminalRow(row: { readonly updatedAt: string }, nowMs: number): boolean {+ const updatedAtMs = Option.match(DateTime.make(row.updatedAt), {+ onNone: () => null,+ onSome: (dt) => dt.epochMilliseconds,+ });+ return updatedAtMs !== null && nowMs - updatedAtMs <= TERMINAL_NOTIFICATION_FRESHNESS_MS;+}+
// Alert copy for an update whose aggregate contains threads that finished
// (Done/Failed) since the previously delivered aggregate — the mid-flight
// completion buzz while other agents keep the activity alive. Requires the
@@ -219,6 +233,7 @@
readonly previousAggregate: RelayAgentActivityAggregateState | null;
readonly nextAggregate: RelayAgentActivityAggregateState;
readonly preferences: RelayAgentAwarenessPreferences | null;
+ readonly nowMs: number;
}): ApnsLiveActivityAlert | null {
if (input.previousAggregate === null) {
return null;
@@ -230,6 +245,9 @@
if (row.phase !== "completed" && row.phase !== "failed") {
return false;
}
+ if (!isFreshTerminalRow(row, input.nowMs)) {+ return false;+ }
const previousPhase = previousPhases.get(row.threadId);
return (
previousPhase !== undefined &&
@@ -255,11 +273,15 @@
export function alertForTerminalAggregate(input: {
readonly aggregate: RelayAgentActivityAggregateState | null;
readonly preferences: RelayAgentAwarenessPreferences | null;
+ readonly nowMs: number;
}): ApnsLiveActivityAlert | null {
const row = input.aggregate?.activities[0];
if (!row || (row.phase !== "completed" && row.phase !== "failed")) {
return null;
}
+ if (!isFreshTerminalRow(row, input.nowMs)) {+ return null;+ }
if (!alertAllowedForPhase(input.preferences, row.phase)) {
return null;
}
@@ -298,10 +320,6 @@
);
}
-// Completions replayed long after the fact (server restarts republish every-// recently-finished thread) must not ring the device again.-const TERMINAL_NOTIFICATION_FRESHNESS_MS = 2 * 60 * 1_000;-
function notificationForAggregate(input: {
readonly target: LiveActivities.TargetRow;
readonly aggregate: RelayAgentActivityAggregateState | null;
@@ -318,14 +336,11 @@
if (!activity) {
return null;
}
- if (activity.phase === "completed" || activity.phase === "failed") {- const updatedAtMs = Option.match(DateTime.make(activity.updatedAt), {- onNone: () => null,- onSome: (dt) => dt.epochMilliseconds,- });- if (updatedAtMs === null || input.nowMs - updatedAtMs > TERMINAL_NOTIFICATION_FRESHNESS_MS) {- return null;- }+ if (+ (activity.phase === "completed" || activity.phase === "failed") &&+ !isFreshTerminalRow(activity, input.nowMs)+ ) {+ return null;
}
const enabled =
(activity.phase === "waiting_for_approval" && preferences.notifyOnApproval) ||
@@ -419,6 +434,7 @@
previousAggregate,
nextAggregate,
preferences,
+ nowMs: input.nowMs,
}),
}
: "suppressed";
@@ -1100,6 +1116,7 @@
: alertForTerminalAggregate({
aggregate: delivery.aggregate,
preferences: parsePreferences(input.target.preferences_json),
+ nowMs: input.nowMs,
})
: delivery.alert;
const result = yield* deliveryQueue.enqueueLiveActivity({

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

Comment threadinfra/relay/src/agentActivity/ApnsDeliveries.ts
…A pref gates
Fixes the real findings from the PR review bots ahead of the TestFlight
build:
- Deferred publish confirmations (tombstones and first-state
completions) now re-enqueue through the same drainable worker as every
other publish instead of a detached fiber calling the publisher
directly, so a confirmed tombstone can never race an in-flight live
update; a deadline map replaces the pending set, which also guarantees
a single scheduled confirmation per thread and clears when the state
recovers. (cursor/macroscope High)
- Newly-terminal transitions bypass the 15s live-activity update
throttle: a completion landing in the same window as a new start keeps
activeCount unchanged and would previously be suppressed, silently
dropping the Done transition and its buzz. (macroscope High)
- The newly-terminal alert honors the same 2-minute freshness rule as
terminal push notifications, so replayed aggregates repaint without
ringing. (cursor Medium)
- Live Activity arming and priming treat an unset preference as enabled
(the toggle's default): fresh installs now prime, and arm-on-send
respects an explicit off. (cursor High/Medium)
Remaining findings triaged as stale (superseded designs), intended
behavior (replay painting recent Done, LA-disabled end retiring the
token), or accepted edge cases (grace extension on re-registration,
completion alerts beyond the 3-row display cap, ready-with-lastError
mapping to Done, cancelled turns showing Done).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

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

  • ✅ Fixed: Stale confirm deadline publishes tombstone
    • Cleared the thread's publishConfirmDeadlines entry in the unchanged-identity early-return path so a recovered projection cancels the deferred confirmation, preventing a later transient null state from confirming immediately against a stale expired deadline.

Create PR

Or push these changes by commenting:

@cursor push e02169b78e
Preview (e02169b78e)
diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts--- a/apps/server/src/relay/AgentAwarenessRelay.ts+++ b/apps/server/src/relay/AgentAwarenessRelay.ts@@ -410,6 +410,11 @@
const publishIdentity = agentAwarenessPublishIdentity(snapshot.state);
const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef);
if (publishedStateByThread.get(threadId) === publishIdentity) {
+ // The projection recovered to the last published state, so any deferred+ // confirmation (tombstone or first-state completion) is moot. Clear the+ // deadline so the next divergence gets a fresh deferral window instead+ // of confirming immediately against a stale, expired deadline.+ publishConfirmDeadlines.delete(threadId);
yield* Effect.logDebug("agent activity publish skipped; projected state unchanged", {
environmentId,
threadId,

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

Reviewed by Cursor Bugbot for commit 97ed442. Configure here.

Comment threadapps/server/src/relay/AgentAwarenessRelay.ts
…d state
A recovered projection exits at the unchanged-identity dedupe, which sits
above the confirmation block, so a deferred publish's deadline survived
recovery. A much later transient null would then find the deadline
already expired and publish its tombstone immediately, bypassing the
deferral window entirely. The dedupe path now clears any pending
deadline: the state is back at what was last published, so the deferred
confirmation is moot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@juliusmarmingejuliusmarminge added the 🚀 Mobile Continuous Deployment Trigger Expo preview build label Jul 7, 2026
Fixes the Effect Service Conventions check: the service builds a plain
object with no effectful acquisition, so wrapping it in Effect.sync just
to feed Layer.effect was the make-only-to-force-Layer.effect shape the
conventions call out.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

ghost commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

🚀 Expo continuous deployment is ready!

  • Project → t3-code
  • Platforms → android, ios
  • Scheme → t3code-preview
🤖 Android🍎 iOS
Fingerprint106610ae982be2d8aa28dda9217ff853daf9d30b4f388ce49fd16563ef62f563f42c0528f092d42b
Build DetailsBuild Permalink
DetailsDistribution: INTERNAL
Build profile: preview:dev
Runtime version: 106610ae982be2d8aa28dda9217ff853daf9d30b
App version: 0.1.0
Git commit: 74888084257320d42e7562c74996c949010bdf45
Build Permalink
DetailsDistribution: INTERNAL
Build profile: preview:dev
Runtime version: 4f388ce49fd16563ef62f563f42c0528f092d42b
App version: 0.1.0
Git commit: c622444d589c97adb591101ffca9161d785806b0
Update DetailsUpdate Permalink
DetailsBranch: pr-3685
Runtime version: 106610ae982be2d8aa28dda9217ff853daf9d30b
Git commit: 74888084257320d42e7562c74996c949010bdf45
Update Permalink
DetailsBranch: pr-3685
Runtime version: 4f388ce49fd16563ef62f563f42c0528f092d42b
Git commit: 74888084257320d42e7562c74996c949010bdf45
Update QR

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

Labels

🚀 Mobile Continuous DeploymentTrigger Expo preview buildsize:XXL1,000+ changed lines (additions + deletions).vouch:trustedPR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@juliusmarminge