From aa2215563af0ef8e58c6ab9582711eb579df2a2e Mon Sep 17 00:00:00 2001 From: macodev00 Date: Thu, 24 Sep 2026 07:49:03 +0000 Subject: [PATCH] fix(cli): report the running server version from t3 triage t3 triage labeled the CLI package version as the installed version, so npx t3 triage diagnosed against that binary instead of the desktop app or service the bug happened on. The context now names the triage CLI separately and records serverVersion from the live server when the recorded pid still owns the process. A stalled body, a reused pid, or a corrupt origin falls back instead of aborting. The playbook asks which version, device, and surface the bug happened on before cloning. Fixes #13337 Co-authored-by: maco --- .github/triage/PLAYBOOK.md | 30 +++- apps/server/src/cli/triage.test.ts | 153 ++++++++++++++++++ apps/server/src/cli/triage.ts | 198 +++++++++++++++++++++-- apps/server/src/cli/triagePrompt.test.ts | 46 +++++- apps/server/src/cli/triagePrompt.ts | 50 ++++-- 5 files changed, 445 insertions(+), 32 deletions(-) create mode 100644 apps/server/src/cli/triage.test.ts diff --git a/.github/triage/PLAYBOOK.md b/.github/triage/PLAYBOOK.md index 39bf3ea01052..d215c515955b 100644 --- a/.github/triage/PLAYBOOK.md +++ b/.github/triage/PLAYBOOK.md @@ -18,9 +18,14 @@ valuable thing you can extract from this conversation. ## 2. Read the machine facts -Read the triage context file before investigating. It tells you the installed -version, the OS, whether the server process is currently running, and the exact -paths for state, logs, and the database. +Read the triage context file before investigating. It tells you the OS, whether +the server process is currently running, and the exact paths for state, logs, +and the database. + +`Installed version` (older CLIs) and `Triage CLI version` (newer CLIs) name the +`t3` binary that wrote the context file. That is not the build the bug happened +on. `npx t3 triage` is whichever `t3` npm resolved, and it can be older than the +desktop app or background service. ## 3. Check for a newer playbook @@ -30,8 +35,21 @@ instead of this one. The user may be on an old release with an old copy. ## 4. Get the source -Clone the repo at the tag matching the user's installed version, into the source -cache directory named in the context file, one subdirectory per commit hash: +Before cloning, ask which version, device, and surface the bug happened on. A +version read on this machine is the wrong tree when the bug was on another +device, a remote server, or a build the user has updated since. + +If a server is up, read `serverVersion` from +`GET /.well-known/t3/environment` (unauthenticated). The context file's +`Server process` line has the origin. Newer context files may already record it +as `Local server version`. Desktop and server builds share a version, so that +value is the desktop app when its bundled server is the one answering. Treat it +as a local fact to check against the user's answer. Clone it only when they +confirm the bug happened on that server. + +Clone the repo at the tag matching the version the bug happened on, into the +source cache directory named in the context file, one subdirectory per commit +hash: git clone --depth 1 --filter=blob:none --branch \ https://github.com/pingdotgg/t3code / @@ -81,7 +99,7 @@ comes from this repo's `main` branch. Search existing issues in pingdotgg/t3code (use `gh`, or the public GitHub search API if `gh` is missing or not logged in). Then check whether the problem is already -fixed in a release newer than the user's version: compare versions, read release +fixed in a release newer than the version the bug happened on: compare versions, read release notes and recent commits touching the relevant code. If the user is behind and the fix likely shipped, say so plainly and give them the diff --git a/apps/server/src/cli/triage.test.ts b/apps/server/src/cli/triage.test.ts new file mode 100644 index 000000000000..94aaf9bf307f --- /dev/null +++ b/apps/server/src/cli/triage.test.ts @@ -0,0 +1,153 @@ +// @effect-diagnostics nodeBuiltinImport:off - the probe test stands up a local HTTP server. +import * as NodeHttp from "node:http"; + +import { assert, describe, it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import type { PersistedServerRuntimeState } from "../serverRuntimeState.ts"; +import { readLocalServerVersion, triageReleaseTag } from "./triage.ts"; + +const nightly = "0.0.43-nightly.20260923.2173"; + +const descriptor = { + environmentId: "env_triage_test", + label: "T3 Code", + platform: { os: "linux", arch: "x64" }, + serverVersion: nightly, + capabilities: {}, +}; + +// Later than this process's start, so the pid still matches the recorded server. +const OWNED_STARTED_AT = "2099-01-01T00:00:00.000Z"; + +const stateFor = ( + origin: string, + pid: number, + startedAt = OWNED_STARTED_AT, +): PersistedServerRuntimeState => ({ + version: 1, + pid, + port: 1, + origin, + startedAt, +}); + +const withServer = ( + respond: (response: NodeHttp.ServerResponse) => void, + run: (origin: string, hits: { count: number }) => Effect.Effect, +) => + Effect.gen(function* () { + const hits = { count: 0 }; + return yield* Effect.acquireUseRelease( + Effect.callback((resume) => { + const server = NodeHttp.createServer((_request, response) => { + hits.count += 1; + respond(response); + }); + server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server))); + }), + (server) => { + const address = server.address(); + if (address === null || typeof address === "string") { + return Effect.die(new Error("Expected a TCP address")); + } + return run(`http://127.0.0.1:${String(address.port)}`, hits); + }, + (server) => + Effect.sync(() => { + server.closeAllConnections(); + server.close(); + }), + ); + }); + +describe("triage version", () => { + it("formats a prerelease tag with the clone-main caveat", () => { + assert.equal( + triageReleaseTag(nightly), + `v${nightly} (prerelease build; if this tag does not exist, clone main)`, + ); + assert.equal(triageReleaseTag("0.0.42"), "v0.0.42"); + }); + + it.effect("reads serverVersion from the live server, not the triage CLI", () => + withServer( + (response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify(descriptor)); + }, + (origin) => + Effect.gen(function* () { + const probed = yield* readLocalServerVersion(Option.some(stateFor(origin, process.pid))); + assert.deepEqual(probed, { status: "probed", version: nightly }); + }), + ), + ); + + it.effect("does not probe a dead pid", () => + Effect.gen(function* () { + const probed = yield* readLocalServerVersion( + Option.some(stateFor("http://127.0.0.1:9", 2_147_483_646, "2026-09-24T00:08:56.777Z")), + ); + assert.deepEqual(probed, { status: "not-running" }); + }), + ); + + it.effect("does not probe a reused pid that is not the recorded server", () => + withServer( + (response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify(descriptor)); + }, + (origin, hits) => + Effect.gen(function* () { + const probed = yield* readLocalServerVersion( + Option.some(stateFor(origin, process.pid, "2000-01-01T00:00:00.000Z")), + ); + assert.deepEqual(probed, { status: "not-running" }); + assert.equal(hits.count, 0); + }), + ), + ); + + it.effect("reports unavailable when the origin is not a URL", () => + Effect.gen(function* () { + const probed = yield* readLocalServerVersion(Option.some(stateFor("not a url", process.pid))); + assert.deepEqual(probed, { status: "unavailable" }); + }), + ); + + it.effect("reports unavailable when the live server never finishes its body", () => + withServer( + (response) => { + response.writeHead(200, { "content-type": "application/json" }); + }, + (origin) => + Effect.gen(function* () { + const started = yield* Clock.currentTimeMillis; + const probed = yield* readLocalServerVersion( + Option.some(stateFor(origin, process.pid)), + "200 millis", + ); + assert.deepEqual(probed, { status: "unavailable" }); + assert.isBelow((yield* Clock.currentTimeMillis) - started, 2_000); + }), + ), + ); + + it.effect("reports unavailable when the live server is not a T3 environment", () => + withServer( + (response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ ok: true })); + }, + (origin) => + Effect.gen(function* () { + const probed = yield* readLocalServerVersion(Option.some(stateFor(origin, process.pid))); + assert.deepEqual(probed, { status: "unavailable" }); + }), + ), + ); +}); diff --git a/apps/server/src/cli/triage.ts b/apps/server/src/cli/triage.ts index c621d331c404..bcb80d82f5db 100644 --- a/apps/server/src/cli/triage.ts +++ b/apps/server/src/cli/triage.ts @@ -11,25 +11,39 @@ */ // @effect-diagnostics nodeBuiltinImport:off import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodeReadlinePromises from "node:readline/promises"; +import { ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Config from "effect/Config"; import * as Console from "effect/Console"; import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { Command, Flag } from "effect/unstable/cli"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; import { resolveBaseDir } from "../os-jank.ts"; -import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; +import { + isProcessAlive, + readPersistedServerRuntimeState, + type PersistedServerRuntimeState, +} from "../serverRuntimeState.ts"; import { baseDirFlag } from "./config.ts"; import { resolveCliCommand } from "./invocation.ts"; import { @@ -76,22 +90,178 @@ export class TriageAgentSpawnError extends Schema.TaggedError + /^[^-+]+-(?:nightly|preview)\./.test(version) + ? `v${version} (prerelease build; if this tag does not exist, clone main)` + : `v${version}`; + +/** + * Start time of `pid`, in epoch ms. Linux reads `/proc/` (directory mtime + * is process start). Other platforms ask the OS. Unreadable means unknown. + */ +const readProcessStartedAtMs = (pid: number): number | undefined => { + try { + if (NodeOS.platform() === "linux") { + return NodeFS.statSync(`/proc/${pid}`).mtimeMs; + } + if (NodeOS.platform() === "darwin") { + const stdout = NodeChildProcess.execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { + encoding: "utf8", + timeout: 2_000, + env: { ...process.env, LC_ALL: "C" }, + }); + const parsed = Date.parse(stdout.trim()); + return Number.isFinite(parsed) ? parsed : undefined; + } + if (NodeOS.platform() === "win32") { + const stdout = NodeChildProcess.execFileSync( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + `(Get-Process -Id ${String(pid)}).StartTime.ToUniversalTime().ToString('o')`, + ], + { encoding: "utf8", timeout: 5_000, windowsHide: true }, + ); + const parsed = Date.parse(stdout.trim()); + return Number.isFinite(parsed) ? parsed : undefined; + } + } catch { + return undefined; + } + return undefined; +}; + +/** + * True when `pid` is still the process that wrote `startedAt`. The state file + * is written after that process starts, so a reused pid (started later) is not + * a match. Unknown start time is not a match: do not probe a stranger. + */ +const recordedServerStillOwnsPid = (state: PersistedServerRuntimeState): boolean => { + const startedAtMs = Date.parse(state.startedAt); + if (!Number.isFinite(startedAtMs)) { + return false; + } + const processStartedAtMs = readProcessStartedAtMs(state.pid); + if (processStartedAtMs === undefined) { + return false; + } + return processStartedAtMs <= startedAtMs + PROCESS_START_SKEW_MS; +}; + /** One human-readable line about the local server, for `context.md`. */ -const describeServerProcess = Effect.fn("triage.describeServerProcess")(function* ( - serverRuntimeStatePath: string, -) { - // readPersistedServerRuntimeState swallows read/decode failures itself and - // returns none, so a corrupt state file reads as "not running" here. - const state = yield* readPersistedServerRuntimeState(serverRuntimeStatePath); +const describeServerProcess = (state: Option.Option): string => { if (Option.isNone(state)) { return "not running (no server-runtime.json; the server may never have started here)"; } if (!isProcessAlive(state.value.pid)) { return `not running (state file is stale: pid ${String(state.value.pid)} is dead; last origin ${state.value.origin})`; } + if (!recordedServerStillOwnsPid(state.value)) { + return `not running (state file is stale: pid ${String(state.value.pid)} is not the server that wrote it; last origin ${state.value.origin})`; + } return `running (pid ${String(state.value.pid)}, ${state.value.origin})`; +}; + +export type LocalServerVersion = + | { readonly status: "not-running" } + | { readonly status: "unavailable" } + | { readonly status: "probed"; readonly version: string }; + +/** http(s) environment URL. `new URL` throws on a corrupt origin; that stays here. */ +const environmentUrl = (origin: string): string | undefined => { + try { + const base = new URL(origin); + if (base.protocol !== "http:" && base.protocol !== "https:") { + return undefined; + } + return new URL(WELL_KNOWN_ENVIRONMENT_PATH, base).toString(); + } catch { + return undefined; + } +}; + +/** + * `response.arrayBuffer()` does not observe Effect interruption, so a timeout + * has to abort the fetch itself or a stalled body never settles. + */ +const fetchWithDeadline = (timeout: Duration.Input): typeof fetch => { + const deadline = AbortSignal.timeout(Duration.toMillis(timeout)); + return (input, init) => + // @effect-diagnostics-next-line globalFetch:off + globalThis.fetch(input, { + ...init, + signal: init?.signal == null ? deadline : AbortSignal.any([init.signal, deadline]), + }); +}; + +const probeServerVersion = (url: string, timeout: Duration.Input) => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const descriptor = yield* client + .execute(HttpClientRequest.get(url)) + .pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(HttpClientResponse.schemaBodyJson(ExecutionEnvironmentDescriptor)), + ); + return descriptor.serverVersion; + }).pipe( + // Covers the status check and the body read, not only the header response. + Effect.timeout(timeout), + Effect.provide( + FetchHttpClient.layer.pipe( + Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchWithDeadline(timeout))), + ), + ), + Effect.orElseSucceed(() => undefined), + Effect.catchCause(() => Effect.succeed(undefined)), + ); + +/** + * `serverVersion` of the server recorded in `server-runtime.json`. + * Desktop and service installs publish it on the unauthenticated environment + * descriptor. A dead or reused pid is not probed. + */ +export const readLocalServerVersion = Effect.fn("triage.readLocalServerVersion")(function* ( + state: Option.Option, + probeTimeout: Duration.Input = SERVER_VERSION_PROBE_TIMEOUT, +): Effect.fn.Return { + if ( + Option.isNone(state) || + !isProcessAlive(state.value.pid) || + !recordedServerStillOwnsPid(state.value) + ) { + return { status: "not-running" }; + } + const url = environmentUrl(state.value.origin); + if (url === undefined) { + return { status: "unavailable" }; + } + const version = yield* probeServerVersion(url, probeTimeout); + return version === undefined ? { status: "unavailable" } : { status: "probed", version }; }); +const formatLocalServerVersion = (probed: LocalServerVersion) => { + if (probed.status === "probed") { + return { + localServerVersion: probed.version, + localServerReleaseTag: triageReleaseTag(probed.version), + }; + } + return { + localServerVersion: + probed.status === "unavailable" ? LOCAL_SERVER_VERSION_UNAVAILABLE : "not running", + }; +}; + const pickAgent = (agents: ReadonlyArray) => Effect.promise(async () => { const readline = NodeReadlinePromises.createInterface({ @@ -182,20 +352,22 @@ export const triageCommand = Command.make("triage", { ); yield* fs.makeDirectory(scratchDir, { recursive: true }); - const version = packageJson.version; + const cliVersion = packageJson.version; + // A corrupt state file reads as "not running"; the reader swallows that. + const serverState = yield* readPersistedServerRuntimeState(paths.serverRuntimeStatePath); + const localServer = formatLocalServerVersion(yield* readLocalServerVersion(serverState)); const contextFilePath = path.join(scratchDir, "context.md"); yield* fs.writeFileString( contextFilePath, buildTriageContext({ generatedAt: DateTime.formatIso(now), - version, - releaseTag: /^[^-+]+-(?:nightly|preview)\./.test(version) - ? `v${version} (prerelease build; if this tag does not exist, clone main)` - : `v${version}`, + cliVersion, + cliReleaseTag: triageReleaseTag(cliVersion), + ...localServer, os: `${yield* HostProcessPlatform} ${yield* HostProcessArchitecture} (${NodeOS.release()})`, nodeVersion: process.version, launchedAs: yield* resolveCliCommand("triage"), - server: yield* describeServerProcess(paths.serverRuntimeStatePath), + server: describeServerProcess(serverState), paths: { stateDir: paths.stateDir, dbPath: paths.dbPath, diff --git a/apps/server/src/cli/triagePrompt.test.ts b/apps/server/src/cli/triagePrompt.test.ts index bf1ac5dbbe5e..9a3d11cd624d 100644 --- a/apps/server/src/cli/triagePrompt.test.ts +++ b/apps/server/src/cli/triagePrompt.test.ts @@ -41,8 +41,11 @@ it("launch prompt stays a single argv-safe line naming the prompt file", () => { it("context file carries every path the playbook depends on", () => { const context = buildTriageContext({ generatedAt: "2026-08-13T00:00:00.000Z", - version: "0.0.33", - releaseTag: "v0.0.33", + cliVersion: "0.0.33", + cliReleaseTag: "v0.0.33", + localServerVersion: "0.0.43-nightly.20260923.2173", + localServerReleaseTag: + "v0.0.43-nightly.20260923.2173 (prerelease build; if this tag does not exist, clone main)", os: "linux x64 (7.0.0)", nodeVersion: "v24.0.0", launchedAs: "npx t3 triage", @@ -67,5 +70,42 @@ it("context file carries every path the playbook depends on", () => { assert.include(context, "/home/u/.t3/userdata/secrets"); assert.include(context, "/home/u/.t3/source"); assert.include(context, "npx t3 triage"); - assert.include(context, "v0.0.33"); + assert.include(context, "Triage CLI version: 0.0.33"); + assert.include(context, "Release tag for the triage CLI: v0.0.33"); + assert.include(context, "Local server version: 0.0.43-nightly.20260923.2173"); + assert.include( + context, + "Release tag for the local server: v0.0.43-nightly.20260923.2173 (prerelease build; if this tag does not exist, clone main)", + ); + assert.notInclude(context, "Installed version:"); + assert.include(TRIAGE_PLAYBOOK, "ask which version, device, and surface the bug happened on"); + assert.include(TRIAGE_PLAYBOOK, "check against the user's answer"); +}); + +it("omits a local-server release tag when no server version was probed", () => { + const context = buildTriageContext({ + generatedAt: "2026-08-13T00:00:00.000Z", + cliVersion: "0.0.42", + cliReleaseTag: "v0.0.42", + localServerVersion: "not running", + os: "linux x64 (7.0.0)", + nodeVersion: "v24.0.0", + launchedAs: "npx t3 triage", + server: "not running (no server-runtime.json; the server may never have started here)", + paths: { + stateDir: "/home/u/.t3/userdata", + dbPath: "/home/u/.t3/userdata/state.sqlite", + settingsPath: "/home/u/.t3/userdata/settings.json", + logsDir: "/home/u/.t3/userdata/logs", + serverLogPath: "/home/u/.t3/userdata/logs/server.log", + serverTracePath: "/home/u/.t3/userdata/logs/server.trace.ndjson", + providerEventLogPath: "/home/u/.t3/userdata/logs/provider/events.log", + terminalLogsDir: "/home/u/.t3/userdata/logs/terminals", + providerStatusCacheDir: "/home/u/.t3/caches", + secretsDir: "/home/u/.t3/userdata/secrets", + sourceCacheDir: "/home/u/.t3/source", + }, + }); + assert.include(context, "Local server version: not running"); + assert.notInclude(context, "Release tag for the local server"); }); diff --git a/apps/server/src/cli/triagePrompt.ts b/apps/server/src/cli/triagePrompt.ts index c2b93a1840a1..7b572dad10b2 100644 --- a/apps/server/src/cli/triagePrompt.ts +++ b/apps/server/src/cli/triagePrompt.ts @@ -30,9 +30,14 @@ valuable thing you can extract from this conversation. ## 2. Read the machine facts -Read the triage context file before investigating. It tells you the installed -version, the OS, whether the server process is currently running, and the exact -paths for state, logs, and the database. +Read the triage context file before investigating. It tells you the OS, whether +the server process is currently running, and the exact paths for state, logs, +and the database. + +\`Installed version\` (older CLIs) and \`Triage CLI version\` (newer CLIs) name the +\`t3\` binary that wrote the context file. That is not the build the bug happened +on. \`npx t3 triage\` is whichever \`t3\` npm resolved, and it can be older than the +desktop app or background service. ## 3. Check for a newer playbook @@ -42,8 +47,21 @@ instead of this one. The user may be on an old release with an old copy. ## 4. Get the source -Clone the repo at the tag matching the user's installed version, into the source -cache directory named in the context file, one subdirectory per commit hash: +Before cloning, ask which version, device, and surface the bug happened on. A +version read on this machine is the wrong tree when the bug was on another +device, a remote server, or a build the user has updated since. + +If a server is up, read \`serverVersion\` from +\`GET /.well-known/t3/environment\` (unauthenticated). The context file's +\`Server process\` line has the origin. Newer context files may already record it +as \`Local server version\`. Desktop and server builds share a version, so that +value is the desktop app when its bundled server is the one answering. Treat it +as a local fact to check against the user's answer. Clone it only when they +confirm the bug happened on that server. + +Clone the repo at the tag matching the version the bug happened on, into the +source cache directory named in the context file, one subdirectory per commit +hash: git clone --depth 1 --filter=blob:none --branch \\ https://github.com/pingdotgg/t3code / @@ -93,7 +111,7 @@ comes from this repo's \`main\` branch. Search existing issues in pingdotgg/t3code (use \`gh\`, or the public GitHub search API if \`gh\` is missing or not logged in). Then check whether the problem is already -fixed in a release newer than the user's version: compare versions, read release +fixed in a release newer than the version the bug happened on: compare versions, read release notes and recent commits touching the relevant code. If the user is behind and the fix likely shipped, say so plainly and give them the @@ -165,8 +183,15 @@ ${TRIAGE_PLAYBOOK}`; /** Machine facts for one triage run, pre-formatted so the template stays plain. */ export interface TriageContextInput { readonly generatedAt: string; - readonly version: string; - readonly releaseTag: string; + /** Version of the `t3` process that wrote this file. Not the product the bug happened on. */ + readonly cliVersion: string; + readonly cliReleaseTag: string; + /** + * `serverVersion` from the live local server, or a short reason none was read. + * Desktop and service installs publish this on `/.well-known/t3/environment`. + */ + readonly localServerVersion: string; + readonly localServerReleaseTag?: string; readonly os: string; readonly nodeVersion: string; readonly launchedAs: string; @@ -191,8 +216,13 @@ export const buildTriageContext = (input: TriageContextInput) => `# T3 Code tria Generated by \`t3 triage\` at ${input.generatedAt}. -- Installed version: ${input.version} -- Release tag for this version: ${input.releaseTag} +- Triage CLI version: ${input.cliVersion} +- Release tag for the triage CLI: ${input.cliReleaseTag} +- Local server version: ${input.localServerVersion}${ + input.localServerReleaseTag === undefined + ? "" + : `\n- Release tag for the local server: ${input.localServerReleaseTag}` +} - OS: ${input.os} - Node: ${input.nodeVersion} - CLI launched as: ${input.launchedAs}