authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-02 04:28:16-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 04:28:16-04:00
log5b7d72aad14ed37e8e5e4c02a6d49814bfe528ac
tree9b11a577e538ebb99793512667fd67e208b1363a
parent14f15cfed48bc7519e8e5eeac03f483f4b11ef3a
signature Signed by PGP key B5690EEEBB952194

feat(updates): continue active threads across server restarts (#9167)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

26 files changed, 1292 insertions(+), 71 deletions(-)

apps/desktop/src/settings/DesktopClientSettings.test.ts+1
......@@ -23,6 +23,7 @@ const clientSettings: ClientSettings = {
2323 confirmThreadArchive: true,
2424 confirmThreadDelete: false,
2525 confirmThreadUnpin: false,
26 continueThreadsAfterServerUpdate: true,
2627 contextWindowMeterEnabled: false,
2728 dismissedProviderUpdateNotificationKeys: [],
2829 diffIgnoreWhitespace: true,
apps/server/src/cloud/selfUpdate.test.ts+217
......@@ -1,6 +1,8 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
22import { expect, it } from "@effect/vitest";
3import { ServerSelfUpdateError, ThreadId } from "@t3tools/contracts";
34import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess";
5import * as Cause from "effect/Cause";
46import * as Deferred from "effect/Deferred";
57import * as Effect from "effect/Effect";
68import * as FileSystem from "effect/FileSystem";
......@@ -104,6 +106,221 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* (
104106});
105107
106108it.layer(NodeServices.layer)("server self update", (it) => {
109 it.effect("marks running threads at the boot-service handoff", () =>
110 Effect.gen(function* () {
111 const events: string[] = [];
112 const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({
113 mode: "web",
114 selfUpdate: {
115 update: (_input, reportProgress = () => Effect.void) =>
116 reportProgress("downloading").pipe(
117 Effect.andThen(reportProgress("installing")),
118 Effect.as({
119 targetVersion: "1.1.0",
120 method: "boot-service" as const,
121 updateId: "update-id",
122 }),
123 ),
124 commitDesktopUpdate: () => Effect.never,
125 },
126 prepare: Effect.sync(() => {
127 events.push("prepare");
128 return [ThreadId.make("thread-running")];
129 }),
130 clear: () => Effect.sync(() => void events.push("clear")),
131 });
132
133 yield* selfUpdate.update({ targetVersion: "1.1.0", continueRunningThreads: true }, (stage) =>
134 Effect.sync(() => void events.push(stage)),
135 );
136
137 expect(events).toEqual(["downloading", "prepare", "installing"]);
138 }),
139 );
140
141 it.effect("marks desktop threads only when the prepared update commits", () =>
142 Effect.gen(function* () {
143 const threadId = ThreadId.make("thread-running-desktop");
144 const events: string[] = [];
145 const commitError = new ServerSelfUpdateError({ reason: "install failed" });
146 const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({
147 mode: "desktop",
148 selfUpdate: {
149 update: (_input, reportProgress = () => Effect.void) =>
150 reportProgress("installing").pipe(
151 Effect.as({
152 targetVersion: "1.2.0",
153 method: "desktop-app" as const,
154 desktopUpdateToken: "desktop-token",
155 }),
156 ),
157 commitDesktopUpdate: () =>
158 Effect.sync(() => events.push("commit")).pipe(Effect.andThen(Effect.fail(commitError))),
159 },
160 prepare: Effect.sync(() => {
161 events.push("prepare");
162 return [threadId];
163 }),
164 clear: (threadIds) => Effect.sync(() => void events.push(`clear:${threadIds.join(",")}`)),
165 });
166
167 yield* selfUpdate.update({ targetVersion: "1.2.0", continueRunningThreads: true }, (stage) =>
168 Effect.sync(() => void events.push(stage)),
169 );
170 expect(events).toEqual(["installing"]);
171 expect(yield* selfUpdate.commitDesktopUpdate("desktop-token").pipe(Effect.flip)).toBe(
172 commitError,
173 );
174 expect(events).toEqual(["installing", "prepare", "commit", `clear:${threadId}`]);
175 expect(yield* selfUpdate.commitDesktopUpdate("desktop-token").pipe(Effect.flip)).toBe(
176 commitError,
177 );
178 expect(events).toEqual([
179 "installing",
180 "prepare",
181 "commit",
182 `clear:${threadId}`,
183 "prepare",
184 "commit",
185 `clear:${threadId}`,
186 ]);
187 }),
188 );
189
190 it.effect("reports a failed continuation-marker cleanup", () =>
191 Effect.gen(function* () {
192 const updateError = new ServerSelfUpdateError({ reason: "update failed" });
193 const clearError = new ServerSelfUpdateError({ reason: "marker cleanup failed" });
194 const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({
195 mode: "web",
196 selfUpdate: {
197 update: (_input, reportProgress = () => Effect.void) =>
198 reportProgress("installing").pipe(Effect.andThen(Effect.fail(updateError))),
199 commitDesktopUpdate: () => Effect.never,
200 },
201 prepare: Effect.succeed([ThreadId.make("thread-cleanup-failure")]),
202 clear: () => Effect.fail(clearError),
203 });
204
205 expect(
206 yield* selfUpdate
207 .update({ targetVersion: "1.1.0", continueRunningThreads: true })
208 .pipe(Effect.flip),
209 ).toBe(clearError);
210 }),
211 );
212
213 it.effect("keeps continuation markers after the boot-service handoff is accepted", () =>
214 Effect.gen(function* () {
215 const events: string[] = [];
216 const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({
217 mode: "web",
218 selfUpdate: {
219 update: (
220 _input,
221 reportProgress = () => Effect.void,
222 onHandoffAccepted = () => Effect.void,
223 ) =>
224 reportProgress("installing").pipe(
225 Effect.andThen(onHandoffAccepted()),
226 Effect.andThen(Effect.interrupt),
227 ),
228 commitDesktopUpdate: () => Effect.never,
229 },
230 prepare: Effect.sync(() => {
231 events.push("prepare");
232 return [ThreadId.make("thread-accepted-boot-handoff")];
233 }),
234 clear: () => Effect.sync(() => void events.push("clear")),
235 });
236
237 const exit = yield* selfUpdate
238 .update({ targetVersion: "1.1.0", continueRunningThreads: true })
239 .pipe(Effect.exit);
240
241 expect(exit._tag).toBe("Failure");
242 expect(events).toEqual(["prepare"]);
243 }),
244 );
245
246 it.effect("keeps continuation markers after the desktop handoff is accepted", () =>
247 Effect.gen(function* () {
248 const events: string[] = [];
249 const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({
250 mode: "desktop",
251 selfUpdate: {
252 update: () =>
253 Effect.succeed({
254 targetVersion: "1.2.0",
255 method: "desktop-app" as const,
256 desktopUpdateToken: "accepted-desktop-token",
257 }),
258 commitDesktopUpdate: (_requestId, onHandoffAccepted = () => Effect.void) =>
259 onHandoffAccepted().pipe(Effect.andThen(Effect.interrupt)),
260 },
261 prepare: Effect.sync(() => {
262 events.push("prepare");
263 return [ThreadId.make("thread-accepted-desktop-handoff")];
264 }),
265 clear: () => Effect.sync(() => void events.push("clear")),
266 });
267
268 yield* selfUpdate.update({
269 targetVersion: "1.2.0",
270 continueRunningThreads: true,
271 });
272 const exit = yield* selfUpdate
273 .commitDesktopUpdate("accepted-desktop-token")
274 .pipe(Effect.exit);
275
276 expect(exit._tag).toBe("Failure");
277 expect(events).toEqual(["prepare"]);
278 }),
279 );
280
281 it.effect("clears continuation markers for mixed failure and interrupt causes", () =>
282 Effect.gen(function* () {
283 const events: string[] = [];
284 const commitError = new ServerSelfUpdateError({ reason: "install failed" });
285 const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({
286 mode: "desktop",
287 selfUpdate: {
288 update: () =>
289 Effect.succeed({
290 targetVersion: "1.2.0",
291 method: "desktop-app" as const,
292 desktopUpdateToken: "failed-desktop-token",
293 }),
294 commitDesktopUpdate: (_requestId, onHandoffAccepted = () => Effect.void) =>
295 onHandoffAccepted().pipe(
296 Effect.andThen(
297 Effect.failCause(
298 Cause.fromReasons([
299 Cause.makeFailReason(commitError),
300 Cause.makeInterruptReason(),
301 ]),
302 ),
303 ),
304 ),
305 },
306 prepare: Effect.sync(() => [ThreadId.make("thread-failed-desktop-install")]),
307 clear: () => Effect.sync(() => void events.push("clear")),
308 });
309
310 yield* selfUpdate.update({
311 targetVersion: "1.2.0",
312 continueRunningThreads: true,
313 });
314 const exit = yield* selfUpdate.commitDesktopUpdate("failed-desktop-token").pipe(Effect.exit);
315 expect(exit._tag).toBe("Failure");
316 if (exit._tag === "Failure") {
317 expect(Cause.hasInterrupts(exit.cause)).toBe(true);
318 expect(Cause.hasInterruptsOnly(exit.cause)).toBe(false);
319 }
320 expect(events).toEqual(["clear"]);
321 }),
322 );
323
107324 it.effect("stages and preflights before asking the launcher for an update ID", () =>
108325 Effect.gen(function* () {
109326 const { selfUpdate, order } = yield* makeHarness();
apps/server/src/cloud/selfUpdate.ts+124-8
......@@ -4,15 +4,18 @@ import {
44 type ServerSelfUpdateInput,
55 type ServerSelfUpdateProgressStage,
66 type ServerSelfUpdateResult,
7 type ThreadId,
78} from "@t3tools/contracts";
89import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess";
10import * as Cause from "effect/Cause";
911import * as Context from "effect/Context";
1012import * as Duration from "effect/Duration";
1113import * as Effect from "effect/Effect";
14import * as HashSet from "effect/HashSet";
15import * as Ref from "effect/Ref";
1216import * as FileSystem from "effect/FileSystem";
1317import * as Layer from "effect/Layer";
1418import * as Path from "effect/Path";
15import * as Ref from "effect/Ref";
1619
1720import * as ServerConfig from "../config.ts";
1821import * as DesktopAppUpdate from "../desktopUpdate/DesktopAppUpdate.ts";
......@@ -41,14 +44,125 @@ export class ServerSelfUpdate extends Context.Service<
4144 {
4245 readonly update: (
4346 input: ServerSelfUpdateInput,
44 reportProgress?: (stage: ServerSelfUpdateProgressStage) => Effect.Effect<void>,
47 reportProgress?: (
48 stage: ServerSelfUpdateProgressStage,
49 ) => Effect.Effect<void, ServerSelfUpdateError>,
50 onHandoffAccepted?: () => Effect.Effect<void>,
4551 ) => Effect.Effect<ServerSelfUpdateResult, ServerSelfUpdateError>;
4652 readonly commitDesktopUpdate: (
4753 requestId: string,
54 onHandoffAccepted?: () => Effect.Effect<void>,
4855 ) => Effect.Effect<never, ServerSelfUpdateError>;
4956 }
5057>()("t3/cloud/selfUpdate/ServerSelfUpdate") {}
5158
59export const withRunningThreadContinuation = Effect.fn(
60 "cloud.server_self_update.withRunningThreadContinuation",
61)(function* (input: {
62 readonly mode: ServerConfig.RuntimeMode;
63 readonly selfUpdate: ServerSelfUpdate["Service"];
64 readonly prepare: Effect.Effect<ReadonlyArray<ThreadId>, ServerSelfUpdateError>;
65 readonly clear: (
66 threadIds: ReadonlyArray<ThreadId>,
67 ) => Effect.Effect<void, ServerSelfUpdateError>;
68}) {
69 const desktopContinuationTokens = yield* Ref.make(HashSet.empty<string>());
70 const clearOnError = <A>(
71 effect: Effect.Effect<A, ServerSelfUpdateError>,
72 threadIds: () => ReadonlyArray<ThreadId>,
73 handoffAccepted: () => boolean,
74 ): Effect.Effect<A, ServerSelfUpdateError> =>
75 effect.pipe(
76 Effect.catchCause((cause) =>
77 (handoffAccepted() && Cause.hasInterruptsOnly(cause)
78 ? Effect.void
79 : input.clear(threadIds())
80 ).pipe(Effect.andThen(Effect.failCause(cause))),
81 ),
82 );
83
84 const update: ServerSelfUpdate["Service"]["update"] = (
85 request,
86 reportProgress = () => Effect.void,
87 ) => {
88 let prepared = false;
89 let handoffAccepted = false;
90 let continuationThreadIds: ReadonlyArray<ThreadId> = [];
91 return clearOnError(
92 input.selfUpdate
93 .update(
94 request,
95 (stage) =>
96 (request.continueRunningThreads === true &&
97 input.mode !== "desktop" &&
98 stage === "installing" &&
99 !prepared
100 ? input.prepare.pipe(
101 Effect.tap((threadIds) =>
102 Effect.sync(() => {
103 prepared = true;
104 continuationThreadIds = threadIds;
105 }),
106 ),
107 Effect.asVoid,
108 )
109 : Effect.void
110 ).pipe(Effect.andThen(reportProgress(stage))),
111 () =>
112 Effect.sync(() => {
113 handoffAccepted = true;
114 }),
115 )
116 .pipe(
117 Effect.tap((result) => {
118 if (
119 result.method === "desktop-app" &&
120 result.desktopUpdateToken !== undefined &&
121 request.continueRunningThreads === true
122 ) {
123 return Ref.update(desktopContinuationTokens, HashSet.add(result.desktopUpdateToken));
124 }
125 return Effect.void;
126 }),
127 ),
128 () => continuationThreadIds,
129 () => handoffAccepted,
130 );
131 };
132
133 return ServerSelfUpdate.of({
134 update,
135 commitDesktopUpdate: (requestId) =>
136 Effect.gen(function* () {
137 const shouldContinue = yield* Ref.modify(desktopContinuationTokens, (tokens) => [
138 HashSet.has(tokens, requestId),
139 HashSet.remove(tokens, requestId),
140 ]);
141 let handoffAccepted = false;
142 let continuationThreadIds: ReadonlyArray<ThreadId> = [];
143 return yield* clearOnError(
144 Effect.gen(function* () {
145 continuationThreadIds = shouldContinue ? yield* input.prepare : [];
146 return yield* input.selfUpdate.commitDesktopUpdate(requestId, () =>
147 Effect.sync(() => {
148 handoffAccepted = true;
149 }),
150 );
151 }),
152 () => continuationThreadIds,
153 () => handoffAccepted,
154 ).pipe(
155 Effect.catchCause((cause) =>
156 (shouldContinue && !handoffAccepted
157 ? Ref.update(desktopContinuationTokens, HashSet.add(requestId))
158 : Effect.void
159 ).pipe(Effect.andThen(Effect.failCause(cause))),
160 ),
161 );
162 }),
163 });
164});
165
52166export const make = Effect.fn("cloud.server_self_update.make")(function* () {
53167 const serverConfig = yield* ServerConfig.ServerConfig;
54168 const desktopAppUpdate = yield* DesktopAppUpdate.DesktopAppUpdate;
......@@ -68,7 +182,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () {
68182
69183 const update: ServerSelfUpdate["Service"]["update"] = Effect.fn(
70184 "cloud.server_self_update.update",
71 )(function* (input, reportProgress = () => Effect.void) {
185 )(function* (input, reportProgress = () => Effect.void, onHandoffAccepted = () => Effect.void) {
72186 if (capability === "desktop-managed") {
73187 // input.targetVersion is meaningless here: the desktop app's own
74188 // update feed decides what it downloads, and the result carries what
......@@ -180,9 +294,8 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () {
180294 );
181295
182296 yield* reportProgress("installing");
183 const updateId = yield* launcher
184 .requestUpdate({ targetVersion, dbPath: serverConfig.dbPath })
185 .pipe(
297 const updateId = yield* Effect.uninterruptible(
298 launcher.requestUpdate({ targetVersion, dbPath: serverConfig.dbPath }).pipe(
186299 Effect.mapError((error) =>
187300 failWith(
188301 error._tag === "ServiceLauncherRejectedError"
......@@ -191,7 +304,9 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () {
191304 error,
192305 ),
193306 ),
194 );
307 Effect.tap(() => onHandoffAccepted()),
308 ),
309 );
195310
196311 yield* Effect.logInfo("Server update prepared; handing off to the service launcher.", {
197312 updateId,
......@@ -204,7 +319,8 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () {
204319
205320 return ServerSelfUpdate.of({
206321 update,
207 commitDesktopUpdate: (requestId) => desktopAppUpdate.commit(requestId),
322 commitDesktopUpdate: (requestId, onHandoffAccepted) =>
323 desktopAppUpdate.commit(requestId, onHandoffAccepted),
208324 });
209325});
210326
apps/server/src/desktopUpdate/DesktopAppUpdate.ts+22-10
......@@ -49,11 +49,16 @@ export class DesktopAppUpdate extends Context.Service<
4949 /** Checks and downloads through the desktop app, then returns a token
5050 while this server is still connected. `commit` starts installation. */
5151 readonly run: (
52 reportProgress: (stage: ServerSelfUpdateProgressStage) => Effect.Effect<void>,
52 reportProgress: (
53 stage: ServerSelfUpdateProgressStage,
54 ) => Effect.Effect<void, ServerSelfUpdateError>,
5355 ) => Effect.Effect<ServerSelfUpdateResult, ServerSelfUpdateError>;
5456 /** Starts the prepared install. Success stops this server, so this effect
5557 returns only when installation fails or times out. */
56 readonly commit: (requestId: string) => Effect.Effect<never, ServerSelfUpdateError>;
58 readonly commit: (
59 requestId: string,
60 onHandoffAccepted?: () => Effect.Effect<void>,
61 ) => Effect.Effect<never, ServerSelfUpdateError>;
5762 }
5863>()("t3/desktopUpdate/DesktopAppUpdate") {}
5964
......@@ -72,11 +77,15 @@ export const make = Effect.fn("desktopUpdate.desktopAppUpdate.make")(function* (
7277 const consumeReports = (
7378 requestId: string,
7479 changes: Stream.Stream<DesktopUpdateStatusReport>,
75 reportProgress: (stage: ServerSelfUpdateProgressStage) => Effect.Effect<void>,
80 reportProgress: (
81 stage: ServerSelfUpdateProgressStage,
82 ) => Effect.Effect<void, ServerSelfUpdateError>,
7683 ) =>
7784 Effect.gen(function* () {
7885 const lastStage = yield* Ref.make<ServerSelfUpdateProgressStage | null>(null);
79 const emitStage = (stage: ServerSelfUpdateProgressStage | null): Effect.Effect<void> =>
86 const emitStage = (
87 stage: ServerSelfUpdateProgressStage | null,
88 ): Effect.Effect<void, ServerSelfUpdateError> =>
8089 stage === null
8190 ? Effect.void
8291 : Ref.get(lastStage).pipe(
......@@ -90,7 +99,9 @@ export const make = Effect.fn("desktopUpdate.desktopAppUpdate.make")(function* (
9099 const terminal = yield* changes.pipe(
91100 Stream.filter((report) => report.requestId === requestId),
92101 Stream.mapEffect(
93 (report): Effect.Effect<Option.Option<DesktopUpdateStatusReport>> =>
102 (
103 report,
104 ): Effect.Effect<Option.Option<DesktopUpdateStatusReport>, ServerSelfUpdateError> =>
94105 report.outcome === undefined
95106 ? emitStage(desktopUpdateProgressStage(report.state)).pipe(
96107 Effect.as(Option.none<DesktopUpdateStatusReport>()),
......@@ -176,7 +187,7 @@ export const make = Effect.fn("desktopUpdate.desktopAppUpdate.make")(function* (
176187
177188 const commit: DesktopAppUpdate["Service"]["commit"] = Effect.fn(
178189 "desktopUpdate.desktopAppUpdate.commit",
179 )(function* (requestId) {
190 )(function* (requestId, onHandoffAccepted = () => Effect.void) {
180191 if (!available) {
181192 return yield* failWith("This server cannot commit a desktop app update.");
182193 }
......@@ -187,11 +198,12 @@ export const make = Effect.fn("desktopUpdate.desktopAppUpdate.make")(function* (
187198 onNone: () => changes,
188199 onSome: (report) => Stream.concat(Stream.make(report), changes),
189200 });
190 yield* receiver
191 .commitDesktopUpdate(requestId)
192 .pipe(
201 yield* Effect.uninterruptible(
202 receiver.commitDesktopUpdate(requestId).pipe(
193203 Effect.mapError((error) => failWith("Could not reach the T3 Code desktop app.", error)),
194 );
204 Effect.tap(() => onHandoffAccepted()),
205 ),
206 );
195207 return yield* reports.pipe(
196208 Stream.filter((report) => report.requestId === requestId && report.outcome === "failed"),
197209 Stream.runHead,
apps/server/src/environment/ServerEnvironment.test.ts+2
......@@ -244,11 +244,13 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
244244 expect(withFd.capabilities.serverSelfUpdate).toBe("desktop-managed");
245245 expect(withFd.capabilities.desktopAppUpdate).toBe(true);
246246 expect(withFd.capabilities.serverSelfUpdateProgress).toBe(true);
247 expect(withFd.capabilities.serverUpdateThreadContinuation).toBe(true);
247248
248249 const withoutFd = yield* describeWith({ mode: "desktop" });
249250 expect(withoutFd.capabilities.serverSelfUpdate).toBe("desktop-managed");
250251 expect(withoutFd.capabilities.desktopAppUpdate).toBeUndefined();
251252 expect(withoutFd.capabilities.serverSelfUpdateProgress).toBeUndefined();
253 expect(withoutFd.capabilities.serverUpdateThreadContinuation).toBeUndefined();
252254
253255 const web = yield* describeWith({ mode: "web", desktopTelemetryControlFd: 5 });
254256 expect(web.capabilities.desktopAppUpdate).toBeUndefined();
apps/server/src/environment/ServerEnvironment.ts+4-1
......@@ -224,7 +224,10 @@ export const make = Effect.gen(function* () {
224224 threadPullRequestLinking: true,
225225 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
226226 ...(serverSelfUpdate === "boot-service" || desktopAppUpdate
227 ? { serverSelfUpdateProgress: true }
227 ? {
228 serverSelfUpdateProgress: true,
229 serverUpdateThreadContinuation: true,
230 }
228231 : {}),
229232 ...(desktopAppUpdate ? { desktopAppUpdate: true } : {}),
230233 },
apps/server/src/provider/Layers/CodexAdapter.ts+1
......@@ -2010,6 +2010,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
20102010 provider: PROVIDER,
20112011 capabilities: {
20122012 sessionModelSwitch: "in-session",
2013 promptlessTurnContinuation: true,
20132014 },
20142015 startSession,
20152016 sendTurn,
apps/server/src/provider/Layers/ProviderService.test.ts+51
......@@ -246,6 +246,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) {
246246 provider,
247247 capabilities: {
248248 sessionModelSwitch: "in-session",
249 ...(provider === CODEX_DRIVER ? { promptlessTurnContinuation: true } : {}),
249250 },
250251 startSession,
251252 sendTurn,
......@@ -960,6 +961,56 @@ it.effect(
960961);
961962
962963routing.layer("ProviderServiceLive routing", (it) => {
964 it.effect("allows promptless continuation only for capable providers", () =>
965 Effect.gen(function* () {
966 const provider = yield* ProviderService.ProviderService;
967 const codexThreadId = asThreadId("thread-promptless-continuation");
968 yield* provider.startSession(codexThreadId, {
969 provider: CODEX_DRIVER,
970 providerInstanceId: codexInstanceId,
971 threadId: codexThreadId,
972 runtimeMode: "full-access",
973 });
974
975 yield* provider.sendTurn({ threadId: codexThreadId, continuation: true });
976 assert.deepEqual(routing.codex.sendTurn.mock.calls.at(-1)?.[0], {
977 threadId: codexThreadId,
978 continuation: true,
979 });
980
981 const claudeThreadId = asThreadId("thread-promptless-continuation-unsupported");
982 yield* provider.startSession(claudeThreadId, {
983 provider: CLAUDE_AGENT_DRIVER,
984 providerInstanceId: claudeAgentInstanceId,
985 threadId: claudeThreadId,
986 runtimeMode: "full-access",
987 });
988 const failure = yield* Effect.flip(
989 provider.sendTurn({ threadId: claudeThreadId, continuation: true }),
990 );
991 assert.instanceOf(failure, ProviderValidationError);
992 assert.include(failure.issue, "requires an explicit continuation prompt");
993 assert.equal(routing.claude.sendTurn.mock.calls.length, 0);
994
995 yield* provider.stopSession({ threadId: claudeThreadId });
996 routing.claude.startSession.mockClear();
997 const stoppedFailure = yield* Effect.flip(
998 provider.sendTurn({ threadId: claudeThreadId, continuation: true }),
999 );
1000 assert.instanceOf(stoppedFailure, ProviderValidationError);
1001 assert.include(stoppedFailure.issue, "requires an explicit continuation prompt");
1002 assert.equal(routing.claude.startSession.mock.calls.length, 0);
1003
1004 yield* provider.stopSession({ threadId: codexThreadId });
1005 routing.codex.startSession.mockClear();
1006 routing.codex.sendTurn.mockClear();
1007 routing.codex.stopSession.mockClear();
1008 routing.claude.startSession.mockClear();
1009 routing.claude.sendTurn.mockClear();
1010 routing.claude.stopSession.mockClear();
1011 }),
1012 );
1013
9631014 it.effect("routes provider operations and rollback conversation", () =>
9641015 Effect.gen(function* () {
9651016 const provider = yield* ProviderService.ProviderService;
apps/server/src/provider/Layers/ProviderService.ts+21-3
......@@ -723,7 +723,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
723723 });
724724
725725 const attachments = parsed.attachments ?? [];
726 if (!parsed.input && attachments.length === 0) {
726 if (!parsed.input && attachments.length === 0 && parsed.continuation !== true) {
727727 return yield* toValidationError(
728728 "ProviderService.sendTurn",
729729 "Either input text or at least one attachment is required",
......@@ -777,11 +777,29 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
777777 let metricProvider = "unknown";
778778 let metricModel = input.modelSelection?.model;
779779 return yield* Effect.gen(function* () {
780 const routed = yield* resolveRoutableSession({
780 let routed = yield* resolveRoutableSession({
781781 threadId: input.threadId,
782782 operation: "ProviderService.sendTurn",
783 allowRecovery: true,
783 allowRecovery: false,
784784 });
785 if (
786 input.continuation === true &&
787 !input.input &&
788 attachments.length === 0 &&
789 routed.adapter.capabilities.promptlessTurnContinuation !== true
790 ) {
791 return yield* toValidationError(
792 "ProviderService.sendTurn",
793 `Provider '${routed.adapter.provider}' requires an explicit continuation prompt`,
794 );
795 }
796 if (!routed.isActive) {
797 routed = yield* resolveRoutableSession({
798 threadId: input.threadId,
799 operation: "ProviderService.sendTurn",
800 allowRecovery: true,
801 });
802 }
785803 metricProvider = routed.adapter.provider;
786804 metricModel = input.modelSelection?.model;
787805 yield* Effect.annotateCurrentSpan({
apps/server/src/provider/Services/ProviderAdapter.ts+3
......@@ -32,6 +32,9 @@ export interface ProviderAdapterCapabilities {
3232 * Declares whether changing the model on an existing session is supported.
3333 */
3434 readonly sessionModelSwitch: ProviderSessionModelSwitchMode;
35 /** Starts a resumed turn with no synthetic user prompt. Omitted means the
36 adapter needs an explicit continuation instruction. */
37 readonly promptlessTurnContinuation?: boolean;
3538}
3639
3740export interface ProviderThreadTurnSnapshot {
apps/server/src/server.test.ts+2
......@@ -937,6 +937,8 @@ const buildAppUnderTest = (options?: {
937937 Layer.mock(ServerRuntimeStartup.ServerRuntimeStartup)({
938938 awaitCommandReady: Effect.void,
939939 markHttpListening: Effect.void,
940 markRunningProviderSessionsForContinuation: Effect.succeed([]),
941 clearProviderSessionContinuationMarkers: () => Effect.void,
940942 enqueueCommand: (effect) => effect,
941943 ...options?.layers?.serverRuntimeStartup,
942944 }),
apps/server/src/serverRuntimeStartup.reconcile.test.ts+365-4
......@@ -3,10 +3,12 @@ import {
33 type OrchestrationCommand,
44 ProviderDriverKind,
55 ProviderInstanceId,
6 type ProviderSendTurnInput,
67 ThreadId,
78 TurnId,
89} from "@t3tools/contracts";
910import { assert, it } from "@effect/vitest";
11import * as Deferred from "effect/Deferred";
1012import * as Effect from "effect/Effect";
1113import * as Option from "effect/Option";
1214import * as Stream from "effect/Stream";
......@@ -27,10 +29,12 @@ const makeThread = (
2729 status: "starting" | "running" | "ready" | "stopped" | "error",
2830 activeTurnId: TurnId | null = null,
2931 archivedAt: string | null = null,
32 deletedAt: string | null = null,
3033) => ({
3134 id: ThreadId.make(id),
3235 archivedAt,
33 deletedAt: null,
36 deletedAt,
37 interactionMode: "default" as const,
3438 session: {
3539 threadId: ThreadId.make(id),
3640 status,
......@@ -67,6 +71,7 @@ const queryWithThreads = (threads: ReadonlyArray<ReturnType<typeof makeThread>>)
6771const runReconciliation = (input: {
6872 readonly threads: ReadonlyArray<ReturnType<typeof makeThread>>;
6973 readonly liveThreadIds?: ReadonlyArray<ThreadId>;
74 readonly providerService?: ProviderService.ProviderService["Service"];
7075 readonly directory: ProviderSessionDirectory.ProviderSessionDirectory["Service"];
7176 readonly dispatch: OrchestrationEngine.OrchestrationEngineService["Service"]["dispatch"];
7277}) =>
......@@ -77,7 +82,7 @@ const runReconciliation = (input: {
7782 ),
7883 Effect.provideService(
7984 ProviderService.ProviderService,
80 makeProviderService(input.liveThreadIds),
85 input.providerService ?? makeProviderService(input.liveThreadIds),
8186 ),
8287 Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, input.directory),
8388 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {
......@@ -90,6 +95,347 @@ const runReconciliation = (input: {
9095 Effect.provide(NodeServices.layer),
9196 );
9297
98it.effect("marks active running sessions that have persisted resume state", () => {
99 const active = makeThread("thread-mark-active", "running", TurnId.make("turn-mark-active"));
100 const archived = makeThread(
101 "thread-mark-archived",
102 "running",
103 TurnId.make("turn-mark-archived"),
104 updatedAt,
105 );
106 const ready = makeThread("thread-mark-ready", "ready");
107 const missingResumeState = makeThread(
108 "thread-mark-missing-resume-state",
109 "running",
110 TurnId.make("turn-mark-missing-resume-state"),
111 );
112 const bindingReads: ThreadId[] = [];
113 const upserts: ProviderSessionDirectory.ProviderRuntimeBinding[] = [];
114
115 return ServerRuntimeStartup.markRunningProviderSessionsForContinuation.pipe(
116 Effect.provideService(
117 ProjectionSnapshotQuery.ProjectionSnapshotQuery,
118 queryWithThreads([active, archived, ready, missingResumeState]),
119 ),
120 Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, {
121 getBinding: (threadId) =>
122 Effect.sync(() => bindingReads.push(threadId)).pipe(
123 Effect.as(
124 Option.some({
125 threadId,
126 provider: ProviderDriverKind.make("codex"),
127 providerInstanceId,
128 ...(threadId === active.id ? { resumeCursor: { threadId } } : {}),
129 runtimePayload: { activeTurnId: "turn-mark-active" },
130 }),
131 ),
132 ),
133 upsert: (binding) => Effect.sync(() => upserts.push(binding)),
134 getProvider: () => Effect.die("unused"),
135 listThreadIds: () => Effect.die("unused"),
136 listBindings: () => Effect.die("unused"),
137 }),
138 Effect.tap((marked) =>
139 Effect.sync(() => {
140 assert.deepStrictEqual(bindingReads, [active.id, missingResumeState.id]);
141 assert.deepStrictEqual(marked, [active.id]);
142 assert.deepStrictEqual(upserts[0]?.runtimePayload, {
143 activeTurnId: "turn-mark-active",
144 continueAfterServerUpdate: active.session.activeTurnId,
145 });
146 }),
147 ),
148 );
149});
150
151it.effect("continues marked sessions after activation with provider-specific input", () =>
152 Effect.gen(function* () {
153 const codex = makeThread(
154 "thread-continue-codex",
155 "running",
156 TurnId.make("turn-continue-codex"),
157 );
158 const fallback = makeThread("thread-continue-fallback", "starting");
159 const fallbackContinuationTurnId = TurnId.make("turn-continue-fallback");
160 const fallbackProviderInstanceId = ProviderInstanceId.make("claudeAgent");
161 const continuationSent = yield* Deferred.make<void>();
162 const continuationCleared = yield* Deferred.make<void>();
163 const sends: ProviderSendTurnInput[] = [];
164 const dispatched: OrchestrationCommand[] = [];
165 const upserts: ProviderSessionDirectory.ProviderRuntimeBinding[] = [];
166 const bindings = new Map<ThreadId, ProviderSessionDirectory.ProviderRuntimeBinding>(
167 [codex, fallback].map((thread) => [
168 thread.id,
169 {
170 threadId: thread.id,
171 provider:
172 thread.id === codex.id
173 ? ProviderDriverKind.make("codex")
174 : ProviderDriverKind.make("claudeAgent"),
175 providerInstanceId:
176 thread.id === codex.id ? providerInstanceId : fallbackProviderInstanceId,
177 status: "running" as const,
178 runtimePayload: {
179 continueAfterServerUpdate:
180 thread.id === codex.id ? codex.session.activeTurnId : fallbackContinuationTurnId,
181 },
182 },
183 ]),
184 );
185 const providerService: ProviderService.ProviderService["Service"] = {
186 ...makeProviderService(),
187 getCapabilities: (instanceId) =>
188 Effect.succeed({
189 sessionModelSwitch: "in-session",
190 ...(instanceId === providerInstanceId ? { promptlessTurnContinuation: true } : {}),
191 }),
192 sendTurn: (input) =>
193 Effect.gen(function* () {
194 sends.push(input);
195 if (sends.length === 2) {
196 yield* Deferred.succeed(continuationSent, undefined);
197 }
198 return {
199 threadId: input.threadId,
200 turnId: TurnId.make(`continued-${String(input.threadId)}`),
201 };
202 }),
203 };
204
205 yield* runReconciliation({
206 threads: [codex, fallback],
207 providerService,
208 directory: {
209 getBinding: (threadId) =>
210 Effect.sync(() => {
211 const binding = bindings.get(threadId);
212 return binding === undefined ? Option.none() : Option.some(binding);
213 }),
214 upsert: (binding) =>
215 Effect.sync(() => {
216 bindings.set(binding.threadId, binding);
217 upserts.push(binding);
218 const clearedCount = upserts.filter((candidate) => {
219 const payload = candidate.runtimePayload;
220 return (
221 payload !== null &&
222 typeof payload === "object" &&
223 !Array.isArray(payload) &&
224 "continueAfterServerUpdate" in payload &&
225 payload.continueAfterServerUpdate === null
226 );
227 }).length;
228 return clearedCount === 1;
229 }).pipe(
230 Effect.flatMap((firstMarkerCleared) =>
231 firstMarkerCleared ? Deferred.succeed(continuationCleared, undefined) : Effect.void,
232 ),
233 ),
234 getProvider: () => Effect.die("unused"),
235 listThreadIds: () => Effect.die("unused"),
236 listBindings: () => Effect.die("unused"),
237 },
238 dispatch: (command) =>
239 Effect.sync(() => dispatched.push(command)).pipe(
240 Effect.as({ sequence: dispatched.length }),
241 ),
242 });
243 yield* Deferred.await(continuationSent);
244 yield* Deferred.await(continuationCleared);
245
246 assert.deepStrictEqual(
247 sends.toSorted((left, right) => String(left.threadId).localeCompare(String(right.threadId))),
248 [
249 { threadId: codex.id, continuation: true, interactionMode: "default" },
250 {
251 threadId: fallback.id,
252 input: "Continue where you left off.",
253 interactionMode: "default",
254 },
255 ],
256 );
257 assert.deepStrictEqual(
258 dispatched.map((command) =>
259 command.type === "thread.session.set"
260 ? {
261 threadId: command.threadId,
262 status: command.session.status,
263 activeTurnId: command.session.activeTurnId,
264 }
265 : null,
266 ),
267 [
268 {
269 threadId: codex.id,
270 status: "starting",
271 activeTurnId: null,
272 },
273 {
274 threadId: fallback.id,
275 status: "starting",
276 activeTurnId: fallback.session.activeTurnId,
277 },
278 ],
279 );
280 for (const [thread, continuationTurnId] of [
281 [codex, codex.session.activeTurnId],
282 [fallback, fallbackContinuationTurnId],
283 ] as const) {
284 assert.deepStrictEqual(
285 upserts
286 .filter((binding) => binding.threadId === thread.id)
287 .map((binding) => binding.runtimePayload)[0],
288 {
289 continueAfterServerUpdate: continuationTurnId,
290 activeTurnId: null,
291 },
292 );
293 }
294 assert.equal(
295 upserts.some((binding) => {
296 const payload = binding.runtimePayload;
297 return (
298 payload !== null &&
299 typeof payload === "object" &&
300 !Array.isArray(payload) &&
301 "continueAfterServerUpdate" in payload &&
302 payload.continueAfterServerUpdate === null
303 );
304 }),
305 true,
306 );
307 }),
308);
309
310it.effect("does not continue archived or deleted marked sessions", () => {
311 const archived = makeThread(
312 "thread-continue-archived",
313 "running",
314 TurnId.make("turn-continue-archived"),
315 updatedAt,
316 );
317 const deleted = makeThread(
318 "thread-continue-deleted",
319 "running",
320 TurnId.make("turn-continue-deleted"),
321 null,
322 updatedAt,
323 );
324 const sends: ProviderSendTurnInput[] = [];
325 const dispatched: OrchestrationCommand[] = [];
326
327 return runReconciliation({
328 threads: [archived, deleted],
329 providerService: {
330 ...makeProviderService(),
331 sendTurn: (input) =>
332 Effect.sync(() => {
333 sends.push(input);
334 return {
335 threadId: input.threadId,
336 turnId: TurnId.make("unexpected-archived-turn"),
337 };
338 }),
339 },
340 directory: {
341 getBinding: (threadId) => {
342 const thread = threadId === archived.id ? archived : deleted;
343 return Effect.succeed(
344 Option.some({
345 threadId,
346 provider: ProviderDriverKind.make("codex"),
347 providerInstanceId,
348 status: "running" as const,
349 resumeCursor: { cursor: threadId },
350 runtimePayload: {
351 continueAfterServerUpdate: thread.session.activeTurnId,
352 },
353 }),
354 );
355 },
356 upsert: () => Effect.void,
357 getProvider: () => Effect.die("unused"),
358 listThreadIds: () => Effect.die("unused"),
359 listBindings: () => Effect.die("unused"),
360 },
361 dispatch: (command) =>
362 Effect.sync(() => dispatched.push(command)).pipe(Effect.as({ sequence: dispatched.length })),
363 }).pipe(
364 Effect.tap(() =>
365 Effect.sync(() => {
366 assert.deepStrictEqual(sends, []);
367 assert.deepStrictEqual(
368 dispatched.map((command) =>
369 command.type === "thread.session.set"
370 ? { threadId: command.threadId, status: command.session.status }
371 : null,
372 ),
373 [
374 { threadId: archived.id, status: "error" },
375 { threadId: deleted.id, status: "error" },
376 ],
377 );
378 }),
379 ),
380 );
381});
382
383it.effect("retries continuation preparation before settling a persistent failure", () => {
384 const thread = makeThread(
385 "thread-continuation-preparation-failure",
386 "running",
387 TurnId.make("turn-continuation-preparation-failure"),
388 );
389 const dispatched: OrchestrationCommand[] = [];
390 const failure = new OrchestrationCommandInvariantError({
391 commandType: "thread.session.set",
392 detail: "simulated continuation preparation failure",
393 });
394
395 return runReconciliation({
396 threads: [thread],
397 directory: {
398 getBinding: () =>
399 Effect.succeed(
400 Option.some({
401 threadId: thread.id,
402 provider: ProviderDriverKind.make("codex"),
403 providerInstanceId,
404 status: "running" as const,
405 resumeCursor: { cursor: thread.id },
406 runtimePayload: {
407 continueAfterServerUpdate: thread.session.activeTurnId,
408 },
409 }),
410 ),
411 upsert: () => Effect.void,
412 getProvider: () => Effect.die("unused"),
413 listThreadIds: () => Effect.die("unused"),
414 listBindings: () => Effect.die("unused"),
415 },
416 dispatch: (command) => {
417 if (command.type !== "thread.session.set") {
418 return Effect.die("unexpected command");
419 }
420 dispatched.push(command);
421 return command.session.status === "starting"
422 ? Effect.fail(failure)
423 : Effect.succeed({ sequence: dispatched.length });
424 },
425 }).pipe(
426 Effect.tap(() =>
427 Effect.sync(() =>
428 assert.deepStrictEqual(
429 dispatched.map(
430 (command) => command.type === "thread.session.set" && command.session.status,
431 ),
432 ["starting", "starting", "error"],
433 ),
434 ),
435 ),
436 );
437});
438
93439it.effect("reconciles multiple active and archived orphans but skips live sessions", () => {
94440 const starting = makeThread("thread-starting", "starting");
95441 const running = makeThread("thread-running", "running", TurnId.make("turn-running"));
......@@ -123,7 +469,13 @@ it.effect("reconciles multiple active and archived orphans but skips live sessio
123469 providerInstanceId,
124470 status: "running" as const,
125471 resumeCursor: { cursor: candidate },
126 runtimePayload: { activeTurnId: "stale", unrelated: candidate },
472 runtimePayload: {
473 activeTurnId: "stale",
474 unrelated: candidate,
475 ...(candidate === staleActiveTurn.id
476 ? { continueAfterServerUpdate: "turn-from-an-earlier-update" }
477 : {}),
478 },
127479 }),
128480 ),
129481 ),
......@@ -157,7 +509,16 @@ it.effect("reconciles multiple active and archived orphans but skips live sessio
157509 assert.equal(upserts.length, orphanIds.length);
158510 for (const binding of upserts) {
159511 assert.equal(binding.status, "stopped");
160 assert.deepStrictEqual(binding.runtimePayload, { activeTurnId: null });
512 assert.deepStrictEqual(
513 binding.runtimePayload,
514 binding.threadId === staleActiveTurn.id
515 ? {
516 activeTurnId: null,
517 unrelated: binding.threadId,
518 continueAfterServerUpdate: null,
519 }
520 : { activeTurnId: null, unrelated: binding.threadId },
521 );
161522 assert.deepStrictEqual(binding.resumeCursor, { cursor: binding.threadId });
162523 }
163524 }),
apps/server/src/serverRuntimeStartup.ts+308-36
......@@ -6,6 +6,7 @@ import {
66 ProjectId,
77 ProviderInstanceId,
88 ThreadId,
9 TurnId,
910} from "@t3tools/contracts";
1011import * as Cause from "effect/Cause";
1112import * as Console from "effect/Console";
......@@ -65,6 +66,13 @@ export class ServerRuntimeStartup extends Context.Service<
6566 {
6667 readonly awaitCommandReady: Effect.Effect<void, ServerRuntimeStartupError>;
6768 readonly markHttpListening: Effect.Effect<void>;
69 readonly markRunningProviderSessionsForContinuation: Effect.Effect<
70 ReadonlyArray<ThreadId>,
71 ServerUpdateThreadContinuationError
72 >;
73 readonly clearProviderSessionContinuationMarkers: (
74 threadIds: ReadonlyArray<ThreadId>,
75 ) => Effect.Effect<void, ServerUpdateThreadContinuationError>;
6876 readonly enqueueCommand: <A, E>(
6977 effect: Effect.Effect<A, E>,
7078 ) => Effect.Effect<A, E | ServerRuntimeStartupError>;
......@@ -294,6 +302,138 @@ const runStartupPhase = <A, E, R>(phase: string, effect: Effect.Effect<A, E, R>)
294302
295303const ORPHANED_PROVIDER_SESSION_ERROR =
296304 "Provider session did not survive a server restart. Send a new message to continue.";
305const SERVER_UPDATE_CONTINUATION_KEY = "continueAfterServerUpdate";
306const SERVER_UPDATE_CONTINUATION_PROMPT = "Continue where you left off.";
307
308class ProviderSessionContinuationError extends Schema.TaggedErrorClass<ProviderSessionContinuationError>()(
309 "ProviderSessionContinuationError",
310 {
311 threadId: ThreadId,
312 },
313) {
314 override get message(): string {
315 return `Could not continue thread '${this.threadId}': the provider instance is missing.`;
316 }
317}
318
319export class ServerUpdateThreadContinuationError extends Schema.TaggedErrorClass<ServerUpdateThreadContinuationError>()(
320 "ServerUpdateThreadContinuationError",
321 {
322 cause: Schema.Defect(),
323 },
324) {
325 override get message(): string {
326 return "Could not prepare running threads to continue after the update.";
327 }
328}
329
330function hasServerUpdateContinuationMarker(
331 runtimePayload: unknown,
332): runtimePayload is Record<string, unknown> {
333 return (
334 runtimePayload !== null &&
335 typeof runtimePayload === "object" &&
336 !Array.isArray(runtimePayload) &&
337 SERVER_UPDATE_CONTINUATION_KEY in runtimePayload
338 );
339}
340
341function readRuntimePayload(runtimePayload: unknown): Record<string, unknown> {
342 return runtimePayload !== null &&
343 typeof runtimePayload === "object" &&
344 !Array.isArray(runtimePayload)
345 ? (runtimePayload as Record<string, unknown>)
346 : {};
347}
348
349const isServerUpdateThreadContinuationError = Schema.is(ServerUpdateThreadContinuationError);
350
351function readServerUpdateContinuationTurnId(runtimePayload: unknown): TurnId | null {
352 if (!hasServerUpdateContinuationMarker(runtimePayload)) {
353 return null;
354 }
355 const value = runtimePayload[SERVER_UPDATE_CONTINUATION_KEY];
356 return typeof value === "string" && value.length > 0 ? TurnId.make(value) : null;
357}
358
359const toServerUpdateThreadContinuationError = (cause: unknown) =>
360 isServerUpdateThreadContinuationError(cause)
361 ? cause
362 : new ServerUpdateThreadContinuationError({ cause });
363
364export const markRunningProviderSessionsForContinuation = Effect.gen(function* () {
365 const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
366 const query = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
367 const { threads } = yield* query.getCommandReadModel();
368 const running = threads.filter(
369 (thread) =>
370 thread.archivedAt === null &&
371 thread.deletedAt === null &&
372 thread.session?.status === "running" &&
373 thread.session.activeTurnId !== null,
374 );
375
376 const marked: ThreadId[] = [];
377 return yield* Effect.gen(function* () {
378 for (const thread of running) {
379 const activeTurnId = thread.session?.activeTurnId;
380 if (activeTurnId === null || activeTurnId === undefined) {
381 continue;
382 }
383 const binding = yield* directory.getBinding(thread.id);
384 if (Option.isNone(binding)) {
385 continue;
386 }
387 if (binding.value.resumeCursor === null || binding.value.resumeCursor === undefined) {
388 continue;
389 }
390 yield* directory.upsert({
391 ...binding.value,
392 runtimePayload: {
393 ...readRuntimePayload(binding.value.runtimePayload),
394 [SERVER_UPDATE_CONTINUATION_KEY]: activeTurnId,
395 },
396 });
397 marked.push(thread.id);
398 }
399 return marked;
400 }).pipe(
401 Effect.catchCause((cause) =>
402 clearProviderSessionContinuationMarkers(marked).pipe(Effect.andThen(Effect.failCause(cause))),
403 ),
404 );
405}).pipe(Effect.mapError(toServerUpdateThreadContinuationError));
406
407const clearContinuationMarkers = (
408 directory: ProviderSessionDirectory.ProviderSessionDirectory["Service"],
409 threadIds: ReadonlyArray<ThreadId>,
410) =>
411 Effect.forEach(
412 threadIds,
413 (threadId) =>
414 directory.getBinding(threadId).pipe(
415 Effect.flatMap(
416 Option.match({
417 onNone: () => Effect.void,
418 onSome: (binding) =>
419 directory.upsert({
420 ...binding,
421 runtimePayload: {
422 ...readRuntimePayload(binding.runtimePayload),
423 [SERVER_UPDATE_CONTINUATION_KEY]: null,
424 },
425 }),
426 }),
427 ),
428 ),
429 { concurrency: "unbounded", discard: true },
430 );
431
432export const clearProviderSessionContinuationMarkers = (threadIds: ReadonlyArray<ThreadId>) =>
433 Effect.gen(function* () {
434 const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
435 yield* clearContinuationMarkers(directory, threadIds);
436 }).pipe(Effect.mapError(toServerUpdateThreadContinuationError));
297437
298438export const reconcileProviderSessions = Effect.gen(function* () {
299439 const crypto = yield* Crypto.Crypto;
......@@ -320,52 +460,165 @@ export const reconcileProviderSessions = Effect.gen(function* () {
320460 if (session === null) {
321461 continue;
322462 }
323 yield* Effect.gen(function* () {
324 const binding = yield* directory.getBinding(thread.id);
325 if (Option.isSome(binding)) {
326 yield* directory.upsert({
327 ...binding.value,
328 status: "stopped",
329 runtimePayload: { activeTurnId: null },
330 });
331 }
332 }).pipe(
463 const binding = yield* directory.getBinding(thread.id).pipe(
333464 Effect.catchCause((cause) =>
334465 Cause.hasInterrupts(cause)
335466 ? Effect.failCause(cause)
336 : Effect.logWarning("failed to reconcile orphaned provider session directory binding", {
467 : Effect.logWarning("failed to read orphaned provider session directory binding", {
337468 threadId: thread.id,
338469 cause,
339 }),
470 }).pipe(Effect.as(Option.none())),
340471 ),
341472 );
473 const continuationMarkerPresent =
474 Option.isSome(binding) && hasServerUpdateContinuationMarker(binding.value.runtimePayload);
475 const continuationTurnId = Option.isSome(binding)
476 ? readServerUpdateContinuationTurnId(binding.value.runtimePayload)
477 : null;
478 const continuationMarked =
479 continuationTurnId !== null &&
480 (session.activeTurnId === null || continuationTurnId === session.activeTurnId);
481 const settleAsError = (lastError: string) =>
482 Effect.gen(function* () {
483 yield* Effect.gen(function* () {
484 if (Option.isSome(binding)) {
485 yield* directory.upsert({
486 ...binding.value,
487 status: "stopped",
488 runtimePayload: {
489 ...readRuntimePayload(binding.value.runtimePayload),
490 activeTurnId: null,
491 ...(continuationMarkerPresent ? { [SERVER_UPDATE_CONTINUATION_KEY]: null } : {}),
492 },
493 });
494 }
495 }).pipe(
496 Effect.catchCause((cause) =>
497 Cause.hasInterrupts(cause)
498 ? Effect.failCause(cause)
499 : Effect.logWarning(
500 "failed to reconcile orphaned provider session directory binding",
501 { threadId: thread.id, cause },
502 ),
503 ),
504 );
342505
343 yield* Effect.gen(function* () {
344 const reconciledAt = DateTime.formatIso(yield* DateTime.now);
345 yield* orchestrationEngine.dispatch({
346 type: "thread.session.set",
347 commandId: CommandId.make(yield* crypto.randomUUIDv4),
348 threadId: thread.id,
349 session: {
350 ...session,
351 status: "error",
352 activeTurnId: null,
353 lastError: ORPHANED_PROVIDER_SESSION_ERROR,
354 updatedAt: reconciledAt,
355 },
356 createdAt: reconciledAt,
506 yield* Effect.gen(function* () {
507 const reconciledAt = DateTime.formatIso(yield* DateTime.now);
508 yield* orchestrationEngine.dispatch({
509 type: "thread.session.set",
510 commandId: CommandId.make(yield* crypto.randomUUIDv4),
511 threadId: thread.id,
512 session: {
513 ...session,
514 status: "error",
515 activeTurnId: null,
516 lastError,
517 updatedAt: reconciledAt,
518 },
519 createdAt: reconciledAt,
520 });
521 }).pipe(
522 Effect.retry({ times: 1 }),
523 Effect.catchCause((cause) =>
524 Cause.hasInterrupts(cause)
525 ? Effect.failCause(cause)
526 : Effect.logWarning("failed to settle orphaned provider session projection", {
527 threadId: thread.id,
528 cause,
529 }),
530 ),
531 );
357532 });
358 }).pipe(
359 Effect.retry({ times: 1 }),
360 Effect.catchCause((cause) =>
361 Cause.hasInterrupts(cause)
362 ? Effect.failCause(cause)
363 : Effect.logWarning("failed to settle orphaned provider session projection", {
533
534 if (
535 Option.isSome(binding) &&
536 continuationMarked &&
537 thread.archivedAt === null &&
538 thread.deletedAt === null
539 ) {
540 const prepared = yield* Effect.gen(function* () {
541 yield* directory.upsert({
542 ...binding.value,
543 status: "starting",
544 runtimePayload: {
545 ...readRuntimePayload(binding.value.runtimePayload),
546 activeTurnId: null,
547 },
548 });
549 const resumedAt = DateTime.formatIso(yield* DateTime.now);
550 yield* orchestrationEngine.dispatch({
551 type: "thread.session.set",
552 commandId: CommandId.make(yield* crypto.randomUUIDv4),
553 threadId: thread.id,
554 session: {
555 ...session,
556 status: "starting",
557 activeTurnId: null,
558 lastError: null,
559 updatedAt: resumedAt,
560 },
561 createdAt: resumedAt,
562 });
563 }).pipe(Effect.retry({ times: 1 }), Effect.exit);
564 if (Exit.isFailure(prepared)) {
565 if (Cause.hasInterrupts(prepared.cause)) {
566 return yield* Effect.failCause(prepared.cause);
567 }
568 yield* Effect.logWarning("failed to prepare provider session continuation", {
569 threadId: thread.id,
570 cause: prepared.cause,
571 });
572 yield* settleAsError(ORPHANED_PROVIDER_SESSION_ERROR);
573 continue;
574 }
575
576 yield* forkParked(
577 Effect.gen(function* () {
578 const continuation = Effect.gen(function* () {
579 const providerInstanceId = binding.value.providerInstanceId;
580 if (providerInstanceId === undefined) {
581 return yield* new ProviderSessionContinuationError({
582 threadId: thread.id,
583 });
584 }
585 const capabilities = yield* providerService.getCapabilities(providerInstanceId);
586 yield* providerService.sendTurn({
364587 threadId: thread.id,
365 cause,
366 }),
367 ),
368 );
588 ...(capabilities.promptlessTurnContinuation === true
589 ? { continuation: true }
590 : { input: SERVER_UPDATE_CONTINUATION_PROMPT }),
591 interactionMode: thread.interactionMode,
592 });
593 });
594 const continuationExit = yield* Effect.exit(continuation);
595 if (Exit.isSuccess(continuationExit) || Cause.hasInterrupts(continuationExit.cause)) {
596 if (Exit.isSuccess(continuationExit)) {
597 yield* clearContinuationMarkers(directory, [thread.id]).pipe(
598 Effect.uninterruptible,
599 Effect.catchCause((cause) =>
600 Effect.logWarning("failed to clear completed provider session continuation", {
601 threadId: thread.id,
602 cause,
603 }),
604 ),
605 );
606 }
607 return;
608 }
609 yield* Effect.logWarning("failed to continue provider session after server update", {
610 threadId: thread.id,
611 cause: continuationExit.cause,
612 });
613 yield* settleAsError(
614 "Could not continue this thread after the server update. Send a new message to continue.",
615 ).pipe(Effect.ignoreCause);
616 }),
617 );
618 continue;
619 }
620
621 yield* settleAsError(ORPHANED_PROVIDER_SESSION_ERROR);
369622 }
370623}).pipe(
371624 Effect.catchCause((cause) =>
......@@ -390,6 +643,8 @@ export const make = (options?: StartupOptions) =>
390643 const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents;
391644 const serverSettings = yield* ServerSettings.ServerSettingsService;
392645 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
646 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
647 const providerSessionDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
393648 const crypto = yield* Crypto.Crypto;
394649 const launcher = yield* ServiceLauncherClient.ServiceLauncherClient;
395650
......@@ -577,6 +832,23 @@ export const make = (options?: StartupOptions) =>
577832 return {
578833 awaitCommandReady: commandGate.awaitCommandReady,
579834 markHttpListening: Deferred.succeed(httpListening, undefined),
835 markRunningProviderSessionsForContinuation: markRunningProviderSessionsForContinuation.pipe(
836 Effect.provideService(
837 ProjectionSnapshotQuery.ProjectionSnapshotQuery,
838 projectionSnapshotQuery,
839 ),
840 Effect.provideService(
841 ProviderSessionDirectory.ProviderSessionDirectory,
842 providerSessionDirectory,
843 ),
844 ),
845 clearProviderSessionContinuationMarkers: (threadIds) =>
846 clearProviderSessionContinuationMarkers(threadIds).pipe(
847 Effect.provideService(
848 ProviderSessionDirectory.ProviderSessionDirectory,
849 providerSessionDirectory,
850 ),
851 ),
580852 enqueueCommand: commandGate.enqueueCommand,
581853 } satisfies ServerRuntimeStartup["Service"];
582854 });
apps/server/src/ws.ts+31-6
......@@ -50,7 +50,7 @@ import {
5050 ProviderUploadFeedbackError,
5151 RelayClientInstallFailedError,
5252 type RelayClientInstallProgressEvent,
53 type ServerSelfUpdateError,
53 ServerSelfUpdateError,
5454 type ServerSelfUpdateProgressEvent,
5555 type FilesystemBrowseFailure,
5656 FilesystemBrowseError,
......@@ -509,7 +509,7 @@ const makeWsRpcLayer = (
509509 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
510510 const providerService = yield* ProviderService.ProviderService;
511511 const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner;
512 const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate;
512 const serverUpdate = yield* ServerSelfUpdate.ServerSelfUpdate;
513513 const config = yield* ServerConfig.ServerConfig;
514514 const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents;
515515 const serverSettings = yield* ServerSettings.ServerSettingsService;
......@@ -1700,14 +1700,14 @@ const makeWsRpcLayer = (
17001700 },
17011701 ),
17021702 [WS_METHODS.serverUpdateServer]: (input) =>
1703 observeRpcEffect(WS_METHODS.serverUpdateServer, serverSelfUpdate.update(input), {
1703 observeRpcEffect(WS_METHODS.serverUpdateServer, serverUpdate.update(input), {
17041704 "rpc.aggregate": "server",
17051705 }),
17061706 [WS_METHODS.serverUpdateServerWithProgress]: (input) =>
17071707 observeRpcStream(
17081708 WS_METHODS.serverUpdateServerWithProgress,
17091709 Stream.callback<ServerSelfUpdateProgressEvent, ServerSelfUpdateError>((queue) =>
1710 serverSelfUpdate
1710 serverUpdate
17111711 .update(input, (stage) =>
17121712 Queue.offer(queue, {
17131713 type: "progress",
......@@ -1733,7 +1733,7 @@ const makeWsRpcLayer = (
17331733 [WS_METHODS.serverCommitDesktopUpdate]: (input) =>
17341734 observeRpcEffect(
17351735 WS_METHODS.serverCommitDesktopUpdate,
1736 serverSelfUpdate.commitDesktopUpdate(input.requestId),
1736 serverUpdate.commitDesktopUpdate(input.requestId),
17371737 { "rpc.aggregate": "server" },
17381738 ),
17391739 [WS_METHODS.serverUpsertKeybinding]: (rule) =>
......@@ -2578,7 +2578,32 @@ const makeWsRpcLayer = (
25782578export const websocketRpcRouteLayer = Layer.unwrap(
25792579 Effect.gen(function* () {
25802580 const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
2581 const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate;
2581 const baseServerSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate;
2582 const config = yield* ServerConfig.ServerConfig;
2583 const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup;
2584 const serverSelfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({
2585 mode: config.mode,
2586 selfUpdate: baseServerSelfUpdate,
2587 prepare: startup.markRunningProviderSessionsForContinuation.pipe(
2588 Effect.mapError(
2589 (cause) =>
2590 new ServerSelfUpdateError({
2591 reason: "Could not prepare running threads to continue after the update.",
2592 cause,
2593 }),
2594 ),
2595 ),
2596 clear: (threadIds) =>
2597 startup.clearProviderSessionContinuationMarkers(threadIds).pipe(
2598 Effect.mapError(
2599 (cause) =>
2600 new ServerSelfUpdateError({
2601 reason: "Could not clear thread continuation markers after the update failed.",
2602 cause,
2603 }),
2604 ),
2605 ),
2606 });
25822607 const pullRequests = yield* PullRequestService.PullRequestService;
25832608 return HttpRouter.add(
25842609 "GET",
apps/web/src/components/ChatView.tsx+4
......@@ -428,6 +428,7 @@ import {
428428 resolveServerSelfUpdateCapability,
429429 serverUpdateGuidance,
430430 supportsDesktopAppUpdate,
431 supportsServerUpdateThreadContinuation,
431432} from "../versionSkew";
432433import { useAssetUrls } from "../assets/assetUrls";
433434
......@@ -2221,6 +2222,7 @@ function ChatViewContent(props: ChatViewProps) {
22212222 const serverUpdateEnvironmentId = activeThread?.environmentId ?? null;
22222223 const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig);
22232224 const versionMismatchDesktopAppUpdate = supportsDesktopAppUpdate(serverConfig);
2225 const versionMismatchThreadContinuation = supportsServerUpdateThreadContinuation(serverConfig);
22242226 const serverUpdateState = useAtomValue(
22252227 serverEnvironment.updateStateAtom(serverUpdateEnvironmentId),
22262228 );
......@@ -2356,6 +2358,7 @@ function ChatViewContent(props: ChatViewProps) {
23562358 serverLabel={versionMismatchServerLabel}
23572359 selfUpdate={versionMismatchSelfUpdate}
23582360 desktopAppUpdate={versionMismatchDesktopAppUpdate}
2361 threadContinuation={versionMismatchThreadContinuation}
23592362 targetVersion={versionMismatch.clientVersion}
23602363 label={updateFailed ? "Retry" : "Update"}
23612364 variant="ghost"
......@@ -2391,6 +2394,7 @@ function ChatViewContent(props: ChatViewProps) {
23912394 serverUpdateEnvironmentId,
23922395 versionMismatchSelfUpdate,
23932396 versionMismatchDesktopAppUpdate,
2397 versionMismatchThreadContinuation,
23942398 versionMismatchServerLabel,
23952399 ]);
23962400 const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS;
apps/web/src/components/ServerUpdateAction.test.tsx+50
......@@ -8,11 +8,17 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
88const testState = vi.hoisted(() => ({
99 updateServer: vi.fn(),
1010 toast: vi.fn(),
11 continueThreadsAfterServerUpdate: false,
1112}));
1213
1314vi.mock("~/hooks/useCopyToClipboard", () => ({
1415 useCopyToClipboard: () => ({ copyToClipboard: vi.fn() }),
1516}));
17vi.mock("~/hooks/useSettings", () => ({
18 useClientSettings: (
19 selector: (settings: { continueThreadsAfterServerUpdate: boolean }) => unknown,
20 ) => selector({ continueThreadsAfterServerUpdate: testState.continueThreadsAfterServerUpdate }),
21}));
1622vi.mock("~/state/server", () => ({
1723 serverEnvironment: { updateServer: Symbol("updateServer") },
1824}));
......@@ -47,6 +53,7 @@ describe("ServerUpdateAction", () => {
4753 beforeEach(() => {
4854 testState.updateServer.mockReset();
4955 testState.toast.mockReset();
56 testState.continueThreadsAfterServerUpdate = false;
5057 });
5158
5259 it("reports success only after the shared update flow reconnects", async () => {
......@@ -141,6 +148,49 @@ describe("ServerUpdateAction", () => {
141148 description: "Desktop app relaunched on 0.0.34.",
142149 });
143150 });
151
152 it("leaves thread continuation off by default", async () => {
153 testState.updateServer.mockResolvedValue(
154 AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const }),
155 );
156 const action = ServerUpdateAction({
157 environmentId: "env-test" as EnvironmentId,
158 serverLabel: "Test server",
159 selfUpdate: "boot-service",
160 threadContinuation: true,
161 targetVersion: "0.0.31",
162 }) as ActionElement;
163
164 action.props.onClick?.();
165 await flushPromises();
166
167 expect(testState.updateServer).toHaveBeenCalledWith({
168 environmentId: "env-test",
169 input: { targetVersion: "0.0.31" },
170 });
171 });
172
173 it("applies the saved thread continuation preference automatically", async () => {
174 testState.updateServer.mockResolvedValue(
175 AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const }),
176 );
177 testState.continueThreadsAfterServerUpdate = true;
178 const action = ServerUpdateAction({
179 environmentId: "env-test" as EnvironmentId,
180 serverLabel: "Test server",
181 selfUpdate: "boot-service",
182 threadContinuation: true,
183 targetVersion: "0.0.31",
184 }) as ActionElement;
185
186 action.props.onClick?.();
187 await flushPromises();
188
189 expect(testState.updateServer).toHaveBeenCalledWith({
190 environmentId: "env-test",
191 input: { targetVersion: "0.0.31", continueRunningThreads: true },
192 });
193 });
144194});
145195
146196describe("ServerUpdateProgress", () => {
apps/web/src/components/ServerUpdateAction.tsx+13-1
......@@ -8,6 +8,7 @@ import type { ComponentProps } from "react";
88
99import { requestConfirmDialog } from "~/confirmDialog";
1010import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
11import { useClientSettings } from "~/hooks/useSettings";
1112import { serverEnvironment } from "~/state/server";
1213import { useAtomCommand } from "~/state/use-atom-command";
1314import { manualServerUpdateCommand } from "~/versionSkew";
......@@ -78,6 +79,7 @@ export function ServerUpdateAction({
7879 serverLabel,
7980 selfUpdate,
8081 desktopAppUpdate = false,
82 threadContinuation = false,
8183 targetVersion,
8284 label = "Update",
8385 variant = "outline",
......@@ -88,11 +90,16 @@ export function ServerUpdateAction({
8890 /** The desktop app supervising this server accepts remote update
8991 requests (capabilities.desktopAppUpdate). */
9092 readonly desktopAppUpdate?: boolean;
93 /** The server can durably continue running provider turns after updating. */
94 readonly threadContinuation?: boolean;
9195 readonly targetVersion: string;
9296 readonly label?: string;
9397 readonly variant?: ComponentProps<typeof Button>["variant"];
9498}) {
9599 const isDesktopAppUpdate = selfUpdate === "desktop-managed";
100 const continueThreadsAfterServerUpdate = useClientSettings(
101 (settings) => settings.continueThreadsAfterServerUpdate,
102 );
96103 const updateServer = useAtomCommand(serverEnvironment.updateServer, {
97104 reportFailure: false,
98105 });
......@@ -137,7 +144,12 @@ export function ServerUpdateAction({
137144 try {
138145 const result = await updateServer({
139146 environmentId,
140 input: { targetVersion },
147 input: {
148 targetVersion,
149 ...(threadContinuation && continueThreadsAfterServerUpdate
150 ? { continueRunningThreads: true }
151 : {}),
152 },
141153 });
142154 if (result._tag === "Failure") {
143155 if (isAtomCommandInterrupted(result)) {
apps/web/src/components/settings/ConnectionsSettings.tsx+5
......@@ -105,6 +105,7 @@ import {
105105 resolveServerConfigVersionMismatch,
106106 resolveServerSelfUpdateCapability,
107107 supportsDesktopAppUpdate,
108 supportsServerUpdateThreadContinuation,
108109} from "~/versionSkew";
109110import { hasCloudPublicConfig } from "~/cloud/publicConfig";
110111import { useCloudLinkController } from "~/cloud/useCloudLinkController";
......@@ -1487,6 +1488,7 @@ function SavedBackendListRow({
14871488 serverLabel={`${environment.label} server`}
14881489 selfUpdate={resolveServerSelfUpdateCapability(environment.serverConfig)}
14891490 desktopAppUpdate={supportsDesktopAppUpdate(environment.serverConfig)}
1491 threadContinuation={supportsServerUpdateThreadContinuation(environment.serverConfig)}
14901492 targetVersion={versionMismatch.clientVersion}
14911493 label={serverUpdateState.status === "failed" ? "Retry" : "Update"}
14921494 />
......@@ -3064,6 +3066,9 @@ export function ConnectionsSettings() {
30643066 }
30653067 selfUpdate={resolveServerSelfUpdateCapability(primaryServerConfig)}
30663068 desktopAppUpdate={supportsDesktopAppUpdate(primaryServerConfig)}
3069 threadContinuation={supportsServerUpdateThreadContinuation(
3070 primaryServerConfig,
3071 )}
30673072 targetVersion={primaryVersionMismatch.clientVersion}
30683073 label={primaryServerUpdateState.status === "failed" ? "Retry" : "Update"}
30693074 />
apps/web/src/components/settings/SettingsPanels.tsx+34
......@@ -533,6 +533,10 @@ export function useSettingsRestore(onRestored?: () => void) {
533533 DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks
534534 ? ["Provider update checks"]
535535 : []),
536 ...(settings.continueThreadsAfterServerUpdate !==
537 DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate
538 ? ["Continue threads after server updates"]
539 : []),
536540 ...(isBackgroundActivityDirty ? ["Background activity"] : []),
537541 ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode
538542 ? ["New thread mode"]
......@@ -591,6 +595,7 @@ export function useSettingsRestore(onRestored?: () => void) {
591595 settings.glassOpacity,
592596 settings.enableLegacyTokenStreaming,
593597 settings.enableProviderUpdateChecks,
598 settings.continueThreadsAfterServerUpdate,
594599 settings.sidebarAutoSettleAfterDays,
595600 settings.sidebarAutoSettleOnMerge,
596601 settings.sidebarProjectGroupingMode,
......@@ -681,6 +686,7 @@ export function useSettingsRestore(onRestored?: () => void) {
681686 sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,
682687 enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming,
683688 enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks,
689 continueThreadsAfterServerUpdate: DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate,
684690 backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity,
685691 backgroundActivityProfile: DEFAULT_UNIFIED_SETTINGS.backgroundActivityProfile,
686692 automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval,
......@@ -2185,6 +2191,34 @@ export function GeneralSettingsPanel() {
21852191 }
21862192 />
21872193
2194 <SettingsRow
2195 {...searchableSetting("continue-threads-after-server-update")}
2196 description="Automatically resume active threads after a server update restarts the environment."
2197 resetAction={
2198 settings.continueThreadsAfterServerUpdate !==
2199 DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate ? (
2200 <SettingResetButton
2201 label="continue threads after server updates"
2202 onClick={() =>
2203 updateSettings({
2204 continueThreadsAfterServerUpdate:
2205 DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate,
2206 })
2207 }
2208 />
2209 ) : null
2210 }
2211 control={
2212 <Switch
2213 checked={settings.continueThreadsAfterServerUpdate}
2214 onCheckedChange={(checked) =>
2215 updateSettings({ continueThreadsAfterServerUpdate: Boolean(checked) })
2216 }
2217 aria-label="Continue threads after server updates"
2218 />
2219 }
2220 />
2221
21882222 <SettingsRow
21892223 serverScoped
21902224 id={searchableSetting("background-activity").id}
apps/web/src/components/settings/settingsSearch.ts+6
......@@ -191,6 +191,12 @@ export const SETTINGS_SEARCH_ITEMS = [
191191 to: "/settings/general",
192192 searchTerms: ["installed cli versions newer available codex claude cursor grok opencode"],
193193 },
194 {
195 id: "continue-threads-after-server-update",
196 title: "Continue threads after server updates",
197 to: "/settings/general",
198 searchTerms: ["resume running active work restart desktop update automatically"],
199 },
194200 {
195201 id: "background-activity",
196202 title: "Background activity",
apps/web/src/versionSkew.ts+8
......@@ -106,6 +106,14 @@ export function supportsDesktopAppUpdate(
106106 return serverConfig?.environment.capabilities.desktopAppUpdate === true;
107107}
108108
109/** True when the connected server can recover opted-in running turns after
110 its self-update restart. */
111export function supportsServerUpdateThreadContinuation(
112 serverConfig: Pick<ServerConfig, "environment"> | null | undefined,
113): boolean {
114 return serverConfig?.environment.capabilities.serverUpdateThreadContinuation === true;
115}
116
109117/** The command to hand users whose server cannot update itself. */
110118export function manualServerUpdateCommand(targetVersion: string): string {
111119 return `npx t3@${targetVersion}`;
docs/user/updating.md+6-2
......@@ -15,8 +15,12 @@ update the server, and the version difference remains visible in Connections.
1515
1616## Before You Update
1717
18Let active agent work and terminal commands finish first. Updating restarts the server, so the
19connection will disappear briefly and work that is still running may be interrupted.
18Updating restarts the server, so the connection will disappear briefly. **Settings** → **General**
19has a **Continue threads after server updates** preference. It is off by default. When enabled, the
20update buttons automatically resume supported provider threads after the replacement server is
21ready. Providers with native promptless continuation use it; other providers receive a short
22instruction to continue where they left off. Terminal commands and other running work may still be
23interrupted during the update.
2024
2125The update does not remove saved threads, settings, or project files.
2226
packages/contracts/src/environment.ts+3
......@@ -93,6 +93,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
9393 /** Server can stream self-update progress before acknowledging the
9494 restart. Clients fall back to server.updateServer when absent. */
9595 serverSelfUpdateProgress: Schema.optionalKey(Schema.Boolean),
96 /** Server can durably mark running provider turns before a self-update and
97 continue them after the replacement process starts. */
98 serverUpdateThreadContinuation: Schema.optionalKey(Schema.Boolean),
9699 /** Agent-activity publishes (push notifications and Live Activities)
97100 currently leave this environment: the publish opt-in is enabled and the
98101 relay link credentials exist. Clients skip seeding a Live Activity when
packages/contracts/src/provider.ts+3
......@@ -67,6 +67,9 @@ export type ProviderSessionStartInput = typeof ProviderSessionStartInput.Type;
6767
6868export const ProviderSendTurnInput = Schema.Struct({
6969 threadId: ThreadId,
70 /** Internal recovery signal. Allows an empty turn only for adapters that
71 explicitly support promptless continuation. */
72 continuation: Schema.optional(Schema.Boolean),
7073 input: Schema.optional(
7174 TrimmedNonEmptyString.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)),
7275 ),
packages/contracts/src/server.ts+4
......@@ -754,6 +754,10 @@ export const ServerSelfUpdateInput = Schema.Struct({
754754 /** Exact npm version of the `t3` package to install (never a dist-tag, so
755755 the server and the acknowledging client agree on what was requested). */
756756 targetVersion: TrimmedNonEmptyString,
757 /** Opt-in recovery for provider turns that are running when the server
758 hands off to its replacement. Missing and false keep restart behavior
759 conservative under version skew. */
760 continueRunningThreads: Schema.optionalKey(Schema.Boolean),
757761});
758762export type ServerSelfUpdateInput = typeof ServerSelfUpdateInput.Type;
759763
packages/contracts/src/settings.ts+4
......@@ -207,6 +207,9 @@ export const ClientSettingsSchema = Schema.Struct({
207207 confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
208208 confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
209209 confirmThreadUnpin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
210 continueThreadsAfterServerUpdate: Schema.Boolean.pipe(
211 Schema.withDecodingDefault(Effect.succeed(false)),
212 ),
210213 dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe(
211214 Schema.withDecodingDefault(Effect.succeed([])),
212215 ),
......@@ -959,6 +962,7 @@ export const ClientSettingsPatch = Schema.Struct({
959962 confirmThreadArchive: Schema.optionalKey(Schema.Boolean),
960963 confirmThreadDelete: Schema.optionalKey(Schema.Boolean),
961964 confirmThreadUnpin: Schema.optionalKey(Schema.Boolean),
965 continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean),
962966 diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),
963967 environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode),
964968 glassOpacity: Schema.optionalKey(GlassOpacity),