Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions .github/triage/PLAYBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 <release-tag> \
https://github.com/pingdotgg/t3code <source-cache-dir>/<hash>
Expand Down Expand Up @@ -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
Expand Down
135 changes: 121 additions & 14 deletions apps/server/src/cli/triage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
*/
// @effect-diagnostics nodeBuiltinImport:off
import * as NodeChildProcess from "node:child_process";
import * as NodeFS from "node:fs";
import * as NodeHttp from "node:http";
import * as NodeHttps from "node:https";
import * as NodeOS from "node:os";
import * as NodeReadlinePromises from "node:readline/promises";

Expand All @@ -19,6 +22,7 @@ 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 FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
Expand All @@ -29,14 +33,25 @@ import { Command, Flag } from "effect/unstable/cli";
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 {
buildTriageContext,
buildTriageLaunchPrompt,
buildTriageSeedPrompt,
} from "./triagePrompt.ts";
import {
formatLocalServerVersion,
readLocalServerVersion,
recordedServerStillOwnsPid,
triageReleaseTag,
type ServerVersionProbe,
} from "./triageServerVersion.ts";

interface TriageAgent {
readonly id: "claude" | "codex";
Expand Down Expand Up @@ -76,21 +91,109 @@ export class TriageAgentSpawnError extends Schema.TaggedError<TriageAgentSpawnEr
}
}

/**
* Start time of `pid`, in epoch ms. Linux reads `/proc/<pid>` (directory
* mtime is process start). Other platforms ask the OS. Unreadable means
* unknown, which is not treated as the recorded server.
*/
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;
};

const serverVersionProbe = {
isAlive: isProcessAlive,
processStartedAtMs: readProcessStartedAtMs,
readText: (url, timeout) => readEnvironmentBody(url, timeout),
} satisfies ServerVersionProbe;

/**
* GET the environment descriptor. A socket timeout and fiber interruption
* both destroy the request, so a stalled body settles as a failure.
*/
export const readEnvironmentBody = (url: string, timeout: Duration.Input) =>
Effect.callback<string, Error>((resume) => {
const timeoutMs = Duration.toMillis(timeout);
let settled = false;
const finish = (effect: Effect.Effect<string, Error>) => {
if (settled) return;
settled = true;
resume(effect);
};
const request = (url.startsWith("https:") ? NodeHttps : NodeHttp).get(url, (response) => {
response.setTimeout(timeoutMs, () => {
request.destroy();
});
if (response.statusCode !== 200) {
response.resume();
request.destroy();
finish(Effect.fail(new Error(`environment status ${String(response.statusCode)}`)));
return;
}
const chunks: Array<Buffer> = [];
response.on("data", (chunk: Buffer) => {
chunks.push(chunk);
});
response.on("end", () => {
finish(Effect.succeed(Buffer.concat(chunks).toString("utf8")));
});
response.on("error", (cause) => {
finish(Effect.fail(cause));
});
});
request.setTimeout(timeoutMs, () => {
request.destroy();
});
request.on("error", (cause) => {
finish(Effect.fail(cause));
});
return Effect.sync(() => {
request.destroy();
});
});

/** 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<PersistedServerRuntimeState>): 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.startedAt, readProcessStartedAtMs(state.value.pid))) {
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})`;
});
};

const pickAgent = (agents: ReadonlyArray<TriageAgent>) =>
Effect.promise(async () => {
Expand Down Expand Up @@ -182,20 +285,24 @@ 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, serverVersionProbe),
);
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,
Expand Down
101 changes: 98 additions & 3 deletions apps/server/src/cli/triagePrompt.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
// @effect-diagnostics nodeBuiltinImport:off
import * as NodeFS from "node:fs";
import * as NodeHttp from "node:http";
import * as NodePath from "node:path";

import { assert, it } from "@effect/vitest";
import * as Clock from "effect/Clock";
import * as Effect from "effect/Effect";

import { readEnvironmentBody } from "./triage.ts";
import {
buildTriageContext,
buildTriageLaunchPrompt,
Expand Down Expand Up @@ -41,8 +45,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",
Expand All @@ -67,5 +74,93 @@ 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");
});

const withOpenPort = <A, E, R>(
respond: (response: NodeHttp.ServerResponse) => void,
run: (origin: string) => Effect.Effect<A, E, R>,
) =>
Effect.acquireUseRelease(
Effect.callback<NodeHttp.Server>((resume) => {
const server = NodeHttp.createServer((_request, response) => {
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("Expected a TCP address");
}
return run(`http://127.0.0.1:${String(address.port)}`);
},
(server) =>
Effect.sync(() => {
server.closeAllConnections();
server.close();
}),
);

it.effect("environment read fails on a closed port instead of hanging", () =>
Effect.gen(function* () {
const started = yield* Clock.currentTimeMillis;
const exit = yield* Effect.exit(readEnvironmentBody("http://127.0.0.1:1", "1 second"));
assert.equal(exit._tag, "Failure");
assert.isBelow((yield* Clock.currentTimeMillis) - started, 2_000);
}),
);

it.effect("environment read gives up when the response body never finishes", () =>
withOpenPort(
(response) => {
response.writeHead(200, { "content-type": "application/json" });
},
(origin) =>
Effect.gen(function* () {
const started = yield* Clock.currentTimeMillis;
const exit = yield* Effect.exit(
readEnvironmentBody(`${origin}/.well-known/t3/environment`, "200 millis"),
);
assert.equal(exit._tag, "Failure");
assert.isBelow((yield* Clock.currentTimeMillis) - started, 2_000);
}),
),
);
Loading
Loading