| author | |
| committer | |
| log | 735240f3162470032120603474605b8a6e42580b |
| tree | 9186fdac724db040037f7b1aab1fa284f78133e8 |
| parent | 8de254a685f4356c719e744926397362c800f426 |
| signature | Signed by PGP key B5690EEEBB952194 |
9 files changed, 406 insertions(+), 13 deletions(-)
apps/server/src/relay/AgentAwarenessRelay.test.ts+11-2| ... | @@ -137,7 +137,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { | ... | @@ -137,7 +137,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { |
| 137 | expect(AgentAwarenessRelay.eventThreadId(event)).toBe(threadId); | 137 | expect(AgentAwarenessRelay.eventThreadId(event)).toBe(threadId); |
| 138 | }); | 138 | }); |
| 139 | 139 | ||
| 140 | it("does not publish streaming content or non-awareness activity events", () => { | 140 | it("does not publish start intents, streaming content, or non-awareness activity events", () => { |
| 141 | const now = "2026-05-25T00:00:00.000Z"; | 141 | const now = "2026-05-25T00:00:00.000Z"; |
| 142 | const base = { | 142 | const base = { |
| 143 | sequence: 1, | 143 | sequence: 1, |
| ... | @@ -191,7 +191,16 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { | ... | @@ -191,7 +191,16 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { |
| 191 | streaming: false, | 191 | streaming: false, |
| 192 | }, | 192 | }, |
| 193 | } as unknown as OrchestrationEvent), | 193 | } as unknown as OrchestrationEvent), |
| 194 | ).toBe(true); | 194 | ).toBe(false); |
| 195 | expect( | ||
| 196 | AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ | ||
| 197 | ...base, | ||
| 198 | type: "thread.turn-start-requested", | ||
| 199 | payload: { | ||
| 200 | threadId: "thread-1" as ThreadId, | ||
| 201 | }, | ||
| 202 | } as unknown as OrchestrationEvent), | ||
| 203 | ).toBe(false); | ||
| 195 | }); | 204 | }); |
| 196 | 205 | ||
| 197 | it("deduplicates awareness state updates whose only change is their event timestamp", () => { | 206 | it("deduplicates awareness state updates whose only change is their event timestamp", () => { |
apps/server/src/relay/AgentAwarenessRelay.ts+7-1| ... | @@ -67,7 +67,13 @@ export function eventThreadId(event: OrchestrationEvent): ThreadId | null { | ... | @@ -67,7 +67,13 @@ export function eventThreadId(event: OrchestrationEvent): ThreadId | null { |
| 67 | export function shouldPublishAgentAwarenessEvent(event: OrchestrationEvent): boolean { | 67 | export function shouldPublishAgentAwarenessEvent(event: OrchestrationEvent): boolean { |
| 68 | switch (event.type) { | 68 | switch (event.type) { |
| 69 | case "thread.message-sent": | 69 | case "thread.message-sent": |
| 70 | return !event.payload.streaming; | 70 | case "thread.turn-start-requested": |
| 71 | // These events express intent to start work, but the shell still contains | ||
| 72 | // the previous turn's terminal state until the provider acknowledges the | ||
| 73 | // new turn. Publishing that snapshot can queue a fresh "Done" alert just | ||
| 74 | // before the real running state arrives. Provider lifecycle events publish | ||
| 75 | // the authoritative starting/running state instead. | ||
| 76 | return false; | ||
| 71 | case "thread.proposed-plan-upserted": | 77 | case "thread.proposed-plan-upserted": |
| 72 | case "thread.runtime-mode-set": | 78 | case "thread.runtime-mode-set": |
| 73 | case "thread.interaction-mode-set": | 79 | case "thread.interaction-mode-set": |
infra/relay/src/agentActivity/AgentActivityPublisher.test.ts+1| ... | @@ -64,6 +64,7 @@ function makeAgentActivityRows( | ... | @@ -64,6 +64,7 @@ function makeAgentActivityRows( |
| 64 | remove: () => Effect.void, | 64 | remove: () => Effect.void, |
| 65 | pruneTerminal: () => Effect.void, | 65 | pruneTerminal: () => Effect.void, |
| 66 | listForUser: () => Effect.succeed([state]), | 66 | listForUser: () => Effect.succeed([state]), |
| 67 | getForUserThread: () => Effect.succeed(state), | ||
| 67 | ...overrides, | 68 | ...overrides, |
| 68 | }; | 69 | }; |
| 69 | } | 70 | } |
infra/relay/src/agentActivity/AgentActivityRows.test.ts+9| ... | @@ -75,6 +75,15 @@ describe("AgentActivityRows", () => { | ... | @@ -75,6 +75,15 @@ describe("AgentActivityRows", () => { |
| 75 | const listError = yield* rows.listForUser({ userId: "user-2" }).pipe(Effect.flip); | 75 | const listError = yield* rows.listForUser({ userId: "user-2" }).pipe(Effect.flip); |
| 76 | expect(listError).toMatchObject({ userId: "user-2", cause }); | 76 | expect(listError).toMatchObject({ userId: "user-2", cause }); |
| 77 | expect(listError.message).toBe("Failed to list agent activity state for user user-2."); | 77 | expect(listError.message).toBe("Failed to list agent activity state for user user-2."); |
| 78 | |||
| 79 | const getError = yield* rows | ||
| 80 | .getForUserThread({ | ||
| 81 | userId: "user-2", | ||
| 82 | environmentId: "env-1", | ||
| 83 | threadId: "thread-1", | ||
| 84 | }) | ||
| 85 | .pipe(Effect.flip); | ||
| 86 | expect(getError).toMatchObject({ userId: "user-2", cause }); | ||
| 78 | }).pipe( | 87 | }).pipe( |
| 79 | Effect.provide( | 88 | Effect.provide( |
| 80 | AgentActivityRows.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, failingDb))), | 89 | AgentActivityRows.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, failingDb))), |
infra/relay/src/agentActivity/AgentActivityRows.ts+53| ... | @@ -83,6 +83,11 @@ export class AgentActivityRows extends Context.Service< | ... | @@ -83,6 +83,11 @@ export class AgentActivityRows extends Context.Service< |
| 83 | ReadonlyArray<RelayAgentActivityState>, | 83 | ReadonlyArray<RelayAgentActivityState>, |
| 84 | AgentActivityRowListPersistenceError | 84 | AgentActivityRowListPersistenceError |
| 85 | >; | 85 | >; |
| 86 | readonly getForUserThread: (input: { | ||
| 87 | readonly userId: string; | ||
| 88 | readonly environmentId: string; | ||
| 89 | readonly threadId: string; | ||
| 90 | }) => Effect.Effect<RelayAgentActivityState | null, AgentActivityRowListPersistenceError>; | ||
| 86 | } | 91 | } |
| 87 | >()("t3code-relay/agentActivity/AgentActivityRows") {} | 92 | >()("t3code-relay/agentActivity/AgentActivityRows") {} |
| 88 | 93 | ||
| ... | @@ -241,6 +246,54 @@ export const make = Effect.gen(function* () { | ... | @@ -241,6 +246,54 @@ export const make = Effect.gen(function* () { |
| 241 | ), | 246 | ), |
| 242 | ); | 247 | ); |
| 243 | }), | 248 | }), |
| 249 | |||
| 250 | getForUserThread: Effect.fn("relay.agent_activity_rows.get_for_user_thread")(function* (input) { | ||
| 251 | return yield* db | ||
| 252 | .select({ stateJson: relayAgentActivityRows.stateJson }) | ||
| 253 | .from(relayAgentActivityRows) | ||
| 254 | .innerJoin( | ||
| 255 | relayEnvironmentLinks, | ||
| 256 | and( | ||
| 257 | eq(relayEnvironmentLinks.environmentId, relayAgentActivityRows.environmentId), | ||
| 258 | eq( | ||
| 259 | relayEnvironmentLinks.environmentPublicKey, | ||
| 260 | relayAgentActivityRows.environmentPublicKey, | ||
| 261 | ), | ||
| 262 | ), | ||
| 263 | ) | ||
| 264 | .where( | ||
| 265 | and( | ||
| 266 | eq(relayEnvironmentLinks.userId, input.userId), | ||
| 267 | isNull(relayEnvironmentLinks.revokedAt), | ||
| 268 | eq(relayAgentActivityRows.environmentId, input.environmentId), | ||
| 269 | eq(relayAgentActivityRows.threadId, input.threadId), | ||
| 270 | ), | ||
| 271 | ) | ||
| 272 | .orderBy(desc(relayAgentActivityRows.updatedAt)) | ||
| 273 | .pipe( | ||
| 274 | Effect.flatMap((rows) => | ||
| 275 | Effect.forEach(rows, (row) => encodeJsonValue(row.stateJson), { | ||
| 276 | concurrency: "unbounded", | ||
| 277 | }), | ||
| 278 | ), | ||
| 279 | Effect.map((rows) => { | ||
| 280 | for (const row of rows) { | ||
| 281 | const decoded = decodeRelayAgentActivityStateJson(row); | ||
| 282 | if (Option.isSome(decoded)) { | ||
| 283 | return decoded.value; | ||
| 284 | } | ||
| 285 | } | ||
| 286 | return null; | ||
| 287 | }), | ||
| 288 | Effect.mapError( | ||
| 289 | (cause) => | ||
| 290 | new AgentActivityRowListPersistenceError({ | ||
| 291 | userId: input.userId, | ||
| 292 | cause, | ||
| 293 | }), | ||
| 294 | ), | ||
| 295 | ); | ||
| 296 | }), | ||
| 244 | }); | 297 | }); |
| 245 | }); | 298 | }); |
| 246 | 299 |
infra/relay/src/agentActivity/ApnsDeliveries.test.ts+202-9| ... | @@ -5,7 +5,6 @@ import type { | ... | @@ -5,7 +5,6 @@ import type { |
| 5 | import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; | 5 | import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; |
| 6 | import { describe, expect, it } from "@effect/vitest"; | 6 | import { describe, expect, it } from "@effect/vitest"; |
| 7 | import * as NodeCrypto from "node:crypto"; | 7 | import * as NodeCrypto from "node:crypto"; |
| 8 | import * as DateTime from "effect/DateTime"; | ||
| 9 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| 10 | import * as Layer from "effect/Layer"; | 9 | import * as Layer from "effect/Layer"; |
| 11 | import * as Logger from "effect/Logger"; | 10 | import * as Logger from "effect/Logger"; |
| ... | @@ -161,10 +160,13 @@ function makeLayer(input: { | ... | @@ -161,10 +160,13 @@ function makeLayer(input: { |
| 161 | >; | 160 | >; |
| 162 | readonly currentTargets?: ReadonlyArray<LiveActivities.TargetRow>; | 161 | readonly currentTargets?: ReadonlyArray<LiveActivities.TargetRow>; |
| 163 | readonly config?: RelayConfiguration.RelayConfiguration["Service"]; | 162 | readonly config?: RelayConfiguration.RelayConfiguration["Service"]; |
| 164 | // Live agent-activity rows returned by the delivery-time start recheck. | 163 | // Live agent-activity rows returned by delivery-time state rechecks. |
| 165 | // Defaults to one freshly-updated running thread so queued starts stay | 164 | // Defaults to the fixture row so queued updates match unless a test is |
| 166 | // deliverable in tests that don't care about the recheck. | 165 | // explicitly exercising stale-state behavior. |
| 167 | readonly activityStates?: ReadonlyArray<RelayAgentActivityState>; | 166 | readonly activityStates?: ReadonlyArray<RelayAgentActivityState>; |
| 167 | // Current per-thread rows used to reject queued updates/notifications that | ||
| 168 | // have been superseded before APNs delivery. | ||
| 169 | readonly currentActivityStates?: ReadonlyArray<RelayAgentActivityState>; | ||
| 168 | readonly execute?: ( | 170 | readonly execute?: ( |
| 169 | request: HttpClientRequest.HttpClientRequest, | 171 | request: HttpClientRequest.HttpClientRequest, |
| 170 | ) => Effect.Effect<HttpClientResponse.HttpClientResponse>; | 172 | ) => Effect.Effect<HttpClientResponse.HttpClientResponse>; |
| ... | @@ -182,9 +184,14 @@ function makeLayer(input: { | ... | @@ -182,9 +184,14 @@ function makeLayer(input: { |
| 182 | listForUser: () => | 184 | listForUser: () => |
| 183 | input.activityStates !== undefined | 185 | input.activityStates !== undefined |
| 184 | ? Effect.succeed([...input.activityStates]) | 186 | ? Effect.succeed([...input.activityStates]) |
| 185 | : DateTime.now.pipe( | 187 | : Effect.succeed([state]), |
| 186 | Effect.map((now) => [{ ...state, updatedAt: DateTime.formatIso(now) }]), | 188 | getForUserThread: ({ environmentId, threadId }) => |
| 187 | ), | 189 | Effect.succeed( |
| 190 | (input.currentActivityStates ?? input.activityStates ?? [state]).find( | ||
| 191 | (current) => | ||
| 192 | current.environmentId === environmentId && current.threadId === threadId, | ||
| 193 | ) ?? null, | ||
| 194 | ), | ||
| 188 | } satisfies AgentActivityRows.AgentActivityRows["Service"]), | 195 | } satisfies AgentActivityRows.AgentActivityRows["Service"]), |
| 189 | Layer.succeed(ApnsDeliveryQueue.ApnsDeliveryQueueSender, { | 196 | Layer.succeed(ApnsDeliveryQueue.ApnsDeliveryQueueSender, { |
| 190 | send: (body) => | 197 | send: (body) => |
| ... | @@ -674,6 +681,8 @@ describe("ApnsDeliveries", () => { | ... | @@ -674,6 +681,8 @@ describe("ApnsDeliveries", () => { |
| 674 | environmentId: "env", | 681 | environmentId: "env", |
| 675 | threadId: "thread", | 682 | threadId: "thread", |
| 676 | deepLink: "/", | 683 | deepLink: "/", |
| 684 | phase: "waiting_for_input", | ||
| 685 | updatedAt: state.updatedAt, | ||
| 677 | }, | 686 | }, |
| 678 | }, | 687 | }, |
| 679 | }, | 688 | }, |
| ... | @@ -778,6 +787,30 @@ describe("ApnsDeliveries", () => { | ... | @@ -778,6 +787,30 @@ describe("ApnsDeliveries", () => { |
| 778 | }, | 787 | }, |
| 779 | ); | 788 | ); |
| 780 | 789 | ||
| 790 | it.effect("does not queue a push notification when a thread starts working", () => { | ||
| 791 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | ||
| 792 | const queuedJobs: Array<SignedApnsDeliveryJob> = []; | ||
| 793 | |||
| 794 | return Effect.gen(function* () { | ||
| 795 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; | ||
| 796 | const result = yield* deliveries.sendForTarget({ | ||
| 797 | target: { | ||
| 798 | ...target, | ||
| 799 | push_token: "apns-device-token", | ||
| 800 | push_to_start_token: null, | ||
| 801 | activity_push_token: null, | ||
| 802 | remote_started_at: null, | ||
| 803 | }, | ||
| 804 | aggregate, | ||
| 805 | nowMs: 5_000, | ||
| 806 | }); | ||
| 807 | |||
| 808 | expect(result).toBeNull(); | ||
| 809 | expect(queuedJobs).toEqual([]); | ||
| 810 | expect(attempts).toEqual([]); | ||
| 811 | }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs }))); | ||
| 812 | }); | ||
| 813 | |||
| 781 | it.effect("queues bounded alert notification payloads", () => { | 814 | it.effect("queues bounded alert notification payloads", () => { |
| 782 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | 815 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; |
| 783 | const queuedJobs: Array<SignedApnsDeliveryJob> = []; | 816 | const queuedJobs: Array<SignedApnsDeliveryJob> = []; |
| ... | @@ -1176,6 +1209,150 @@ describe("ApnsDeliveries", () => { | ... | @@ -1176,6 +1209,150 @@ describe("ApnsDeliveries", () => { |
| 1176 | ); | 1209 | ); |
| 1177 | }); | 1210 | }); |
| 1178 | 1211 | ||
| 1212 | it.effect("skips a queued Done Live Activity update after the thread resumes working", () => { | ||
| 1213 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | ||
| 1214 | let executeCount = 0; | ||
| 1215 | const completedAt = "1970-01-01T00:00:01.000Z"; | ||
| 1216 | const completedAggregate: RelayAgentActivityAggregateState = { | ||
| 1217 | ...aggregate, | ||
| 1218 | activeCount: 0, | ||
| 1219 | updatedAt: completedAt, | ||
| 1220 | activities: [ | ||
| 1221 | { | ||
| 1222 | ...aggregate.activities[0]!, | ||
| 1223 | phase: "completed", | ||
| 1224 | status: "Done", | ||
| 1225 | updatedAt: completedAt, | ||
| 1226 | }, | ||
| 1227 | ], | ||
| 1228 | }; | ||
| 1229 | const payload = makeApnsDeliveryJobPayload({ | ||
| 1230 | kind: "live_activity_update", | ||
| 1231 | userId: target.user_id, | ||
| 1232 | deviceId: target.device_id, | ||
| 1233 | token: target.activity_push_token ?? "activity-token", | ||
| 1234 | aggregate: completedAggregate, | ||
| 1235 | alert: { title: "Thread", body: "Done: Project" }, | ||
| 1236 | createdAt: completedAt, | ||
| 1237 | expiresAt: "1970-01-01T00:10:00.000Z", | ||
| 1238 | jobId: "job-update-superseded-by-running", | ||
| 1239 | }); | ||
| 1240 | const signed = signApnsDeliveryJob({ | ||
| 1241 | secret: config.apnsDeliveryJobSigningSecret, | ||
| 1242 | payload, | ||
| 1243 | }); | ||
| 1244 | const execute = (request: HttpClientRequest.HttpClientRequest) => | ||
| 1245 | Effect.sync(() => { | ||
| 1246 | executeCount += 1; | ||
| 1247 | return HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); | ||
| 1248 | }); | ||
| 1249 | |||
| 1250 | return Effect.gen(function* () { | ||
| 1251 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; | ||
| 1252 | const result = yield* deliveries.processSignedJob(signed); | ||
| 1253 | |||
| 1254 | expect(result).toMatchObject({ | ||
| 1255 | kind: "live_activity_update", | ||
| 1256 | ok: true, | ||
| 1257 | apnsStatus: null, | ||
| 1258 | apnsReason: "Stale APNs delivery job skipped.", | ||
| 1259 | }); | ||
| 1260 | expect(executeCount).toBe(0); | ||
| 1261 | expect(attempts).toMatchObject([ | ||
| 1262 | { | ||
| 1263 | sourceJobId: "job-update-superseded-by-running", | ||
| 1264 | apnsReason: "Stale agent activity state skipped.", | ||
| 1265 | }, | ||
| 1266 | ]); | ||
| 1267 | }).pipe( | ||
| 1268 | Effect.provide( | ||
| 1269 | makeLayer({ | ||
| 1270 | attempts, | ||
| 1271 | activityStates: [ | ||
| 1272 | { | ||
| 1273 | ...state, | ||
| 1274 | phase: "running", | ||
| 1275 | headline: "Running", | ||
| 1276 | updatedAt: "1970-01-01T00:00:02.000Z", | ||
| 1277 | }, | ||
| 1278 | ], | ||
| 1279 | config: signingConfig, | ||
| 1280 | execute, | ||
| 1281 | }), | ||
| 1282 | ), | ||
| 1283 | ); | ||
| 1284 | }); | ||
| 1285 | |||
| 1286 | it.effect("skips a queued Done notification after the thread resumes working", () => { | ||
| 1287 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | ||
| 1288 | let executeCount = 0; | ||
| 1289 | const completedAt = "1970-01-01T00:00:01.000Z"; | ||
| 1290 | const payload = makeApnsDeliveryJobPayload({ | ||
| 1291 | kind: "push_notification", | ||
| 1292 | userId: target.user_id, | ||
| 1293 | deviceId: target.device_id, | ||
| 1294 | token: "apns-device-token", | ||
| 1295 | aggregate: null, | ||
| 1296 | notification: { | ||
| 1297 | title: "Thread", | ||
| 1298 | body: "Done: Project", | ||
| 1299 | environmentId: "env", | ||
| 1300 | threadId: "thread", | ||
| 1301 | deepLink: "/", | ||
| 1302 | phase: "completed", | ||
| 1303 | updatedAt: completedAt, | ||
| 1304 | }, | ||
| 1305 | createdAt: completedAt, | ||
| 1306 | expiresAt: "1970-01-01T00:10:00.000Z", | ||
| 1307 | jobId: "job-push-superseded-by-running", | ||
| 1308 | }); | ||
| 1309 | const signed = signApnsDeliveryJob({ | ||
| 1310 | secret: config.apnsDeliveryJobSigningSecret, | ||
| 1311 | payload, | ||
| 1312 | }); | ||
| 1313 | const execute = (request: HttpClientRequest.HttpClientRequest) => | ||
| 1314 | Effect.sync(() => { | ||
| 1315 | executeCount += 1; | ||
| 1316 | return HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); | ||
| 1317 | }); | ||
| 1318 | |||
| 1319 | return Effect.gen(function* () { | ||
| 1320 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; | ||
| 1321 | const result = yield* deliveries.processSignedJob(signed); | ||
| 1322 | |||
| 1323 | expect(result).toMatchObject({ | ||
| 1324 | kind: "push_notification", | ||
| 1325 | ok: true, | ||
| 1326 | apnsStatus: null, | ||
| 1327 | apnsReason: "Stale APNs delivery job skipped.", | ||
| 1328 | }); | ||
| 1329 | expect(executeCount).toBe(0); | ||
| 1330 | expect(attempts).toMatchObject([ | ||
| 1331 | { | ||
| 1332 | sourceJobId: "job-push-superseded-by-running", | ||
| 1333 | apnsReason: "Stale agent activity state skipped.", | ||
| 1334 | }, | ||
| 1335 | ]); | ||
| 1336 | }).pipe( | ||
| 1337 | Effect.provide( | ||
| 1338 | makeLayer({ | ||
| 1339 | attempts, | ||
| 1340 | currentTargets: [{ ...target, push_token: "apns-device-token" }], | ||
| 1341 | currentActivityStates: [ | ||
| 1342 | { | ||
| 1343 | ...state, | ||
| 1344 | phase: "running", | ||
| 1345 | headline: "Running", | ||
| 1346 | updatedAt: "1970-01-01T00:00:02.000Z", | ||
| 1347 | }, | ||
| 1348 | ], | ||
| 1349 | config: signingConfig, | ||
| 1350 | execute, | ||
| 1351 | }), | ||
| 1352 | ), | ||
| 1353 | ); | ||
| 1354 | }); | ||
| 1355 | |||
| 1179 | it.effect("retries signed queue jobs that are already claimed but not completed", () => { | 1356 | it.effect("retries signed queue jobs that are already claimed but not completed", () => { |
| 1180 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | 1357 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; |
| 1181 | let executeCount = 0; | 1358 | let executeCount = 0; |
| ... | @@ -1327,7 +1504,15 @@ describe("ApnsDeliveries", () => { | ... | @@ -1327,7 +1504,15 @@ describe("ApnsDeliveries", () => { |
| 1327 | deviceId: target.device_id, | 1504 | deviceId: target.device_id, |
| 1328 | }, | 1505 | }, |
| 1329 | ]); | 1506 | ]); |
| 1330 | }).pipe(Effect.provide(makeLayer({ attempts, clearedStarts }))); | 1507 | }).pipe( |
| 1508 | Effect.provide( | ||
| 1509 | makeLayer({ | ||
| 1510 | attempts, | ||
| 1511 | clearedStarts, | ||
| 1512 | activityStates: [{ ...state, updatedAt: "9999-01-01T00:00:00.000Z" }], | ||
| 1513 | }), | ||
| 1514 | ), | ||
| 1515 | ); | ||
| 1331 | }); | 1516 | }); |
| 1332 | 1517 | ||
| 1333 | it.effect("invalidates dead push-to-start tokens after permanent APNs start failures", () => { | 1518 | it.effect("invalidates dead push-to-start tokens after permanent APNs start failures", () => { |
| ... | @@ -1373,7 +1558,15 @@ describe("ApnsDeliveries", () => { | ... | @@ -1373,7 +1558,15 @@ describe("ApnsDeliveries", () => { |
| 1373 | }, | 1558 | }, |
| 1374 | ]); | 1559 | ]); |
| 1375 | }).pipe( | 1560 | }).pipe( |
| 1376 | Effect.provide(makeLayer({ attempts, invalidatedTokens, config: signingConfig, execute })), | 1561 | Effect.provide( |
| 1562 | makeLayer({ | ||
| 1563 | attempts, | ||
| 1564 | invalidatedTokens, | ||
| 1565 | activityStates: [{ ...state, updatedAt: "9999-01-01T00:00:00.000Z" }], | ||
| 1566 | config: signingConfig, | ||
| 1567 | execute, | ||
| 1568 | }), | ||
| 1569 | ), | ||
| 1377 | ); | 1570 | ); |
| 1378 | }); | 1571 | }); |
| 1379 | 1572 |
infra/relay/src/agentActivity/ApnsDeliveries.ts+112| ... | @@ -369,6 +369,8 @@ function notificationForAggregate(input: { | ... | @@ -369,6 +369,8 @@ function notificationForAggregate(input: { |
| 369 | environmentId: activity.environmentId, | 369 | environmentId: activity.environmentId, |
| 370 | threadId: activity.threadId, | 370 | threadId: activity.threadId, |
| 371 | deepLink: activity.deepLink, | 371 | deepLink: activity.deepLink, |
| 372 | phase: activity.phase, | ||
| 373 | updatedAt: activity.updatedAt, | ||
| 372 | }; | 374 | }; |
| 373 | } | 375 | } |
| 374 | 376 | ||
| ... | @@ -722,6 +724,87 @@ export const make = Effect.gen(function* () { | ... | @@ -722,6 +724,87 @@ export const make = Effect.gen(function* () { |
| 722 | ); | 724 | ); |
| 723 | }); | 725 | }); |
| 724 | 726 | ||
| 727 | const stateIdentityIsCurrent = Effect.fnUntraced(function* (input: { | ||
| 728 | readonly userId: string; | ||
| 729 | readonly environmentId: string; | ||
| 730 | readonly threadId: string; | ||
| 731 | readonly phase: RelayAgentActivityAggregateState["activities"][number]["phase"]; | ||
| 732 | readonly updatedAt: string; | ||
| 733 | }) { | ||
| 734 | return yield* activityRows | ||
| 735 | .getForUserThread({ | ||
| 736 | userId: input.userId, | ||
| 737 | environmentId: input.environmentId, | ||
| 738 | threadId: input.threadId, | ||
| 739 | }) | ||
| 740 | .pipe( | ||
| 741 | Effect.map( | ||
| 742 | (current) => | ||
| 743 | current !== null && | ||
| 744 | current.phase === input.phase && | ||
| 745 | current.updatedAt === input.updatedAt, | ||
| 746 | ), | ||
| 747 | // A transient persistence failure must not permanently discard a | ||
| 748 | // legitimate alert. Fail open and let the signed job's retry/dedupe | ||
| 749 | // protections handle transport failures as usual. | ||
| 750 | Effect.catchCause((cause) => | ||
| 751 | Effect.logWarning("agent-activity state recheck failed; allowing queued delivery", { | ||
| 752 | cause, | ||
| 753 | environmentId: input.environmentId, | ||
| 754 | threadId: input.threadId, | ||
| 755 | }).pipe(Effect.as(true)), | ||
| 756 | ), | ||
| 757 | ); | ||
| 758 | }); | ||
| 759 | |||
| 760 | const aggregateRowsAreCurrent = Effect.fnUntraced(function* (input: { | ||
| 761 | readonly userId: string; | ||
| 762 | readonly aggregate: RelayAgentActivityAggregateState; | ||
| 763 | }) { | ||
| 764 | return yield* activityRows.listForUser({ userId: input.userId }).pipe( | ||
| 765 | Effect.map((currentStates) => { | ||
| 766 | const currentByThread = new Map( | ||
| 767 | currentStates.map((current) => [ | ||
| 768 | `${current.environmentId}\u0000${current.threadId}`, | ||
| 769 | current, | ||
| 770 | ]), | ||
| 771 | ); | ||
| 772 | return input.aggregate.activities.every((row) => { | ||
| 773 | const current = currentByThread.get(`${row.environmentId}\u0000${row.threadId}`); | ||
| 774 | return ( | ||
| 775 | current !== undefined && | ||
| 776 | current.phase === row.phase && | ||
| 777 | current.updatedAt === row.updatedAt | ||
| 778 | ); | ||
| 779 | }); | ||
| 780 | }), | ||
| 781 | Effect.catchCause((cause) => | ||
| 782 | Effect.logWarning("agent-activity aggregate recheck failed; allowing queued delivery", { | ||
| 783 | cause, | ||
| 784 | userId: input.userId, | ||
| 785 | }).pipe(Effect.as(true)), | ||
| 786 | ), | ||
| 787 | ); | ||
| 788 | }); | ||
| 789 | |||
| 790 | const notificationStateIsCurrent = Effect.fnUntraced(function* (input: { | ||
| 791 | readonly userId: string; | ||
| 792 | readonly notification: ApnsNotificationPayload; | ||
| 793 | }) { | ||
| 794 | // Jobs from older relay versions do not carry a state identity. Preserve | ||
| 795 | // backwards compatibility and only revalidate newly queued jobs. | ||
| 796 | if (input.notification.phase === undefined || input.notification.updatedAt === undefined) { | ||
| 797 | return true; | ||
| 798 | } | ||
| 799 | return yield* stateIdentityIsCurrent({ | ||
| 800 | userId: input.userId, | ||
| 801 | environmentId: input.notification.environmentId, | ||
| 802 | threadId: input.notification.threadId, | ||
| 803 | phase: input.notification.phase, | ||
| 804 | updatedAt: input.notification.updatedAt, | ||
| 805 | }); | ||
| 806 | }); | ||
| 807 | |||
| 725 | const isCurrentSignedJobToken = Effect.fnUntraced(function* (input: { | 808 | const isCurrentSignedJobToken = Effect.fnUntraced(function* (input: { |
| 726 | readonly target: LiveActivityDeliveryTarget; | 809 | readonly target: LiveActivityDeliveryTarget; |
| 727 | readonly kind: RelayDeliveryKind; | 810 | readonly kind: RelayDeliveryKind; |
| ... | @@ -791,6 +874,20 @@ export const make = Effect.gen(function* () { | ... | @@ -791,6 +874,20 @@ export const make = Effect.gen(function* () { |
| 791 | }); | 874 | }); |
| 792 | return staleJobResult({ deviceId: input.target.device_id, kind: input.kind }); | 875 | return staleJobResult({ deviceId: input.target.device_id, kind: input.kind }); |
| 793 | } | 876 | } |
| 877 | if ( | ||
| 878 | input.kind !== "live_activity_start" && | ||
| 879 | aggregate !== null && | ||
| 880 | !(yield* aggregateRowsAreCurrent({ | ||
| 881 | userId: input.target.user_id, | ||
| 882 | aggregate, | ||
| 883 | })) | ||
| 884 | ) { | ||
| 885 | yield* attempts.completeSourceJob({ | ||
| 886 | sourceJobId: input.sourceJobId, | ||
| 887 | apnsReason: "Stale agent activity state skipped.", | ||
| 888 | }); | ||
| 889 | return staleJobResult({ deviceId: input.target.device_id, kind: input.kind }); | ||
| 890 | } | ||
| 794 | } | 891 | } |
| 795 | if ( | 892 | if ( |
| 796 | input.kind === "live_activity_start" && | 893 | input.kind === "live_activity_start" && |
| ... | @@ -930,6 +1027,21 @@ export const make = Effect.gen(function* () { | ... | @@ -930,6 +1027,21 @@ export const make = Effect.gen(function* () { |
| 930 | kind: "push_notification", | 1027 | kind: "push_notification", |
| 931 | }); | 1028 | }); |
| 932 | } | 1029 | } |
| 1030 | if ( | ||
| 1031 | !(yield* notificationStateIsCurrent({ | ||
| 1032 | userId: input.target.user_id, | ||
| 1033 | notification, | ||
| 1034 | })) | ||
| 1035 | ) { | ||
| 1036 | yield* attempts.completeSourceJob({ | ||
| 1037 | sourceJobId: input.sourceJobId, | ||
| 1038 | apnsReason: "Stale agent activity state skipped.", | ||
| 1039 | }); | ||
| 1040 | return staleJobResult({ | ||
| 1041 | deviceId: input.target.device_id, | ||
| 1042 | kind: "push_notification", | ||
| 1043 | }); | ||
| 1044 | } | ||
| 933 | } | 1045 | } |
| 934 | const result = yield* apns | 1046 | const result = yield* apns |
| 935 | .sendPushNotificationRequest({ | 1047 | .sendPushNotificationRequest({ |
infra/relay/src/agentActivity/MobileRegistrations.test.ts+1| ... | @@ -86,6 +86,7 @@ function makeAgentActivityRows( | ... | @@ -86,6 +86,7 @@ function makeAgentActivityRows( |
| 86 | }; | 86 | }; |
| 87 | return Effect.succeed([activeState]); | 87 | return Effect.succeed([activeState]); |
| 88 | }, | 88 | }, |
| 89 | getForUserThread: () => Effect.succeed(null), | ||
| 89 | ...overrides, | 90 | ...overrides, |
| 90 | }; | 91 | }; |
| 91 | } | 92 | } |
infra/relay/src/agentActivity/apnsDeliveryJobs.ts+10-1| ... | @@ -1,6 +1,10 @@ | ... | @@ -1,6 +1,10 @@ |
| 1 | import * as NodeCrypto from "node:crypto"; | 1 | import * as NodeCrypto from "node:crypto"; |
| 2 | 2 | ||
| 3 | import { RelayAgentActivityAggregateState, type RelayDeliveryKind } from "@t3tools/contracts/relay"; | 3 | import { |
| 4 | RelayAgentActivityAggregateState, | ||
| 5 | RelayAgentAwarenessPhase, | ||
| 6 | type RelayDeliveryKind, | ||
| 7 | } from "@t3tools/contracts/relay"; | ||
| 4 | import { stableStringify } from "@t3tools/shared/relaySigning"; | 8 | import { stableStringify } from "@t3tools/shared/relaySigning"; |
| 5 | import * as DateTime from "effect/DateTime"; | 9 | import * as DateTime from "effect/DateTime"; |
| 6 | import * as Option from "effect/Option"; | 10 | import * as Option from "effect/Option"; |
| ... | @@ -38,6 +42,11 @@ export const ApnsNotificationPayload = Schema.Struct({ | ... | @@ -38,6 +42,11 @@ export const ApnsNotificationPayload = Schema.Struct({ |
| 38 | environmentId: Schema.String, | 42 | environmentId: Schema.String, |
| 39 | threadId: Schema.String, | 43 | threadId: Schema.String, |
| 40 | deepLink: Schema.String, | 44 | deepLink: Schema.String, |
| 45 | // Optional so delivery jobs queued by older relay builds still decode. | ||
| 46 | // New jobs use these fields to avoid delivering a stale Done/attention | ||
| 47 | // notification after the thread has moved to another phase. | ||
| 48 | phase: Schema.optional(RelayAgentAwarenessPhase), | ||
| 49 | updatedAt: Schema.optional(Schema.String), | ||
| 41 | }); | 50 | }); |
| 42 | export type ApnsNotificationPayload = typeof ApnsNotificationPayload.Type; | 51 | export type ApnsNotificationPayload = typeof ApnsNotificationPayload.Type; |
| 43 | 52 |