Skip to content

Commit 5ab40bb

Browse files
committed
Keep the arming clock fixed across same-token re-registrations
The end-on-empty grace window keys off remote_started_at, but register refreshed it on every token upsert — including the foreground reconciliation that re-registers the same token. An orphaned card whose relay state was already empty then always replayed inside a fresh grace window, so the live_activity_end it was waiting for never sent and the stale card sat on the lock screen indefinitely. The token claim now skips the device's own row, and the upsert only resets remote_started_at when the token actually changed (a new activity); re-registering the same token preserves the original arming time so the grace expires as designed.
1 parent fd82469 commit 5ab40bb

2 files changed

Lines changed: 38 additions & 6 deletions

File tree

infra/relay/src/agentActivity/LiveActivities.test.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,10 +109,12 @@ describe("LiveActivities", () => {
109109
remoteStartedAt: null,
110110
}),
111111
]);
112+
// The claim skips this device's own row: it must stay intact so the
113+
// upsert can tell a same-token re-registration apart from a fresh arm.
112114
expect(updateConditions.map((condition) => dialect.sqlToQuery(condition))).toEqual([
113115
{
114-
sql: '"relay_live_activities"."activity_push_token" = $1',
115-
params: ["activity-push-token"],
116+
sql: '(("relay_live_activities"."activity_push_token" = $1) and ((("relay_live_activities"."user_id" <> $2) or ("relay_live_activities"."device_id" <> $3))))',
117+
params: ["activity-push-token", "user-2", "device-1"],
116118
},
117119
]);
118120
expect(insertedValues).toEqual([
@@ -131,12 +133,20 @@ describe("LiveActivities", () => {
131133
expect.objectContaining({
132134
activityPushToken: "activity-push-token",
133135
remoteStartQueuedAt: null,
134-
remoteStartedAt: expect.any(String),
135136
endedAt: null,
136137
lastAggregateJson: null,
137138
lastLiveActivityDeliveryAt: null,
138139
}),
139140
);
141+
// Re-registering the SAME token (foreground reconciliation) must not
142+
// restart the arming clock, or the end-on-empty grace window would
143+
// renew forever and orphaned cards would never receive their end.
144+
const remoteStartedAtSet = dialect.sqlToQuery(
145+
conflictConfigs[0]?.set?.remoteStartedAt as SQL,
146+
);
147+
expect(remoteStartedAtSet.sql.replace(/\s+/g, " ")).toBe(
148+
'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',
149+
);
140150
}).pipe(
141151
Effect.provide(
142152
LiveActivities.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))),

infra/relay/src/agentActivity/LiveActivities.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import * as Effect from "effect/Effect";
1313
import * as Function from "effect/Function";
1414
import * as Layer from "effect/Layer";
1515
import * as Schema from "effect/Schema";
16-
import { and, eq, sql } from "drizzle-orm";
16+
import { and, eq, ne, or, sql } from "drizzle-orm";
1717

1818
import * as RelayDb from "../db.ts";
1919
import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts";
@@ -141,6 +141,10 @@ export const make = Effect.gen(function* () {
141141
const updatedAt = DateTime.formatIso(yield* DateTime.now);
142142
const registration = input.registration;
143143

144+
// Claim the token from any OTHER row that still holds it (a token
145+
// addresses exactly one activity). The device's own row is left
146+
// untouched so the upsert below can tell a same-token re-registration
147+
// apart from a fresh arm.
144148
yield* db
145149
.update(relayLiveActivities)
146150
.set({
@@ -150,7 +154,15 @@ export const make = Effect.gen(function* () {
150154
endedAt: updatedAt,
151155
updatedAt,
152156
})
153-
.where(eq(relayLiveActivities.activityPushToken, registration.activityPushToken));
157+
.where(
158+
and(
159+
eq(relayLiveActivities.activityPushToken, registration.activityPushToken),
160+
or(
161+
ne(relayLiveActivities.userId, input.userId),
162+
ne(relayLiveActivities.deviceId, registration.deviceId),
163+
),
164+
),
165+
);
154166

155167
yield* db
156168
.insert(relayLiveActivities)
@@ -171,7 +183,17 @@ export const make = Effect.gen(function* () {
171183
set: {
172184
activityPushToken: registration.activityPushToken,
173185
remoteStartQueuedAt: null,
174-
remoteStartedAt: updatedAt,
186+
// remote_started_at records when the CARD was armed, and the
187+
// end-on-empty grace window keys off it. Foreground
188+
// reconciliation re-registers the same token periodically;
189+
// refreshing the arming time there would perpetually renew the
190+
// grace and suppress the end an orphaned card is waiting for.
191+
// Only a new token (a new activity) restarts the clock.
192+
remoteStartedAt: sql`case
193+
when ${relayLiveActivities.activityPushToken} = excluded.activity_push_token
194+
then coalesce(${relayLiveActivities.remoteStartedAt}, excluded.remote_started_at)
195+
else excluded.remote_started_at
196+
end`,
175197
endedAt: null,
176198
lastAggregateJson: null,
177199
lastLiveActivityDeliveryAt: null,

0 commit comments

Comments
 (0)