From b9ad3daf8d489604e0eca9d32a7e08af7c4e9fb7 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 11 Oct 2026 19:46:05 -0700 Subject: [PATCH] fix(server): cap automatic thread continuations and turn them on by default (#18461) Co-authored-by: Claude Opus 5.5 --- .../FoundationPersistence.test.ts | 2 + .../ProjectionRecovery.test.ts | 2 +- .../ProviderRuntimeRecoveryService.ts | 4 +- .../RestartContinuation.test.ts | 169 ++++++++++++++++-- .../orchestration-v2/RestartContinuation.ts | 96 +++++++++- apps/server/src/serverSettings.test.ts | 6 +- apps/server/src/serverSettings.ts | 2 + docs/internals/server-updates.md | 13 +- docs/user/updating.md | 9 +- packages/contracts/src/settings.ts | 5 +- 10 files changed, 270 insertions(+), 38 deletions(-) diff --git a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts index f791977235c95f8afcc4e1c90e75f050ad62822d..d6bd2b1b823f31ef924bb7f6f4369259819a4173 100644 --- a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts +++ b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts @@ -2779,6 +2779,8 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { { id: sessionId, driver: "codex", providerInstanceId, status: "running" }, ], providerTurns: [{ providerThreadId, runAttemptId: attemptId, status: "running" }], + runtimeRequests: [], + turnItems: [], } as unknown as OrchestrationV2ThreadProjection; const recovery = yield* ProviderRuntimeRecovery.make.pipe( Effect.provide( diff --git a/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts b/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts index 2ec0533e4b8ab78fc4951b7e3614f56089404752..95efab067f807e5c6557ffa75473ad25efcbbebe 100644 --- a/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts @@ -502,7 +502,7 @@ it.effect("includes shared sessions and provider-owned background rosters in rec preparedState.providerSessions.map((session) => session.id), [preparedSessionId], ); - assert.equal(restartContinuationRun(preparedState)?.id, preparedRunId); + assert.equal(restartContinuationRun(preparedState, yield* DateTime.now)?.id, preparedRunId); assert.deepEqual(yield* projections.getUnreadableThreadIds(), []); const sql = yield* SqlClient.SqlClient; yield* sql` diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts index b3c74621302e2f4f203c498df934ad969442a3c9..ad57247e03847eb27b2cde47ea30fe3680eedf07 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts @@ -561,7 +561,7 @@ export const make = Effect.gen(function* () { } const continuationRun = continueAfterRestart && trigger === "startup" - ? restartContinuationRun(projection) + ? restartContinuationRun(projection, now) : undefined; const effects: Array = continuationRun ? [ @@ -697,7 +697,7 @@ export const make = Effect.gen(function* () { .continueThreadsAfterServerUpdate ) return; - const run = restartContinuationRun(projection); + const run = restartContinuationRun(projection, yield* DateTime.now); if (!run) return; const commandId = CommandId.make(`command:restart-prepare:${run.id}`); yield* eventSink.writeWithEffects({ diff --git a/apps/server/src/orchestration-v2/RestartContinuation.test.ts b/apps/server/src/orchestration-v2/RestartContinuation.test.ts index 34b608f961e5fa607be0319d5cf4cf046245444e..eb03389adc62646b23f044f53875072294a8e419 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.test.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.test.ts @@ -1,6 +1,7 @@ import { assert, it } from "@effect/vitest"; import { MessageId, + NodeId, ProjectId, ProviderDriverKind, ProviderInstanceId, @@ -9,6 +10,7 @@ import { ProviderTurnId, RunAttemptId, RunId, + RuntimeRequestId, ThreadId, type OrchestrationV2Command, type OrchestrationV2ThreadProjection, @@ -35,6 +37,7 @@ const sessionId = ProviderSessionId.make("session:restart"); const attemptId = RunAttemptId.make("attempt:restart"); // "No project" threads belong to the environment's Scratch project. const scratchProjectId = ProjectId.make("project:scratch"); +const now = DateTime.makeUnsafe("2026-10-03T12:00:00.000Z"); function makeProjection() { return { @@ -90,7 +93,7 @@ function makeProjection() { it("requires matching saved native state for an unfinished root run", () => { const projection = makeProjection(); - assert.equal(restartContinuationRun(projection)?.id, runId); + assert.equal(restartContinuationRun(projection, now)?.id, runId); for (const invalid of [ { ...projection, thread: { ...projection.thread, archivedAt: {} } }, { ...projection, thread: { ...projection.thread, deletedAt: {} } }, @@ -123,7 +126,7 @@ it("requires matching saved native state for an unfinished root run", () => { "interrupted", ].map((status) => ({ ...projection, runs: [{ ...projection.runs[0]!, status }] })), ]) - assert.isUndefined(restartContinuationRun(invalid as OrchestrationV2ThreadProjection)); + assert.isUndefined(restartContinuationRun(invalid as OrchestrationV2ThreadProjection, now)); }); it("continues a live turn whose session the adapter never marked running", () => { @@ -135,9 +138,9 @@ it("continues a live turn whose session the adapter never marked running", () => providerSessions: [{ ...projection.providerSessions[0]!, status }], }) as OrchestrationV2ThreadProjection; for (const status of ["starting", "ready", "running", "waiting"]) - assert.equal(restartContinuationRun(withSessionStatus(status))?.id, runId, status); + assert.equal(restartContinuationRun(withSessionStatus(status), now)?.id, runId, status); for (const status of ["stopped", "error"]) - assert.isUndefined(restartContinuationRun(withSessionStatus(status)), status); + assert.isUndefined(restartContinuationRun(withSessionStatus(status), now), status); }); it("recovers an admitted continuation after another crash before provider start", () => { @@ -155,7 +158,7 @@ it("recovers an admitted continuation after another crash before provider start" providerSessions: [{ ...projection.providerSessions[0]!, status: "stopped" as const }], providerTurns: [], }; - assert.equal(restartContinuationRun(starting)?.id, runId); + assert.equal(restartContinuationRun(starting, now)?.id, runId); }); it("does not continue settled root runs with restart-cancelled background work", () => { @@ -176,11 +179,14 @@ it("does not continue settled root runs with restart-cancelled background work", for (const projectId of [scratchProjectId, projection.thread.projectId]) for (const status of ["completed", "waiting"] as const) assert.isUndefined( - restartContinuationRun({ - ...settled, - thread: { ...settled.thread, projectId }, - runs: [{ ...settled.runs[0]!, status }], - }), + restartContinuationRun( + { + ...settled, + thread: { ...settled.thread, projectId }, + runs: [{ ...settled.runs[0]!, status }], + }, + now, + ), ); }); @@ -663,10 +669,13 @@ it.effect("continues a cut run past queued follow-ups, which stay held", () => Effect.gen(function* () { const live = makeProjection(); assert.equal( - restartContinuationRun({ - ...live, - runs: [...live.runs, queuedFollowUp], - } as unknown as OrchestrationV2ThreadProjection)?.id, + restartContinuationRun( + { + ...live, + runs: [...live.runs, queuedFollowUp], + } as unknown as OrchestrationV2ThreadProjection, + now, + )?.id, runId, ); const cut = cutMidTurn(); @@ -692,10 +701,13 @@ it.effect("continues a resumed queued run that ran after an earlier continuation }; const live = makeProjection(); assert.equal( - restartContinuationRun({ - ...live, - runs: [...live.runs, finishedContinuation], - } as unknown as OrchestrationV2ThreadProjection)?.id, + restartContinuationRun( + { + ...live, + runs: [...live.runs, finishedContinuation], + } as unknown as OrchestrationV2ThreadProjection, + now, + )?.id, runId, ); const cut = cutMidTurn({ completedAt: DateTime.makeUnsafe("2026-10-03T10:05:00.000Z") }); @@ -835,3 +847,124 @@ it.effect("prepares later threads' continuations when one thread fails", () => ); }), ); + +it.effect("stops continuing after three automatic restart continuations in a row", () => + Effect.gen(function* () { + const first = RunId.make("run:first-continuation"); + const second = RunId.make("run:second-continuation"); + const cut = cutMidTurn({ restartContinuationOfRunId: second }); + const withTwoLinks = { + ...cut, + runs: [ + ...cut.runs, + { ...cut.runs[0]!, id: second, restartContinuationOfRunId: first }, + { ...cut.runs[0]!, id: first, restartContinuationOfRunId: undefined }, + ], + } as unknown as OrchestrationV2ThreadProjection; + assert.lengthOf(yield* continuationTexts(withTwoLinks), 1); + + const withThreeLinks = { + ...withTwoLinks, + runs: withTwoLinks.runs.map((run) => + run.id === first ? { ...run, restartContinuationOfRunId: RunId.make("run:root") } : run, + ), + } as unknown as OrchestrationV2ThreadProjection; + assert.lengthOf(yield* continuationTexts(withThreeLinks), 0); + }), +); + +it.effect.each([ + ["user_input", "pending", "live", false], + ["user_input", "expired", "not_resumable", false], + ["user_input", "cancelled", "not_resumable", false], + ["command", "pending", "live", false], + ["command", "expired", "not_resumable", false], + ["user_input", "resolved", "live", true], + ["command", "cancelled", "live", true], + ["user_input", "pending", "message", true], + ["dynamic_tool_call", "pending", "live", true], + ["auth_refresh", "pending", "live", true], +] as const)( + "a %s request that is %s with %s capability allows continuation: %s", + ([kind, status, capability, shouldContinue]) => + Effect.gen(function* () { + const base = makeProjection(); + const runtimeRequests = [ + { + id: RuntimeRequestId.make("request:restart"), + nodeId: NodeId.make("node:restart-question"), + providerTurnId: base.providerTurns[0]!.id, + kind, + status, + responseCapability: + capability === "live" + ? { type: capability, providerSessionId: sessionId } + : capability === "not_resumable" + ? { type: capability, reason: "The server restarted." } + : { type: capability }, + }, + ]; + const live = { ...base, runtimeRequests } as unknown as OrchestrationV2ThreadProjection; + assert.equal(restartContinuationRun(live, now) !== undefined, shouldContinue); + const cut = { + ...cutMidTurn(), + runtimeRequests, + } as unknown as OrchestrationV2ThreadProjection; + assert.lengthOf(yield* continuationTexts(cut), shouldContinue ? 1 : 0); + }), +); + +it.effect("does not wake a snoozed or settled thread, but continues once a snooze elapsed", () => + Effect.gen(function* () { + const live = makeProjection(); + for (const held of [ + { snoozedUntil: DateTime.add(now, { days: 1 }) }, + { settledOverride: "settled" as const }, + ]) { + const thread = { ...live.thread, ...held }; + assert.isUndefined(restartContinuationRun({ ...live, thread }, now)); + assert.deepEqual(yield* continuationTexts({ ...cutMidTurn(), thread }), []); + } + const thread = { ...live.thread, snoozedUntil: DateTime.subtract(now, { minutes: 1 }) }; + assert.equal(restartContinuationRun({ ...live, thread }, now)?.id, runId); + }), +); + +it.effect("does not continue a run waiting on a secret the restart closed", () => + Effect.gen(function* () { + const completedAt = DateTime.makeUnsafe("2026-10-03T10:05:00.000Z"); + const secret = (secretStatus: string, at: DateTime.Utc | null) => + ({ type: "secret_request", runId, secretStatus, completedAt: at }) as never; + assert.isUndefined( + restartContinuationRun({ ...makeProjection(), turnItems: [secret("pending", null)] }, now), + ); + const cut = cutMidTurn({ completedAt }); + assert.deepEqual( + yield* continuationTexts({ ...cut, turnItems: [secret("cancelled", completedAt)] }), + [], + ); + // A secret the user dismissed earlier in the run does not hold it. + assert.lengthOf( + yield* continuationTexts({ + ...cut, + turnItems: [secret("cancelled", DateTime.subtract(completedAt, { seconds: 1 }))], + }), + 1, + ); + }), +); + +it.effect.each(["completed", "failed", "interrupted"] as const)( + "does not continue when the provider turn %s before the app run settled", + (status) => + Effect.gen(function* () { + const cut = cutMidTurn(); + assert.deepEqual( + yield* continuationTexts({ + ...cut, + providerTurns: [{ ...cut.providerTurns[0]!, status }], + }), + [], + ); + }), +); diff --git a/apps/server/src/orchestration-v2/RestartContinuation.ts b/apps/server/src/orchestration-v2/RestartContinuation.ts index 19a257883cdc1ba41899bd9fe7c208d61efe255b..cb435d10435086f9f34e5e180144c7c84f1513f9 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.ts @@ -2,11 +2,13 @@ import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { CommandId, + latestProviderTurnForAttempt, MessageId, type OrchestrationV2Run, type RunId, type ThreadId, } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import type { ProjectionRuntimeRecoveryState } from "./ProjectionStore.ts"; @@ -21,6 +23,64 @@ import { const CONTINUE_PROMPT = "Continue where you left off."; +/** Consecutive automatic continuations allowed before a person has to step in. */ +const MAX_CONTINUATION_CHAIN = 3; + +/** + * Whether a restart must leave this run for the user: the thread is put away, + * the provider turn already ended, or the user owes an answer. Checked when + * intent is recorded and again when the continuation is delivered. + */ +function restartContinuationBlocked( + projection: Pick< + ProjectionRuntimeRecoveryState, + "thread" | "runtimeRequests" | "providerTurns" | "turnItems" + >, + source: OrchestrationV2Run, + now: DateTime.Utc, +): boolean { + const { thread } = projection; + if ( + thread.archivedAt !== null || + thread.deletedAt !== null || + thread.settledOverride === "settled" || + (thread.snoozedUntil != null && DateTime.isGreaterThan(thread.snoozedUntil, now)) + ) + return true; + const turn = latestProviderTurnForAttempt(projection.providerTurns, source.activeAttemptId); + if ( + turn !== undefined && + (turn.status === "completed" || turn.status === "failed" || turn.status === "interrupted") + ) + return true; + const waitingRequest = projection.runtimeRequests.some( + (request) => + // Tool calls and auth refreshes do not wait on a person, and a + // message-capable question stays answerable after the restart. + request.kind !== "dynamic_tool_call" && + request.kind !== "auth_refresh" && + request.responseCapability.type !== "message" && + (request.status === "pending" || + // Recovery closes live callbacks on the cut turn; it does not answer them. + ((request.status === "expired" || request.status === "cancelled") && + request.responseCapability.type === "not_resumable" && + turn !== undefined && + turn.id === request.providerTurnId)), + ); + if (waitingRequest) return true; + return projection.turnItems.some( + (item) => + item.type === "secret_request" && + item.runId === source.id && + (item.secretStatus === "pending" || + // Recovery cancels an open secret card and its run at the same instant. + (item.secretStatus === "cancelled" && + item.completedAt !== null && + source.completedAt !== null && + DateTime.Equivalence(item.completedAt, source.completedAt))), + ); +} + /** * Resume only an unfinished root turn. Leftover background work is cleaned up * separately and reported on the next user turn; it must not wake a settled run. @@ -28,10 +88,16 @@ const CONTINUE_PROMPT = "Continue where you left off."; export function restartContinuationRun( projection: Pick< ProjectionRuntimeRecoveryState, - "thread" | "runs" | "providerThreads" | "providerSessions" | "providerTurns" + | "thread" + | "runs" + | "providerThreads" + | "providerSessions" + | "providerTurns" + | "runtimeRequests" + | "turnItems" >, + now: DateTime.Utc, ): OrchestrationV2Run | undefined { - if (projection.thread.archivedAt !== null || projection.thread.deletedAt !== null) return; // Queued runs never started; recovery holds them behind the cut run. const run = projection.runs.reduce( (latest, candidate) => @@ -44,6 +110,7 @@ export function restartContinuationRun( const preparedContinuation = run.status === "starting" && run.restartContinuationOfRunId !== undefined; if (run.status !== "running" && !preparedContinuation) return; + if (restartContinuationBlocked(projection, run, now)) return; const liveTurnRequired = !preparedContinuation; if (projection.thread.providerInstanceId !== run.providerInstanceId) return; const providerThread = projection.providerThreads.find( @@ -96,16 +163,14 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta const messageId = MessageId.make(`message:restart-continuation:${input.sourceRunId}`); const projection = yield* threads.getThreadRecords( input.threadId, - ["messages", "runs", "providerTurns", "attempts"], - { messageIds: [messageId] }, + ["messages", "runs", "providerTurns", "attempts", "runtimeRequests", "turnItems"], + { messageIds: [messageId], turnItemTypes: ["secret_request"] }, ); if ( !resolveProjectSettings(enabled, projection.thread.projectId).settings .continueThreadsAfterServerUpdate ) return; - if (projection.thread.archivedAt !== null || projection.thread.deletedAt !== null) return; - if (projection.messages.some((message) => message.id === messageId)) return; const source = projection.runs.find((run) => run.id === input.sourceRunId); // Pending effects from older versions may target settled background work, @@ -113,7 +178,8 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta if ( !source || source.status !== "cancelled" || - isRestartNoteSource(source, projection.providerTurns) + isRestartNoteSource(source, projection.providerTurns) || + restartContinuationBlocked(projection, source, yield* DateTime.now) ) return; // A user submission after reconciliation takes precedence over an automatic @@ -125,6 +191,22 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta ) return; if (projection.thread.providerInstanceId !== source.providerInstanceId) return; + // A turn that keeps crashing the server would otherwise restart it forever. + let chainLength = 0; + for ( + let run: OrchestrationV2Run | undefined = source; + run?.restartContinuationOfRunId !== undefined && chainLength < MAX_CONTINUATION_CHAIN; + run = projection.runs.find((candidate) => candidate.id === run?.restartContinuationOfRunId) + ) { + chainLength += 1; + } + if (chainLength >= MAX_CONTINUATION_CHAIN) { + yield* Effect.logWarning("Not continuing a run restarted too many times in a row", { + threadId: input.threadId, + sourceRunId: input.sourceRunId, + }); + return; + } const sourceRecords = yield* threads.getThreadRecords( input.threadId, ["messages", "turnItems"], diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index aba409b8eafac1473f42de6666575d78de4779c1..43b6eaf1ce669ea302a41920b88338aad4208b66 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1118,7 +1118,8 @@ it.layer(NodeServices.layer)("server settings", (it) => { const serverConfig = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - const raw = '{"addProjectBaseDirectory":42,"providers":{"codex":{"binaryPath":"/x"}}}'; + const raw = + '{"addProjectBaseDirectory":42,"continueThreadsAfterServerUpdate":"false","providers":{"codex":{"binaryPath":"/x"}}}'; yield* fileSystem.writeFileString(serverConfig.settingsPath, raw); const settings = yield* serverSettings.getSettings; @@ -1128,6 +1129,8 @@ it.layer(NodeServices.layer)("server settings", (it) => { driver: ProviderDriverKind.make("codex"), config: { binaryPath: "/x" }, }); + // An unreadable file may hold an opt-out, so threads are not resumed on a guess. + assert.isFalse(settings.continueThreadsAfterServerUpdate); assert.equal(yield* fileSystem.readFileString(serverConfig.settingsPath), raw); }).pipe(Effect.provide(layerServerSettings())), ); @@ -1969,6 +1972,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { const settings = yield* serverSettings.getSettings; assert.isFalse(settings.projectSettingsFolded); + assert.isFalse(settings.continueThreadsAfterServerUpdate); assert.deepEqual(settings.projectSettingsOverrides, {}); // The user's file is still there to repair; nothing was written over it. assert.equal(yield* fileSystem.readFileString(serverConfig.settingsPath), broken); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index ca50480969dc1913061abe3c95b9203187116ea6..18f3ada048e3f73fdb67a92d4902f48efbc01134 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -834,6 +834,8 @@ const make = Effect.gen(function* () { if (decoded._tag === "Failure" || legacySettings._tag === "Failure") { const failure = decoded._tag === "Failure" ? decoded : legacySettings; settingsFileTrusted = false; + // The file may hold an opt-out we can no longer read; never resume threads on a guess. + settings = { ...DEFAULT_SERVER_SETTINGS, continueThreadsAfterServerUpdate: false }; if (failure._tag === "Failure") { yield* Effect.logWarning("failed to parse settings.json, using defaults", { path: settingsPath, diff --git a/docs/internals/server-updates.md b/docs/internals/server-updates.md index 34fa5aa47b09444ff7eee6a3dc26ef2c4613b633..51bc16775d08c0533a526d53577db3228e5adee2 100644 --- a/docs/internals/server-updates.md +++ b/docs/internals/server-updates.md @@ -58,7 +58,9 @@ stopped backends and replays the failure for the same token. ## Recovering interrupted threads -Restart continuation is an environment-owned preference, off by default. The +Restart continuation is an environment-owned preference, on by default. A +`settings.json` that fails to decode turns it off, since the file may hold an +opt-out. The [v2 recovery service](../../apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts) requires matching durable run, provider thread, session, and native resume identity. Queued runs never started, so recovery holds them and continues the run they wait @@ -73,8 +75,13 @@ closing providers, then reconciles after ingestion has stopped so a late complet cannot be overwritten by a stale cancellation. The [continuation handler](../../apps/server/src/orchestration-v2/RestartContinuation.ts) -rechecks the preference, archive state, provider selection, newer user work, a stop -the user requested, and maintenance turns such as `/compact` before dispatching. Stable +rechecks the preference, provider selection, newer user work, a stop the user requested, +and maintenance turns such as `/compact` before dispatching. Intent and delivery both +refuse a thread that is archived, settled, or still snoozed, a provider turn that already +ended, and a run waiting on the user: an approval, question, or secret that was pending, +or that recovery closed on the cut turn. Message-capable questions stay answerable, so +they do not block. Delivery also stops after three consecutive continuations in one +`restartContinuationOfRunId` chain, so a turn that crashes the server cannot loop. Stable command and message IDs prevent duplicate submissions after an outbox retry. Codex resumes without adding provider prompt text unless the turn lost background work; other adapters receive the continuation message through their normal turn path. diff --git a/docs/user/updating.md b/docs/user/updating.md index 3461dae9844c36d7c20db55eefa16edaa9c9608c..122f52be1300412127da958200ae876400fa260b 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -10,16 +10,17 @@ notice. Server updates restart the connection and can interrupt active agents and terminal commands. Saved threads, settings, and project files remain. -**Settings → General → Continue threads after restarts** is off by default. -Enable it to resume supported active threads after an update, crash, or machine +**Settings → General → Continue threads after restarts** is on by default. +It resumes supported active threads after an update, crash, or machine restart. Changes are saved to connected environments that support this setting; update older servers first. If a supported environment was offline or has a different value, use **Apply to all** in Settings after it connects. T3 Code must start again on that machine; the setting does not enable automatic startup. Terminal commands may still be interrupted, and threads without saved provider resume state need a new message. -If you previously enabled continuation for updates, enable this setting once -to allow recovery without a connected client. +A thread is not resumed while it waits for your approval, answer, or secret, or +while it is archived, snoozed, or settled. After three automatic resumes in a row, +the thread waits for your next message. Updates from the previous orchestration system preserve conversation transcripts but cannot carry every kind of runtime history forward. Read [Threads from older T3 Code versions](./thread-migration.md) diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 2d3fd53e31f12880c9c5c643c7110716a201d66c..15db09f45e8a184e4aed0b1498dd1221e2fa1a60 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1107,9 +1107,10 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed("paragraph" as const)), ), enableProviderUpdateChecks: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), - // Retain the update-era key; recovery now needs an environment-owned opt-in. + // Retain the update-era key. On by default so restarts resume the work they + // interrupted; recovery stops after three automatic continuations in a row. continueThreadsAfterServerUpdate: Schema.Boolean.pipe( - Schema.withDecodingDefault(Effect.succeed(false)), + Schema.withDecodingDefault(Effect.succeed(true)), ), /** * Whether agents may drive the in-app preview browser. Turning this off -- 2.54.0