| author | |
| committer | |
| log | d8655ed2fcf5cac3e1742459476b4a392b949367 |
| tree | be3d52b16823d4cf916045708d644663548cf564 |
| parent | cba7dd77817df2a458d86977f1daf4606c67dfc8 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 45 | it("gives a direct repair command for a stale service", () => { |
| 46 | 46 | assert.include( |
| 47 | 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 | }); |
| 51 | 51 | |
| ... | ... | @@ -64,17 +64,17 @@ it("explains an incomplete nightly installation and keeps repair on its installe |
| 64 | 64 | expect(output).toContain("last login session ends"); |
| 65 | 65 | expect(output).toContain('sudo loginctl enable-linger "$(id -un)"'); |
| 66 | 66 | expect(output).toContain("[service-stopped]"); |
| 67 | expect(output).toContain("npx t3@0.0.32-nightly.1 service update"); | |
| 68 | expect(output).not.toContain("t3@latest"); | |
| 67 | expect(output).toContain("Run `t3 service install` to repair it."); | |
| 68 | expect(output).not.toContain("npx"); | |
| 69 | 69 | }); |
| 70 | 70 | |
| 71 | it("suggests the newer CLI version when the installed service needs an update", () => { | |
| 71 | it("points an older service at a repair, never at npx", () => { | |
| 72 | 72 | const output = formatServiceStatus( |
| 73 | 73 | { ...status, current: false, installedVersion: "0.0.28" }, |
| 74 | 74 | "0.0.29", |
| 75 | 75 | ); |
| 76 | expect(output).toContain("npx t3@0.0.29 service update"); | |
| 77 | expect(output).not.toContain("npx t3@0.0.28 service update"); | |
| 76 | expect(output).toContain("Run `t3 service install` to repair it."); | |
| 77 | expect(output).not.toContain("npx"); | |
| 78 | 78 | }); |
| 79 | 79 | |
| 80 | 80 | it("explains where the service is supported", () => { |
| ... | ... | @@ -84,23 +84,28 @@ it("explains where the service is supported", () => { |
| 84 | 84 | ); |
| 85 | 85 | }); |
| 86 | 86 | |
| 87 | it("reports a newer installed service and gives an exact-version repair command", () => { | |
| 87 | it("reports a newer installed service and tells the CLI to catch up to it", () => { | |
| 88 | 88 | const output = formatServiceStatus( |
| 89 | 89 | { ...status, current: false, installedVersion: "0.0.32-nightly.1" }, |
| 90 | 90 | "0.0.31", |
| 91 | 91 | ); |
| 92 | 92 | |
| 93 | 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"); | |
| 95 | assert.notInclude(output, "npx t3@latest service update"); | |
| 94 | assert.include(output, "Run `t3 update 0.0.32-nightly.1` to match it"); | |
| 95 | assert.notInclude(output, "npx"); | |
| 96 | 96 | }); |
| 97 | 97 | |
| 98 | 98 | const newerServiceStatus = { ...status, current: false, installedVersion: "999.0.0" }; |
| 99 | 99 | |
| 100 | 100 | function makeTestService(serviceStatus: BootService.BootServiceStatus) { |
| 101 | 101 | const installOptions: Array<Parameters<BootService.BootService["Service"]["install"]>[0]> = []; |
| 102 | const restarts: Array<true> = []; | |
| 102 | 103 | const service = BootService.BootService.of({ |
| 103 | 104 | status: Effect.succeed(serviceStatus), |
| 105 | restart: Effect.sync(() => { | |
| 106 | restarts.push(true); | |
| 107 | return serviceStatus.installed; | |
| 108 | }), | |
| 104 | 109 | install: (options) => |
| 105 | 110 | Effect.sync(() => { |
| 106 | 111 | installOptions.push(options); |
| ... | ... | @@ -113,10 +118,33 @@ function makeTestService(serviceStatus: BootService.BootServiceStatus) { |
| 113 | 118 | }), |
| 114 | 119 | uninstall: Effect.succeed(false), |
| 115 | 120 | }); |
| 116 | return { service, installOptions }; | |
| 121 | return { service, installOptions, restarts }; | |
| 117 | 122 | } |
| 118 | 123 | |
| 119 | 124 | it.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 | 148 | it.effect.each(["install", "update"] as const)( |
| 121 | 149 | "%s refuses a downgrade before changing the service", |
| 122 | 150 | (command) => |
apps/server/src/cli/service.ts+33-6| ... | ... | @@ -38,6 +38,7 @@ export type ServiceReconcileResult = |
| 38 | 38 | /** Install, update, or repair the service using the CLI version running this command. */ |
| 39 | 39 | export const reconcileService = Effect.fn("cli.service.reconcile")(function* (options?: { |
| 40 | 40 | readonly allowDowngrade?: boolean; |
| 41 | readonly start?: boolean; | |
| 41 | 42 | }) { |
| 42 | 43 | const service = yield* BootService.BootService; |
| 43 | 44 | const status = yield* service.status; |
| ... | ... | @@ -87,7 +88,7 @@ export function formatServiceStatus( |
| 87 | 88 | ` Unit: ${status.unitPath}`, |
| 88 | 89 | ` Logs: ${status.logPath}`, |
| 89 | 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 | 92 | ].join("\n"); |
| 92 | 93 | } |
| 93 | 94 | return [ |
| ... | ... | @@ -96,7 +97,7 @@ export function formatServiceStatus( |
| 96 | 97 | ` Unit: ${status.unitPath}`, |
| 97 | 98 | ` Logs: ${status.logPath}`, |
| 98 | 99 | ...problems, |
| 99 | ...(status.current ? [] : [` Next: Run \`npx t3@${cliVersion} service update\`.`]), | |
| 100 | ...(status.current ? [] : [" Next: Run `t3 service install` to repair it."]), | |
| 100 | 101 | ].join("\n"); |
| 101 | 102 | } |
| 102 | 103 | |
| ... | ... | @@ -138,14 +139,18 @@ const serviceInstallCommand = Command.make("install", serviceReconcileFlags).pip |
| 138 | 139 | ), |
| 139 | 140 | ); |
| 140 | 141 | |
| 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`. | |
| 141 | 144 | const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe( |
| 142 | Command.withDescription( | |
| 143 | "Update or repair the background service using this CLI version. Use `t3 update` to move to a newer release first.", | |
| 144 | ), | |
| 145 | Command.withDescription("Deprecated. Run `t3 update` to move to a newer release."), | |
| 146 | Command.unlisted, | |
| 145 | 147 | Command.withHandler((flags) => |
| 146 | 148 | runServiceCommand( |
| 147 | 149 | flags, |
| 148 | 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 | 154 | const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade }); |
| 150 | 155 | if (!result.changed) { |
| 151 | 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 | 164 | ), |
| 160 | 165 | ); |
| 161 | 166 | |
| 167 | const 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 | ||
| 162 | 188 | const serviceUninstallCommand = Command.make("uninstall", projectLocationFlags).pipe( |
| 163 | 189 | Command.withDescription("Stop and remove the T3 Code background service."), |
| 164 | 190 | Command.withHandler((flags) => |
| ... | ... | @@ -265,8 +291,9 @@ export const serviceCommand = Command.make("service").pipe( |
| 265 | 291 | Command.withDescription("Manage the T3 Code background service."), |
| 266 | 292 | Command.withSubcommands([ |
| 267 | 293 | serviceInstallCommand, |
| 294 | serviceRestartCommand, | |
| 268 | 295 | serviceUninstallCommand, |
| 269 | serviceUpdateCommand, | |
| 270 | 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 | 263 | const config = yield* resolveCliAuthConfig(flags, logLevel); |
| 264 | 264 | return yield* runUpdate({ |
| 265 | 265 | baseDir: config.baseDir, |
| 266 | logsDir: config.logsDir, | |
| 266 | 267 | serverRuntimeStatePath: config.serverRuntimeStatePath, |
| 267 | 268 | channel: Option.getOrUndefined(flags.channel), |
| 268 | 269 | requestedVersion: Option.getOrUndefined(flags.version), |
| ... | ... | @@ -333,6 +334,7 @@ const belongsToBootService = Effect.fn("cli.update.belongs_to_boot_service")(fun |
| 333 | 334 | |
| 334 | 335 | const runUpdate = Effect.fn("cli.update.run")(function* (input: { |
| 335 | 336 | readonly baseDir: string; |
| 337 | readonly logsDir: string; | |
| 336 | 338 | readonly serverRuntimeStatePath: string; |
| 337 | 339 | readonly channel: CliReleaseChannel | undefined; |
| 338 | 340 | readonly requestedVersion: string | undefined; |
| ... | ... | @@ -408,8 +410,16 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { |
| 408 | 410 | const serviceVersion = serviceInstalled ? status.installedVersion : undefined; |
| 409 | 411 | const executableCurrent = targetVersion === currentVersion; |
| 410 | 412 | // 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. | |
| 412 | const serviceCurrent = !serviceInstalled || serviceVersion === targetVersion; | |
| 413 | // to be current, so it gets the update rather than being skipped. Nor is | |
| 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 | 423 | const newestInstalled = |
| 414 | 424 | serviceVersion !== undefined && compareExactServiceVersions(serviceVersion, currentVersion) > 0 |
| 415 | 425 | ? serviceVersion |
| ... | ... | @@ -437,11 +447,13 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { |
| 437 | 447 | ); |
| 438 | 448 | |
| 439 | 449 | yield* Console.log( |
| 440 | executableCurrent | |
| 441 | ? `Updating the background service ${serviceVersion ?? "(unknown version)"} -> ${targetVersion} (${targetChannel}).` | |
| 442 | : alreadyOnDisk | |
| 443 | ? `Switching t3 ${currentVersion} -> ${targetVersion} (${targetChannel}, already downloaded).` | |
| 444 | : `Updating t3 ${currentVersion} -> ${targetVersion} (${targetChannel}).`, | |
| 450 | executableCurrent && restartPending | |
| 451 | ? `The background service is still running the version before ${targetVersion} (${targetChannel}).` | |
| 452 | : executableCurrent | |
| 453 | ? `Updating the background service ${serviceVersion ?? "(unknown version)"} -> ${targetVersion} (${targetChannel}).` | |
| 454 | : alreadyOnDisk | |
| 455 | ? `Switching t3 ${currentVersion} -> ${targetVersion} (${targetChannel}, already downloaded).` | |
| 456 | : `Updating t3 ${currentVersion} -> ${targetVersion} (${targetChannel}).`, | |
| 445 | 457 | ); |
| 446 | 458 | let restartService = false; |
| 447 | 459 | if (serviceInstalled && !serviceCurrent) { |
| ... | ... | @@ -459,7 +471,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { |
| 459 | 471 | ).pipe(Effect.catchTag("QuitError", () => Effect.succeed(false))); |
| 460 | 472 | } else { |
| 461 | 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 | 531 | targetEntryPath: runtime.entryPath, |
| 520 | 532 | }); |
| 521 | 533 | |
| 522 | // The new executable owns the service switch: it verifies itself, writes its | |
| 523 | // own version into the unit, and restarts the service on it. | |
| 534 | // The service switch runs in this process against the target version: the | |
| 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 | 540 | let serviceUpdated = false; |
| 525 | if (restartService) { | |
| 526 | const result = yield* runner.run({ | |
| 527 | command: runtime.entryPath, | |
| 528 | args: [ | |
| 529 | "service", | |
| 530 | "update", | |
| 531 | "--base-dir", | |
| 532 | input.baseDir, | |
| 533 | ...(input.allowDowngrade ? ["--allow-downgrade"] : []), | |
| 534 | ], | |
| 535 | timeout: Duration.minutes(5), | |
| 536 | }); | |
| 537 | if (result.code !== 0) { | |
| 538 | return yield* new CliUpdateError({ | |
| 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()}`, | |
| 540 | }); | |
| 541 | } | |
| 542 | serviceUpdated = true; | |
| 541 | if (serviceInstalled && !serviceCurrent) { | |
| 542 | yield* BootService.BootService.pipe( | |
| 543 | Effect.flatMap((target) => | |
| 544 | target.install({ allowDowngrade: input.allowDowngrade, start: restartService }), | |
| 545 | ), | |
| 546 | Effect.provide( | |
| 547 | BootService.layer({ | |
| 548 | baseDir: input.baseDir, | |
| 549 | logsDir: input.logsDir, | |
| 550 | cliVersion: targetVersion, | |
| 551 | }), | |
| 552 | ), | |
| 553 | Effect.mapError( | |
| 554 | (error) => | |
| 555 | new CliUpdateError({ | |
| 556 | reason: `t3@${targetVersion} is installed but the background service could not be ${restartService ? "updated" : "pointed at it"}: ${error.message}`, | |
| 557 | }), | |
| 558 | ), | |
| 559 | ); | |
| 560 | serviceUpdated = restartService; | |
| 543 | 561 | } |
| 544 | 562 | |
| 545 | 563 | yield* Console.log(""); |
| ... | ... | @@ -555,7 +573,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { |
| 555 | 573 | yield* Console.log(` Background service already on ${targetVersion}`); |
| 556 | 574 | } else if (serviceInstalled) { |
| 557 | 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 | 578 | } else if (status.installed && !servesThisHome) { |
| 561 | 579 | yield* Console.log( |
apps/server/src/cloud/bootService.test.ts+161-8| ... | ... | @@ -20,6 +20,7 @@ import { pinnedRuntimePaths } from "./pinnedRuntime.ts"; |
| 20 | 20 | import { |
| 21 | 21 | parseServiceState, |
| 22 | 22 | SERVICE_LAUNCHER_PROTOCOL, |
| 23 | SERVICE_RESTART_PENDING_FILE, | |
| 23 | 24 | serviceStateHasPendingUpdate, |
| 24 | 25 | } from "./serviceProtocol.ts"; |
| 25 | 26 | |
| ... | ... | @@ -181,7 +182,9 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( |
| 181 | 182 | return { |
| 182 | 183 | stdout: |
| 183 | 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 | 188 | : input.command === "loginctl" && input.args[0] === "show-user" |
| 186 | 189 | ? `${control.linger}\n` |
| 187 | 190 | : input.args[1] === "is-enabled" |
| ... | ... | @@ -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) => | |
| 205 | BootService.make({ | |
| 206 | baseDir, | |
| 207 | logsDir: path.join(baseDir, "userdata", "logs"), | |
| 208 | cliVersion: "1.2.3", | |
| 209 | host: { execPath: "/usr/bin/t3" }, | |
| 207 | const makeService = ( | |
| 208 | environmentPath: string | undefined = installerPath, | |
| 209 | cliVersion = "1.2.3", | |
| 210 | serviceBaseDir = baseDir, | |
| 211 | ) => | |
| 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 | 225 | }).pipe( |
| 211 | 226 | Effect.provideService(ProcessRunner.ProcessRunner, runner), |
| 212 | 227 | Effect.provide( |
| ... | ... | @@ -220,7 +235,12 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( |
| 220 | 235 | ), |
| 221 | 236 | ConfigProvider.layer( |
| 222 | 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 | 489 | }), |
| 470 | 490 | ); |
| 471 | 491 | |
| 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 | 625 | it.effect("restarts an installed service when repair fails", () => |
| 473 | 626 | Effect.gen(function* () { |
| 474 | 627 | const { service, commands, control } = yield* makeHarness(); |
apps/server/src/cloud/bootService.ts+87-7| ... | ... | @@ -27,6 +27,7 @@ import { |
| 27 | 27 | } from "./pinnedRuntime.ts"; |
| 28 | 28 | import { |
| 29 | 29 | SERVICE_LAUNCHER_PROTOCOL, |
| 30 | SERVICE_RESTART_PENDING_FILE, | |
| 30 | 31 | SERVICE_STATE_FILE, |
| 31 | 32 | compareExactServiceVersions, |
| 32 | 33 | parseServiceState, |
| ... | ... | @@ -444,6 +445,7 @@ const BootServiceProblem = Schema.Literals([ |
| 444 | 445 | "linger-disabled", |
| 445 | 446 | "service-disabled", |
| 446 | 447 | "service-stopped", |
| 448 | "restart-pending", | |
| 447 | 449 | ]); |
| 448 | 450 | type BootServiceProblem = typeof BootServiceProblem.Type; |
| 449 | 451 | |
| ... | ... | @@ -457,9 +459,11 @@ export function formatBootServiceProblem(problem: BootServiceProblem): string { |
| 457 | 459 | case "linger-disabled": |
| 458 | 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 | 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 | 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 | } |
| 465 | 469 | |
| ... | ... | @@ -523,7 +527,20 @@ export class BootService extends Context.Service< |
| 523 | 527 | { |
| 524 | 528 | readonly install: (options?: { |
| 525 | 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 | 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 | 544 | readonly uninstall: Effect.Effect<boolean, BootServiceError>; |
| 528 | 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 | 601 | const unitPath = detectedManager?.unitPath ?? ""; |
| 585 | 602 | const logPath = path.join(input.logsDir, "boot-service.log"); |
| 586 | 603 | const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE); |
| 604 | const restartPendingPath = path.join(input.baseDir, "runtime", SERVICE_RESTART_PENDING_FILE); | |
| 587 | 605 | const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion, platform); |
| 588 | 606 | const writeDurably = (filePath: string, contents: string) => |
| 589 | 607 | Effect.scoped( |
| ... | ... | @@ -728,6 +746,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 728 | 746 | |
| 729 | 747 | const install = Effect.fn("cloud.boot_service.install")(function* (options?: { |
| 730 | 748 | readonly allowDowngrade?: boolean; |
| 749 | readonly start?: boolean; | |
| 731 | 750 | }) { |
| 732 | 751 | const manager = yield* requireManager; |
| 733 | 752 | yield* fs |
| ... | ... | @@ -795,7 +814,15 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 795 | 814 | const installed = yield* fs |
| 796 | 815 | .exists(unitPath) |
| 797 | 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 | 826 | yield* runSteps(manager.stop); |
| 800 | 827 | } |
| 801 | 828 | |
| ... | ... | @@ -824,6 +851,13 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 824 | 851 | yield* fs |
| 825 | 852 | .makeDirectory(path.dirname(unitPath), { recursive: true }) |
| 826 | 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 | 861 | yield* writeDurably( |
| 828 | 862 | statePath, |
| 829 | 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 | 870 | 2, |
| 837 | 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 | 885 | yield* writeDurably(unitPath, manager.render(plan)); |
| 840 | 886 | |
| 841 | yield* runSteps(manager.activate); | |
| 887 | if (start) { | |
| 888 | yield* runSteps(manager.activate); | |
| 889 | yield* fs.remove(restartPendingPath, { force: true }); | |
| 890 | } | |
| 842 | 891 | }).pipe( |
| 892 | Effect.mapError((cause) => | |
| 893 | cause._tag === "PlatformError" ? new BootServiceInstallError({ cause }) : cause, | |
| 894 | ), | |
| 843 | 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 | 899 | return plan; |
| 848 | 900 | }); |
| 849 | 901 | |
| 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 | 928 | const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () { |
| 851 | 929 | const manager = yield* requireManager; |
| 852 | 930 | if ( |
| ... | ... | @@ -885,7 +963,9 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 885 | 963 | detectedManager.kind === "launchd" |
| 886 | 964 | ? contents.replace(/(<key>PATH<\/key>\n\s*<string>)[^<]*(<\/string>)/, "$1$2") |
| 887 | 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 | 969 | return { |
| 890 | 970 | supported: true, |
| 891 | 971 | installed: true, |
| ... | ... | @@ -908,7 +988,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 908 | 988 | Effect.withSpan("cloud.boot_service.status"), |
| 909 | 989 | ); |
| 910 | 990 | |
| 911 | return BootService.of({ install, uninstall, status }); | |
| 991 | return BootService.of({ install, restart, uninstall, status }); | |
| 912 | 992 | }); |
| 913 | 993 | |
| 914 | 994 | export const layer = (input: { |
apps/server/src/cloud/serviceProtocol.ts+5| ... | ... | @@ -8,6 +8,11 @@ export const SERVICE_STATE_FILE = "service-state.json"; |
| 8 | 8 | the child can tell "the service is going away" from "the launcher is about |
| 9 | 9 | to start my replacement" while a pending update is recorded. */ |
| 10 | 10 | export 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. */ | |
| 15 | export const SERVICE_RESTART_PENDING_FILE = ".restart-pending"; | |
| 11 | 16 | |
| 12 | 17 | export interface PendingServiceUpdate { |
| 13 | 18 | readonly id: string; |
apps/server/src/serviceLauncher.test.ts+45| ... | ... | @@ -10,6 +10,7 @@ import { |
| 10 | 10 | decodeServiceState, |
| 11 | 11 | isExactServiceVersion, |
| 12 | 12 | SERVICE_LAUNCHER_PROTOCOL, |
| 13 | SERVICE_RESTART_PENDING_FILE, | |
| 13 | 14 | SERVICE_STOP_MARKER_FILE, |
| 14 | 15 | } from "./cloud/serviceProtocol.ts"; |
| 15 | 16 | |
| ... | ... | @@ -113,6 +114,50 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { |
| 113 | 114 | }), |
| 114 | 115 | ); |
| 115 | 116 | |
| 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 | 161 | it.effect("serializes shutdown with launcher recovery", () => |
| 117 | 162 | Effect.gen(function* () { |
| 118 | 163 | const fs = yield* FileSystem.FileSystem; |
apps/server/src/serviceLauncher.ts+13-1| ... | ... | @@ -26,6 +26,7 @@ import { |
| 26 | 26 | SERVICE_LAUNCHER_CONTEXT_ENV, |
| 27 | 27 | SERVICE_LAUNCHER_PROTOCOL, |
| 28 | 28 | SERVICE_STATE_FILE, |
| 29 | SERVICE_RESTART_PENDING_FILE, | |
| 29 | 30 | SERVICE_STOP_MARKER_FILE, |
| 30 | 31 | } from "./cloud/serviceProtocol.ts"; |
| 31 | 32 | |
| ... | ... | @@ -273,6 +274,8 @@ async function terminateChild( |
| 273 | 274 | |
| 274 | 275 | const stopMarkerPath = (baseDir: string) => |
| 275 | 276 | NodePath.join(baseDir, "runtime", SERVICE_STOP_MARKER_FILE); |
| 277 | const restartPendingPath = (baseDir: string) => | |
| 278 | NodePath.join(baseDir, "runtime", SERVICE_RESTART_PENDING_FILE); | |
| 276 | 279 | |
| 277 | 280 | export class Launcher { |
| 278 | 281 | readonly #baseDir: string; |
| ... | ... | @@ -365,8 +368,17 @@ export class Launcher { |
| 365 | 368 | async #recover(): Promise<void> { |
| 366 | 369 | // A fresh launcher means servers are running again: any stop marker from |
| 367 | 370 | // 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 | 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 | 382 | const update = this.#state.update; |
| 371 | 383 | if (update?.status !== "pending") { |
| 372 | 384 | if (update !== undefined) { |
docs/user/background-service.md+1| ... | ... | @@ -110,6 +110,7 @@ that session open. |
| 110 | 110 | | `linger-unavailable` | Run `loginctl show-user "$(id -un)" --property=Linger` and check that systemd-logind is available. | |
| 111 | 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 | 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`. | | |
| 113 | 114 | |
| 114 | 115 | On macOS, check **System Settings → General → Login Items** if the service no |
| 115 | 116 | longer starts at login. If agent work cannot access Desktop, Documents, or |