authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-13 23:22:18-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 23:22:18-07:00
logd8655ed2fcf5cac3e1742459476b4a392b949367
treebe3d52b16823d4cf916045708d644663548cf564
parentcba7dd77817df2a458d86977f1daf4606c67dfc8
signature Signed by PGP key B5690EEEBB952194

feat(cli): add t3 service restart and make t3 update repoint the service eagerly (#11702)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

9 files changed, 430 insertions(+), 61 deletions(-)

apps/server/src/cli/service.test.ts+38-10
...@@ -45,7 +45,7 @@ it("reports the installed service version and host paths", () => {...@@ -45,7 +45,7 @@ it("reports the installed service version and host paths", () => {
45it("gives a direct repair command for a stale service", () => {45it("gives a direct repair command for a stale service", () => {
46 assert.include(46 assert.include(
47 formatServiceStatus({ ...status, current: false }, "0.0.29"),47 formatServiceStatus({ ...status, current: false }, "0.0.29"),
48 "Next: Run `npx t3@0.0.29 service update`.",48 "Next: Run `t3 service install` to repair it.",
49 );49 );
50});50});
5151
...@@ -64,17 +64,17 @@ it("explains an incomplete nightly installation and keeps repair on its installe...@@ -64,17 +64,17 @@ it("explains an incomplete nightly installation and keeps repair on its installe
64 expect(output).toContain("last login session ends");64 expect(output).toContain("last login session ends");
65 expect(output).toContain('sudo loginctl enable-linger "$(id -un)"');65 expect(output).toContain('sudo loginctl enable-linger "$(id -un)"');
66 expect(output).toContain("[service-stopped]");66 expect(output).toContain("[service-stopped]");
67 expect(output).toContain("npx t3@0.0.32-nightly.1 service update");67 expect(output).toContain("Run `t3 service install` to repair it.");
68 expect(output).not.toContain("t3@latest");68 expect(output).not.toContain("npx");
69});69});
7070
71it("suggests the newer CLI version when the installed service needs an update", () => {71it("points an older service at a repair, never at npx", () => {
72 const output = formatServiceStatus(72 const output = formatServiceStatus(
73 { ...status, current: false, installedVersion: "0.0.28" },73 { ...status, current: false, installedVersion: "0.0.28" },
74 "0.0.29",74 "0.0.29",
75 );75 );
76 expect(output).toContain("npx t3@0.0.29 service update");76 expect(output).toContain("Run `t3 service install` to repair it.");
77 expect(output).not.toContain("npx t3@0.0.28 service update");77 expect(output).not.toContain("npx");
78});78});
7979
80it("explains where the service is supported", () => {80it("explains where the service is supported", () => {
...@@ -84,23 +84,28 @@ it("explains where the service is supported", () => {...@@ -84,23 +84,28 @@ it("explains where the service is supported", () => {
84 );84 );
85});85});
8686
87it("reports a newer installed service and gives an exact-version repair command", () => {87it("reports a newer installed service and tells the CLI to catch up to it", () => {
88 const output = formatServiceStatus(88 const output = formatServiceStatus(
89 { ...status, current: false, installedVersion: "0.0.32-nightly.1" },89 { ...status, current: false, installedVersion: "0.0.32-nightly.1" },
90 "0.0.31",90 "0.0.31",
91 );91 );
9292
93 assert.include(output, "t3@0.0.32-nightly.1 (newer than this t3@0.0.31 CLI)");93 assert.include(output, "t3@0.0.32-nightly.1 (newer than this t3@0.0.31 CLI)");
94 assert.include(output, "npx t3@0.0.32-nightly.1 service update");94 assert.include(output, "Run `t3 update 0.0.32-nightly.1` to match it");
95 assert.notInclude(output, "npx t3@latest service update");95 assert.notInclude(output, "npx");
96});96});
9797
98const newerServiceStatus = { ...status, current: false, installedVersion: "999.0.0" };98const newerServiceStatus = { ...status, current: false, installedVersion: "999.0.0" };
9999
100function makeTestService(serviceStatus: BootService.BootServiceStatus) {100function makeTestService(serviceStatus: BootService.BootServiceStatus) {
101 const installOptions: Array<Parameters<BootService.BootService["Service"]["install"]>[0]> = [];101 const installOptions: Array<Parameters<BootService.BootService["Service"]["install"]>[0]> = [];
102 const restarts: Array<true> = [];
102 const service = BootService.BootService.of({103 const service = BootService.BootService.of({
103 status: Effect.succeed(serviceStatus),104 status: Effect.succeed(serviceStatus),
105 restart: Effect.sync(() => {
106 restarts.push(true);
107 return serviceStatus.installed;
108 }),
104 install: (options) =>109 install: (options) =>
105 Effect.sync(() => {110 Effect.sync(() => {
106 installOptions.push(options);111 installOptions.push(options);
...@@ -113,10 +118,33 @@ function makeTestService(serviceStatus: BootService.BootServiceStatus) {...@@ -113,10 +118,33 @@ function makeTestService(serviceStatus: BootService.BootServiceStatus) {
113 }),118 }),
114 uninstall: Effect.succeed(false),119 uninstall: Effect.succeed(false),
115 });120 });
116 return { service, installOptions };121 return { service, installOptions, restarts };
117}122}
118123
119it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands", (it) => {124it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands", (it) => {
125 it.effect("restart restarts the installed service", () =>
126 Effect.gen(function* () {
127 const fs = yield* FileSystem.FileSystem;
128 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" });
129 const { service, installOptions, restarts } = makeTestService(status);
130 vi.spyOn(BootService, "layer").mockReturnValue(
131 Layer.succeed(BootService.BootService, service),
132 );
133
134 yield* Command.runWith(serviceCommand, { version: packageJson.version })([
135 "restart",
136 "--base-dir",
137 baseDir,
138 ]).pipe(
139 Effect.provideService(HostProcessEnvironment, {}),
140 Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))),
141 );
142
143 expect(restarts).toEqual([true]);
144 expect(installOptions).toEqual([]);
145 }),
146 );
147
120 it.effect.each(["install", "update"] as const)(148 it.effect.each(["install", "update"] as const)(
121 "%s refuses a downgrade before changing the service",149 "%s refuses a downgrade before changing the service",
122 (command) =>150 (command) =>
apps/server/src/cli/service.ts+33-6
...@@ -38,6 +38,7 @@ export type ServiceReconcileResult =...@@ -38,6 +38,7 @@ export type ServiceReconcileResult =
38/** Install, update, or repair the service using the CLI version running this command. */38/** Install, update, or repair the service using the CLI version running this command. */
39export const reconcileService = Effect.fn("cli.service.reconcile")(function* (options?: {39export const reconcileService = Effect.fn("cli.service.reconcile")(function* (options?: {
40 readonly allowDowngrade?: boolean;40 readonly allowDowngrade?: boolean;
41 readonly start?: boolean;
41}) {42}) {
42 const service = yield* BootService.BootService;43 const service = yield* BootService.BootService;
43 const status = yield* service.status;44 const status = yield* service.status;
...@@ -87,7 +88,7 @@ export function formatServiceStatus(...@@ -87,7 +88,7 @@ export function formatServiceStatus(
87 ` Unit: ${status.unitPath}`,88 ` Unit: ${status.unitPath}`,
88 ` Logs: ${status.logPath}`,89 ` Logs: ${status.logPath}`,
89 ...problems,90 ...problems,
90 ` Next: Use \`npx t3@${installedVersion} service update\` to repair it, or pass \`--allow-downgrade\` explicitly.`,91 ` Next: Run \`t3 update ${installedVersion}\` to match it, or pass \`--allow-downgrade\` to \`t3 service install\` explicitly.`,
91 ].join("\n");92 ].join("\n");
92 }93 }
93 return [94 return [
...@@ -96,7 +97,7 @@ export function formatServiceStatus(...@@ -96,7 +97,7 @@ export function formatServiceStatus(
96 ` Unit: ${status.unitPath}`,97 ` Unit: ${status.unitPath}`,
97 ` Logs: ${status.logPath}`,98 ` Logs: ${status.logPath}`,
98 ...problems,99 ...problems,
99 ...(status.current ? [] : [` Next: Run \`npx t3@${cliVersion} service update\`.`]),100 ...(status.current ? [] : [" Next: Run `t3 service install` to repair it."]),
100 ].join("\n");101 ].join("\n");
101}102}
102103
...@@ -138,14 +139,18 @@ const serviceInstallCommand = Command.make("install", serviceReconcileFlags).pip...@@ -138,14 +139,18 @@ const serviceInstallCommand = Command.make("install", serviceReconcileFlags).pip
138 ),139 ),
139);140);
140141
142// Kept one release for muscle memory and old docs. It did what `t3 service
143// install` does; the way to move to a newer release is `t3 update`.
141const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe(144const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe(
142 Command.withDescription(145 Command.withDescription("Deprecated. Run `t3 update` to move to a newer release."),
143 "Update or repair the background service using this CLI version. Use `t3 update` to move to a newer release first.",146 Command.unlisted,
144 ),
145 Command.withHandler((flags) =>147 Command.withHandler((flags) =>
146 runServiceCommand(148 runServiceCommand(
147 flags,149 flags,
148 Effect.gen(function* () {150 Effect.gen(function* () {
151 yield* Console.log(
152 "`t3 service update` is deprecated: run `t3 update` to move to a newer release, or `t3 service install` to repair the service. Repairing now.",
153 );
149 const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade });154 const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade });
150 if (!result.changed) {155 if (!result.changed) {
151 yield* Console.log(`T3 Code service is already using t3@${packageJson.version}.`);156 yield* Console.log(`T3 Code service is already using t3@${packageJson.version}.`);
...@@ -159,6 +164,27 @@ const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe(...@@ -159,6 +164,27 @@ const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe(
159 ),164 ),
160);165);
161166
167const serviceRestartCommand = Command.make("restart", projectLocationFlags).pipe(
168 Command.withDescription(
169 "Restart the background service. Picks up a version installed by `t3 update` that was not restarted at the time.",
170 ),
171 Command.withHandler((flags) =>
172 runServiceCommand(
173 flags,
174 Effect.gen(function* () {
175 const service = yield* BootService.BootService;
176 const status = yield* service.status;
177 const restarted = yield* service.restart;
178 yield* Console.log(
179 restarted
180 ? `Restarted the T3 Code service${status.installedVersion === undefined ? "" : ` on t3@${status.installedVersion}`}.`
181 : "T3 Code service is not installed.",
182 );
183 }),
184 ),
185 ),
186);
187
162const serviceUninstallCommand = Command.make("uninstall", projectLocationFlags).pipe(188const serviceUninstallCommand = Command.make("uninstall", projectLocationFlags).pipe(
163 Command.withDescription("Stop and remove the T3 Code background service."),189 Command.withDescription("Stop and remove the T3 Code background service."),
164 Command.withHandler((flags) =>190 Command.withHandler((flags) =>
...@@ -265,8 +291,9 @@ export const serviceCommand = Command.make("service").pipe(...@@ -265,8 +291,9 @@ export const serviceCommand = Command.make("service").pipe(
265 Command.withDescription("Manage the T3 Code background service."),291 Command.withDescription("Manage the T3 Code background service."),
266 Command.withSubcommands([292 Command.withSubcommands([
267 serviceInstallCommand,293 serviceInstallCommand,
294 serviceRestartCommand,
268 serviceUninstallCommand,295 serviceUninstallCommand,
269 serviceUpdateCommand,
270 serviceStatusCommand,296 serviceStatusCommand,
297 serviceUpdateCommand,
271 ]),298 ]),
272);299);
apps/server/src/cli/update.ts+47-29
...@@ -263,6 +263,7 @@ export const updateCommand = Command.make("update", {...@@ -263,6 +263,7 @@ export const updateCommand = Command.make("update", {
263 const config = yield* resolveCliAuthConfig(flags, logLevel);263 const config = yield* resolveCliAuthConfig(flags, logLevel);
264 return yield* runUpdate({264 return yield* runUpdate({
265 baseDir: config.baseDir,265 baseDir: config.baseDir,
266 logsDir: config.logsDir,
266 serverRuntimeStatePath: config.serverRuntimeStatePath,267 serverRuntimeStatePath: config.serverRuntimeStatePath,
267 channel: Option.getOrUndefined(flags.channel),268 channel: Option.getOrUndefined(flags.channel),
268 requestedVersion: Option.getOrUndefined(flags.version),269 requestedVersion: Option.getOrUndefined(flags.version),
...@@ -333,6 +334,7 @@ const belongsToBootService = Effect.fn("cli.update.belongs_to_boot_service")(fun...@@ -333,6 +334,7 @@ const belongsToBootService = Effect.fn("cli.update.belongs_to_boot_service")(fun
333334
334const runUpdate = Effect.fn("cli.update.run")(function* (input: {335const runUpdate = Effect.fn("cli.update.run")(function* (input: {
335 readonly baseDir: string;336 readonly baseDir: string;
337 readonly logsDir: string;
336 readonly serverRuntimeStatePath: string;338 readonly serverRuntimeStatePath: string;
337 readonly channel: CliReleaseChannel | undefined;339 readonly channel: CliReleaseChannel | undefined;
338 readonly requestedVersion: string | undefined;340 readonly requestedVersion: string | undefined;
...@@ -408,8 +410,16 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {...@@ -408,8 +410,16 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {
408 const serviceVersion = serviceInstalled ? status.installedVersion : undefined;410 const serviceVersion = serviceInstalled ? status.installedVersion : undefined;
409 const executableCurrent = targetVersion === currentVersion;411 const executableCurrent = targetVersion === currentVersion;
410 // A service whose recorded version is missing or unreadable is not known412 // A service whose recorded version is missing or unreadable is not known
411 // to be current, so it gets the update rather than being skipped.413 // to be current, so it gets the update rather than being skipped. Nor is
412 const serviceCurrent = !serviceInstalled || serviceVersion === targetVersion;414 // one on the right version that is stopped, disabled, or still running the
415 // version before it (an earlier update where the restart was declined):
416 // `status.current` covers all of that when the target is this executable,
417 // and the problem list is what can be judged for any other target.
418 const restartPending = status.problems?.includes("restart-pending") === true;
419 const serviceCurrent =
420 !serviceInstalled ||
421 (serviceVersion === targetVersion &&
422 (executableCurrent ? status.current : (status.problems ?? []).length === 0));
413 const newestInstalled =423 const newestInstalled =
414 serviceVersion !== undefined && compareExactServiceVersions(serviceVersion, currentVersion) > 0424 serviceVersion !== undefined && compareExactServiceVersions(serviceVersion, currentVersion) > 0
415 ? serviceVersion425 ? serviceVersion
...@@ -437,11 +447,13 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {...@@ -437,11 +447,13 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {
437 );447 );
438448
439 yield* Console.log(449 yield* Console.log(
440 executableCurrent450 executableCurrent && restartPending
441 ? `Updating the background service ${serviceVersion ?? "(unknown version)"} -> ${targetVersion} (${targetChannel}).`451 ? `The background service is still running the version before ${targetVersion} (${targetChannel}).`
442 : alreadyOnDisk452 : executableCurrent
443 ? `Switching t3 ${currentVersion} -> ${targetVersion} (${targetChannel}, already downloaded).`453 ? `Updating the background service ${serviceVersion ?? "(unknown version)"} -> ${targetVersion} (${targetChannel}).`
444 : `Updating t3 ${currentVersion} -> ${targetVersion} (${targetChannel}).`,454 : alreadyOnDisk
455 ? `Switching t3 ${currentVersion} -> ${targetVersion} (${targetChannel}, already downloaded).`
456 : `Updating t3 ${currentVersion} -> ${targetVersion} (${targetChannel}).`,
445 );457 );
446 let restartService = false;458 let restartService = false;
447 if (serviceInstalled && !serviceCurrent) {459 if (serviceInstalled && !serviceCurrent) {
...@@ -459,7 +471,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {...@@ -459,7 +471,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {
459 ).pipe(Effect.catchTag("QuitError", () => Effect.succeed(false)));471 ).pipe(Effect.catchTag("QuitError", () => Effect.succeed(false)));
460 } else {472 } else {
461 yield* Console.log(473 yield* Console.log(
462 " Not a terminal, so the service is left on its current version. Rerun with --yes to restart it, or run `t3 service update` later.",474 " Not a terminal, so the service keeps running its current version. Rerun with --yes to restart it now, or run `t3 service restart` later.",
463 );475 );
464 }476 }
465 }477 }
...@@ -519,27 +531,33 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {...@@ -519,27 +531,33 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {
519 targetEntryPath: runtime.entryPath,531 targetEntryPath: runtime.entryPath,
520 });532 });
521533
522 // The new executable owns the service switch: it verifies itself, writes its534 // The service switch runs in this process against the target version: the
523 // own version into the unit, and restarts the service on it.535 // downloaded runtime has already proven it runs (the `--version` check
536 // above), and doing it here rather than through the target's own CLI means
537 // a downgrade to a version without today's commands still works. The unit
538 // is rewritten either way so a later `t3 service restart` lands on the new
539 // version; only the restart itself waits for the user's answer.
524 let serviceUpdated = false;540 let serviceUpdated = false;
525 if (restartService) {541 if (serviceInstalled && !serviceCurrent) {
526 const result = yield* runner.run({542 yield* BootService.BootService.pipe(
527 command: runtime.entryPath,543 Effect.flatMap((target) =>
528 args: [544 target.install({ allowDowngrade: input.allowDowngrade, start: restartService }),
529 "service",545 ),
530 "update",546 Effect.provide(
531 "--base-dir",547 BootService.layer({
532 input.baseDir,548 baseDir: input.baseDir,
533 ...(input.allowDowngrade ? ["--allow-downgrade"] : []),549 logsDir: input.logsDir,
534 ],550 cliVersion: targetVersion,
535 timeout: Duration.minutes(5),551 }),
536 });552 ),
537 if (result.code !== 0) {553 Effect.mapError(
538 return yield* new CliUpdateError({554 (error) =>
539 reason: `t3@${targetVersion} is installed but the background service could not be updated (exit ${String(result.code)}).\n${result.stderr.trim() || result.stdout.trim()}`,555 new CliUpdateError({
540 });556 reason: `t3@${targetVersion} is installed but the background service could not be ${restartService ? "updated" : "pointed at it"}: ${error.message}`,
541 }557 }),
542 serviceUpdated = true;558 ),
559 );
560 serviceUpdated = restartService;
543 }561 }
544562
545 yield* Console.log("");563 yield* Console.log("");
...@@ -555,7 +573,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {...@@ -555,7 +573,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {
555 yield* Console.log(` Background service already on ${targetVersion}`);573 yield* Console.log(` Background service already on ${targetVersion}`);
556 } else if (serviceInstalled) {574 } else if (serviceInstalled) {
557 yield* Console.log(575 yield* Console.log(
558 ` Background service still running ${serviceVersion ?? "an unknown version"}. Run \`t3 service update\` when you are ready to restart it.`,576 ` Background service still running ${serviceVersion ?? "an unknown version"}. Run \`t3 service restart\` when you are ready to switch it to ${targetVersion}.`,
559 );577 );
560 } else if (status.installed && !servesThisHome) {578 } else if (status.installed && !servesThisHome) {
561 yield* Console.log(579 yield* Console.log(
apps/server/src/cloud/bootService.test.ts+161-8
...@@ -20,6 +20,7 @@ import { pinnedRuntimePaths } from "./pinnedRuntime.ts";...@@ -20,6 +20,7 @@ import { pinnedRuntimePaths } from "./pinnedRuntime.ts";
20import {20import {
21 parseServiceState,21 parseServiceState,
22 SERVICE_LAUNCHER_PROTOCOL,22 SERVICE_LAUNCHER_PROTOCOL,
23 SERVICE_RESTART_PENDING_FILE,
23 serviceStateHasPendingUpdate,24 serviceStateHasPendingUpdate,
24} from "./serviceProtocol.ts";25} from "./serviceProtocol.ts";
2526
...@@ -181,7 +182,9 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (...@@ -181,7 +182,9 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
181 return {182 return {
182 stdout:183 stdout:
183 input.args[0] === "--version"184 input.args[0] === "--version"
184 ? "t3 v1.2.3\n"185 ? // The runtime under test reports the version of the directory it
186 // was launched from, like the real executable.
187 `t3 v${/versions\/([^/]+)\//.exec(input.command)?.[1] ?? "1.2.3"}\n`
185 : input.command === "loginctl" && input.args[0] === "show-user"188 : input.command === "loginctl" && input.args[0] === "show-user"
186 ? `${control.linger}\n`189 ? `${control.linger}\n`
187 : input.args[1] === "is-enabled"190 : input.args[1] === "is-enabled"
...@@ -201,12 +204,24 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (...@@ -201,12 +204,24 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
201 };204 };
202 }),205 }),
203 });206 });
204 const makeService = (environmentPath = installerPath) =>207 const makeService = (
205 BootService.make({208 environmentPath: string | undefined = installerPath,
206 baseDir,209 cliVersion = "1.2.3",
207 logsDir: path.join(baseDir, "userdata", "logs"),210 serviceBaseDir = baseDir,
208 cliVersion: "1.2.3",211 ) =>
209 host: { execPath: "/usr/bin/t3" },212 Effect.gen(function* () {
213 // Every version the tests install is present and verified on disk, so
214 // install never downloads.
215 const paths = pinnedRuntimePaths(path, serviceBaseDir, cliVersion, platform);
216 yield* fs.makeDirectory(path.dirname(paths.entryPath), { recursive: true });
217 yield* fs.writeFileString(paths.entryPath, "#!/bin/sh\n");
218 yield* fs.writeFileString(paths.sentinelPath, `${cliVersion}\n`);
219 return yield* BootService.make({
220 baseDir: serviceBaseDir,
221 logsDir: path.join(serviceBaseDir, "userdata", "logs"),
222 cliVersion,
223 host: { execPath: "/usr/bin/t3" },
224 });
210 }).pipe(225 }).pipe(
211 Effect.provideService(ProcessRunner.ProcessRunner, runner),226 Effect.provideService(ProcessRunner.ProcessRunner, runner),
212 Effect.provide(227 Effect.provide(
...@@ -220,7 +235,12 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (...@@ -220,7 +235,12 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
220 ),235 ),
221 ConfigProvider.layer(236 ConfigProvider.layer(
222 ConfigProvider.fromEnv({237 ConfigProvider.fromEnv({
223 env: { HOME: home, ...(environmentPath === "" ? {} : { PATH: environmentPath }) },238 env: {
239 HOME: home,
240 ...(environmentPath === undefined || environmentPath === ""
241 ? {}
242 : { PATH: environmentPath }),
243 },
224 }),244 }),
225 ),245 ),
226 ),246 ),
...@@ -469,6 +489,139 @@ it.layer(NodeServices.layer)("boot service install", (it) => {...@@ -469,6 +489,139 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
469 }),489 }),
470 );490 );
471491
492 it.effect("install with start=false rewrites the files and marks a restart pending", () =>
493 Effect.gen(function* () {
494 const { service, fs, statePath, commands, makeService } = yield* makeHarness();
495 yield* service.install();
496 commands.length = 0;
497
498 const newer = yield* makeService(undefined, "1.2.4");
499 const plan = yield* newer.install({ start: false });
500
501 expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({
502 protocol: SERVICE_LAUNCHER_PROTOCOL,
503 activeVersion: "1.2.4",
504 });
505 expect(yield* fs.readFileString(plan.unitPath)).toContain("versions/1.2.4/t3");
506 expect(
507 commands.filter(
508 (command) => command.startsWith("systemctl ") && !command.includes("show-environment"),
509 ),
510 ).toEqual([]);
511 // The files say 1.2.4 but the process is still 1.2.3: not current, and
512 // the reason is named so `t3 service status` can point at restart.
513 const status = yield* newer.status;
514 expect(status.current).toBe(false);
515 expect(status.problems).toContain("restart-pending");
516
517 commands.length = 0;
518 expect(yield* newer.restart).toBe(true);
519 expect((yield* newer.status).problems).not.toContain("restart-pending");
520 expect((yield* newer.status).current).toBe(true);
521 }),
522 );
523
524 it.effect("install with start=false keeps the marker when a later write fails", () =>
525 Effect.gen(function* () {
526 const { service, fs, statePath, makeService } = yield* makeHarness();
527 const path = yield* Path.Path;
528 yield* service.install();
529 const newer = yield* makeService(undefined, "1.2.4");
530 // A non-empty directory in the unit's place: it still counts as an
531 // installed unit, and the rename that writes the new unit fails.
532 const unitPath = (yield* service.status).unitPath;
533 yield* fs.remove(unitPath);
534 yield* fs.makeDirectory(unitPath);
535 yield* fs.writeFileString(path.join(unitPath, "occupied"), "");
536
537 const error = yield* newer.install({ start: false }).pipe(Effect.flip);
538 expect(error._tag).toBe("BootServiceInstallError");
539 expect(
540 yield* fs.exists(path.join(path.dirname(statePath), SERVICE_RESTART_PENDING_FILE)),
541 ).toBe(true);
542 }),
543 );
544
545 it.effect("install with start=false refuses while a remote update is pending", () =>
546 Effect.gen(function* () {
547 const { service, fs, statePath } = yield* makeHarness();
548 yield* service.install();
549 // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned test document.
550 const pendingState = JSON.stringify({
551 protocol: SERVICE_LAUNCHER_PROTOCOL,
552 activeVersion: "1.2.3",
553 update: {
554 id: "u",
555 fromVersion: "1.2.3",
556 targetVersion: "1.2.4",
557 dbPath: "/tmp/state.sqlite",
558 status: "pending",
559 },
560 });
561 yield* fs.writeFileString(statePath, pendingState);
562
563 const error = yield* service.install({ start: false }).pipe(Effect.flip);
564 expect(error._tag).toBe("BootServiceUpdatePendingError");
565 expect(yield* fs.readFileString(statePath)).toBe(pendingState);
566 }),
567 );
568
569 it.effect("restart stops and starts an installed service, and is a no-op otherwise", () =>
570 Effect.gen(function* () {
571 const { service, commands } = yield* makeHarness();
572 expect(yield* service.restart).toBe(false);
573 yield* service.install();
574 commands.length = 0;
575
576 expect(yield* service.restart).toBe(true);
577 expect(
578 commands.filter(
579 (command) => command.startsWith("systemctl ") && !command.includes("show-environment"),
580 ),
581 ).toEqual([
582 "systemctl --user stop t3code.service",
583 "systemctl --user daemon-reload",
584 "systemctl --user enable t3code.service",
585 "systemctl --user restart t3code.service",
586 ]);
587 }),
588 );
589
590 it.effect("restart leaves a service that serves another T3 home alone", () =>
591 Effect.gen(function* () {
592 const { service, fs, commands, makeService } = yield* makeHarness();
593 yield* service.install();
594 commands.length = 0;
595 const path = yield* Path.Path;
596 const otherHome = yield* fs.makeTempDirectoryScoped({ prefix: "t3-other-home-" });
597
598 const other = yield* makeService(undefined, "1.2.3", path.join(otherHome, ".t3"));
599 expect(yield* other.restart).toBe(false);
600 expect(commands.filter((command) => command.startsWith("systemctl "))).toEqual([]);
601 }),
602 );
603
604 it.effect("restart brings the service back when activation fails", () =>
605 Effect.gen(function* () {
606 const { service, commands, control } = yield* makeHarness();
607 yield* service.install();
608 commands.length = 0;
609 control.failCommand = "systemctl --user daemon-reload";
610
611 const error = yield* service.restart.pipe(Effect.flip);
612 expect(error._tag).toBe("BootServiceCommandError");
613 expect(
614 commands.filter(
615 (command) => command.startsWith("systemctl ") && !command.includes("show-environment"),
616 ),
617 ).toEqual([
618 "systemctl --user stop t3code.service",
619 "systemctl --user daemon-reload",
620 "systemctl --user restart t3code.service",
621 ]);
622 }),
623 );
624
472 it.effect("restarts an installed service when repair fails", () =>625 it.effect("restarts an installed service when repair fails", () =>
473 Effect.gen(function* () {626 Effect.gen(function* () {
474 const { service, commands, control } = yield* makeHarness();627 const { service, commands, control } = yield* makeHarness();
apps/server/src/cloud/bootService.ts+87-7
...@@ -27,6 +27,7 @@ import {...@@ -27,6 +27,7 @@ import {
27} from "./pinnedRuntime.ts";27} from "./pinnedRuntime.ts";
28import {28import {
29 SERVICE_LAUNCHER_PROTOCOL,29 SERVICE_LAUNCHER_PROTOCOL,
30 SERVICE_RESTART_PENDING_FILE,
30 SERVICE_STATE_FILE,31 SERVICE_STATE_FILE,
31 compareExactServiceVersions,32 compareExactServiceVersions,
32 parseServiceState,33 parseServiceState,
...@@ -444,6 +445,7 @@ const BootServiceProblem = Schema.Literals([...@@ -444,6 +445,7 @@ const BootServiceProblem = Schema.Literals([
444 "linger-disabled",445 "linger-disabled",
445 "service-disabled",446 "service-disabled",
446 "service-stopped",447 "service-stopped",
448 "restart-pending",
447]);449]);
448type BootServiceProblem = typeof BootServiceProblem.Type;450type BootServiceProblem = typeof BootServiceProblem.Type;
449451
...@@ -457,9 +459,11 @@ export function formatBootServiceProblem(problem: BootServiceProblem): string {...@@ -457,9 +459,11 @@ export function formatBootServiceProblem(problem: BootServiceProblem): string {
457 case "linger-disabled":459 case "linger-disabled":
458 return 'Lingering is disabled. T3 Code will stop when your last login session ends and will not start at boot. Run `sudo loginctl enable-linger "$(id -un)"` on this machine, then retry the service command as your normal user.';460 return 'Lingering is disabled. T3 Code will stop when your last login session ends and will not start at boot. Run `sudo loginctl enable-linger "$(id -un)"` on this machine, then retry the service command as your normal user.';
459 case "service-disabled":461 case "service-disabled":
460 return "The service is not enabled to start automatically. Run `t3 service update` to repair it.";462 return "The service is not enabled to start automatically. Run `t3 service install` to repair it.";
461 case "service-stopped":463 case "service-stopped":
462 return "The service is not running. Check the service log and `systemctl --user status t3code.service`, then run `t3 service update`.";464 return "The service is not running. Check the service log and `systemctl --user status t3code.service`, then run `t3 service install`.";
465 case "restart-pending":
466 return "A newer version is installed but the service is still running the previous one. Run `t3 service restart` to switch.";
463 }467 }
464}468}
465469
...@@ -523,7 +527,20 @@ export class BootService extends Context.Service<...@@ -523,7 +527,20 @@ export class BootService extends Context.Service<
523 {527 {
524 readonly install: (options?: {528 readonly install: (options?: {
525 readonly allowDowngrade?: boolean;529 readonly allowDowngrade?: boolean;
530 /**
531 * Write the unit for this version but leave the service on whatever it
532 * is running now. `t3 update` uses this when the user declines the
533 * restart, so a later `t3 service restart` lands on the new version.
534 */
535 readonly start?: boolean;
526 }) => Effect.Effect<BootServicePlan, BootServiceError>;536 }) => Effect.Effect<BootServicePlan, BootServiceError>;
537 /**
538 * Stop and start the installed service on the version its unit names.
539 * Only when the unit serves this base dir: the unit name is per user, so
540 * another home's service is left alone. Resolves false when nothing was
541 * restarted.
542 */
543 readonly restart: Effect.Effect<boolean, BootServiceError>;
527 readonly uninstall: Effect.Effect<boolean, BootServiceError>;544 readonly uninstall: Effect.Effect<boolean, BootServiceError>;
528 readonly status: Effect.Effect<BootServiceStatus, BootServiceError>;545 readonly status: Effect.Effect<BootServiceStatus, BootServiceError>;
529 }546 }
...@@ -584,6 +601,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {...@@ -584,6 +601,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
584 const unitPath = detectedManager?.unitPath ?? "";601 const unitPath = detectedManager?.unitPath ?? "";
585 const logPath = path.join(input.logsDir, "boot-service.log");602 const logPath = path.join(input.logsDir, "boot-service.log");
586 const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE);603 const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE);
604 const restartPendingPath = path.join(input.baseDir, "runtime", SERVICE_RESTART_PENDING_FILE);
587 const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion, platform);605 const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion, platform);
588 const writeDurably = (filePath: string, contents: string) =>606 const writeDurably = (filePath: string, contents: string) =>
589 Effect.scoped(607 Effect.scoped(
...@@ -728,6 +746,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {...@@ -728,6 +746,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
728746
729 const install = Effect.fn("cloud.boot_service.install")(function* (options?: {747 const install = Effect.fn("cloud.boot_service.install")(function* (options?: {
730 readonly allowDowngrade?: boolean;748 readonly allowDowngrade?: boolean;
749 readonly start?: boolean;
731 }) {750 }) {
732 const manager = yield* requireManager;751 const manager = yield* requireManager;
733 yield* fs752 yield* fs
...@@ -795,7 +814,15 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {...@@ -795,7 +814,15 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
795 const installed = yield* fs814 const installed = yield* fs
796 .exists(unitPath)815 .exists(unitPath)
797 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));816 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
798 if (installed) {817 // With start=false the service keeps running while its files change. The
818 // launcher reads the state file once at startup and the unit only matters
819 // on the next start, so that is safe as long as the launcher is not in
820 // the middle of a remote update, which is the one time it writes the
821 // state file itself. That case is refused below, before anything is
822 // written, from the same read the downgrade check uses; the stop that
823 // normally serialises against the launcher is skipped on purpose.
824 const start = options?.start !== false;
825 if (installed && start) {
799 yield* runSteps(manager.stop);826 yield* runSteps(manager.stop);
800 }827 }
801828
...@@ -824,6 +851,13 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {...@@ -824,6 +851,13 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
824 yield* fs851 yield* fs
825 .makeDirectory(path.dirname(unitPath), { recursive: true })852 .makeDirectory(path.dirname(unitPath), { recursive: true })
826 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));853 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
854 if (!start && installed) {
855 // Written first: once the files below name the new version, the
856 // running service is behind them, and a failure between the two
857 // writes must not leave it looking current. The launcher removes the
858 // marker when it starts, `restart` and a started install do too.
859 yield* fs.writeFileString(restartPendingPath, `${input.cliVersion}\n`, { mode: 0o600 });
860 }
827 yield* writeDurably(861 yield* writeDurably(
828 statePath,862 statePath,
829 // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned document.863 // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned document.
...@@ -836,17 +870,61 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {...@@ -836,17 +870,61 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
836 2,870 2,
837 )}\n`,871 )}\n`,
838 );872 );
873 if (!start && installed) {
874 // The launcher only writes this file while a remote update is in
875 // flight. One that began after the check above lands either before
876 // this write (then the launcher's copy in memory is what it keeps
877 // acting on, and its next write puts its own outcome back) or after
878 // it, which this read catches: the file no longer says what was just
879 // written, so stop here before repointing the unit.
880 const written = yield* fs.readFileString(statePath);
881 if (serviceStateActiveVersion(written) !== input.cliVersion) {
882 return yield* new BootServiceUpdatePendingError();
883 }
884 }
839 yield* writeDurably(unitPath, manager.render(plan));885 yield* writeDurably(unitPath, manager.render(plan));
840886
841 yield* runSteps(manager.activate);887 if (start) {
888 yield* runSteps(manager.activate);
889 yield* fs.remove(restartPendingPath, { force: true });
890 }
842 }).pipe(891 }).pipe(
892 Effect.mapError((cause) =>
893 cause._tag === "PlatformError" ? new BootServiceInstallError({ cause }) : cause,
894 ),
843 Effect.tapError(() =>895 Effect.tapError(() =>
844 installed ? runSteps(manager.restart).pipe(Effect.ignore) : Effect.void,896 installed && start ? runSteps(manager.restart).pipe(Effect.ignore) : Effect.void,
845 ),897 ),
846 );898 );
847 return plan;899 return plan;
848 });900 });
849901
902 const restart: BootService["Service"]["restart"] = Effect.gen(function* () {
903 const manager = yield* requireManager;
904 const unit = yield* fs.readFileString(unitPath).pipe(Effect.option);
905 if (Option.isNone(unit)) return false;
906 const installedBaseDir = bootServiceBaseDirOf(unit.value);
907 if (
908 installedBaseDir === undefined ||
909 path.resolve(installedBaseDir) !== path.resolve(input.baseDir)
910 ) {
911 return false;
912 }
913 yield* runSteps(manager.stop);
914 yield* runSteps(manager.activate).pipe(
915 // Same recovery as a failed repair: a service that was running should
916 // not be left stopped because daemon-reload or enable failed.
917 Effect.tapError(() => runSteps(manager.restart).pipe(Effect.ignore)),
918 );
919 yield* fs.remove(restartPendingPath, { force: true });
920 return true;
921 }).pipe(
922 Effect.mapError((cause) =>
923 cause._tag === "PlatformError" ? new BootServiceInstallError({ cause }) : cause,
924 ),
925 Effect.withSpan("cloud.boot_service.restart"),
926 );
927
850 const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () {928 const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () {
851 const manager = yield* requireManager;929 const manager = yield* requireManager;
852 if (930 if (
...@@ -885,7 +963,9 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {...@@ -885,7 +963,9 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
885 detectedManager.kind === "launchd"963 detectedManager.kind === "launchd"
886 ? contents.replace(/(<key>PATH<\/key>\n\s*<string>)[^<]*(<\/string>)/, "$1$2")964 ? contents.replace(/(<key>PATH<\/key>\n\s*<string>)[^<]*(<\/string>)/, "$1$2")
887 : contents;965 : contents;
888 const problems = detectedManager.kind === "systemd" ? yield* readSystemdProblems(true) : [];966 const problems: BootServiceProblem[] =
967 detectedManager.kind === "systemd" ? [...(yield* readSystemdProblems(true))] : [];
968 if (yield* fs.exists(restartPendingPath)) problems.push("restart-pending");
889 return {969 return {
890 supported: true,970 supported: true,
891 installed: true,971 installed: true,
...@@ -908,7 +988,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {...@@ -908,7 +988,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
908 Effect.withSpan("cloud.boot_service.status"),988 Effect.withSpan("cloud.boot_service.status"),
909 );989 );
910990
911 return BootService.of({ install, uninstall, status });991 return BootService.of({ install, restart, uninstall, status });
912});992});
913993
914export const layer = (input: {994export const layer = (input: {
apps/server/src/cloud/serviceProtocol.ts+5
...@@ -8,6 +8,11 @@ export const SERVICE_STATE_FILE = "service-state.json";...@@ -8,6 +8,11 @@ export const SERVICE_STATE_FILE = "service-state.json";
8 the child can tell "the service is going away" from "the launcher is about8 the child can tell "the service is going away" from "the launcher is about
9 to start my replacement" while a pending update is recorded. */9 to start my replacement" while a pending update is recorded. */
10export const SERVICE_STOP_MARKER_FILE = ".service-stopping";10export const SERVICE_STOP_MARKER_FILE = ".service-stopping";
11/** Written by `t3 update` when the unit was repointed at a new version but the
12 running service was deliberately left on the old one. The launcher removes
13 it when it starts (whoever restarted the service), so while it exists the
14 service is known to be behind its unit and status reports it that way. */
15export const SERVICE_RESTART_PENDING_FILE = ".restart-pending";
1116
12export interface PendingServiceUpdate {17export interface PendingServiceUpdate {
13 readonly id: string;18 readonly id: string;
apps/server/src/serviceLauncher.test.ts+45
...@@ -10,6 +10,7 @@ import {...@@ -10,6 +10,7 @@ import {
10 decodeServiceState,10 decodeServiceState,
11 isExactServiceVersion,11 isExactServiceVersion,
12 SERVICE_LAUNCHER_PROTOCOL,12 SERVICE_LAUNCHER_PROTOCOL,
13 SERVICE_RESTART_PENDING_FILE,
13 SERVICE_STOP_MARKER_FILE,14 SERVICE_STOP_MARKER_FILE,
14} from "./cloud/serviceProtocol.ts";15} from "./cloud/serviceProtocol.ts";
1516
...@@ -113,6 +114,50 @@ it.layer(NodeServices.layer)("service state persistence", (it) => {...@@ -113,6 +114,50 @@ it.layer(NodeServices.layer)("service state persistence", (it) => {
113 }),114 }),
114 );115 );
115116
117 it.effect("a fresh launcher clears a restart deferred by t3 update", () =>
118 Effect.gen(function* () {
119 const fs = yield* FileSystem.FileSystem;
120 const path = yield* Path.Path;
121 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-restart-" });
122 const statePath = path.join(root, "runtime", "service-state.json");
123 const restartPending = path.join(root, "runtime", SERVICE_RESTART_PENDING_FILE);
124 yield* writeFakeRuntime(
125 fs,
126 path,
127 path.join(root, "runtime", "versions", "1.0.0"),
128 "setInterval(() => {}, 1_000);\n",
129 );
130 yield* Effect.promise(() =>
131 writeServiceState(statePath, {
132 protocol: SERVICE_LAUNCHER_PROTOCOL,
133 activeVersion: "1.0.0",
134 }),
135 );
136 const run = () =>
137 Effect.gen(function* () {
138 const launcher = new Launcher(
139 root,
140 yield* Effect.promise(() => readServiceState(statePath)),
141 );
142 const running = launcher.run();
143 yield* Effect.promise(() => launcher.stop("SIGTERM"));
144 yield* Effect.promise(() => running);
145 });
146
147 // A launcher that is still the old version leaves a marker that waits
148 // for a newer one.
149 yield* fs.writeFileString(restartPending, "1.0.1\n");
150 yield* run();
151 assert.isTrue(yield* fs.exists(restartPending));
152
153 // Whoever restarted the service, the launcher now runs what the unit
154 // names, so the deferred-restart marker is gone.
155 yield* fs.writeFileString(restartPending, "1.0.0\n");
156 yield* run();
157 assert.isFalse(yield* fs.exists(restartPending));
158 }),
159 );
160
116 it.effect("serializes shutdown with launcher recovery", () =>161 it.effect("serializes shutdown with launcher recovery", () =>
117 Effect.gen(function* () {162 Effect.gen(function* () {
118 const fs = yield* FileSystem.FileSystem;163 const fs = yield* FileSystem.FileSystem;
apps/server/src/serviceLauncher.ts+13-1
...@@ -26,6 +26,7 @@ import {...@@ -26,6 +26,7 @@ import {
26 SERVICE_LAUNCHER_CONTEXT_ENV,26 SERVICE_LAUNCHER_CONTEXT_ENV,
27 SERVICE_LAUNCHER_PROTOCOL,27 SERVICE_LAUNCHER_PROTOCOL,
28 SERVICE_STATE_FILE,28 SERVICE_STATE_FILE,
29 SERVICE_RESTART_PENDING_FILE,
29 SERVICE_STOP_MARKER_FILE,30 SERVICE_STOP_MARKER_FILE,
30} from "./cloud/serviceProtocol.ts";31} from "./cloud/serviceProtocol.ts";
3132
...@@ -273,6 +274,8 @@ async function terminateChild(...@@ -273,6 +274,8 @@ async function terminateChild(
273274
274const stopMarkerPath = (baseDir: string) =>275const stopMarkerPath = (baseDir: string) =>
275 NodePath.join(baseDir, "runtime", SERVICE_STOP_MARKER_FILE);276 NodePath.join(baseDir, "runtime", SERVICE_STOP_MARKER_FILE);
277const restartPendingPath = (baseDir: string) =>
278 NodePath.join(baseDir, "runtime", SERVICE_RESTART_PENDING_FILE);
276279
277export class Launcher {280export class Launcher {
278 readonly #baseDir: string;281 readonly #baseDir: string;
...@@ -365,8 +368,17 @@ export class Launcher {...@@ -365,8 +368,17 @@ export class Launcher {
365 async #recover(): Promise<void> {368 async #recover(): Promise<void> {
366 // A fresh launcher means servers are running again: any stop marker from369 // A fresh launcher means servers are running again: any stop marker from
367 // a previous explicit stop is stale and must not make a future update370 // a previous explicit stop is stale and must not make a future update
368 // handoff release its tunnel.371 // handoff release its tunnel. A restart deferred by `t3 update` is done
372 // no matter who restarted the service, but only once this launcher is
373 // the version the marker waits for: a launcher that came up between the
374 // CLI writing the marker and writing the new state still runs the old
375 // version, and the marker has to outlive it.
369 await NodeFSP.rm(stopMarkerPath(this.#baseDir), { force: true }).catch(() => undefined);376 await NodeFSP.rm(stopMarkerPath(this.#baseDir), { force: true }).catch(() => undefined);
377 const restartPending = restartPendingPath(this.#baseDir);
378 const awaitedVersion = await NodeFSP.readFile(restartPending, "utf8").catch(() => undefined);
379 if (awaitedVersion?.trim() === this.#state.activeVersion) {
380 await NodeFSP.rm(restartPending, { force: true }).catch(() => undefined);
381 }
370 const update = this.#state.update;382 const update = this.#state.update;
371 if (update?.status !== "pending") {383 if (update?.status !== "pending") {
372 if (update !== undefined) {384 if (update !== undefined) {
docs/user/background-service.md+1
...@@ -110,6 +110,7 @@ that session open....@@ -110,6 +110,7 @@ that session open.
110| `linger-unavailable` | Run `loginctl show-user "$(id -un)" --property=Linger` and check that systemd-logind is available. |110| `linger-unavailable` | Run `loginctl show-user "$(id -un)" --property=Linger` and check that systemd-logind is available. |
111| `user-manager-unavailable` | Run `systemctl --user status` in a login session for the service user; check your distribution's systemd user-session support. |111| `user-manager-unavailable` | Run `systemctl --user status` in a login session for the service user; check your distribution's systemd user-session support. |
112| `service-disabled` or `service-stopped` | Read the log and `systemctl --user status t3code.service`, then use the repair command printed by T3 Code. |112| `service-disabled` or `service-stopped` | Read the log and `systemctl --user status t3code.service`, then use the repair command printed by T3 Code. |
113| `restart-pending` | A newer version is installed but the service still runs the previous one. Run `t3 service restart`. |
113114
114On macOS, check **System Settings → General → Login Items** if the service no115On macOS, check **System Settings → General → Login Items** if the service no
115longer starts at login. If agent work cannot access Desktop, Documents, or116longer starts at login. If agent work cannot access Desktop, Documents, or