authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-05 01:33:25-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-05 01:33:25-04:00
logf47a3fe90354a3dfa14276e3699541d552426276
treedc60aadc92e40bb9b58f5fd723245f59ad3e08f5
parentc7dc3cbd068a93c9fb5027df7bea719d16e4ffbe
signature Signed by PGP key B5690EEEBB952194

fix(settings): share restart continuation across environments (#9933)


8 files changed, 204 insertions(+), 42 deletions(-)

apps/mobile/src/features/settings/SettingsRouteScreen.tsx+16-2
......@@ -44,6 +44,7 @@ import {
4444 type ServerSettingsPatch,
4545} from "@t3tools/contracts";
4646import {
47 filterSharedServerPatch,
4748 findSharedSettingsMismatches,
4849 pickSharedServerSettings,
4950 supportsSharedSettingsSync,
......@@ -584,11 +585,13 @@ function AutoSettleSettingsRows() {
584585 const mismatches = findSharedSettingsMismatches({
585586 primaryEnvironmentId: reference.environmentId,
586587 primarySettings: referenceSettings,
588 primaryCapabilities: reference.serverConfig?.environment.capabilities,
587589 environments: environments.map((environment) => ({
588590 environmentId: environment.environmentId,
589591 label: environment.label,
590592 syncEligible: supportsSharedSettingsSync(environment),
591593 settings: environment.serverConfig?.settings ?? null,
594 capabilities: environment.serverConfig?.environment.capabilities,
592595 })),
593596 });
594597
......@@ -652,11 +655,22 @@ function AutoSettleSettingsRows() {
652655 <Pressable
653656 accessibilityRole="button"
654657 onPress={() => {
655 const patch = pickSharedServerSettings(referenceSettings);
658 const patch = pickSharedServerSettings(
659 referenceSettings,
660 reference.serverConfig?.environment.capabilities,
661 );
656662 for (const mismatch of mismatches) {
663 const target = environments.find(
664 (candidate) => candidate.environmentId === mismatch.environmentId,
665 );
657666 void updateSettings({
658667 environmentId: mismatch.environmentId,
659 input: { patch },
668 input: {
669 patch: filterSharedServerPatch(
670 patch,
671 target?.serverConfig?.environment.capabilities,
672 ),
673 },
660674 });
661675 }
662676 }}
apps/server/src/environment/ServerEnvironment.ts+1
......@@ -219,6 +219,7 @@ export const make = Effect.gen(function* () {
219219 pullRequests: true,
220220 threadSettlement: true,
221221 threadAutoSettlement: true,
222 threadRestartContinuation: true,
222223 threadSnooze: true,
223224 environmentThemes: true,
224225 usageLimitSources: true,
apps/web/src/components/settings/SettingsPanels.tsx+1-1
......@@ -2439,7 +2439,7 @@ export function GeneralSettingsPanel() {
24392439 <SettingsRow
24402440 {...searchableSetting("continue-threads-after-server-update")}
24412441 serverScoped
2442 description="Automatically resume interrupted threads when this environment starts again after an update, crash, or machine restart."
2442 description="Automatically resume interrupted threads after an update, crash, or machine restart. Applies to this environment and all connected environments that support it. Update older servers first."
24432443 resetAction={
24442444 settings.continueThreadsAfterServerUpdate !==
24452445 DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate ? (
apps/web/src/hooks/useSettings.ts+34-25
......@@ -26,6 +26,7 @@ import {
2626} from "@t3tools/contracts/settings";
2727import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
2828import {
29 filterSharedServerPatch,
2930 findSharedSettingsMismatches,
3031 pickSharedServerSettings,
3132 splitSharedServerPatch,
......@@ -368,18 +369,6 @@ export function usePrimarySettingsAvailable(): boolean {
368369 return primaryEnvironment !== null || !isHostedStaticApp();
369370}
370371
371/** Environments that can receive a shared settings write right now. */
372function useSharedSettingsSyncTargetIds(): ReadonlyArray<EnvironmentId> {
373 const { environments } = useEnvironments();
374 return useMemo(
375 () =>
376 environments
377 .filter(supportsSharedSettingsSync)
378 .map((environment) => environment.environmentId),
379 [environments],
380 );
381}
382
383372/**
384373 * Returns an updater that routes each key to the correct backing store.
385374 *
......@@ -394,7 +383,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
394383 serverEnvironment.updateSettings,
395384 "server settings update",
396385 );
397 const sharedSettingsSyncTargetIds = useSharedSettingsSyncTargetIds();
386 const { environments } = useEnvironments();
398387 const updateSettings = useCallback(
399388 (patch: UnifiedSettingsPatch) => {
400389 const { serverPatch, clientPatch } = splitPatch(patch);
......@@ -402,11 +391,11 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
402391 if (Object.keys(serverPatch).length > 0) {
403392 const { sharedPatch, localPatch } = splitSharedServerPatch(serverPatch);
404393 // Dropping the write silently leaves the control looking saved.
405 const warnUnsaved = () =>
394 const warnUnsaved = (description = PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE) =>
406395 toastManager.add({
407396 type: "warning",
408397 title: "Setting not saved",
409 description: PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE,
398 description,
410399 });
411400 if (Object.keys(localPatch).length > 0) {
412401 if (environmentId) {
......@@ -419,26 +408,38 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
419408 }
420409 }
421410 if (Object.keys(sharedPatch).length > 0) {
422 const targets = new Set(sharedSettingsSyncTargetIds);
411 const targets = new Set(
412 environments.filter(supportsSharedSettingsSync).map((target) => target.environmentId),
413 );
423414 if (environmentId) {
424415 targets.add(environmentId);
425416 }
426 if (targets.size === 0) {
427 warnUnsaved();
428 }
417 let wroteToTarget = false;
429418 for (const targetId of targets) {
419 const target = environments.find((candidate) => candidate.environmentId === targetId);
420 const targetPatch = filterSharedServerPatch(
421 sharedPatch,
422 target?.serverConfig?.environment.capabilities,
423 );
424 if (Object.keys(targetPatch).length === 0) continue;
425 wroteToTarget = true;
430426 void persistServerSettings({
431427 environmentId: targetId,
432 input: { patch: sharedPatch },
428 input: { patch: targetPatch },
433429 });
434430 }
431 if (!wroteToTarget) {
432 warnUnsaved(
433 targets.size > 0 ? "Update older servers to save this setting." : undefined,
434 );
435 }
435436 }
436437 }
437438 if (Object.keys(clientPatch).length > 0) {
438439 persistClientSettingsPatch(clientPatch);
439440 }
440441 },
441 [environmentId, persistServerSettings, sharedSettingsSyncTargetIds],
442 [environmentId, environments, persistServerSettings],
442443 );
443444
444445 return updateSettings;
......@@ -453,6 +454,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
453454export function useSharedSettingsSync() {
454455 const primaryEnvironment = usePrimaryEnvironment();
455456 const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null;
457 const primaryCapabilities = primaryEnvironment?.serverConfig?.environment.capabilities;
456458 // Read the loaded config, not `primaryServerSettingsAtom`: that atom falls
457459 // back to defaults while the primary is disconnected, and "apply to all"
458460 // must never push defaults over real values. Same for a primary too old to
......@@ -472,28 +474,35 @@ export function useSharedSettingsSync() {
472474 findSharedSettingsMismatches({
473475 primaryEnvironmentId,
474476 primarySettings,
477 primaryCapabilities,
475478 environments: environments.map((environment) => ({
476479 environmentId: environment.environmentId,
477480 label: environment.label,
478481 syncEligible: supportsSharedSettingsSync(environment),
479482 settings: environment.serverConfig?.settings ?? null,
483 capabilities: environment.serverConfig?.environment.capabilities,
480484 })),
481485 }),
482 [environments, primaryEnvironmentId, primarySettings],
486 [environments, primaryEnvironmentId, primarySettings, primaryCapabilities],
483487 );
484488
485489 const applyToAll = useCallback(() => {
486490 if (primarySettings === null) {
487491 return;
488492 }
489 const patch = pickSharedServerSettings(primarySettings);
493 const patch = pickSharedServerSettings(primarySettings, primaryCapabilities);
490494 for (const mismatch of mismatches) {
495 const target = environments.find(
496 (candidate) => candidate.environmentId === mismatch.environmentId,
497 );
491498 void persistServerSettings({
492499 environmentId: mismatch.environmentId,
493 input: { patch },
500 input: {
501 patch: filterSharedServerPatch(patch, target?.serverConfig?.environment.capabilities),
502 },
494503 });
495504 }
496 }, [mismatches, persistServerSettings, primarySettings]);
505 }, [environments, mismatches, persistServerSettings, primarySettings, primaryCapabilities]);
497506
498507 return { mismatches, applyToAll };
499508}
docs/user/updating.md+7-4
......@@ -11,12 +11,15 @@ Server updates restart the connection and can interrupt active agents and
1111terminal commands. Saved threads, settings, and project files remain.
1212
1313**Settings → General → Continue threads after restarts** is off by default.
14Enable it for each environment to resume supported active threads after an
15update, crash, or machine restart. T3 Code must start again on that machine;
14Enable it to resume supported active threads after an update, crash, or machine
15restart. Changes are saved to connected environments that support this setting;
16update older servers first. If a supported environment was offline or has a
17different value, use **Apply to all** in Settings after it connects.
18T3 Code must start again on that machine;
1619the setting does not enable automatic startup. Terminal commands may still be
1720interrupted, and threads without saved provider resume state need a new message.
18If you previously enabled continuation for updates, enable this environment
19setting once to allow recovery without a connected client.
21If you previously enabled continuation for updates, enable this setting once
22to allow recovery without a connected client.
2023
2124## Update a connected server
2225
packages/client-runtime/src/state/sharedSettings.test.ts+109-2
......@@ -2,6 +2,7 @@ import { DEFAULT_SERVER_SETTINGS, EnvironmentId } from "@t3tools/contracts";
22import { describe, expect, it } from "@effect/vitest";
33
44import {
5 filterSharedServerPatch,
56 findSharedSettingsMismatches,
67 pickSharedServerSettings,
78 splitSharedServerPatch,
......@@ -11,6 +12,7 @@ import {
1112const primaryId = EnvironmentId.make("env-primary");
1213const laptopId = EnvironmentId.make("env-laptop");
1314const boxId = EnvironmentId.make("env-box");
15const restartCapabilities = { threadRestartContinuation: true };
1416
1517describe("supportsSharedSettingsSync", () => {
1618 it("accepts only connected servers that advertise the shared-settings capability", () => {
......@@ -40,16 +42,24 @@ describe("splitSharedServerPatch", () => {
4042 const { sharedPatch, localPatch } = splitSharedServerPatch({
4143 sidebarAutoSettleAfterDays: 7,
4244 sidebarAutoSettleOnMerge: false,
45 continueThreadsAfterServerUpdate: true,
4346 enableAgentBrowserAccess: false,
4447 });
45 expect(sharedPatch).toEqual({ sidebarAutoSettleAfterDays: 7, sidebarAutoSettleOnMerge: false });
48 expect(sharedPatch).toEqual({
49 sidebarAutoSettleAfterDays: 7,
50 sidebarAutoSettleOnMerge: false,
51 continueThreadsAfterServerUpdate: true,
52 });
4653 expect(localPatch).toEqual({ enableAgentBrowserAccess: false });
4754 });
4855});
4956
5057describe("pickSharedServerSettings", () => {
5158 it("returns only the shared keys", () => {
52 expect(Object.keys(pickSharedServerSettings(DEFAULT_SERVER_SETTINGS)).sort()).toEqual([
59 expect(
60 Object.keys(pickSharedServerSettings(DEFAULT_SERVER_SETTINGS, restartCapabilities)).sort(),
61 ).toEqual([
62 "continueThreadsAfterServerUpdate",
5363 "defaultThreadEnvMode",
5464 "newWorktreesStartFromOrigin",
5565 "sidebarAutoSettleAfterDays",
......@@ -59,9 +69,106 @@ describe("pickSharedServerSettings", () => {
5969 });
6070});
6171
72describe("filterSharedServerPatch", () => {
73 it.each([true, false])("preserves supported restart preference %s", (enabled) => {
74 const patch = { continueThreadsAfterServerUpdate: enabled, sidebarAutoSettleAfterDays: 7 };
75 expect(filterSharedServerPatch(patch, restartCapabilities)).toEqual(patch);
76 });
77
78 it.each([undefined, {}, { threadRestartContinuation: false }])(
79 "omits only the unsupported restart preference with capabilities %j",
80 (capabilities) => {
81 expect(
82 filterSharedServerPatch(
83 { continueThreadsAfterServerUpdate: true, sidebarAutoSettleAfterDays: 7 },
84 capabilities,
85 ),
86 ).toEqual({ sidebarAutoSettleAfterDays: 7 });
87 expect(pickSharedServerSettings(DEFAULT_SERVER_SETTINGS, capabilities)).not.toHaveProperty(
88 "continueThreadsAfterServerUpdate",
89 );
90 },
91 );
92});
93
6294describe("findSharedSettingsMismatches", () => {
6395 const primarySettings = { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: 7 };
6496
97 it.each([true, false])(
98 "detects remote restart continuation drift when the preference is %s",
99 (enabled) => {
100 const settings = { ...primarySettings, continueThreadsAfterServerUpdate: enabled };
101 const remoteSettings = { ...settings, continueThreadsAfterServerUpdate: !enabled };
102 const environment = {
103 environmentId: boxId,
104 label: "Remote Box",
105 syncEligible: true,
106 settings: remoteSettings,
107 capabilities: restartCapabilities,
108 };
109 expect(
110 findSharedSettingsMismatches({
111 primaryEnvironmentId: primaryId,
112 primarySettings: settings,
113 primaryCapabilities: restartCapabilities,
114 environments: [environment],
115 }),
116 ).toEqual([{ environmentId: boxId, label: "Remote Box" }]);
117 expect(
118 findSharedSettingsMismatches({
119 primaryEnvironmentId: primaryId,
120 primarySettings: settings,
121 primaryCapabilities: restartCapabilities,
122 environments: [
123 {
124 ...environment,
125 settings: Object.assign(
126 {},
127 remoteSettings,
128 pickSharedServerSettings(settings, restartCapabilities),
129 ),
130 },
131 ],
132 }),
133 ).toEqual([]);
134 },
135 );
136
137 it.each([
138 [undefined, restartCapabilities],
139 [restartCapabilities, undefined],
140 [undefined, undefined],
141 ])(
142 "ignores restart drift unless both servers support it (%j, %j)",
143 (primaryCapabilities, capabilities) => {
144 const environment = {
145 environmentId: boxId,
146 label: "Remote Box",
147 syncEligible: true,
148 capabilities,
149 settings: { ...primarySettings, continueThreadsAfterServerUpdate: true },
150 };
151 const input = {
152 primaryEnvironmentId: primaryId,
153 primarySettings,
154 primaryCapabilities,
155 environments: [environment],
156 };
157 expect(findSharedSettingsMismatches(input)).toEqual([]);
158 expect(
159 findSharedSettingsMismatches({
160 ...input,
161 environments: [
162 {
163 ...environment,
164 settings: { ...environment.settings, sidebarAutoSettleAfterDays: 14 },
165 },
166 ],
167 }),
168 ).toEqual([{ environmentId: boxId, label: "Remote Box" }]);
169 },
170 );
171
65172 it("lists sync-eligible environments whose shared settings differ", () => {
66173 const mismatches = findSharedSettingsMismatches({
67174 primaryEnvironmentId: primaryId,
packages/client-runtime/src/state/sharedSettings.ts+34-8
......@@ -21,6 +21,7 @@ import type { EnvironmentConnectionPhase } from "../connection/presentation.ts";
2121
2222/** Server keys that hold a user preference rather than machine config. */
2323export const SHARED_SERVER_SETTING_KEYS = [
24 "continueThreadsAfterServerUpdate",
2425 "sidebarAutoSettleAfterDays",
2526 "sidebarAutoSettleOnMerge",
2627 "defaultThreadEnvMode",
......@@ -52,15 +53,27 @@ export function splitSharedServerPatch(patch: ServerSettingsPatch): {
5253 };
5354}
5455
55/** The shared subset of one environment's settings, as a patch that can be written elsewhere. */
56export function pickSharedServerSettings(settings: ServerSettings): ServerSettingsPatch {
57 return Struct.pick(settings, SHARED_SERVER_SETTING_KEYS);
56/** Omit restart recovery on servers that cannot persist its preference. */
57export function filterSharedServerPatch(
58 patch: ServerSettingsPatch,
59 capabilities: Pick<ExecutionEnvironmentCapabilities, "threadRestartContinuation"> | undefined,
60): ServerSettingsPatch {
61 return capabilities?.threadRestartContinuation === true
62 ? patch
63 : Struct.omit(patch, ["continueThreadsAfterServerUpdate"]);
64}
65
66/** The shared subset supported by one environment. */
67export function pickSharedServerSettings(
68 settings: ServerSettings,
69 capabilities?: Pick<ExecutionEnvironmentCapabilities, "threadRestartContinuation">,
70): ServerSettingsPatch {
71 return filterSharedServerPatch(Struct.pick(settings, SHARED_SERVER_SETTING_KEYS), capabilities);
5872}
5973
6074/**
6175 * Whether an environment can participate in shared-settings sync right now.
62 * Auto-settlement is the newest feature backed by a shared key, so a server
63 * advertising `threadAutoSettlement` can hold every shared key.
76 * Auto-settlement establishes baseline support; newer preferences are filtered separately.
6477 */
6578export function supportsSharedSettingsSync(environment: {
6679 readonly connection: { readonly phase: EnvironmentConnectionPhase };
......@@ -81,12 +94,15 @@ export interface SharedSettingsEnvironment {
8194 readonly label: string;
8295 readonly syncEligible: boolean;
8396 readonly settings: ServerSettings | null;
97 readonly capabilities?:
98 | Pick<ExecutionEnvironmentCapabilities, "threadRestartContinuation">
99 | undefined;
84100}
85101
86102/**
87103 * Shared-settings sync targets whose values differ from the primary
88104 * environment's. Other environments are skipped: nothing can be read from or
89 * written to them, or their server cannot hold every shared key. With no
105 * written to them, or their server lacks baseline shared-settings support. With no
90106 * primary settings loaded there is nothing to compare against, so nothing is
91107 * reported. Callers must pass the real loaded settings, never a default
92108 * fallback, or "apply to all" would push defaults over real values.
......@@ -94,12 +110,18 @@ export interface SharedSettingsEnvironment {
94110export function findSharedSettingsMismatches(input: {
95111 readonly primaryEnvironmentId: EnvironmentId | null;
96112 readonly primarySettings: ServerSettings | null;
113 readonly primaryCapabilities?:
114 | Pick<ExecutionEnvironmentCapabilities, "threadRestartContinuation">
115 | undefined;
97116 readonly environments: ReadonlyArray<SharedSettingsEnvironment>;
98117}): ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly label: string }> {
99118 if (input.primaryEnvironmentId === null || input.primarySettings === null) {
100119 return [];
101120 }
102 const expected = pickSharedServerSettings(input.primarySettings);
121 const primarySettings = pickSharedServerSettings(
122 input.primarySettings,
123 input.primaryCapabilities,
124 );
103125 return input.environments.flatMap((environment) => {
104126 if (
105127 environment.environmentId === input.primaryEnvironmentId ||
......@@ -108,7 +130,11 @@ export function findSharedSettingsMismatches(input: {
108130 ) {
109131 return [];
110132 }
111 const actual = pickSharedServerSettings(environment.settings);
133 const expected = filterSharedServerPatch(primarySettings, environment.capabilities);
134 const actual = filterSharedServerPatch(
135 pickSharedServerSettings(environment.settings, environment.capabilities),
136 input.primaryCapabilities,
137 );
112138 return Equal.equals(actual, expected)
113139 ? []
114140 : [{ environmentId: environment.environmentId, label: environment.label }];
packages/contracts/src/environment.ts+2
......@@ -94,6 +94,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
9494 threadSettlement: Schema.optionalKey(Schema.Boolean),
9595 /** Server evaluates merge and inactivity settlement without a client. */
9696 threadAutoSettlement: Schema.optionalKey(Schema.Boolean),
97 /** Server persists the opt-in for continuing interrupted threads after restarts. */
98 threadRestartContinuation: Schema.optionalKey(Schema.Boolean),
9799 /** Server understands thread.snooze / thread.unsnooze commands. Same
98100 version-skew contract as threadSettlement. */
99101 threadSnooze: Schema.optionalKey(Schema.Boolean),