authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-05 17:04:24-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 00:04:24+00:00
logee6991ad502c7c8979ab47b6a8fc645498f5b4ce
treea803603f5d8fda20b429818d2e282e495326ab28
parent7a047d0136de606accc8270dce64f2260529c120
signature Signed by PGP key B5690EEEBB952194

fix(server): a T3 Connect preferences save finishes even if the client disconnects (#16266)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

3 files changed, 36 insertions(+), 16 deletions(-)

apps/server/src/cloud/CloudPreferences.test.ts+29
...@@ -217,3 +217,32 @@ it.effect("changes nothing when the current hold setting can't be read", () =>...@@ -217,3 +217,32 @@ it.effect("changes nothing when the current hold setting can't be read", () =>
217 }),217 }),
218 ),218 ),
219);219);
220
221it.effect("an update the client walks away from still finishes", () => {
222 let release!: () => void;
223 let started!: () => void;
224 const released = new Promise<void>((resolve) => {
225 release = resolve;
226 });
227 const atRelay = new Promise<void>((resolve) => {
228 started = resolve;
229 });
230 return withService({ holdFirstRelayCall: { started, released } }, ({ preferences, stored }) =>
231 Effect.gen(function* () {
232 const update = yield* preferences
233 .update({ publishAgentActivity: true, holdWebhooksWhileOffline: true })
234 .pipe(Effect.forkChild);
235 yield* Effect.promise(() => atRelay);
236 // The interrupt is in flight before the relay answers.
237 const interrupted = yield* Fiber.interrupt(update).pipe(Effect.forkChild);
238 yield* Effect.yieldNow;
239 release();
240 yield* Fiber.join(interrupted);
241 assert.equal(new TextDecoder().decode(stored.get(PUBLISH_AGENT_ACTIVITY_SECRET)), "true");
242 assert.equal(
243 new TextDecoder().decode(stored.get(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET)),
244 "true",
245 );
246 }),
247 );
248});
apps/server/src/cloud/CloudPreferences.ts+3
...@@ -121,6 +121,9 @@ const make = Effect.gen(function* () {...@@ -121,6 +121,9 @@ const make = Effect.gen(function* () {
121 }121 }
122 yield* awarenessRelay.requestCatchUp();122 yield* awarenessRelay.requestCatchUp();
123 },123 },
124 // A client that disconnects mid-update must not stop it between a save and
125 // its rollback, so an update always finishes or undoes itself.
126 Effect.uninterruptible,
124 updateLock.withPermits(1),127 updateLock.withPermits(1),
125 );128 );
126129
apps/server/src/secrets/SecretRequests.ts+4-16
...@@ -105,11 +105,7 @@ const make = Effect.gen(function* () {...@@ -105,11 +105,7 @@ const make = Effect.gen(function* () {
105 turnItemTypes: ["secret_request"],105 turnItemTypes: ["secret_request"],
106 messageRoles: [],106 messageRoles: [],
107 })107 })
108 .pipe(108 .pipe(Effect.mapError((cause) => new SecretRequestError({ reason: "load_failed", cause })));
109 Effect.mapError(
110 (cause) => new SecretRequestError({ reason: "load_failed", cause: cause }),
111 ),
112 );
113 const item = records.turnItems.find((candidate) => candidate.id === input.turnItemId);109 const item = records.turnItems.find((candidate) => candidate.id === input.turnItemId);
114 if (item?.type !== "secret_request" || item.runId === null || item.nodeId === null) {110 if (item?.type !== "secret_request" || item.runId === null || item.nodeId === null) {
115 return yield* new SecretRequestError({ reason: "not_found" });111 return yield* new SecretRequestError({ reason: "not_found" });
...@@ -160,9 +156,7 @@ const make = Effect.gen(function* () {...@@ -160,9 +156,7 @@ const make = Effect.gen(function* () {
160 secretStatus,156 secretStatus,
161 })157 })
162 .pipe(158 .pipe(
163 Effect.mapError(159 Effect.mapError((cause) => new SecretRequestError({ reason: "record_failed", cause })),
164 (cause) => new SecretRequestError({ reason: "record_failed", cause: cause }),
165 ),
166 // The card still says pending, so the user can save again; the value160 // The card still says pending, so the user can save again; the value
167 // stored above would make that retry look already answered.161 // stored above would make that retry look already answered.
168 Effect.tapError(() =>162 Effect.tapError(() =>
...@@ -181,9 +175,7 @@ const make = Effect.gen(function* () {...@@ -181,9 +175,7 @@ const make = Effect.gen(function* () {
181 messageRoles: [],175 messageRoles: [],
182 })176 })
183 .pipe(177 .pipe(
184 Effect.mapError(178 Effect.mapError((cause) => new SecretRequestError({ reason: "record_failed", cause })),
185 (cause) => new SecretRequestError({ reason: "record_failed", cause: cause }),
186 ),
187 );179 );
188 const card = recorded.turnItems.find((candidate) => candidate.id === item.id);180 const card = recorded.turnItems.find((candidate) => candidate.id === item.id);
189 if (card?.type === "secret_request" && card.secretStatus !== "saved") {181 if (card?.type === "secret_request" && card.secretStatus !== "saved") {
...@@ -229,11 +221,7 @@ const make = Effect.gen(function* () {...@@ -229,11 +221,7 @@ const make = Effect.gen(function* () {
229 return yield* new SecretRequestError({ reason: "invalid_ref" });221 return yield* new SecretRequestError({ reason: "invalid_ref" });
230 const stored = yield* store222 const stored = yield* store
231 .get(storeName(input.ref))223 .get(storeName(input.ref))
232 .pipe(224 .pipe(Effect.mapError((cause) => new SecretRequestError({ reason: "read_failed", cause })));
233 Effect.mapError(
234 (cause) => new SecretRequestError({ reason: "read_failed", cause: cause }),
235 ),
236 );
237 const decoded = Option.flatMap(stored, (bytes) =>225 const decoded = Option.flatMap(stored, (bytes) =>
238 decodeStored(new TextDecoder().decode(bytes)),226 decodeStored(new TextDecoder().decode(bytes)),
239 );227 );