| author | |
| committer | |
| log | 5b7d72aad14ed37e8e5e4c02a6d49814bfe528ac |
| tree | 9b11a577e538ebb99793512667fd67e208b1363a |
| parent | 14f15cfed48bc7519e8e5eeac03f483f4b11ef3a |
| signature | Signed by PGP key B5690EEEBB952194 |
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 = { |
| 23 | 23 | confirmThreadArchive: true, |
| 24 | 24 | confirmThreadDelete: false, |
| 25 | 25 | confirmThreadUnpin: false, |
| 26 | continueThreadsAfterServerUpdate: true, | |
| 26 | 27 | contextWindowMeterEnabled: false, |
| 27 | 28 | dismissedProviderUpdateNotificationKeys: [], |
| 28 | 29 | diffIgnoreWhitespace: true, |
apps/server/src/cloud/selfUpdate.test.ts+217| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 2 | import { expect, it } from "@effect/vitest"; |
| 3 | import { ServerSelfUpdateError, ThreadId } from "@t3tools/contracts"; | |
| 3 | 4 | import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess"; |
| 5 | import * as Cause from "effect/Cause"; | |
| 4 | 6 | import * as Deferred from "effect/Deferred"; |
| 5 | 7 | import * as Effect from "effect/Effect"; |
| 6 | 8 | import * as FileSystem from "effect/FileSystem"; |
| ... | ... | @@ -104,6 +106,221 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( |
| 104 | 106 | }); |
| 105 | 107 | |
| 106 | 108 | it.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 | ||
| 107 | 324 | it.effect("stages and preflights before asking the launcher for an update ID", () => |
| 108 | 325 | Effect.gen(function* () { |
| 109 | 326 | const { selfUpdate, order } = yield* makeHarness(); |
apps/server/src/cloud/selfUpdate.ts+124-8| ... | ... | @@ -4,15 +4,18 @@ import { |
| 4 | 4 | type ServerSelfUpdateInput, |
| 5 | 5 | type ServerSelfUpdateProgressStage, |
| 6 | 6 | type ServerSelfUpdateResult, |
| 7 | type ThreadId, | |
| 7 | 8 | } from "@t3tools/contracts"; |
| 8 | 9 | import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess"; |
| 10 | import * as Cause from "effect/Cause"; | |
| 9 | 11 | import * as Context from "effect/Context"; |
| 10 | 12 | import * as Duration from "effect/Duration"; |
| 11 | 13 | import * as Effect from "effect/Effect"; |
| 14 | import * as HashSet from "effect/HashSet"; | |
| 15 | import * as Ref from "effect/Ref"; | |
| 12 | 16 | import * as FileSystem from "effect/FileSystem"; |
| 13 | 17 | import * as Layer from "effect/Layer"; |
| 14 | 18 | import * as Path from "effect/Path"; |
| 15 | import * as Ref from "effect/Ref"; | |
| 16 | 19 | |
| 17 | 20 | import * as ServerConfig from "../config.ts"; |
| 18 | 21 | import * as DesktopAppUpdate from "../desktopUpdate/DesktopAppUpdate.ts"; |
| ... | ... | @@ -41,14 +44,125 @@ export class ServerSelfUpdate extends Context.Service< |
| 41 | 44 | { |
| 42 | 45 | readonly update: ( |
| 43 | 46 | input: ServerSelfUpdateInput, |
| 44 | reportProgress?: (stage: ServerSelfUpdateProgressStage) => Effect.Effect<void>, | |
| 47 | reportProgress?: ( | |
| 48 | stage: ServerSelfUpdateProgressStage, | |
| 49 | ) => Effect.Effect<void, ServerSelfUpdateError>, | |
| 50 | onHandoffAccepted?: () => Effect.Effect<void>, | |
| 45 | 51 | ) => Effect.Effect<ServerSelfUpdateResult, ServerSelfUpdateError>; |
| 46 | 52 | readonly commitDesktopUpdate: ( |
| 47 | 53 | requestId: string, |
| 54 | onHandoffAccepted?: () => Effect.Effect<void>, | |
| 48 | 55 | ) => Effect.Effect<never, ServerSelfUpdateError>; |
| 49 | 56 | } |
| 50 | 57 | >()("t3/cloud/selfUpdate/ServerSelfUpdate") {} |
| 51 | 58 | |
| 59 | export 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 | ||
| 52 | 166 | export const make = Effect.fn("cloud.server_self_update.make")(function* () { |
| 53 | 167 | const serverConfig = yield* ServerConfig.ServerConfig; |
| 54 | 168 | const desktopAppUpdate = yield* DesktopAppUpdate.DesktopAppUpdate; |
| ... | ... | @@ -68,7 +182,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { |
| 68 | 182 | |
| 69 | 183 | const update: ServerSelfUpdate["Service"]["update"] = Effect.fn( |
| 70 | 184 | "cloud.server_self_update.update", |
| 71 | )(function* (input, reportProgress = () => Effect.void) { | |
| 185 | )(function* (input, reportProgress = () => Effect.void, onHandoffAccepted = () => Effect.void) { | |
| 72 | 186 | if (capability === "desktop-managed") { |
| 73 | 187 | // input.targetVersion is meaningless here: the desktop app's own |
| 74 | 188 | // 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* () { |
| 180 | 294 | ); |
| 181 | 295 | |
| 182 | 296 | 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( | |
| 186 | 299 | Effect.mapError((error) => |
| 187 | 300 | failWith( |
| 188 | 301 | error._tag === "ServiceLauncherRejectedError" |
| ... | ... | @@ -191,7 +304,9 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { |
| 191 | 304 | error, |
| 192 | 305 | ), |
| 193 | 306 | ), |
| 194 | ); | |
| 307 | Effect.tap(() => onHandoffAccepted()), | |
| 308 | ), | |
| 309 | ); | |
| 195 | 310 | |
| 196 | 311 | yield* Effect.logInfo("Server update prepared; handing off to the service launcher.", { |
| 197 | 312 | updateId, |
| ... | ... | @@ -204,7 +319,8 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { |
| 204 | 319 | |
| 205 | 320 | return ServerSelfUpdate.of({ |
| 206 | 321 | update, |
| 207 | commitDesktopUpdate: (requestId) => desktopAppUpdate.commit(requestId), | |
| 322 | commitDesktopUpdate: (requestId, onHandoffAccepted) => | |
| 323 | desktopAppUpdate.commit(requestId, onHandoffAccepted), | |
| 208 | 324 | }); |
| 209 | 325 | }); |
| 210 | 326 |
apps/server/src/desktopUpdate/DesktopAppUpdate.ts+22-10| ... | ... | @@ -49,11 +49,16 @@ export class DesktopAppUpdate extends Context.Service< |
| 49 | 49 | /** Checks and downloads through the desktop app, then returns a token |
| 50 | 50 | while this server is still connected. `commit` starts installation. */ |
| 51 | 51 | readonly run: ( |
| 52 | reportProgress: (stage: ServerSelfUpdateProgressStage) => Effect.Effect<void>, | |
| 52 | reportProgress: ( | |
| 53 | stage: ServerSelfUpdateProgressStage, | |
| 54 | ) => Effect.Effect<void, ServerSelfUpdateError>, | |
| 53 | 55 | ) => Effect.Effect<ServerSelfUpdateResult, ServerSelfUpdateError>; |
| 54 | 56 | /** Starts the prepared install. Success stops this server, so this effect |
| 55 | 57 | 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>; | |
| 57 | 62 | } |
| 58 | 63 | >()("t3/desktopUpdate/DesktopAppUpdate") {} |
| 59 | 64 | |
| ... | ... | @@ -72,11 +77,15 @@ export const make = Effect.fn("desktopUpdate.desktopAppUpdate.make")(function* ( |
| 72 | 77 | const consumeReports = ( |
| 73 | 78 | requestId: string, |
| 74 | 79 | changes: Stream.Stream<DesktopUpdateStatusReport>, |
| 75 | reportProgress: (stage: ServerSelfUpdateProgressStage) => Effect.Effect<void>, | |
| 80 | reportProgress: ( | |
| 81 | stage: ServerSelfUpdateProgressStage, | |
| 82 | ) => Effect.Effect<void, ServerSelfUpdateError>, | |
| 76 | 83 | ) => |
| 77 | 84 | Effect.gen(function* () { |
| 78 | 85 | 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> => | |
| 80 | 89 | stage === null |
| 81 | 90 | ? Effect.void |
| 82 | 91 | : Ref.get(lastStage).pipe( |
| ... | ... | @@ -90,7 +99,9 @@ export const make = Effect.fn("desktopUpdate.desktopAppUpdate.make")(function* ( |
| 90 | 99 | const terminal = yield* changes.pipe( |
| 91 | 100 | Stream.filter((report) => report.requestId === requestId), |
| 92 | 101 | Stream.mapEffect( |
| 93 | (report): Effect.Effect<Option.Option<DesktopUpdateStatusReport>> => | |
| 102 | ( | |
| 103 | report, | |
| 104 | ): Effect.Effect<Option.Option<DesktopUpdateStatusReport>, ServerSelfUpdateError> => | |
| 94 | 105 | report.outcome === undefined |
| 95 | 106 | ? emitStage(desktopUpdateProgressStage(report.state)).pipe( |
| 96 | 107 | Effect.as(Option.none<DesktopUpdateStatusReport>()), |
| ... | ... | @@ -176,7 +187,7 @@ export const make = Effect.fn("desktopUpdate.desktopAppUpdate.make")(function* ( |
| 176 | 187 | |
| 177 | 188 | const commit: DesktopAppUpdate["Service"]["commit"] = Effect.fn( |
| 178 | 189 | "desktopUpdate.desktopAppUpdate.commit", |
| 179 | )(function* (requestId) { | |
| 190 | )(function* (requestId, onHandoffAccepted = () => Effect.void) { | |
| 180 | 191 | if (!available) { |
| 181 | 192 | return yield* failWith("This server cannot commit a desktop app update."); |
| 182 | 193 | } |
| ... | ... | @@ -187,11 +198,12 @@ export const make = Effect.fn("desktopUpdate.desktopAppUpdate.make")(function* ( |
| 187 | 198 | onNone: () => changes, |
| 188 | 199 | onSome: (report) => Stream.concat(Stream.make(report), changes), |
| 189 | 200 | }); |
| 190 | yield* receiver | |
| 191 | .commitDesktopUpdate(requestId) | |
| 192 | .pipe( | |
| 201 | yield* Effect.uninterruptible( | |
| 202 | receiver.commitDesktopUpdate(requestId).pipe( | |
| 193 | 203 | Effect.mapError((error) => failWith("Could not reach the T3 Code desktop app.", error)), |
| 194 | ); | |
| 204 | Effect.tap(() => onHandoffAccepted()), | |
| 205 | ), | |
| 206 | ); | |
| 195 | 207 | return yield* reports.pipe( |
| 196 | 208 | Stream.filter((report) => report.requestId === requestId && report.outcome === "failed"), |
| 197 | 209 | Stream.runHead, |
apps/server/src/environment/ServerEnvironment.test.ts+2| ... | ... | @@ -244,11 +244,13 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { |
| 244 | 244 | expect(withFd.capabilities.serverSelfUpdate).toBe("desktop-managed"); |
| 245 | 245 | expect(withFd.capabilities.desktopAppUpdate).toBe(true); |
| 246 | 246 | expect(withFd.capabilities.serverSelfUpdateProgress).toBe(true); |
| 247 | expect(withFd.capabilities.serverUpdateThreadContinuation).toBe(true); | |
| 247 | 248 | |
| 248 | 249 | const withoutFd = yield* describeWith({ mode: "desktop" }); |
| 249 | 250 | expect(withoutFd.capabilities.serverSelfUpdate).toBe("desktop-managed"); |
| 250 | 251 | expect(withoutFd.capabilities.desktopAppUpdate).toBeUndefined(); |
| 251 | 252 | expect(withoutFd.capabilities.serverSelfUpdateProgress).toBeUndefined(); |
| 253 | expect(withoutFd.capabilities.serverUpdateThreadContinuation).toBeUndefined(); | |
| 252 | 254 | |
| 253 | 255 | const web = yield* describeWith({ mode: "web", desktopTelemetryControlFd: 5 }); |
| 254 | 256 | expect(web.capabilities.desktopAppUpdate).toBeUndefined(); |
apps/server/src/environment/ServerEnvironment.ts+4-1| ... | ... | @@ -224,7 +224,10 @@ export const make = Effect.gen(function* () { |
| 224 | 224 | threadPullRequestLinking: true, |
| 225 | 225 | ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), |
| 226 | 226 | ...(serverSelfUpdate === "boot-service" || desktopAppUpdate |
| 227 | ? { serverSelfUpdateProgress: true } | |
| 227 | ? { | |
| 228 | serverSelfUpdateProgress: true, | |
| 229 | serverUpdateThreadContinuation: true, | |
| 230 | } | |
| 228 | 231 | : {}), |
| 229 | 232 | ...(desktopAppUpdate ? { desktopAppUpdate: true } : {}), |
| 230 | 233 | }, |
apps/server/src/provider/Layers/CodexAdapter.ts+1| ... | ... | @@ -2010,6 +2010,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( |
| 2010 | 2010 | provider: PROVIDER, |
| 2011 | 2011 | capabilities: { |
| 2012 | 2012 | sessionModelSwitch: "in-session", |
| 2013 | promptlessTurnContinuation: true, | |
| 2013 | 2014 | }, |
| 2014 | 2015 | startSession, |
| 2015 | 2016 | sendTurn, |
apps/server/src/provider/Layers/ProviderService.test.ts+51| ... | ... | @@ -246,6 +246,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { |
| 246 | 246 | provider, |
| 247 | 247 | capabilities: { |
| 248 | 248 | sessionModelSwitch: "in-session", |
| 249 | ...(provider === CODEX_DRIVER ? { promptlessTurnContinuation: true } : {}), | |
| 249 | 250 | }, |
| 250 | 251 | startSession, |
| 251 | 252 | sendTurn, |
| ... | ... | @@ -960,6 +961,56 @@ it.effect( |
| 960 | 961 | ); |
| 961 | 962 | |
| 962 | 963 | routing.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 | ||
| 963 | 1014 | it.effect("routes provider operations and rollback conversation", () => |
| 964 | 1015 | Effect.gen(function* () { |
| 965 | 1016 | const provider = yield* ProviderService.ProviderService; |
apps/server/src/provider/Layers/ProviderService.ts+21-3| ... | ... | @@ -723,7 +723,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 723 | 723 | }); |
| 724 | 724 | |
| 725 | 725 | const attachments = parsed.attachments ?? []; |
| 726 | if (!parsed.input && attachments.length === 0) { | |
| 726 | if (!parsed.input && attachments.length === 0 && parsed.continuation !== true) { | |
| 727 | 727 | return yield* toValidationError( |
| 728 | 728 | "ProviderService.sendTurn", |
| 729 | 729 | "Either input text or at least one attachment is required", |
| ... | ... | @@ -777,11 +777,29 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 777 | 777 | let metricProvider = "unknown"; |
| 778 | 778 | let metricModel = input.modelSelection?.model; |
| 779 | 779 | return yield* Effect.gen(function* () { |
| 780 | const routed = yield* resolveRoutableSession({ | |
| 780 | let routed = yield* resolveRoutableSession({ | |
| 781 | 781 | threadId: input.threadId, |
| 782 | 782 | operation: "ProviderService.sendTurn", |
| 783 | allowRecovery: true, | |
| 783 | allowRecovery: false, | |
| 784 | 784 | }); |
| 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 | } | |
| 785 | 803 | metricProvider = routed.adapter.provider; |
| 786 | 804 | metricModel = input.modelSelection?.model; |
| 787 | 805 | yield* Effect.annotateCurrentSpan({ |
apps/server/src/provider/Services/ProviderAdapter.ts+3| ... | ... | @@ -32,6 +32,9 @@ export interface ProviderAdapterCapabilities { |
| 32 | 32 | * Declares whether changing the model on an existing session is supported. |
| 33 | 33 | */ |
| 34 | 34 | 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; | |
| 35 | 38 | } |
| 36 | 39 | |
| 37 | 40 | export interface ProviderThreadTurnSnapshot { |
apps/server/src/server.test.ts+2| ... | ... | @@ -937,6 +937,8 @@ const buildAppUnderTest = (options?: { |
| 937 | 937 | Layer.mock(ServerRuntimeStartup.ServerRuntimeStartup)({ |
| 938 | 938 | awaitCommandReady: Effect.void, |
| 939 | 939 | markHttpListening: Effect.void, |
| 940 | markRunningProviderSessionsForContinuation: Effect.succeed([]), | |
| 941 | clearProviderSessionContinuationMarkers: () => Effect.void, | |
| 940 | 942 | enqueueCommand: (effect) => effect, |
| 941 | 943 | ...options?.layers?.serverRuntimeStartup, |
| 942 | 944 | }), |
apps/server/src/serverRuntimeStartup.reconcile.test.ts+365-4| ... | ... | @@ -3,10 +3,12 @@ import { |
| 3 | 3 | type OrchestrationCommand, |
| 4 | 4 | ProviderDriverKind, |
| 5 | 5 | ProviderInstanceId, |
| 6 | type ProviderSendTurnInput, | |
| 6 | 7 | ThreadId, |
| 7 | 8 | TurnId, |
| 8 | 9 | } from "@t3tools/contracts"; |
| 9 | 10 | import { assert, it } from "@effect/vitest"; |
| 11 | import * as Deferred from "effect/Deferred"; | |
| 10 | 12 | import * as Effect from "effect/Effect"; |
| 11 | 13 | import * as Option from "effect/Option"; |
| 12 | 14 | import * as Stream from "effect/Stream"; |
| ... | ... | @@ -27,10 +29,12 @@ const makeThread = ( |
| 27 | 29 | status: "starting" | "running" | "ready" | "stopped" | "error", |
| 28 | 30 | activeTurnId: TurnId | null = null, |
| 29 | 31 | archivedAt: string | null = null, |
| 32 | deletedAt: string | null = null, | |
| 30 | 33 | ) => ({ |
| 31 | 34 | id: ThreadId.make(id), |
| 32 | 35 | archivedAt, |
| 33 | deletedAt: null, | |
| 36 | deletedAt, | |
| 37 | interactionMode: "default" as const, | |
| 34 | 38 | session: { |
| 35 | 39 | threadId: ThreadId.make(id), |
| 36 | 40 | status, |
| ... | ... | @@ -67,6 +71,7 @@ const queryWithThreads = (threads: ReadonlyArray<ReturnType<typeof makeThread>>) |
| 67 | 71 | const runReconciliation = (input: { |
| 68 | 72 | readonly threads: ReadonlyArray<ReturnType<typeof makeThread>>; |
| 69 | 73 | readonly liveThreadIds?: ReadonlyArray<ThreadId>; |
| 74 | readonly providerService?: ProviderService.ProviderService["Service"]; | |
| 70 | 75 | readonly directory: ProviderSessionDirectory.ProviderSessionDirectory["Service"]; |
| 71 | 76 | readonly dispatch: OrchestrationEngine.OrchestrationEngineService["Service"]["dispatch"]; |
| 72 | 77 | }) => |
| ... | ... | @@ -77,7 +82,7 @@ const runReconciliation = (input: { |
| 77 | 82 | ), |
| 78 | 83 | Effect.provideService( |
| 79 | 84 | ProviderService.ProviderService, |
| 80 | makeProviderService(input.liveThreadIds), | |
| 85 | input.providerService ?? makeProviderService(input.liveThreadIds), | |
| 81 | 86 | ), |
| 82 | 87 | Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, input.directory), |
| 83 | 88 | Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { |
| ... | ... | @@ -90,6 +95,347 @@ const runReconciliation = (input: { |
| 90 | 95 | Effect.provide(NodeServices.layer), |
| 91 | 96 | ); |
| 92 | 97 | |
| 98 | it.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 | ||
| 151 | it.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 | ||
| 310 | it.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 | ||
| 383 | it.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 | ||
| 93 | 439 | it.effect("reconciles multiple active and archived orphans but skips live sessions", () => { |
| 94 | 440 | const starting = makeThread("thread-starting", "starting"); |
| 95 | 441 | 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 |
| 123 | 469 | providerInstanceId, |
| 124 | 470 | status: "running" as const, |
| 125 | 471 | 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 | }, | |
| 127 | 479 | }), |
| 128 | 480 | ), |
| 129 | 481 | ), |
| ... | ... | @@ -157,7 +509,16 @@ it.effect("reconciles multiple active and archived orphans but skips live sessio |
| 157 | 509 | assert.equal(upserts.length, orphanIds.length); |
| 158 | 510 | for (const binding of upserts) { |
| 159 | 511 | 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 | ); | |
| 161 | 522 | assert.deepStrictEqual(binding.resumeCursor, { cursor: binding.threadId }); |
| 162 | 523 | } |
| 163 | 524 | }), |
apps/server/src/serverRuntimeStartup.ts+308-36| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | ProjectId, |
| 7 | 7 | ProviderInstanceId, |
| 8 | 8 | ThreadId, |
| 9 | TurnId, | |
| 9 | 10 | } from "@t3tools/contracts"; |
| 10 | 11 | import * as Cause from "effect/Cause"; |
| 11 | 12 | import * as Console from "effect/Console"; |
| ... | ... | @@ -65,6 +66,13 @@ export class ServerRuntimeStartup extends Context.Service< |
| 65 | 66 | { |
| 66 | 67 | readonly awaitCommandReady: Effect.Effect<void, ServerRuntimeStartupError>; |
| 67 | 68 | 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>; | |
| 68 | 76 | readonly enqueueCommand: <A, E>( |
| 69 | 77 | effect: Effect.Effect<A, E>, |
| 70 | 78 | ) => Effect.Effect<A, E | ServerRuntimeStartupError>; |
| ... | ... | @@ -294,6 +302,138 @@ const runStartupPhase = <A, E, R>(phase: string, effect: Effect.Effect<A, E, R>) |
| 294 | 302 | |
| 295 | 303 | const ORPHANED_PROVIDER_SESSION_ERROR = |
| 296 | 304 | "Provider session did not survive a server restart. Send a new message to continue."; |
| 305 | const SERVER_UPDATE_CONTINUATION_KEY = "continueAfterServerUpdate"; | |
| 306 | const SERVER_UPDATE_CONTINUATION_PROMPT = "Continue where you left off."; | |
| 307 | ||
| 308 | class 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 | ||
| 319 | export 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 | ||
| 330 | function 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 | ||
| 341 | function 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 | ||
| 349 | const isServerUpdateThreadContinuationError = Schema.is(ServerUpdateThreadContinuationError); | |
| 350 | ||
| 351 | function 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 | ||
| 359 | const toServerUpdateThreadContinuationError = (cause: unknown) => | |
| 360 | isServerUpdateThreadContinuationError(cause) | |
| 361 | ? cause | |
| 362 | : new ServerUpdateThreadContinuationError({ cause }); | |
| 363 | ||
| 364 | export 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 | ||
| 407 | const 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 | ||
| 432 | export 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)); | |
| 297 | 437 | |
| 298 | 438 | export const reconcileProviderSessions = Effect.gen(function* () { |
| 299 | 439 | const crypto = yield* Crypto.Crypto; |
| ... | ... | @@ -320,52 +460,165 @@ export const reconcileProviderSessions = Effect.gen(function* () { |
| 320 | 460 | if (session === null) { |
| 321 | 461 | continue; |
| 322 | 462 | } |
| 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( | |
| 333 | 464 | Effect.catchCause((cause) => |
| 334 | 465 | Cause.hasInterrupts(cause) |
| 335 | 466 | ? 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", { | |
| 337 | 468 | threadId: thread.id, |
| 338 | 469 | cause, |
| 339 | }), | |
| 470 | }).pipe(Effect.as(Option.none())), | |
| 340 | 471 | ), |
| 341 | 472 | ); |
| 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 | ); | |
| 342 | 505 | |
| 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 | ); | |
| 357 | 532 | }); |
| 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({ | |
| 364 | 587 | 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); | |
| 369 | 622 | } |
| 370 | 623 | }).pipe( |
| 371 | 624 | Effect.catchCause((cause) => |
| ... | ... | @@ -390,6 +643,8 @@ export const make = (options?: StartupOptions) => |
| 390 | 643 | const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; |
| 391 | 644 | const serverSettings = yield* ServerSettings.ServerSettingsService; |
| 392 | 645 | const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; |
| 646 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; | |
| 647 | const providerSessionDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory; | |
| 393 | 648 | const crypto = yield* Crypto.Crypto; |
| 394 | 649 | const launcher = yield* ServiceLauncherClient.ServiceLauncherClient; |
| 395 | 650 | |
| ... | ... | @@ -577,6 +832,23 @@ export const make = (options?: StartupOptions) => |
| 577 | 832 | return { |
| 578 | 833 | awaitCommandReady: commandGate.awaitCommandReady, |
| 579 | 834 | 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 | ), | |
| 580 | 852 | enqueueCommand: commandGate.enqueueCommand, |
| 581 | 853 | } satisfies ServerRuntimeStartup["Service"]; |
| 582 | 854 | }); |
apps/server/src/ws.ts+31-6| ... | ... | @@ -50,7 +50,7 @@ import { |
| 50 | 50 | ProviderUploadFeedbackError, |
| 51 | 51 | RelayClientInstallFailedError, |
| 52 | 52 | type RelayClientInstallProgressEvent, |
| 53 | type ServerSelfUpdateError, | |
| 53 | ServerSelfUpdateError, | |
| 54 | 54 | type ServerSelfUpdateProgressEvent, |
| 55 | 55 | type FilesystemBrowseFailure, |
| 56 | 56 | FilesystemBrowseError, |
| ... | ... | @@ -509,7 +509,7 @@ const makeWsRpcLayer = ( |
| 509 | 509 | const providerRegistry = yield* ProviderRegistry.ProviderRegistry; |
| 510 | 510 | const providerService = yield* ProviderService.ProviderService; |
| 511 | 511 | const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; |
| 512 | const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; | |
| 512 | const serverUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; | |
| 513 | 513 | const config = yield* ServerConfig.ServerConfig; |
| 514 | 514 | const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; |
| 515 | 515 | const serverSettings = yield* ServerSettings.ServerSettingsService; |
| ... | ... | @@ -1700,14 +1700,14 @@ const makeWsRpcLayer = ( |
| 1700 | 1700 | }, |
| 1701 | 1701 | ), |
| 1702 | 1702 | [WS_METHODS.serverUpdateServer]: (input) => |
| 1703 | observeRpcEffect(WS_METHODS.serverUpdateServer, serverSelfUpdate.update(input), { | |
| 1703 | observeRpcEffect(WS_METHODS.serverUpdateServer, serverUpdate.update(input), { | |
| 1704 | 1704 | "rpc.aggregate": "server", |
| 1705 | 1705 | }), |
| 1706 | 1706 | [WS_METHODS.serverUpdateServerWithProgress]: (input) => |
| 1707 | 1707 | observeRpcStream( |
| 1708 | 1708 | WS_METHODS.serverUpdateServerWithProgress, |
| 1709 | 1709 | Stream.callback<ServerSelfUpdateProgressEvent, ServerSelfUpdateError>((queue) => |
| 1710 | serverSelfUpdate | |
| 1710 | serverUpdate | |
| 1711 | 1711 | .update(input, (stage) => |
| 1712 | 1712 | Queue.offer(queue, { |
| 1713 | 1713 | type: "progress", |
| ... | ... | @@ -1733,7 +1733,7 @@ const makeWsRpcLayer = ( |
| 1733 | 1733 | [WS_METHODS.serverCommitDesktopUpdate]: (input) => |
| 1734 | 1734 | observeRpcEffect( |
| 1735 | 1735 | WS_METHODS.serverCommitDesktopUpdate, |
| 1736 | serverSelfUpdate.commitDesktopUpdate(input.requestId), | |
| 1736 | serverUpdate.commitDesktopUpdate(input.requestId), | |
| 1737 | 1737 | { "rpc.aggregate": "server" }, |
| 1738 | 1738 | ), |
| 1739 | 1739 | [WS_METHODS.serverUpsertKeybinding]: (rule) => |
| ... | ... | @@ -2578,7 +2578,32 @@ const makeWsRpcLayer = ( |
| 2578 | 2578 | export const websocketRpcRouteLayer = Layer.unwrap( |
| 2579 | 2579 | Effect.gen(function* () { |
| 2580 | 2580 | 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 | }); | |
| 2582 | 2607 | const pullRequests = yield* PullRequestService.PullRequestService; |
| 2583 | 2608 | return HttpRouter.add( |
| 2584 | 2609 | "GET", |
apps/web/src/components/ChatView.tsx+4| ... | ... | @@ -428,6 +428,7 @@ import { |
| 428 | 428 | resolveServerSelfUpdateCapability, |
| 429 | 429 | serverUpdateGuidance, |
| 430 | 430 | supportsDesktopAppUpdate, |
| 431 | supportsServerUpdateThreadContinuation, | |
| 431 | 432 | } from "../versionSkew"; |
| 432 | 433 | import { useAssetUrls } from "../assets/assetUrls"; |
| 433 | 434 | |
| ... | ... | @@ -2221,6 +2222,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 2221 | 2222 | const serverUpdateEnvironmentId = activeThread?.environmentId ?? null; |
| 2222 | 2223 | const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig); |
| 2223 | 2224 | const versionMismatchDesktopAppUpdate = supportsDesktopAppUpdate(serverConfig); |
| 2225 | const versionMismatchThreadContinuation = supportsServerUpdateThreadContinuation(serverConfig); | |
| 2224 | 2226 | const serverUpdateState = useAtomValue( |
| 2225 | 2227 | serverEnvironment.updateStateAtom(serverUpdateEnvironmentId), |
| 2226 | 2228 | ); |
| ... | ... | @@ -2356,6 +2358,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 2356 | 2358 | serverLabel={versionMismatchServerLabel} |
| 2357 | 2359 | selfUpdate={versionMismatchSelfUpdate} |
| 2358 | 2360 | desktopAppUpdate={versionMismatchDesktopAppUpdate} |
| 2361 | threadContinuation={versionMismatchThreadContinuation} | |
| 2359 | 2362 | targetVersion={versionMismatch.clientVersion} |
| 2360 | 2363 | label={updateFailed ? "Retry" : "Update"} |
| 2361 | 2364 | variant="ghost" |
| ... | ... | @@ -2391,6 +2394,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 2391 | 2394 | serverUpdateEnvironmentId, |
| 2392 | 2395 | versionMismatchSelfUpdate, |
| 2393 | 2396 | versionMismatchDesktopAppUpdate, |
| 2397 | versionMismatchThreadContinuation, | |
| 2394 | 2398 | versionMismatchServerLabel, |
| 2395 | 2399 | ]); |
| 2396 | 2400 | 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"; |
| 8 | 8 | const testState = vi.hoisted(() => ({ |
| 9 | 9 | updateServer: vi.fn(), |
| 10 | 10 | toast: vi.fn(), |
| 11 | continueThreadsAfterServerUpdate: false, | |
| 11 | 12 | })); |
| 12 | 13 | |
| 13 | 14 | vi.mock("~/hooks/useCopyToClipboard", () => ({ |
| 14 | 15 | useCopyToClipboard: () => ({ copyToClipboard: vi.fn() }), |
| 15 | 16 | })); |
| 17 | vi.mock("~/hooks/useSettings", () => ({ | |
| 18 | useClientSettings: ( | |
| 19 | selector: (settings: { continueThreadsAfterServerUpdate: boolean }) => unknown, | |
| 20 | ) => selector({ continueThreadsAfterServerUpdate: testState.continueThreadsAfterServerUpdate }), | |
| 21 | })); | |
| 16 | 22 | vi.mock("~/state/server", () => ({ |
| 17 | 23 | serverEnvironment: { updateServer: Symbol("updateServer") }, |
| 18 | 24 | })); |
| ... | ... | @@ -47,6 +53,7 @@ describe("ServerUpdateAction", () => { |
| 47 | 53 | beforeEach(() => { |
| 48 | 54 | testState.updateServer.mockReset(); |
| 49 | 55 | testState.toast.mockReset(); |
| 56 | testState.continueThreadsAfterServerUpdate = false; | |
| 50 | 57 | }); |
| 51 | 58 | |
| 52 | 59 | it("reports success only after the shared update flow reconnects", async () => { |
| ... | ... | @@ -141,6 +148,49 @@ describe("ServerUpdateAction", () => { |
| 141 | 148 | description: "Desktop app relaunched on 0.0.34.", |
| 142 | 149 | }); |
| 143 | 150 | }); |
| 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 | }); | |
| 144 | 194 | }); |
| 145 | 195 | |
| 146 | 196 | describe("ServerUpdateProgress", () => { |
apps/web/src/components/ServerUpdateAction.tsx+13-1| ... | ... | @@ -8,6 +8,7 @@ import type { ComponentProps } from "react"; |
| 8 | 8 | |
| 9 | 9 | import { requestConfirmDialog } from "~/confirmDialog"; |
| 10 | 10 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 11 | import { useClientSettings } from "~/hooks/useSettings"; | |
| 11 | 12 | import { serverEnvironment } from "~/state/server"; |
| 12 | 13 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 13 | 14 | import { manualServerUpdateCommand } from "~/versionSkew"; |
| ... | ... | @@ -78,6 +79,7 @@ export function ServerUpdateAction({ |
| 78 | 79 | serverLabel, |
| 79 | 80 | selfUpdate, |
| 80 | 81 | desktopAppUpdate = false, |
| 82 | threadContinuation = false, | |
| 81 | 83 | targetVersion, |
| 82 | 84 | label = "Update", |
| 83 | 85 | variant = "outline", |
| ... | ... | @@ -88,11 +90,16 @@ export function ServerUpdateAction({ |
| 88 | 90 | /** The desktop app supervising this server accepts remote update |
| 89 | 91 | requests (capabilities.desktopAppUpdate). */ |
| 90 | 92 | readonly desktopAppUpdate?: boolean; |
| 93 | /** The server can durably continue running provider turns after updating. */ | |
| 94 | readonly threadContinuation?: boolean; | |
| 91 | 95 | readonly targetVersion: string; |
| 92 | 96 | readonly label?: string; |
| 93 | 97 | readonly variant?: ComponentProps<typeof Button>["variant"]; |
| 94 | 98 | }) { |
| 95 | 99 | const isDesktopAppUpdate = selfUpdate === "desktop-managed"; |
| 100 | const continueThreadsAfterServerUpdate = useClientSettings( | |
| 101 | (settings) => settings.continueThreadsAfterServerUpdate, | |
| 102 | ); | |
| 96 | 103 | const updateServer = useAtomCommand(serverEnvironment.updateServer, { |
| 97 | 104 | reportFailure: false, |
| 98 | 105 | }); |
| ... | ... | @@ -137,7 +144,12 @@ export function ServerUpdateAction({ |
| 137 | 144 | try { |
| 138 | 145 | const result = await updateServer({ |
| 139 | 146 | environmentId, |
| 140 | input: { targetVersion }, | |
| 147 | input: { | |
| 148 | targetVersion, | |
| 149 | ...(threadContinuation && continueThreadsAfterServerUpdate | |
| 150 | ? { continueRunningThreads: true } | |
| 151 | : {}), | |
| 152 | }, | |
| 141 | 153 | }); |
| 142 | 154 | if (result._tag === "Failure") { |
| 143 | 155 | if (isAtomCommandInterrupted(result)) { |
apps/web/src/components/settings/ConnectionsSettings.tsx+5| ... | ... | @@ -105,6 +105,7 @@ import { |
| 105 | 105 | resolveServerConfigVersionMismatch, |
| 106 | 106 | resolveServerSelfUpdateCapability, |
| 107 | 107 | supportsDesktopAppUpdate, |
| 108 | supportsServerUpdateThreadContinuation, | |
| 108 | 109 | } from "~/versionSkew"; |
| 109 | 110 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; |
| 110 | 111 | import { useCloudLinkController } from "~/cloud/useCloudLinkController"; |
| ... | ... | @@ -1487,6 +1488,7 @@ function SavedBackendListRow({ |
| 1487 | 1488 | serverLabel={`${environment.label} server`} |
| 1488 | 1489 | selfUpdate={resolveServerSelfUpdateCapability(environment.serverConfig)} |
| 1489 | 1490 | desktopAppUpdate={supportsDesktopAppUpdate(environment.serverConfig)} |
| 1491 | threadContinuation={supportsServerUpdateThreadContinuation(environment.serverConfig)} | |
| 1490 | 1492 | targetVersion={versionMismatch.clientVersion} |
| 1491 | 1493 | label={serverUpdateState.status === "failed" ? "Retry" : "Update"} |
| 1492 | 1494 | /> |
| ... | ... | @@ -3064,6 +3066,9 @@ export function ConnectionsSettings() { |
| 3064 | 3066 | } |
| 3065 | 3067 | selfUpdate={resolveServerSelfUpdateCapability(primaryServerConfig)} |
| 3066 | 3068 | desktopAppUpdate={supportsDesktopAppUpdate(primaryServerConfig)} |
| 3069 | threadContinuation={supportsServerUpdateThreadContinuation( | |
| 3070 | primaryServerConfig, | |
| 3071 | )} | |
| 3067 | 3072 | targetVersion={primaryVersionMismatch.clientVersion} |
| 3068 | 3073 | label={primaryServerUpdateState.status === "failed" ? "Retry" : "Update"} |
| 3069 | 3074 | /> |
apps/web/src/components/settings/SettingsPanels.tsx+34| ... | ... | @@ -533,6 +533,10 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 533 | 533 | DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks |
| 534 | 534 | ? ["Provider update checks"] |
| 535 | 535 | : []), |
| 536 | ...(settings.continueThreadsAfterServerUpdate !== | |
| 537 | DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate | |
| 538 | ? ["Continue threads after server updates"] | |
| 539 | : []), | |
| 536 | 540 | ...(isBackgroundActivityDirty ? ["Background activity"] : []), |
| 537 | 541 | ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode |
| 538 | 542 | ? ["New thread mode"] |
| ... | ... | @@ -591,6 +595,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 591 | 595 | settings.glassOpacity, |
| 592 | 596 | settings.enableLegacyTokenStreaming, |
| 593 | 597 | settings.enableProviderUpdateChecks, |
| 598 | settings.continueThreadsAfterServerUpdate, | |
| 594 | 599 | settings.sidebarAutoSettleAfterDays, |
| 595 | 600 | settings.sidebarAutoSettleOnMerge, |
| 596 | 601 | settings.sidebarProjectGroupingMode, |
| ... | ... | @@ -681,6 +686,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 681 | 686 | sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, |
| 682 | 687 | enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming, |
| 683 | 688 | enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, |
| 689 | continueThreadsAfterServerUpdate: DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate, | |
| 684 | 690 | backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, |
| 685 | 691 | backgroundActivityProfile: DEFAULT_UNIFIED_SETTINGS.backgroundActivityProfile, |
| 686 | 692 | automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, |
| ... | ... | @@ -2185,6 +2191,34 @@ export function GeneralSettingsPanel() { |
| 2185 | 2191 | } |
| 2186 | 2192 | /> |
| 2187 | 2193 | |
| 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 | ||
| 2188 | 2222 | <SettingsRow |
| 2189 | 2223 | serverScoped |
| 2190 | 2224 | id={searchableSetting("background-activity").id} |
apps/web/src/components/settings/settingsSearch.ts+6| ... | ... | @@ -191,6 +191,12 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 191 | 191 | to: "/settings/general", |
| 192 | 192 | searchTerms: ["installed cli versions newer available codex claude cursor grok opencode"], |
| 193 | 193 | }, |
| 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 | }, | |
| 194 | 200 | { |
| 195 | 201 | id: "background-activity", |
| 196 | 202 | title: "Background activity", |
apps/web/src/versionSkew.ts+8| ... | ... | @@ -106,6 +106,14 @@ export function supportsDesktopAppUpdate( |
| 106 | 106 | return serverConfig?.environment.capabilities.desktopAppUpdate === true; |
| 107 | 107 | } |
| 108 | 108 | |
| 109 | /** True when the connected server can recover opted-in running turns after | |
| 110 | its self-update restart. */ | |
| 111 | export function supportsServerUpdateThreadContinuation( | |
| 112 | serverConfig: Pick<ServerConfig, "environment"> | null | undefined, | |
| 113 | ): boolean { | |
| 114 | return serverConfig?.environment.capabilities.serverUpdateThreadContinuation === true; | |
| 115 | } | |
| 116 | ||
| 109 | 117 | /** The command to hand users whose server cannot update itself. */ |
| 110 | 118 | export function manualServerUpdateCommand(targetVersion: string): string { |
| 111 | 119 | 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. |
| 15 | 15 | |
| 16 | 16 | ## Before You Update |
| 17 | 17 | |
| 18 | Let active agent work and terminal commands finish first. Updating restarts the server, so the | |
| 19 | connection will disappear briefly and work that is still running may be interrupted. | |
| 18 | Updating restarts the server, so the connection will disappear briefly. **Settings** → **General** | |
| 19 | has a **Continue threads after server updates** preference. It is off by default. When enabled, the | |
| 20 | update buttons automatically resume supported provider threads after the replacement server is | |
| 21 | ready. Providers with native promptless continuation use it; other providers receive a short | |
| 22 | instruction to continue where they left off. Terminal commands and other running work may still be | |
| 23 | interrupted during the update. | |
| 20 | 24 | |
| 21 | 25 | The update does not remove saved threads, settings, or project files. |
| 22 | 26 |
packages/contracts/src/environment.ts+3| ... | ... | @@ -93,6 +93,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ |
| 93 | 93 | /** Server can stream self-update progress before acknowledging the |
| 94 | 94 | restart. Clients fall back to server.updateServer when absent. */ |
| 95 | 95 | 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), | |
| 96 | 99 | /** Agent-activity publishes (push notifications and Live Activities) |
| 97 | 100 | currently leave this environment: the publish opt-in is enabled and the |
| 98 | 101 | 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; |
| 67 | 67 | |
| 68 | 68 | export const ProviderSendTurnInput = Schema.Struct({ |
| 69 | 69 | 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), | |
| 70 | 73 | input: Schema.optional( |
| 71 | 74 | TrimmedNonEmptyString.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)), |
| 72 | 75 | ), |
packages/contracts/src/server.ts+4| ... | ... | @@ -754,6 +754,10 @@ export const ServerSelfUpdateInput = Schema.Struct({ |
| 754 | 754 | /** Exact npm version of the `t3` package to install (never a dist-tag, so |
| 755 | 755 | the server and the acknowledging client agree on what was requested). */ |
| 756 | 756 | 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), | |
| 757 | 761 | }); |
| 758 | 762 | export type ServerSelfUpdateInput = typeof ServerSelfUpdateInput.Type; |
| 759 | 763 |
packages/contracts/src/settings.ts+4| ... | ... | @@ -207,6 +207,9 @@ export const ClientSettingsSchema = Schema.Struct({ |
| 207 | 207 | confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 208 | 208 | confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 209 | 209 | confirmThreadUnpin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 210 | continueThreadsAfterServerUpdate: Schema.Boolean.pipe( | |
| 211 | Schema.withDecodingDefault(Effect.succeed(false)), | |
| 212 | ), | |
| 210 | 213 | dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( |
| 211 | 214 | Schema.withDecodingDefault(Effect.succeed([])), |
| 212 | 215 | ), |
| ... | ... | @@ -959,6 +962,7 @@ export const ClientSettingsPatch = Schema.Struct({ |
| 959 | 962 | confirmThreadArchive: Schema.optionalKey(Schema.Boolean), |
| 960 | 963 | confirmThreadDelete: Schema.optionalKey(Schema.Boolean), |
| 961 | 964 | confirmThreadUnpin: Schema.optionalKey(Schema.Boolean), |
| 965 | continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), | |
| 962 | 966 | diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), |
| 963 | 967 | environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode), |
| 964 | 968 | glassOpacity: Schema.optionalKey(GlassOpacity), |