authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-08-14 10:00:25+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-14 10:00:25+02:00
logbaaeda305c60933e5e573b30807ecfc3a0871a80
treeb38b09f039d9e1a53cd7631f255abeabf2390794
parent7e01d33f0eeb9435299791392d546756cc09c5d3
signature Signed by PGP key B5690EEEBB952194

fix: avoid stale Live Activities when publishing is disabled (#6325)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

10 files changed, 236 insertions(+), 7 deletions(-)

apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts+76
......@@ -20,12 +20,15 @@ import {
2020 clearAgentAwarenessRegistrationRecord,
2121 loadAgentAwarenessRegistrationRecord,
2222 loadOrCreateAgentAwarenessDeviceId,
23 loadPreferences,
2324 saveAgentAwarenessRegistrationRecord,
2425} from "../../persistence/imperative";
26import type { Preferences } from "../../persistence/mobile-preferences";
2527import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload";
2628import {
2729 AgentAwarenessOperationError,
2830 __resetAgentAwarenessRemoteRegistrationForTest,
31 armAgentAwarenessLiveActivityForLocalWork,
2932 getAgentAwarenessRegistrationStatus,
3033 mergeAgentAwarenessRegistrationPreferences,
3134 refreshActiveLiveActivityRemoteRegistration,
......@@ -43,6 +46,13 @@ import * as Notifications from "expo-notifications";
4346const secureStore = vi.hoisted(() => new Map<string, string>());
4447const widgetMocks = vi.hoisted(() => ({
4548 getInstances: vi.fn(() => []),
49 start: vi.fn(() => ({})),
50}));
51const environmentConfigsMock = vi.hoisted(() => ({
52 configs: new Map<
53 string,
54 { environment: { capabilities: { agentActivityPublishing?: boolean } } }
55 >(),
4656}));
4757const backgroundRuntime = vi.hoisted(() => ({
4858 pending: [] as Array<{
......@@ -77,9 +87,22 @@ vi.mock("expo-widgets", () => ({
7787vi.mock("../../widgets/AgentActivity", () => ({
7888 default: {
7989 getInstances: widgetMocks.getInstances,
90 start: widgetMocks.start,
8091 },
8192}));
8293
94// The state modules pull the whole connection stack (and native expo modules)
95// into the import graph; the arming gate only needs the configs map.
96vi.mock("../../state/atom-registry", () => ({
97 appAtomRegistry: {
98 get: () => environmentConfigsMock.configs,
99 },
100}));
101
102vi.mock("../../state/server", () => ({
103 environmentServerConfigsAtom: Symbol("environmentServerConfigsAtom"),
104}));
105
83106vi.mock("expo-notifications", () => ({
84107 addPushTokenListener: vi.fn(() => ({ remove: vi.fn() })),
85108 getDevicePushTokenAsync: vi.fn(() => Promise.resolve({ type: "ios", data: "apns-token" })),
......@@ -227,6 +250,8 @@ describe("makeRelayDeviceRegistrationRequest", () => {
227250 vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockResolvedValue("device-1");
228251 widgetMocks.getInstances.mockReset();
229252 widgetMocks.getInstances.mockReturnValue([]);
253 widgetMocks.start.mockClear();
254 environmentConfigsMock.configs.clear();
230255 });
231256
232257 it("preserves disabled Live Activity preferences in relay registrations", () => {
......@@ -856,4 +881,55 @@ describe("makeRelayDeviceRegistrationRequest", () => {
856881 }).pipe(Effect.provide(relayTestLayer));
857882 },
858883 );
884
885 it("skips the Live Activity seed when the environment reports publishing disabled", async () => {
886 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
887 vi.mocked(loadPreferences).mockResolvedValueOnce({
888 liveActivitiesEnabled: true,
889 } as Preferences);
890 environmentConfigsMock.configs.set("env-1", {
891 environment: { capabilities: { agentActivityPublishing: false } },
892 });
893
894 armAgentAwarenessLiveActivityForLocalWork({
895 environmentId: "env-1" as EnvironmentId,
896 threadTitle: "Fix the flaky test",
897 projectTitle: "t3code",
898 });
899 await new Promise((resolve) => setTimeout(resolve, 0));
900
901 expect(widgetMocks.start).not.toHaveBeenCalled();
902 });
903
904 it("seeds the Live Activity for publishing and pre-capability environments", async () => {
905 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
906 environmentConfigsMock.configs.set("env-publishing", {
907 environment: { capabilities: { agentActivityPublishing: true } },
908 });
909
910 vi.mocked(loadPreferences).mockResolvedValueOnce({
911 liveActivitiesEnabled: true,
912 } as Preferences);
913 armAgentAwarenessLiveActivityForLocalWork({
914 environmentId: "env-publishing" as EnvironmentId,
915 threadTitle: "Fix the flaky test",
916 projectTitle: "t3code",
917 });
918 await new Promise((resolve) => setTimeout(resolve, 0));
919 expect(widgetMocks.start).toHaveBeenCalledTimes(1);
920
921 // An environment without the capability may run an older server that
922 // still publishes; only an explicit false skips the seed.
923 widgetMocks.start.mockClear();
924 vi.mocked(loadPreferences).mockResolvedValueOnce({
925 liveActivitiesEnabled: true,
926 } as Preferences);
927 armAgentAwarenessLiveActivityForLocalWork({
928 environmentId: "env-pre-capability" as EnvironmentId,
929 threadTitle: "Fix the flaky test",
930 projectTitle: "t3code",
931 });
932 await new Promise((resolve) => setTimeout(resolve, 0));
933 expect(widgetMocks.start).toHaveBeenCalledTimes(1);
934 });
859935});
apps/mobile/src/features/agent-awareness/remoteRegistration.ts+23-1
......@@ -20,6 +20,8 @@ import {
2020
2121import type { SavedRemoteConnection } from "../../lib/connection";
2222import { runtime } from "../../lib/runtime";
23import { appAtomRegistry } from "../../state/atom-registry";
24import { environmentServerConfigsAtom } from "../../state/server";
2325import type { Preferences } from "../../persistence/mobile-preferences";
2426import {
2527 clearAgentAwarenessRegistrationRecord,
......@@ -448,18 +450,38 @@ function unregisterDeviceWithRelay(input: {
448450 });
449451}
450452
453// The environment descriptor advertises whether agent-activity publishes
454// currently leave that server (`capabilities.agentActivityPublishing`). Only
455// an explicit false skips the seed card: older servers omit the capability
456// but may still publish.
457function environmentPublishesAgentActivity(environmentId: EnvironmentId): boolean {
458 return (
459 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
460 .agentActivityPublishing !== false
461 );
462}
463
451464// Arms the lock-screen card the moment the user starts agent work from this
452465// phone, while the app is still foregrounded and the fresh activity's token
453466// can be registered immediately. The seeded row is a best-effort placeholder;
454467// the relay's registration replay repaints it with the authoritative
455// aggregate within seconds. No-ops when a card is already armed.
468// aggregate within seconds. No-ops when a card is already armed, and skips
469// environments that report publishing disabled — the seed would sit on
470// "Connecting" forever with no update ever arriving to repaint or end it.
456471export function armAgentAwarenessLiveActivityForLocalWork(input: {
472 readonly environmentId: EnvironmentId;
457473 readonly threadTitle: string;
458474 readonly projectTitle: string;
459475}): void {
460476 if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) {
461477 return;
462478 }
479 if (!environmentPublishesAgentActivity(input.environmentId)) {
480 logRegistrationDebug("live activity arming skipped; environment does not publish", {
481 environmentId: input.environmentId,
482 });
483 return;
484 }
463485 void loadPreferences()
464486 .catch(() => null)
465487 .then((preferences) => {
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+1
......@@ -709,6 +709,7 @@ export function NewTaskDraftScreen(props: {
709709 // -only Activity start. If creation fails, the token registration's replay
710710 // finds no work and ends the card within seconds.
711711 armAgentAwarenessLiveActivityForLocalWork({
712 environmentId: selectedProject.environmentId,
712713 threadTitle: deriveThreadTitleFromPrompt(initialMessageText),
713714 projectTitle: selectedProject.title,
714715 });
apps/mobile/src/features/threads/ThreadComposer.tsx+1
......@@ -541,6 +541,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
541541 // after the send so its preference read and native Activity start don't
542542 // contend with the queued-message feedback on the tap frame.
543543 armAgentAwarenessLiveActivityForLocalWork({
544 environmentId: props.environmentId,
544545 threadTitle: props.selectedThread.title,
545546 projectTitle: props.environmentLabel ?? "T3 Code",
546547 });
apps/server/src/cli/connect.ts+3-2
......@@ -36,6 +36,7 @@ import * as CliState from "../cloud/CliState.ts";
3636import * as CliTokenManager from "../cloud/CliTokenManager.ts";
3737import {
3838 CLOUD_LINKED_USER_ID,
39 isAgentActivityPublishingEnabledValue,
3940 PUBLISH_AGENT_ACTIVITY_SECRET,
4041 RELAY_URL_SECRET,
4142} from "../cloud/config.ts";
......@@ -142,7 +143,7 @@ function stringToBytes(value: string): Uint8Array {
142143}
143144
144145export function isPublishAgentActivityEnabledValue(value: string | null): boolean {
145 return value === "true";
146 return isAgentActivityPublishingEnabledValue(value);
146147}
147148
148149interface CloudCliStatus {
......@@ -447,7 +448,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* <A, E
447448 ),
448449 RelayClient.layerCloudflared({ baseDir: config.baseDir }),
449450 EnvironmentAuth.runtimeLayer,
450 ServerEnvironment.layer,
451 ServerEnvironment.layer.pipe(Layer.provide(ServerSecretStore.layer)),
451452 bootServiceLayer(config),
452453 headlessRelayClientTracingLayer,
453454 ).pipe(
apps/server/src/cloud/config.ts+40
......@@ -1,6 +1,10 @@
11import { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
24import * as Schema from "effect/Schema";
35
6import type * as ServerSecretStore from "../auth/ServerSecretStore.ts";
7
48export const CLOUD_MINT_PUBLIC_KEY = "cloud-mint-ed25519-public-key";
59export const CLOUD_ENDPOINT_RUNTIME_CONFIG = "cloud-endpoint-runtime-config";
610export const CLOUD_LINKED_USER_ID = "cloud-linked-user-id";
......@@ -16,3 +20,39 @@ export const encodeEndpointRuntimeConfigJson = Schema.encodeEffect(
1620export const decodeRuntimeConfig = Schema.decodeUnknownOption(
1721 Schema.fromJsonString(RelayManagedEndpointRuntimeConfig),
1822);
23
24export function isAgentActivityPublishingEnabledValue(value: string | null): boolean {
25 return value === "true";
26}
27
28/** Whether agent-activity publishes currently leave this environment: the
29 publish opt-in secret is enabled and the relay link credentials exist.
30 Mirrors the per-publish gate in AgentAwarenessRelay, so the descriptor
31 capability never advertises publishing that the publisher would skip. */
32export const readAgentActivityPublishingActive = (
33 secrets: ServerSecretStore.ServerSecretStore["Service"],
34): Effect.Effect<boolean> =>
35 Effect.gen(function* () {
36 const readSecretString = (name: string) =>
37 secrets
38 .get(name)
39 .pipe(
40 Effect.map((bytes) =>
41 Option.isSome(bytes) ? new TextDecoder().decode(bytes.value) : null,
42 ),
43 );
44 const [enabled, url, environmentCredential] = yield* Effect.all([
45 readSecretString(PUBLISH_AGENT_ACTIVITY_SECRET),
46 readSecretString(RELAY_URL_SECRET),
47 readSecretString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET),
48 ]);
49 // Empty strings are as unconfigured as missing files: the publisher's
50 // truthiness gate skips them, so the capability must too.
51 return (
52 isAgentActivityPublishingEnabledValue(enabled) &&
53 url !== null &&
54 url !== "" &&
55 environmentCredential !== null &&
56 environmentCredential !== ""
57 );
58 }).pipe(Effect.orElseSucceed(() => false));
apps/server/src/environment/ServerEnvironment.test.ts+70-1
......@@ -3,9 +3,16 @@ import { expect, it } from "@effect/vitest";
33import * as Effect from "effect/Effect";
44import * as FileSystem from "effect/FileSystem";
55import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
67import * as PlatformError from "effect/PlatformError";
78import * as Schema from "effect/Schema";
89
10import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
11import {
12 PUBLISH_AGENT_ACTIVITY_SECRET,
13 RELAY_ENVIRONMENT_CREDENTIAL_SECRET,
14 RELAY_URL_SECRET,
15} from "../cloud/config.ts";
916import * as ServerConfig from "../config.ts";
1017import * as ServerEnvironment from "./ServerEnvironment.ts";
1118
......@@ -14,7 +21,21 @@ const isServerEnvironmentIdPersistenceError = Schema.is(
1421);
1522
1623const makeServerEnvironmentLayer = (baseDir: string) =>
17 ServerEnvironment.layer.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir)));
24 ServerEnvironment.layer.pipe(
25 Layer.provide(ServerSecretStore.layer),
26 Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir)),
27 );
28
29const emptySecretStoreLayer = Layer.succeed(
30 ServerSecretStore.ServerSecretStore,
31 ServerSecretStore.ServerSecretStore.of({
32 get: () => Effect.succeed(Option.none()),
33 set: () => Effect.void,
34 create: () => Effect.void,
35 getOrCreateRandom: () => Effect.succeed(new Uint8Array()),
36 remove: () => Effect.void,
37 }),
38);
1839
1940const makeServerConfig = Effect.fn(function* (baseDir: string) {
2041 const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined);
......@@ -71,6 +92,53 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
7192 expect(second.capabilities.connectionProbe).toBe(true);
7293 expect(second.capabilities.pullRequests).toBe(true);
7394 expect(second.capabilities.threadTitleRegeneration).toBe(true);
95 expect(second.capabilities.agentActivityPublishing).toBe(false);
96 }),
97 );
98
99 it.effect("reports agent activity publishing from the current secret state", () =>
100 Effect.gen(function* () {
101 const fileSystem = yield* FileSystem.FileSystem;
102 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
103 prefix: "t3-server-environment-publish-test-",
104 });
105 const testLayer = Layer.mergeAll(
106 ServerEnvironment.layer.pipe(Layer.provide(ServerSecretStore.layer)),
107 ServerSecretStore.layer,
108 ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir)));
109
110 yield* Effect.gen(function* () {
111 const secrets = yield* ServerSecretStore.ServerSecretStore;
112 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
113 const encode = (value: string) => new TextEncoder().encode(value);
114
115 const unlinked = yield* serverEnvironment.getDescriptor;
116 expect(unlinked.capabilities.agentActivityPublishing).toBe(false);
117
118 // The opt-in alone is not enough: without relay link credentials no
119 // publish would leave this environment.
120 yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, encode("true"));
121 const withoutLink = yield* serverEnvironment.getDescriptor;
122 expect(withoutLink.capabilities.agentActivityPublishing).toBe(false);
123
124 // Empty credentials are as unconfigured as missing ones: the
125 // publisher's truthiness gate skips them, so the capability must not
126 // advertise publishing.
127 yield* secrets.set(RELAY_URL_SECRET, encode(""));
128 yield* secrets.set(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, encode("credential"));
129 const emptyUrl = yield* serverEnvironment.getDescriptor;
130 expect(emptyUrl.capabilities.agentActivityPublishing).toBe(false);
131
132 yield* secrets.set(RELAY_URL_SECRET, encode("https://relay.example"));
133 const linked = yield* serverEnvironment.getDescriptor;
134 expect(linked.capabilities.agentActivityPublishing).toBe(true);
135
136 // The toggle changes at runtime, so the same service instance must
137 // reflect a flip without a restart.
138 yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, encode("false"));
139 const disabled = yield* serverEnvironment.getDescriptor;
140 expect(disabled.capabilities.agentActivityPublishing).toBe(false);
141 }).pipe(Effect.provide(testLayer));
74142 }),
75143 );
76144
......@@ -113,6 +181,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
113181 }).pipe(
114182 Effect.provide(
115183 ServerEnvironment.layer.pipe(
184 Layer.provide(emptySecretStoreLayer),
116185 Layer.provide(Layer.merge(ServerConfig.layer(serverConfig), failingFileSystemLayer)),
117186 ),
118187 ),
apps/server/src/environment/ServerEnvironment.ts+14-2
......@@ -9,6 +9,8 @@ import * as Path from "effect/Path";
99import * as Schema from "effect/Schema";
1010
1111import packageJson from "../../package.json" with { type: "json" };
12import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
13import { readAgentActivityPublishingActive } from "../cloud/config.ts";
1214import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts";
1315import { resolveServiceLauncherMode } from "../cloud/serviceLauncherClient.ts";
1416import * as ServerConfig from "../config.ts";
......@@ -66,6 +68,7 @@ export const make = Effect.gen(function* () {
6668 const fileSystem = yield* FileSystem.FileSystem;
6769 const path = yield* Path.Path;
6870 const serverConfig = yield* ServerConfig.ServerConfig;
71 const secrets = yield* ServerSecretStore.ServerSecretStore;
6972 const crypto = yield* Crypto.Crypto;
7073 const hostPlatform = yield* HostProcessPlatform;
7174 const hostArchitecture = yield* HostProcessArchitecture;
......@@ -156,13 +159,22 @@ export const make = Effect.gen(function* () {
156159
157160 return ServerEnvironment.of({
158161 getEnvironmentId: Effect.succeed(environmentId),
159 getDescriptor: Effect.succeed(descriptor),
162 // The publish opt-in and relay link change at runtime (`t3 connect
163 // publish`, the client settings toggle), so the capability is read per
164 // descriptor request rather than baked in at startup.
165 getDescriptor: readAgentActivityPublishingActive(secrets).pipe(
166 Effect.map((agentActivityPublishing) => ({
167 ...descriptor,
168 capabilities: { ...descriptor.capabilities, agentActivityPublishing },
169 })),
170 ),
160171 });
161172});
162173
163174/**
164175 * ServerEnvironment is acquired from persisted filesystem and host-process
165176 * state. It intentionally has no fallback Layer.succeed value: callers must
166 * provide the external platform services and a ServerConfig.
177 * provide the external platform services, a ServerConfig, and the
178 * ServerSecretStore backing the descriptor's publishing capability.
167179 */
168180export const layer = Layer.effect(ServerEnvironment, make).pipe(Layer.provide(ProcessRunner.layer));
apps/server/src/relay/AgentAwarenessRelay.ts+2-1
......@@ -35,6 +35,7 @@ import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
3535
3636import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
3737import {
38 isAgentActivityPublishingEnabledValue,
3839 PUBLISH_AGENT_ACTIVITY_SECRET,
3940 RELAY_ENVIRONMENT_CREDENTIAL_SECRET,
4041 RELAY_ISSUER_SECRET,
......@@ -102,7 +103,7 @@ export function agentAwarenessPublishIdentity(state: RelayAgentActivityState | n
102103}
103104
104105export function isAgentActivityPublishingEnabled(value: string | null): boolean {
105 return value === "true";
106 return isAgentActivityPublishingEnabledValue(value);
106107}
107108
108109export function resolveAgentActivityPublishingStartupState(input: {
packages/contracts/src/environment.ts+6
......@@ -74,6 +74,12 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
7474 /** Server can stream self-update progress before acknowledging the
7575 restart. Clients fall back to server.updateServer when absent. */
7676 serverSelfUpdateProgress: Schema.optionalKey(Schema.Boolean),
77 /** Agent-activity publishes (push notifications and Live Activities)
78 currently leave this environment: the publish opt-in is enabled and the
79 relay link credentials exist. Clients skip seeding a Live Activity when
80 this is false — no update would ever repaint it. Absent on older
81 servers, which may still publish, so only an explicit false skips. */
82 agentActivityPublishing: Schema.optionalKey(Schema.Boolean),
7783});
7884export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type;
7985