authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-04 18:35:42-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 18:35:42-04:00
logb906ce2d73d025e801877f42a35b7a2f5629806f
tree17c954deddab43d1179fb18df23979bad108e955
parent5a2f3ebf6ee6eab6124ad68272c2fb5d74bc0f5d
signature Signed by PGP key B5690EEEBB952194

fix(server): recover opted-in threads after machine restarts (#9803)


11 files changed, 708 insertions(+), 183 deletions(-)

apps/desktop/src/settings/DesktopClientSettings.test.ts-1
......@@ -26,7 +26,6 @@ const clientSettings: ClientSettings = {
2626 confirmThreadArchive: true,
2727 confirmThreadDelete: false,
2828 confirmThreadUnpin: false,
29 continueThreadsAfterServerUpdate: true,
3029 contextWindowMeterEnabled: false,
3130 composerCollapseOnBlur: false,
3231 composerCollapseOnScroll: true,
apps/server/src/provider/Layers/ProviderService.test.ts+119
......@@ -452,6 +452,125 @@ function makeProviderServiceLayer(
452452 };
453453}
454454
455for (const [enabled, completed] of [
456 [false, false],
457 [true, false],
458 [true, true],
459] as const) {
460 it.effect(
461 `persists shutdown recovery before stopping providers when enabled=${enabled}, completed=${completed}`,
462 () =>
463 Effect.gen(function* () {
464 const codex = makeFakeCodexAdapter();
465 const persistence = yield* Layer.build(
466 ProviderSessionDirectoryLive.pipe(
467 Layer.provide(
468 ProviderSessionRuntime.layer.pipe(Layer.provide(SqlitePersistenceMemory)),
469 ),
470 ),
471 );
472 const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory.pipe(
473 Effect.provide(persistence),
474 );
475 const threadId = asThreadId("shutdown-recovery");
476 const turnId = asTurnId("shutdown-recovery-turn");
477 const scope = yield* Scope.make();
478 const services = yield* Layer.build(
479 makeProviderServiceLive().pipe(
480 Layer.provide(
481 Layer.succeed(ProviderSessionDirectory.ProviderSessionDirectory, directory),
482 ),
483 Layer.provide(
484 Layer.succeed(
485 ProviderAdapterRegistry.ProviderAdapterRegistry,
486 makeStaticInstanceRegistry([[codexInstanceId, codex.adapter]]),
487 ),
488 ),
489 Layer.provide(ServerSettings.layerTest({ continueThreadsAfterServerUpdate: enabled })),
490 Layer.provide(serverConfigTestLayer),
491 Layer.provide(AnalyticsService.layerTest),
492 Layer.provide(
493 Layer.succeed(
494 ProviderEventLoggers.ProviderEventLoggers,
495 ProviderEventLoggers.NoOpProviderEventLoggers,
496 ),
497 ),
498 ),
499 ).pipe(Scope.provide(scope));
500 const provider = yield* ProviderService.ProviderService.pipe(Effect.provide(services));
501 const session = yield* provider.startSession(threadId, {
502 provider: CODEX_DRIVER,
503 providerInstanceId: codexInstanceId,
504 threadId,
505 runtimeMode: "full-access",
506 });
507 codex.listSessions.mockReturnValue(
508 Effect.succeed([
509 {
510 ...session,
511 status: completed ? "ready" : "running",
512 activeTurnId: completed ? undefined : turnId,
513 },
514 ]),
515 );
516 const pending = yield* directory.getBinding(threadId);
517 assert(Option.isSome(pending));
518 yield* directory.upsert({
519 ...pending.value,
520 runtimePayload: { activeTurnId: null, continueAfterServerUpdate: turnId },
521 });
522 const accepted = yield* provider.sendTurn({ threadId, continuation: true });
523 const admitted = yield* directory.getBinding(threadId);
524 assert(Option.isSome(admitted));
525 assert.propertyVal(admitted.value.runtimePayload, "activeTurnId", accepted.turnId);
526 assert.propertyVal(admitted.value.runtimePayload, "continueAfterServerUpdate", null);
527 if (completed) {
528 // Updates can mark an already-admitted turn immediately before it finishes.
529 yield* directory.upsert({
530 ...admitted.value,
531 runtimePayload: {
532 continueAfterServerUpdate: accepted.turnId,
533 continueAfterServerUpdatePrepared: null,
534 },
535 });
536 }
537 const markers: unknown[] = [];
538 codex.stopAll.mockImplementation(() =>
539 Effect.gen(function* () {
540 const binding = yield* directory.getBinding(threadId);
541 assert(Option.isSome(binding));
542 markers.push(binding.value.runtimePayload);
543 }).pipe(Effect.orDie),
544 );
545 yield* Scope.close(scope, Exit.void);
546 const binding = yield* directory.getBinding(threadId);
547 assert(Option.isSome(binding));
548 assert.equal(codex.stopAll.mock.calls.length, 1);
549 assert.deepStrictEqual(binding.value.resumeCursor, session.resumeCursor);
550 assert.equal(binding.value.status, "stopped");
551 assert.propertyVal(markers[0], "activeTurnId", completed ? null : turnId);
552 if (enabled && !completed) {
553 assert.propertyVal(markers[0], "continueAfterServerUpdate", turnId);
554 assert.propertyVal(binding.value.runtimePayload, "continueAfterServerUpdate", turnId);
555 } else if (completed) {
556 assert.propertyVal(
557 binding.value.runtimePayload,
558 "continueAfterServerUpdate",
559 accepted.turnId,
560 );
561 assert.propertyVal(
562 binding.value.runtimePayload,
563 "continueAfterServerUpdatePrepared",
564 null,
565 );
566 } else {
567 assert.propertyVal(markers[0], "continueAfterServerUpdate", null);
568 assert.propertyVal(binding.value.runtimePayload, "continueAfterServerUpdate", null);
569 }
570 }).pipe(Effect.provide(NodeServices.layer)),
571 );
572}
573
455574it.effect("ProviderServiceLive catches stopAll failures during shutdown", () =>
456575 Effect.gen(function* () {
457576 const codex = makeFakeCodexAdapter();
apps/server/src/provider/Layers/ProviderService.ts+17
......@@ -223,6 +223,7 @@ function toRuntimePayloadFromSession(
223223 session: ProviderSession,
224224 extra?: {
225225 readonly modelSelection?: unknown;
226 readonly continueAfterServerUpdate?: TurnId;
226227 readonly lastRuntimeEvent?: string;
227228 readonly lastRuntimeEventAt?: string;
228229 },
......@@ -232,6 +233,9 @@ function toRuntimePayloadFromSession(
232233 model: session.model ?? null,
233234 activeTurnId: session.activeTurnId ?? null,
234235 lastError: session.lastError ?? null,
236 ...(extra?.continueAfterServerUpdate !== undefined
237 ? { continueAfterServerUpdate: extra.continueAfterServerUpdate }
238 : {}),
235239 ...(extra?.modelSelection !== undefined ? { modelSelection: extra.modelSelection } : {}),
236240 ...(extra?.lastRuntimeEvent !== undefined ? { lastRuntimeEvent: extra.lastRuntimeEvent } : {}),
237241 ...(extra?.lastRuntimeEventAt !== undefined
......@@ -831,6 +835,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
831835 threadId: ThreadId,
832836 extra?: {
833837 readonly modelSelection?: unknown;
838 readonly continueAfterServerUpdate?: TurnId;
834839 readonly lastRuntimeEvent?: string;
835840 readonly lastRuntimeEventAt?: string;
836841 },
......@@ -1433,6 +1438,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
14331438 runtimePayload: {
14341439 ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}),
14351440 activeTurnId: turn.turnId,
1441 // Admission and marker consumption must survive the same restart.
1442 continueAfterServerUpdate: null,
1443 continueAfterServerUpdatePrepared: null,
14361444 lastRuntimeEvent: "provider.sendTurn",
14371445 lastRuntimeEventAt: yield* nowIso,
14381446 },
......@@ -1733,6 +1741,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
17331741 status: "stopped",
17341742 runtimePayload: {
17351743 activeTurnId: null,
1744 continueAfterServerUpdate: null,
1745 continueAfterServerUpdatePrepared: null,
17361746 },
17371747 });
17381748 yield* analytics.record("provider.session.stopped", {
......@@ -1942,6 +1952,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
19421952 );
19431953
19441954 const runStopAll = Effect.fn("runStopAll")(function* () {
1955 const continueAfterRestart = yield* serverSettings.getSettings.pipe(
1956 Effect.map((settings) => settings.continueThreadsAfterServerUpdate),
1957 Effect.orElseSucceed(() => false),
1958 );
19451959 const properties = yield* Ref.modify(turnAnalytics, (state) => {
19461960 const completed: Array<Readonly<Record<string, unknown>>> = [];
19471961 for (const [sessionKey, session] of state.sessions) {
......@@ -1969,6 +1983,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
19691983 yield* Effect.forEach(activeSessions, (session) =>
19701984 Effect.flatMap(nowIso, (lastRuntimeEventAt) =>
19711985 upsertSessionBinding(session, session.threadId, {
1986 ...(continueAfterRestart && session.status === "running" && session.activeTurnId
1987 ? { continueAfterServerUpdate: session.activeTurnId }
1988 : {}),
19721989 lastRuntimeEvent: "provider.stopAll",
19731990 lastRuntimeEventAt,
19741991 }),
apps/server/src/serverRuntimeStartup.reconcile.test.ts+469-160
......@@ -1,6 +1,7 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
22import {
33 type OrchestrationCommand,
4 type OrchestrationSessionStatus,
45 ProviderDriverKind,
56 ProviderInstanceId,
67 type ProviderSendTurnInput,
......@@ -10,15 +11,21 @@ import {
1011import { assert, it } from "@effect/vitest";
1112import * as Deferred from "effect/Deferred";
1213import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";
1315import * as Option from "effect/Option";
1416import * as Stream from "effect/Stream";
1517
1618import { OrchestrationCommandInvariantError } from "./orchestration/Errors.ts";
1719import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";
1820import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
19import { ProviderSessionDirectoryPersistenceError } from "./provider/Errors.ts";
21import {
22 ProviderSessionDirectoryPersistenceError,
23 ProviderSessionNotFoundError,
24} from "./provider/Errors.ts";
2025import * as ProviderService from "./provider/Services/ProviderService.ts";
2126import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts";
27import { ServerActivation } from "./serverActivation.ts";
28import * as ServerSettings from "./serverSettings.ts";
2229import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
2330
2431const providerInstanceId = ProviderInstanceId.make("codex");
......@@ -26,7 +33,7 @@ const updatedAt = "2026-08-20T12:00:00.000Z";
2633
2734const makeThread = (
2835 id: string,
29 status: "starting" | "running" | "ready" | "stopped" | "error",
36 status: OrchestrationSessionStatus,
3037 activeTurnId: TurnId | null = null,
3138 archivedAt: string | null = null,
3239 deletedAt: string | null = null,
......@@ -73,6 +80,7 @@ const queryWithThreads = (threads: ReadonlyArray<ReturnType<typeof makeThread>>)
7380
7481const runReconciliation = (input: {
7582 readonly threads: ReadonlyArray<ReturnType<typeof makeThread>>;
83 readonly continueAfterRestart?: boolean;
7684 readonly liveThreadIds?: ReadonlyArray<ThreadId>;
7785 readonly providerService?: ProviderService.ProviderService["Service"];
7886 readonly directory: ProviderSessionDirectory.ProviderSessionDirectory["Service"];
......@@ -97,7 +105,14 @@ const runReconciliation = (input: {
97105 subscribeDomainEvents: Effect.succeed(Stream.empty),
98106 latestSequence: Effect.succeed(0),
99107 }),
100 Effect.provide(NodeServices.layer),
108 Effect.provide(
109 Layer.mergeAll(
110 ServerSettings.layerTest({
111 continueThreadsAfterServerUpdate: input.continueAfterRestart ?? false,
112 }),
113 NodeServices.layer,
114 ),
115 ),
101116 );
102117
103118it.effect("marks active running sessions that have persisted resume state", () => {
......@@ -138,7 +153,7 @@ it.effect("marks active running sessions that have persisted resume state", () =
138153 upsert: (binding) => Effect.sync(() => upserts.push(binding)),
139154 getProvider: () => Effect.die("unused"),
140155 listThreadIds: () => Effect.die("unused"),
141 listBindings: () => Effect.die("unused"),
156 listBindings: () => Effect.succeed([]),
142157 }),
143158 Effect.tap((marked) =>
144159 Effect.sync(() => {
......@@ -147,169 +162,186 @@ it.effect("marks active running sessions that have persisted resume state", () =
147162 assert.deepStrictEqual(upserts[0]?.runtimePayload, {
148163 activeTurnId: "turn-mark-active",
149164 continueAfterServerUpdate: active.session.activeTurnId,
165 continueAfterServerUpdatePrepared: null,
150166 });
151167 }),
152168 ),
153169 );
154170});
155171
156it.effect("continues marked sessions after activation with provider-specific input", () =>
157 Effect.gen(function* () {
158 const codex = makeThread(
159 "thread-continue-codex",
160 "running",
161 TurnId.make("turn-continue-codex"),
162 );
163 const fallback = makeThread("thread-continue-fallback", "starting");
164 const fallbackContinuationTurnId = TurnId.make("turn-continue-fallback");
165 const fallbackProviderInstanceId = ProviderInstanceId.make("claudeAgent");
166 const continuationSent = yield* Deferred.make<void>();
167 const continuationCleared = yield* Deferred.make<void>();
168 const sends: ProviderSendTurnInput[] = [];
169 const dispatched: OrchestrationCommand[] = [];
170 const upserts: ProviderSessionDirectory.ProviderRuntimeBinding[] = [];
171 const bindings = new Map<ThreadId, ProviderSessionDirectory.ProviderRuntimeBinding>(
172 [codex, fallback].map((thread) => [
173 thread.id,
174 {
175 threadId: thread.id,
176 provider:
177 thread.id === codex.id
178 ? ProviderDriverKind.make("codex")
179 : ProviderDriverKind.make("claudeAgent"),
180 providerInstanceId:
181 thread.id === codex.id ? providerInstanceId : fallbackProviderInstanceId,
182 status: "running" as const,
183 runtimePayload: {
184 continueAfterServerUpdate:
185 thread.id === codex.id ? codex.session.activeTurnId : fallbackContinuationTurnId,
172it.effect.each(["marked update", "opt-in restart"] as const)(
173 "continues %s sessions after activation with provider-specific input",
174 (recovery) =>
175 Effect.gen(function* () {
176 const codex = makeThread(
177 "thread-continue-codex",
178 "running",
179 TurnId.make("turn-continue-codex"),
180 );
181 const fallbackContinuationTurnId = TurnId.make("turn-continue-fallback");
182 const fallback = makeThread(
183 "thread-continue-fallback",
184 recovery === "marked update" ? "starting" : "running",
185 recovery === "marked update" ? null : fallbackContinuationTurnId,
186 );
187 const fallbackProviderInstanceId = ProviderInstanceId.make("claudeAgent");
188 const continuationSent = yield* Deferred.make<void>();
189 const continuationCleared = yield* Deferred.make<void>();
190 const sends: ProviderSendTurnInput[] = [];
191 const dispatched: OrchestrationCommand[] = [];
192 const upserts: ProviderSessionDirectory.ProviderRuntimeBinding[] = [];
193 const bindings = new Map<ThreadId, ProviderSessionDirectory.ProviderRuntimeBinding>(
194 [codex, fallback].map((thread) => [
195 thread.id,
196 {
197 threadId: thread.id,
198 provider:
199 thread.id === codex.id
200 ? ProviderDriverKind.make("codex")
201 : ProviderDriverKind.make("claudeAgent"),
202 providerInstanceId:
203 thread.id === codex.id ? providerInstanceId : fallbackProviderInstanceId,
204 status: "running" as const,
205 resumeCursor: { threadId: thread.id },
206 runtimePayload:
207 recovery === "marked update"
208 ? {
209 continueAfterServerUpdate:
210 thread.id === codex.id
211 ? codex.session.activeTurnId
212 : fallbackContinuationTurnId,
213 }
214 : { activeTurnId: thread.session.activeTurnId },
186215 },
187 },
188 ]),
189 );
190 const providerService: ProviderService.ProviderService["Service"] = {
191 ...makeProviderService(),
192 getCapabilities: (instanceId) =>
193 Effect.succeed({
194 sessionModelSwitch: "in-session",
195 ...(instanceId === providerInstanceId ? { promptlessTurnContinuation: true } : {}),
196 }),
197 sendTurn: (input) =>
198 Effect.gen(function* () {
199 sends.push(input);
200 if (sends.length === 2) {
201 yield* Deferred.succeed(continuationSent, undefined);
202 }
203 return {
204 threadId: input.threadId,
205 turnId: TurnId.make(`continued-${String(input.threadId)}`),
206 };
207 }),
208 };
209
210 yield* runReconciliation({
211 threads: [codex, fallback],
212 providerService,
213 directory: {
214 getBinding: (threadId) =>
215 Effect.sync(() => {
216 const binding = bindings.get(threadId);
217 return binding === undefined ? Option.none() : Option.some(binding);
216 ]),
217 );
218 const providerService: ProviderService.ProviderService["Service"] = {
219 ...makeProviderService(),
220 getCapabilities: (instanceId) =>
221 Effect.succeed({
222 sessionModelSwitch: "in-session",
223 ...(instanceId === providerInstanceId ? { promptlessTurnContinuation: true } : {}),
218224 }),
219 upsert: (binding) =>
220 Effect.sync(() => {
221 bindings.set(binding.threadId, binding);
222 upserts.push(binding);
223 const clearedCount = upserts.filter((candidate) => {
224 const payload = candidate.runtimePayload;
225 return (
226 payload !== null &&
227 typeof payload === "object" &&
228 !Array.isArray(payload) &&
229 "continueAfterServerUpdate" in payload &&
230 payload.continueAfterServerUpdate === null
231 );
232 }).length;
233 return clearedCount === 1;
234 }).pipe(
235 Effect.flatMap((firstMarkerCleared) =>
236 firstMarkerCleared ? Deferred.succeed(continuationCleared, undefined) : Effect.void,
225 sendTurn: (input) =>
226 Effect.gen(function* () {
227 sends.push(input);
228 if (sends.length === 2) {
229 yield* Deferred.succeed(continuationSent, undefined);
230 }
231 return {
232 threadId: input.threadId,
233 turnId: TurnId.make(`continued-${String(input.threadId)}`),
234 };
235 }),
236 };
237
238 yield* runReconciliation({
239 threads: [codex, fallback],
240 continueAfterRestart: recovery === "opt-in restart",
241 providerService,
242 directory: {
243 getBinding: (threadId) =>
244 Effect.sync(() => {
245 const binding = bindings.get(threadId);
246 return binding === undefined ? Option.none() : Option.some(binding);
247 }),
248 upsert: (binding) =>
249 Effect.sync(() => {
250 bindings.set(binding.threadId, binding);
251 upserts.push(binding);
252 const clearedCount = upserts.filter((candidate) => {
253 const payload = candidate.runtimePayload;
254 return (
255 payload !== null &&
256 typeof payload === "object" &&
257 !Array.isArray(payload) &&
258 "continueAfterServerUpdate" in payload &&
259 payload.continueAfterServerUpdate === null
260 );
261 }).length;
262 return clearedCount === 1;
263 }).pipe(
264 Effect.flatMap((firstMarkerCleared) =>
265 firstMarkerCleared ? Deferred.succeed(continuationCleared, undefined) : Effect.void,
266 ),
237267 ),
268 getProvider: () => Effect.die("unused"),
269 listThreadIds: () => Effect.die("unused"),
270 listBindings: () => Effect.succeed([]),
271 },
272 dispatch: (command) =>
273 Effect.sync(() => dispatched.push(command)).pipe(
274 Effect.as({ sequence: dispatched.length }),
238275 ),
239 getProvider: () => Effect.die("unused"),
240 listThreadIds: () => Effect.die("unused"),
241 listBindings: () => Effect.die("unused"),
242 },
243 dispatch: (command) =>
244 Effect.sync(() => dispatched.push(command)).pipe(
245 Effect.as({ sequence: dispatched.length }),
246 ),
247 });
248 yield* Deferred.await(continuationSent);
249 yield* Deferred.await(continuationCleared);
276 });
277 yield* Deferred.await(continuationSent);
278 yield* Deferred.await(continuationCleared);
250279
251 assert.deepStrictEqual(
252 sends.toSorted((left, right) => String(left.threadId).localeCompare(String(right.threadId))),
253 [
254 { threadId: codex.id, continuation: true, interactionMode: "default" },
255 {
256 threadId: fallback.id,
257 input: "Continue where you left off.",
258 interactionMode: "default",
259 },
260 ],
261 );
262 assert.deepStrictEqual(
263 dispatched.map((command) =>
264 command.type === "thread.session.set"
265 ? {
266 threadId: command.threadId,
267 status: command.session.status,
268 activeTurnId: command.session.activeTurnId,
269 }
270 : null,
271 ),
272 [
273 {
274 threadId: codex.id,
275 status: "starting",
276 activeTurnId: null,
277 },
278 {
279 threadId: fallback.id,
280 status: "starting",
281 activeTurnId: fallback.session.activeTurnId,
282 },
283 ],
284 );
285 for (const [thread, continuationTurnId] of [
286 [codex, codex.session.activeTurnId],
287 [fallback, fallbackContinuationTurnId],
288 ] as const) {
289280 assert.deepStrictEqual(
290 upserts
291 .filter((binding) => binding.threadId === thread.id)
292 .map((binding) => binding.runtimePayload)[0],
293 {
294 continueAfterServerUpdate: continuationTurnId,
295 activeTurnId: null,
296 },
281 sends.toSorted((left, right) =>
282 String(left.threadId).localeCompare(String(right.threadId)),
283 ),
284 [
285 { threadId: codex.id, continuation: true, interactionMode: "default" },
286 {
287 threadId: fallback.id,
288 input: "Continue where you left off.",
289 interactionMode: "default",
290 },
291 ],
292 );
293 assert.deepStrictEqual(
294 dispatched.map((command) =>
295 command.type === "thread.session.set"
296 ? {
297 threadId: command.threadId,
298 status: command.session.status,
299 activeTurnId: command.session.activeTurnId,
300 }
301 : null,
302 ),
303 [
304 {
305 threadId: codex.id,
306 status: "starting",
307 activeTurnId: null,
308 },
309 {
310 threadId: fallback.id,
311 status: "starting",
312 activeTurnId: null,
313 },
314 ],
297315 );
298 }
299 assert.equal(
300 upserts.some((binding) => {
301 const payload = binding.runtimePayload;
302 return (
303 payload !== null &&
304 typeof payload === "object" &&
305 !Array.isArray(payload) &&
306 "continueAfterServerUpdate" in payload &&
307 payload.continueAfterServerUpdate === null
316 for (const [thread, continuationTurnId] of [
317 [codex, codex.session.activeTurnId],
318 [fallback, fallbackContinuationTurnId],
319 ] as const) {
320 assert.deepStrictEqual(
321 upserts
322 .filter((binding) => binding.threadId === thread.id)
323 .map((binding) => binding.runtimePayload)[0],
324 {
325 continueAfterServerUpdate: continuationTurnId,
326 continueAfterServerUpdatePrepared: true,
327 activeTurnId: null,
328 },
308329 );
309 }),
310 true,
311 );
312 }),
330 }
331 assert.equal(
332 upserts.some((binding) => {
333 const payload = binding.runtimePayload;
334 return (
335 payload !== null &&
336 typeof payload === "object" &&
337 !Array.isArray(payload) &&
338 "continueAfterServerUpdate" in payload &&
339 payload.continueAfterServerUpdate === null
340 );
341 }),
342 true,
343 );
344 }),
313345);
314346
315347it.effect("does not continue archived or deleted marked sessions", () => {
......@@ -361,7 +393,7 @@ it.effect("does not continue archived or deleted marked sessions", () => {
361393 upsert: () => Effect.void,
362394 getProvider: () => Effect.die("unused"),
363395 listThreadIds: () => Effect.die("unused"),
364 listBindings: () => Effect.die("unused"),
396 listBindings: () => Effect.succeed([]),
365397 },
366398 dispatch: (command) =>
367399 Effect.sync(() => dispatched.push(command)).pipe(Effect.as({ sequence: dispatched.length })),
......@@ -416,7 +448,7 @@ it.effect("retries continuation preparation before settling a persistent failure
416448 upsert: () => Effect.void,
417449 getProvider: () => Effect.die("unused"),
418450 listThreadIds: () => Effect.die("unused"),
419 listBindings: () => Effect.die("unused"),
451 listBindings: () => Effect.succeed([]),
420452 },
421453 dispatch: (command) => {
422454 if (command.type !== "thread.session.set") {
......@@ -487,7 +519,7 @@ it.effect("reconciles multiple active and archived orphans but skips live sessio
487519 upsert: (binding) => Effect.sync(() => upserts.push(binding)),
488520 getProvider: () => Effect.die("unused"),
489521 listThreadIds: () => Effect.die("unused"),
490 listBindings: () => Effect.die("unused"),
522 listBindings: () => Effect.succeed([]),
491523 },
492524 dispatch: (command) =>
493525 Effect.sync(() => dispatched.push(command)).pipe(Effect.as({ sequence: dispatched.length })),
......@@ -521,6 +553,7 @@ it.effect("reconciles multiple active and archived orphans but skips live sessio
521553 activeTurnId: null,
522554 unrelated: binding.threadId,
523555 continueAfterServerUpdate: null,
556 continueAfterServerUpdatePrepared: null,
524557 }
525558 : { activeTurnId: null, unrelated: binding.threadId },
526559 );
......@@ -565,7 +598,7 @@ it.effect(
565598 upsert: () => Effect.fail(writeFailure),
566599 getProvider: () => Effect.die("unused"),
567600 listThreadIds: () => Effect.die("unused"),
568 listBindings: () => Effect.die("unused"),
601 listBindings: () => Effect.succeed([]),
569602 },
570603 dispatch: (command) =>
571604 Effect.sync(() => dispatched.push(command)).pipe(
......@@ -602,7 +635,7 @@ it.effect("retries failed projections and continues after a persistent failure",
602635 upsert: () => Effect.void,
603636 getProvider: () => Effect.die("unused"),
604637 listThreadIds: () => Effect.die("unused"),
605 listBindings: () => Effect.die("unused"),
638 listBindings: () => Effect.succeed([]),
606639 },
607640 dispatch: (command) => {
608641 if (command.type !== "thread.session.set") {
......@@ -651,7 +684,7 @@ it.effect("does not fail startup when the live provider session inventory cannot
651684 upsert: () => Effect.die("unused"),
652685 getProvider: () => Effect.die("unused"),
653686 listThreadIds: () => Effect.die("unused"),
654 listBindings: () => Effect.die("unused"),
687 listBindings: () => Effect.succeed([]),
655688 }),
656689 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {
657690 readEvents: () => Stream.empty,
......@@ -662,7 +695,283 @@ it.effect("does not fail startup when the live provider session inventory cannot
662695 subscribeDomainEvents: Effect.succeed(Stream.empty),
663696 latestSequence: Effect.succeed(0),
664697 }),
665 Effect.provide(NodeServices.layer),
698 Effect.provide(Layer.mergeAll(NodeServices.layer, ServerSettings.layerTest())),
666699 Effect.tap(() => Effect.sync(() => assert.equal(queried, false))),
667700 );
668701});
702
703for (const scenario of [
704 "disabled",
705 "stopped projection",
706 "finished projection",
707 "stopped binding",
708 "finished binding",
709 "missing cursor",
710 "mismatched turn",
711 "marked without cursor",
712 "marked stopped projection",
713 "marked superseded turn",
714] as const) {
715 it.effect(`does not recover an interrupted session with ${scenario}`, () => {
716 const turnId = TurnId.make("turn-excluded-recovery");
717 const thread = makeThread(
718 "thread-excluded-recovery",
719 scenario.includes("stopped projection")
720 ? "stopped"
721 : scenario === "finished projection"
722 ? "ready"
723 : scenario === "marked superseded turn"
724 ? "starting"
725 : "running",
726 scenario === "marked superseded turn" ? null : turnId,
727 );
728 const dispatched: OrchestrationCommand[] = [];
729 const upserts: ProviderSessionDirectory.ProviderRuntimeBinding[] = [];
730 return runReconciliation({
731 threads: [thread],
732 continueAfterRestart: scenario !== "disabled",
733 directory: {
734 getBinding: () =>
735 Effect.succeed(
736 Option.some({
737 threadId: thread.id,
738 provider: ProviderDriverKind.make("codex"),
739 providerInstanceId,
740 status: scenario === "stopped binding" ? "stopped" : "running",
741 ...(scenario.includes("cursor") ? {} : { resumeCursor: { threadId: thread.id } }),
742 runtimePayload: {
743 activeTurnId:
744 scenario === "finished binding"
745 ? null
746 : scenario === "mismatched turn" || scenario === "marked superseded turn"
747 ? "another-turn"
748 : turnId,
749 ...(scenario.startsWith("marked") ? { continueAfterServerUpdate: turnId } : {}),
750 },
751 }),
752 ),
753 upsert: (binding) =>
754 Effect.sync(() => {
755 upserts.push(binding);
756 }),
757 getProvider: () => Effect.die("unused"),
758 listThreadIds: () => Effect.die("unused"),
759 listBindings: () => Effect.succeed([]),
760 },
761 dispatch: (command) =>
762 Effect.sync(() => {
763 dispatched.push(command);
764 return { sequence: dispatched.length };
765 }),
766 }).pipe(
767 Effect.tap(() =>
768 Effect.sync(() => {
769 assert.deepStrictEqual(
770 dispatched.map(
771 (command) => command.type === "thread.session.set" && command.session.status,
772 ),
773 ["error"],
774 );
775 assert.deepStrictEqual(
776 upserts.map((binding) => binding.status),
777 ["stopped"],
778 );
779 }),
780 ),
781 );
782 });
783}
784
785for (const preparedStatus of [
786 "starting",
787 "ready",
788 "ready with failed scan",
789 "completed after update marking",
790] as const) {
791 it.effect(`recovers again if startup exits with a prepared ${preparedStatus} session`, () =>
792 Effect.gen(function* () {
793 const turnId = TurnId.make("turn-interrupted-startup");
794 const thread = makeThread("thread-interrupted-startup", "running", turnId);
795 const activation = yield* Deferred.make<void>();
796 const cleared = yield* Deferred.make<void>();
797 const sends: ProviderSendTurnInput[] = [];
798 let binding: ProviderSessionDirectory.ProviderRuntimeBinding = {
799 threadId: thread.id,
800 provider: ProviderDriverKind.make("codex"),
801 providerInstanceId,
802 status: "running",
803 resumeCursor: { threadId: thread.id },
804 runtimePayload: { activeTurnId: turnId },
805 };
806 const input = {
807 threads: [thread],
808 continueAfterRestart: true,
809 providerService: {
810 ...makeProviderService(),
811 getCapabilities: () =>
812 Effect.succeed({
813 sessionModelSwitch: "in-session" as const,
814 promptlessTurnContinuation: true,
815 }),
816 sendTurn: (input: ProviderSendTurnInput) =>
817 Effect.sync(() => {
818 sends.push(input);
819 return { threadId: input.threadId, turnId: TurnId.make("turn-recovered") };
820 }),
821 },
822 directory: {
823 getBinding: () => Effect.sync(() => Option.some(binding)),
824 upsert: (next: ProviderSessionDirectory.ProviderRuntimeBinding) =>
825 Effect.gen(function* () {
826 binding = next;
827 if (binding.status !== "starting" || sends.length === 0) return;
828 yield* Deferred.succeed(cleared, undefined);
829 }),
830 getProvider: () => Effect.die("unused"),
831 listThreadIds: () => Effect.die("unused"),
832 listBindings: () =>
833 preparedStatus === "ready with failed scan"
834 ? Effect.fail(
835 new ProviderSessionDirectoryPersistenceError({
836 operation: "listBindings",
837 detail: "unreadable unrelated binding",
838 }),
839 )
840 : Effect.sync(() => [{ ...binding, lastSeenAt: "2026-01-01T00:00:00.000Z" }]),
841 },
842 dispatch: (command: OrchestrationCommand) =>
843 Effect.sync(() => {
844 if (command.type === "thread.session.set") {
845 thread.session.status = command.session.status;
846 thread.session.activeTurnId = command.session.activeTurnId;
847 }
848 return { sequence: 1 };
849 }),
850 };
851
852 yield* runReconciliation(input).pipe(
853 Effect.provideService(ServerActivation, Deferred.await(activation)),
854 Effect.scoped,
855 );
856 assert.deepStrictEqual(sends, []);
857 assert.equal(thread.session.status, "starting");
858 assert.equal(thread.session.activeTurnId, null);
859 assert.deepStrictEqual(binding.runtimePayload, {
860 activeTurnId: null,
861 continueAfterServerUpdate: turnId,
862 continueAfterServerUpdatePrepared: true,
863 });
864
865 if (preparedStatus === "completed after update marking") {
866 thread.session.status = "ready";
867 binding = {
868 ...binding,
869 status: "stopped",
870 runtimePayload: {
871 activeTurnId: null,
872 continueAfterServerUpdate: turnId,
873 continueAfterServerUpdatePrepared: null,
874 },
875 };
876 yield* runReconciliation(input);
877 assert.deepStrictEqual(sends, []);
878 assert.equal(thread.session.status, "ready");
879 return;
880 }
881 thread.session.status =
882 preparedStatus === "ready with failed scan" ? "ready" : preparedStatus;
883 yield* runReconciliation(input);
884 yield* Deferred.await(cleared);
885 assert.deepStrictEqual(sends, [
886 { threadId: thread.id, continuation: true, interactionMode: "default" },
887 ]);
888 assert.deepStrictEqual(binding.runtimePayload, {
889 activeTurnId: null,
890 continueAfterServerUpdate: null,
891 continueAfterServerUpdatePrepared: null,
892 });
893 }),
894 );
895}
896
897it.effect("settles failed opt-in recovery without retrying the provider turn", () =>
898 Effect.gen(function* () {
899 const turnId = TurnId.make("turn-failed-recovery");
900 const thread = makeThread("thread-failed-recovery", "running", turnId);
901 const settled = yield* Deferred.make<void>();
902 const sends: ProviderSendTurnInput[] = [];
903 const dispatched: OrchestrationCommand[] = [];
904 const preparedPayloads: unknown[] = [];
905 let binding: ProviderSessionDirectory.ProviderRuntimeBinding = {
906 threadId: thread.id,
907 provider: ProviderDriverKind.make("codex"),
908 providerInstanceId,
909 status: "running",
910 resumeCursor: { threadId: thread.id },
911 runtimePayload: { activeTurnId: turnId },
912 };
913 yield* runReconciliation({
914 threads: [thread],
915 continueAfterRestart: true,
916 providerService: {
917 ...makeProviderService(),
918 getCapabilities: () =>
919 Effect.succeed({ sessionModelSwitch: "in-session", promptlessTurnContinuation: true }),
920 sendTurn: (input) =>
921 Effect.gen(function* () {
922 sends.push(input);
923 preparedPayloads.push(binding.runtimePayload);
924 return yield* Effect.fail(
925 new ProviderSessionNotFoundError({ threadId: input.threadId }),
926 );
927 }),
928 },
929 directory: {
930 getBinding: () => Effect.sync(() => Option.some(binding)),
931 upsert: (next) =>
932 Effect.sync(() => {
933 binding = next;
934 }),
935 getProvider: () => Effect.die("unused"),
936 listThreadIds: () => Effect.die("unused"),
937 listBindings: () => Effect.succeed([]),
938 },
939 dispatch: (command) =>
940 Effect.gen(function* () {
941 dispatched.push(command);
942 if (command.type === "thread.session.set" && command.session.status === "error") {
943 yield* Deferred.succeed(settled, undefined);
944 }
945 return { sequence: dispatched.length };
946 }),
947 });
948 yield* Deferred.await(settled);
949 assert.equal(sends.length, 1);
950 assert.deepStrictEqual(preparedPayloads, [
951 {
952 activeTurnId: null,
953 continueAfterServerUpdate: turnId,
954 continueAfterServerUpdatePrepared: true,
955 },
956 ]);
957 assert.deepStrictEqual(
958 dispatched.map(
959 (command) =>
960 command.type === "thread.session.set" && {
961 status: command.session.status,
962 activeTurnId: command.session.activeTurnId,
963 },
964 ),
965 [
966 { status: "starting", activeTurnId: null },
967 { status: "error", activeTurnId: null },
968 ],
969 );
970 assert.equal(binding.status, "stopped");
971 assert.deepStrictEqual(binding.runtimePayload, {
972 activeTurnId: null,
973 continueAfterServerUpdate: null,
974 continueAfterServerUpdatePrepared: null,
975 });
976 }),
977);
apps/server/src/serverRuntimeStartup.ts+77-6
......@@ -394,6 +394,7 @@ export const markRunningProviderSessionsForContinuation = Effect.gen(function* (
394394 runtimePayload: {
395395 ...readRuntimePayload(binding.value.runtimePayload),
396396 [SERVER_UPDATE_CONTINUATION_KEY]: activeTurnId,
397 continueAfterServerUpdatePrepared: null,
397398 },
398399 });
399400 marked.push(thread.id);
......@@ -423,6 +424,7 @@ const clearContinuationMarkers = (
423424 runtimePayload: {
424425 ...readRuntimePayload(binding.runtimePayload),
425426 [SERVER_UPDATE_CONTINUATION_KEY]: null,
427 continueAfterServerUpdatePrepared: null,
426428 },
427429 }),
428430 }),
......@@ -443,17 +445,56 @@ export const reconcileProviderSessions = Effect.gen(function* () {
443445 const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService;
444446 const providerService = yield* ProviderService.ProviderService;
445447 const query = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
448 const settings = yield* ServerSettings.ServerSettingsService;
449 const continueAfterRestart = yield* settings.getSettings.pipe(
450 Effect.map((value) => value.continueThreadsAfterServerUpdate),
451 Effect.catch((cause) =>
452 Effect.logWarning("could not read restart continuation preference", { cause }).pipe(
453 Effect.as(false),
454 ),
455 ),
456 );
446457
447458 const liveThreadIds = new Set(
448459 (yield* providerService.listSessions()).map((session) => session.threadId),
449460 );
450461 const { threads } = yield* query.getCommandReadModel();
462 // Provider startup can report ready before the continuation is submitted.
463 // Find those markers in one read rather than querying every idle thread.
464 const preparedThreadIds = new Set(
465 (yield* directory.listBindings().pipe(
466 Effect.catch((cause) =>
467 Effect.logWarning("failed to read prepared provider continuations", { cause }).pipe(
468 Effect.andThen(
469 Effect.forEach(
470 threads.filter(
471 (thread) => thread.session?.status === "ready" && !liveThreadIds.has(thread.id),
472 ),
473 (thread) =>
474 directory.getBinding(thread.id).pipe(Effect.orElseSucceed(() => Option.none())),
475 ),
476 ),
477 Effect.map((bindings) =>
478 bindings.flatMap((binding) => (Option.isSome(binding) ? [binding.value] : [])),
479 ),
480 ),
481 ),
482 ))
483 .filter(
484 (binding) =>
485 readServerUpdateContinuationTurnId(binding.runtimePayload) !== null &&
486 readRuntimePayload(binding.runtimePayload).activeTurnId === null &&
487 readRuntimePayload(binding.runtimePayload).continueAfterServerUpdatePrepared === true,
488 )
489 .map((binding) => binding.threadId),
490 );
451491 const orphanedThreads = threads.filter(
452492 (thread) =>
453493 thread.session !== null &&
454494 (thread.session.status === "starting" ||
455495 thread.session.status === "running" ||
456 thread.session.activeTurnId !== null) &&
496 thread.session.activeTurnId !== null ||
497 (thread.session.status === "ready" && preparedThreadIds.has(thread.id))) &&
457498 !liveThreadIds.has(thread.id),
458499 );
459500
......@@ -479,7 +520,27 @@ export const reconcileProviderSessions = Effect.gen(function* () {
479520 : null;
480521 const continuationMarked =
481522 continuationTurnId !== null &&
482 (session.activeTurnId === null || continuationTurnId === session.activeTurnId);
523 (session.activeTurnId === null || continuationTurnId === session.activeTurnId) &&
524 Option.isSome(binding) &&
525 (readRuntimePayload(binding.value.runtimePayload).activeTurnId == null ||
526 readRuntimePayload(binding.value.runtimePayload).activeTurnId === continuationTurnId);
527 const preparedWhileReady =
528 session.status === "ready" &&
529 session.activeTurnId === null &&
530 continuationMarked &&
531 Option.isSome(binding) &&
532 readRuntimePayload(binding.value.runtimePayload).activeTurnId === null &&
533 readRuntimePayload(binding.value.runtimePayload).continueAfterServerUpdatePrepared === true;
534 // Abrupt shutdowns cannot write an update marker. Require both durable
535 // records to agree on an unfinished turn before recovering one implicitly.
536 const interruptedByRestart =
537 continueAfterRestart &&
538 session.status === "running" &&
539 session.activeTurnId !== null &&
540 Option.isSome(binding) &&
541 binding.value.status === "running" &&
542 binding.value.resumeCursor != null &&
543 readRuntimePayload(binding.value.runtimePayload).activeTurnId === session.activeTurnId;
483544 const settleAsError = (lastError: string) =>
484545 Effect.gen(function* () {
485546 yield* Effect.gen(function* () {
......@@ -490,7 +551,12 @@ export const reconcileProviderSessions = Effect.gen(function* () {
490551 runtimePayload: {
491552 ...readRuntimePayload(binding.value.runtimePayload),
492553 activeTurnId: null,
493 ...(continuationMarkerPresent ? { [SERVER_UPDATE_CONTINUATION_KEY]: null } : {}),
554 ...(continuationMarkerPresent || interruptedByRestart
555 ? {
556 [SERVER_UPDATE_CONTINUATION_KEY]: null,
557 continueAfterServerUpdatePrepared: null,
558 }
559 : {}),
494560 },
495561 });
496562 }
......@@ -535,7 +601,9 @@ export const reconcileProviderSessions = Effect.gen(function* () {
535601
536602 if (
537603 Option.isSome(binding) &&
538 continuationMarked &&
604 (continuationMarked || interruptedByRestart) &&
605 (session.status === "running" || session.status === "starting" || preparedWhileReady) &&
606 binding.value.resumeCursor != null &&
539607 thread.archivedAt === null &&
540608 thread.deletedAt === null
541609 ) {
......@@ -545,6 +613,9 @@ export const reconcileProviderSessions = Effect.gen(function* () {
545613 status: "starting",
546614 runtimePayload: {
547615 ...readRuntimePayload(binding.value.runtimePayload),
616 // Keep recovery durable if this process also exits before sending.
617 [SERVER_UPDATE_CONTINUATION_KEY]: session.activeTurnId ?? continuationTurnId,
618 continueAfterServerUpdatePrepared: true,
548619 activeTurnId: null,
549620 },
550621 });
......@@ -608,12 +679,12 @@ export const reconcileProviderSessions = Effect.gen(function* () {
608679 }
609680 return;
610681 }
611 yield* Effect.logWarning("failed to continue provider session after server update", {
682 yield* Effect.logWarning("failed to continue provider session after server restart", {
612683 threadId: thread.id,
613684 cause: continuationExit.cause,
614685 });
615686 yield* settleAsError(
616 "Could not continue this thread after the server update. Send a new message to continue.",
687 "Could not continue this thread after the server restart. Send a new message to continue.",
617688 ).pipe(Effect.ignoreCause);
618689 }),
619690 );
apps/web/src/components/ServerUpdateAction.test.tsx+2-1
......@@ -15,7 +15,8 @@ vi.mock("~/hooks/useCopyToClipboard", () => ({
1515 useCopyToClipboard: () => ({ copyToClipboard: vi.fn() }),
1616}));
1717vi.mock("~/hooks/useSettings", () => ({
18 useClientSettings: (
18 useEnvironmentSettings: (
19 _environmentId: EnvironmentId,
1920 selector: (settings: { continueThreadsAfterServerUpdate: boolean }) => unknown,
2021 ) => selector({ continueThreadsAfterServerUpdate: testState.continueThreadsAfterServerUpdate }),
2122}));
apps/web/src/components/ServerUpdateAction.tsx+3-2
......@@ -8,7 +8,7 @@ import type { ComponentProps } from "react";
88
99import { requestConfirmDialog } from "~/confirmDialog";
1010import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
11import { useClientSettings } from "~/hooks/useSettings";
11import { useEnvironmentSettings } from "~/hooks/useSettings";
1212import { serverEnvironment } from "~/state/server";
1313import { useAtomCommand } from "~/state/use-atom-command";
1414import { manualServerUpdateCommand } from "~/versionSkew";
......@@ -99,7 +99,8 @@ export function ServerUpdateAction({
9999 readonly size?: ComponentProps<typeof Button>["size"];
100100}) {
101101 const isDesktopAppUpdate = selfUpdate === "desktop-managed";
102 const continueThreadsAfterServerUpdate = useClientSettings(
102 const continueThreadsAfterServerUpdate = useEnvironmentSettings(
103 environmentId,
103104 (settings) => settings.continueThreadsAfterServerUpdate,
104105 );
105106 const updateServer = useAtomCommand(serverEnvironment.updateServer, {
apps/web/src/components/settings/SettingsPanels.tsx+5-4
......@@ -564,7 +564,7 @@ export function useSettingsRestore(onRestored?: () => void) {
564564 : []),
565565 ...(settings.continueThreadsAfterServerUpdate !==
566566 DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate
567 ? ["Continue threads after server updates"]
567 ? ["Continue threads after restarts"]
568568 : []),
569569 ...(isBackgroundActivityDirty ? ["Background activity"] : []),
570570 ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode
......@@ -2438,12 +2438,13 @@ export function GeneralSettingsPanel() {
24382438
24392439 <SettingsRow
24402440 {...searchableSetting("continue-threads-after-server-update")}
2441 description="Automatically resume active threads after a server update restarts the environment."
2441 serverScoped
2442 description="Automatically resume interrupted threads when this environment starts again after an update, crash, or machine restart."
24422443 resetAction={
24432444 settings.continueThreadsAfterServerUpdate !==
24442445 DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate ? (
24452446 <SettingResetButton
2446 label="continue threads after server updates"
2447 label="continue threads after restarts"
24472448 onClick={() =>
24482449 updateSettings({
24492450 continueThreadsAfterServerUpdate:
......@@ -2459,7 +2460,7 @@ export function GeneralSettingsPanel() {
24592460 onCheckedChange={(checked) =>
24602461 updateSettings({ continueThreadsAfterServerUpdate: Boolean(checked) })
24612462 }
2462 aria-label="Continue threads after server updates"
2463 aria-label="Continue threads after restarts"
24632464 />
24642465 }
24652466 />
apps/web/src/components/settings/settingsSearch.ts+4-2
......@@ -218,9 +218,11 @@ export const SETTINGS_SEARCH_ITEMS = [
218218 },
219219 {
220220 id: "continue-threads-after-server-update",
221 title: "Continue threads after server updates",
221 title: "Continue threads after restarts",
222222 to: "/settings/general",
223 searchTerms: ["resume running active work restart desktop update automatically"],
223 searchTerms: [
224 "resume running active interrupted work restart reboot machine crash desktop update automatically",
225 ],
224226 },
225227 {
226228 id: "background-activity",
docs/user/updating.md+7-3
......@@ -10,9 +10,13 @@ notice.
1010Server updates restart the connection and can interrupt active agents and
1111terminal commands. Saved threads, settings, and project files remain.
1212
13**Settings → General → Continue threads after server updates** is off by default.
14Enable it to resume supported active threads once the replacement server is
15ready. Terminal commands may still be interrupted.
13**Settings → General → Continue threads after restarts** is off by default.
14Enable it for each environment to resume supported active threads after an
15update, crash, or machine restart. T3 Code must start again on that machine;
16the setting does not enable automatic startup. Terminal commands may still be
17interrupted, and threads without saved provider resume state need a new message.
18If you previously enabled continuation for updates, enable this environment
19setting once to allow recovery without a connected client.
1620
1721## Update a connected server
1822
packages/contracts/src/settings.ts+5-4
......@@ -255,9 +255,6 @@ export const ClientSettingsSchema = Schema.Struct({
255255 confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
256256 confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
257257 confirmThreadUnpin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
258 continueThreadsAfterServerUpdate: Schema.Boolean.pipe(
259 Schema.withDecodingDefault(Effect.succeed(false)),
260 ),
261258 dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe(
262259 Schema.withDecodingDefault(Effect.succeed([])),
263260 ),
......@@ -835,6 +832,10 @@ export const ServerSettings = Schema.Struct({
835832 Schema.withDecodingDefault(Effect.succeed(false)),
836833 ),
837834 enableProviderUpdateChecks: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
835 // Retain the update-era key; recovery now needs an environment-owned opt-in.
836 continueThreadsAfterServerUpdate: Schema.Boolean.pipe(
837 Schema.withDecodingDefault(Effect.succeed(false)),
838 ),
838839 /**
839840 * Whether agents may drive the in-app preview browser. Turning this off
840841 * withholds the MCP credential, so the `t3-code` server (and with it every
......@@ -1105,6 +1106,7 @@ export const ServerSettingsPatch = Schema.Struct({
11051106 // Server settings
11061107 enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean),
11071108 enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean),
1109 continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean),
11081110 enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean),
11091111 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
11101112 sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),
......@@ -1181,7 +1183,6 @@ export const ClientSettingsPatch = Schema.Struct({
11811183 confirmThreadArchive: Schema.optionalKey(Schema.Boolean),
11821184 confirmThreadDelete: Schema.optionalKey(Schema.Boolean),
11831185 confirmThreadUnpin: Schema.optionalKey(Schema.Boolean),
1184 continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean),
11851186 diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),
11861187 diffLayout: Schema.optionalKey(DiffLayout),
11871188 environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode),