Skip to content

Commit 11eda99

Browse files
t3dotggclaude
andcommitted
fix(triage): address council review
- Sessions started during a clone share it, and the release tag is looked up once per server. The lookup is capped at 5 s so a dead network does not stall triage. - Clones stage beside the source cache, not in it, so an agent pruning the cache cannot delete one in progress. Leftover staging is removed at start. - Web: the draft hero selects a boolean, so typing no longer re-renders it. A loading toast shows if starting takes over 400 ms, and leaving the route meanwhile is no longer reported as an error. - Mobile: triage targets the open draft's machine, the palette row names it, and No project is checked before the server prepares a session. - Tests for clone sharing, clone reuse, and a failed lookup. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent bdd6942 commit 11eda99

10 files changed

Lines changed: 225 additions & 50 deletions

File tree

‎apps/mobile/src/features/keyboard/CommandPalette.tsx‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re
5858
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
5959
import { ThreadSearchMatchExcerpt } from "../threads/thread-search-match";
6060
import { useStartTriage } from "../triage/useStartTriage";
61+
import { useNewTaskFlow } from "../threads/new-task-flow-provider";
62+
import { useActiveNewTask } from "../../state/active-new-task";
6163
import {
6264
filterCommandPaletteItems,
6365
nextPaletteIndex,
@@ -266,7 +268,12 @@ export function CommandPalette(props: {
266268
);
267269
const serverConfigs = useServerConfigs();
268270
const startTriage = useStartTriage();
269-
const triageEnvironmentId = activeThread?.environmentId ?? searchEnvironmentIds[0] ?? null;
271+
const newTaskFlow = useNewTaskFlow();
272+
// Triage the machine on screen: the open thread's, or the open draft's.
273+
const draftEnvironmentId =
274+
useActiveNewTask() === null ? null : (newTaskFlow.selectedProject?.environmentId ?? null);
275+
const triageEnvironmentId =
276+
activeThread?.environmentId ?? draftEnvironmentId ?? searchEnvironmentIds[0] ?? null;
270277
const canTriage =
271278
useAtomValue(serverEnvironment.prepareTriage.permissionAtom(triageEnvironmentId)) &&
272279
triageEnvironmentId !== null &&
@@ -344,6 +351,8 @@ export function CommandPalette(props: {
344351
key: "triage",
345352
kind: "action",
346353
title: "Triage an issue",
354+
// Mobile has no triage hero, so name the machine before it starts.
355+
detail: `On ${savedConnectionsById[triageEnvironmentId]?.environmentLabel ?? "this machine"}`,
347356
searchTerms: ["triage", "bug", "issue", "report", "broken", "debug", "help", "support"],
348357
run: () => void startTriage(triageEnvironmentId),
349358
});

‎apps/mobile/src/features/triage/useStartTriage.ts‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,18 @@ export function useStartTriage() {
4242
error instanceof Error ? error.message : fallback;
4343

4444
return async (environmentId: EnvironmentId) => {
45+
const scratch = await openScratch({ environmentId, input: {} });
46+
if (scratch._tag === "Failure") {
47+
if (!isAtomCommandInterrupted(scratch)) {
48+
fail(
49+
errorMessage(
50+
squashAtomCommandFailure(scratch),
51+
"This machine cannot start threads without a project.",
52+
),
53+
);
54+
}
55+
return;
56+
}
4557
const prepared = await prepare({
4658
environmentId,
4759
input: { client: authClientMetadata(Constants.expoConfig?.version) },
@@ -52,15 +64,6 @@ export function useStartTriage() {
5264
}
5365
return;
5466
}
55-
const scratch = await openScratch({ environmentId, input: {} });
56-
if (scratch._tag === "Failure") {
57-
if (!isAtomCommandInterrupted(scratch)) {
58-
fail(
59-
errorMessage(squashAtomCommandFailure(scratch), "No project threads are not available."),
60-
);
61-
}
62-
return;
63-
}
6467
try {
6568
await waitForComposerDraftsLoaded();
6669
} catch {

‎apps/server/src/cli/triage.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ export const triageCommand = Command.make("triage", {
163163
selected = yield* pickAgent(installed);
164164
}
165165

166-
// Windows .cmd shims cannot carry multiline argv; prompt.md also serves as the paste fallback.
166+
// Windows .cmd shims cannot carry multiline argv; the prompt file also serves as the paste fallback.
167167
const { sessionDir, promptFilePath, contextFilePath } = session;
168168

169169
if (selected === undefined) {

‎apps/server/src/triage/TriageService.test.ts‎

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
1+
import * as NodeServices from "@effect/platform-node/NodeServices";
2+
import { GitCommandError } from "@t3tools/contracts";
13
import { assert, it } from "@effect/vitest";
4+
import * as Context from "effect/Context";
5+
import * as Effect from "effect/Effect";
6+
import * as FileSystem from "effect/FileSystem";
7+
import * as Layer from "effect/Layer";
8+
import * as Queue from "effect/Queue";
9+
import { ChildProcessSpawner } from "effect/process";
210

11+
import packageJson from "../../package.json" with { type: "json" };
12+
import * as ServerConfig from "../config.ts";
13+
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
14+
import * as VcsProcess from "../vcs/VcsProcess.ts";
15+
import * as TriageService from "./TriageService.ts";
316
import { pickSourceRef } from "./TriageService.ts";
417

518
const MAIN = "a1dc856ee61278d0199c36d4bcdef5fdea4d21fe\trefs/heads/main";
@@ -22,3 +35,107 @@ it("falls back to main when the server's version has no tag", () => {
2235
});
2336
assert.isUndefined(pickSourceRef("", "0.1.1"));
2437
});
38+
39+
const TestLayer = GitVcsDriver.layer.pipe(
40+
Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-triage-service-" })),
41+
Layer.provideMerge(VcsProcess.layer),
42+
Layer.provideMerge(NodeServices.layer),
43+
);
44+
45+
const RELEASE_COMMIT = "7027b1f";
46+
47+
/** The service over a stub git that answers each command with `answer`. */
48+
const makeTriage = (
49+
answer: (command: string) => Effect.Effect<string, GitCommandError> = (command) =>
50+
Effect.succeed(
51+
command === "ls-remote" ? `${RELEASE_COMMIT}\trefs/tags/v${packageJson.version}\n` : "",
52+
),
53+
) =>
54+
Effect.gen(function* () {
55+
const git = yield* GitVcsDriver.GitVcsDriver;
56+
const commands: string[] = [];
57+
const triage = yield* Layer.build(TriageService.layer).pipe(
58+
Effect.map(Context.get(TriageService.TriageService)),
59+
Effect.provideService(GitVcsDriver.GitVcsDriver, {
60+
...git,
61+
execute: (input) => {
62+
const command = input.args[0] ?? "";
63+
commands.push(command);
64+
return answer(command).pipe(
65+
Effect.map((stdout) => ({
66+
exitCode: ChildProcessSpawner.ExitCode(0),
67+
stdout,
68+
stderr: "",
69+
stdoutTruncated: false,
70+
stderrTruncated: false,
71+
})),
72+
);
73+
},
74+
}),
75+
);
76+
return { triage, commands };
77+
});
78+
79+
const readContext = Effect.gen(function* () {
80+
const fs = yield* FileSystem.FileSystem;
81+
const config = yield* ServerConfig.ServerConfig;
82+
const sessions = `${config.stateDir}/triage`;
83+
const [session] = yield* fs.readDirectory(sessions);
84+
return yield* fs.readFileString(`${sessions}/${session}/context.md`);
85+
});
86+
87+
it.effect("sessions started during a clone share it, and a release tag is looked up once", () =>
88+
Effect.gen(function* () {
89+
const fetches = yield* Queue.unbounded<void>();
90+
const { triage, commands } = yield* makeTriage((command) =>
91+
command === "fetch"
92+
? Queue.offer(fetches, undefined).pipe(Effect.andThen(Effect.never))
93+
: Effect.succeed(
94+
command === "ls-remote" ? `${RELEASE_COMMIT}\trefs/tags/v${packageJson.version}\n` : "",
95+
),
96+
);
97+
98+
yield* triage.prepare({ client: {} });
99+
yield* triage.prepare({ client: {} });
100+
yield* Queue.take(fetches);
101+
102+
assert.deepEqual(
103+
commands.filter((command) => command === "ls-remote" || command === "init"),
104+
["ls-remote", "init"],
105+
);
106+
}).pipe(Effect.provide(TestLayer)),
107+
);
108+
109+
it.effect("reuses a finished clone without fetching", () =>
110+
Effect.gen(function* () {
111+
const fs = yield* FileSystem.FileSystem;
112+
const config = yield* ServerConfig.ServerConfig;
113+
yield* fs.makeDirectory(`${config.baseDir}/source/${RELEASE_COMMIT}`, { recursive: true });
114+
const { triage, commands } = yield* makeTriage();
115+
116+
yield* triage.prepare({ client: {} });
117+
118+
assert.deepEqual(commands, ["ls-remote"]);
119+
assert.include(yield* readContext, "The server cloned the source for its own version");
120+
}).pipe(Effect.provide(TestLayer)),
121+
);
122+
123+
it.effect("still starts triage when the source lookup fails", () =>
124+
Effect.gen(function* () {
125+
const { triage } = yield* makeTriage(() =>
126+
Effect.fail(
127+
new GitCommandError({
128+
operation: "TriageService.resolveSourceRef",
129+
command: "git ls-remote",
130+
cwd: "/",
131+
detail: "Could not resolve host: github.com",
132+
}),
133+
),
134+
);
135+
136+
const { prompt } = yield* triage.prepare({ client: {} });
137+
138+
assert.include(prompt, "triage-playbook.md");
139+
assert.include(yield* readContext, "Could not resolve host: github.com");
140+
}).pipe(Effect.provide(TestLayer)),
141+
);

‎apps/server/src/triage/TriageService.ts‎

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ const make = Effect.gen(function* () {
6565
const fs = yield* FileSystem.FileSystem;
6666
const path = yield* Path.Path;
6767
const sourceDir = triageSourceCacheDir(config.baseDir, path);
68+
// Beside the cache, not in it: the playbook has agents prune the cache, and a
69+
// clone in progress must not look like a stale entry. Same filesystem, so the
70+
// finished clone moves in with a rename.
71+
const stagingDir = `${sourceDir}-staging`;
6872
const serverVersion = packageJson.version;
6973

7074
const runGit = (operation: string, cwd: string, args: ReadonlyArray<string>, timeoutMs: number) =>
@@ -73,11 +77,14 @@ const make = Effect.gen(function* () {
7377
// Clones outlive the request that started them: a fresh clone takes tens of
7478
// seconds, and the user is writing their description meanwhile.
7579
const serviceScope = yield* Effect.scope;
80+
// Clones a previous server process left half done.
81+
yield* fs.remove(stagingDir, { recursive: true }).pipe(Effect.ignore);
7682

7783
// Fetches the exact commit `ls-remote` resolved, so the folder the session
7884
// names is the one that appears even when main moves meanwhile.
7985
const cloneSource = Effect.fn("TriageService.cloneSource")(function* (commit: string) {
80-
const staging = yield* fs.makeTempDirectory({ directory: sourceDir, prefix: ".clone-" });
86+
yield* fs.makeDirectory(stagingDir, { recursive: true });
87+
const staging = yield* fs.makeTempDirectory({ directory: stagingDir, prefix: "clone-" });
8188
const directory = path.join(sourceDir, commit);
8289
yield* Effect.gen(function* () {
8390
yield* runGit("initSource", staging, ["init", "--quiet"], 10_000);
@@ -108,19 +115,33 @@ const make = Effect.gen(function* () {
108115
);
109116
});
110117

111-
// The playbook's own layout: one folder per commit under the source cache, so
112-
// the agent finds and reuses this clone instead of making another. The folder
113-
// appears only once the clone is complete.
114-
const startSourceClone = Effect.fn("TriageService.startSourceClone")(function* () {
115-
yield* fs.makeDirectory(sourceDir, { recursive: true });
118+
// Release tags never move, so one lookup serves every later session. Builds
119+
// with no tag track main, which does move, so they look it up each time.
120+
let releaseSource: { readonly ref: string; readonly commit: string } | undefined;
121+
const resolveSource = Effect.fn("TriageService.resolveSource")(function* () {
122+
if (releaseSource !== undefined) return releaseSource;
116123
const tag = `refs/tags/v${serverVersion}`;
117124
const listed = yield* runGit(
118125
"resolveSourceRef",
119126
sourceDir,
120127
["ls-remote", T3CODE_REPOSITORY_URL, tag, `${tag}^{}`, "refs/heads/main"],
121-
30_000,
128+
// Triage still starts without source; a dead network should not stall it.
129+
5_000,
122130
);
123131
const source = pickSourceRef(listed.stdout, serverVersion);
132+
if (source?.ref !== "main") releaseSource = source;
133+
return source;
134+
});
135+
136+
// Commits with a clone running, so sessions started meanwhile share it.
137+
const cloning = new Set<string>();
138+
139+
// The playbook's own layout: one folder per commit under the source cache, so
140+
// the agent finds and reuses this clone instead of making another. The folder
141+
// appears only once the clone is complete.
142+
const startSourceClone = Effect.fn("TriageService.startSourceClone")(function* () {
143+
yield* fs.makeDirectory(sourceDir, { recursive: true });
144+
const source = yield* resolveSource();
124145
if (source === undefined) {
125146
return {
126147
_tag: "failed",
@@ -132,10 +153,14 @@ const make = Effect.gen(function* () {
132153
if (yield* fs.exists(directory)) {
133154
return { _tag: "cloned", directory, serverVersion, ...source } as const;
134155
}
135-
yield* cloneSource(source.commit).pipe(
136-
Effect.ignoreCause({ log: true }),
137-
Effect.forkIn(serviceScope),
138-
);
156+
if (!cloning.has(source.commit)) {
157+
cloning.add(source.commit);
158+
yield* cloneSource(source.commit).pipe(
159+
Effect.ignoreCause({ log: true }),
160+
Effect.ensuring(Effect.sync(() => cloning.delete(source.commit))),
161+
Effect.forkIn(serviceScope),
162+
);
163+
}
139164
return { _tag: "cloning", directory, serverVersion, ...source } as const;
140165
});
141166

‎apps/server/src/triage/triagePrompt.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -242,10 +242,11 @@ The server could not clone the source for its version (${clone.serverVersion}):
242242
Clone it as step 4 describes.
243243
`;
244244
}
245-
const status =
246-
clone._tag === "cloned"
247-
? "The server cloned the source for its own version"
248-
: "The server is cloning the source for its own version";
245+
const verb = clone._tag === "cloned" ? "The server cloned" : "The server is cloning";
246+
const what =
247+
clone.ref === "main"
248+
? `main, since its version (${clone.serverVersion}) has no release tag`
249+
: `the source for its own version (${clone.serverVersion})`;
249250
const wait =
250251
clone._tag === "cloned"
251252
? ""
@@ -257,7 +258,7 @@ describes.
257258
return `
258259
## Source
259260
260-
${status} (${clone.serverVersion}):
261+
${verb} ${what}:
261262
262263
${clone.directory} (${clone.ref}, commit ${clone.commit})
263264
${wait}

‎apps/web/src/components/chat/DraftHeroHeadline.tsx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,9 @@ export function DraftHeroHeadline({
7474
(store) => store.setLogicalProjectDraftThreadId,
7575
);
7676
const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft);
77-
const draftPrompt = useComposerDraftStore((store) =>
78-
draftId ? (store.getComposerDraft(draftId)?.prompt ?? "") : "",
77+
// A boolean, so typing in a draft does not re-render the hero.
78+
const isTriageDraftPrompt = useComposerDraftStore((store) =>
79+
draftId ? isTriagePrompt(store.getComposerDraft(draftId)?.prompt ?? "") : false,
7980
);
8081
const applyStickyState = useComposerDraftStore((store) => store.applyStickyState);
8182
const setModelSelection = useComposerDraftStore((store) => store.setModelSelection);
@@ -341,7 +342,7 @@ export function DraftHeroHeadline({
341342
</Tooltip>
342343
) : null;
343344

344-
if (isScratchDraft && isTriagePrompt(draftPrompt)) {
345+
if (isScratchDraft && isTriageDraftPrompt) {
345346
const environment = environments.find(
346347
(entry) => entry.environmentId === activeProject.environmentId,
347348
);

‎apps/web/src/components/chat/TriageDraftHero.tsx‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export function TriageDraftHero(props: {
2121
<p className="mt-2 flex h-6 w-full items-center justify-center gap-1.5 text-sm">
2222
<Tooltip>
2323
<TooltipTrigger
24-
render={<span className="min-w-0 truncate font-medium text-foreground" />}
24+
render={<span tabIndex={0} className="min-w-0 truncate font-medium text-foreground" />}
2525
>
2626
{props.machineLabel ?? "This machine"}
2727
{props.serverVersion === null ? null : (
@@ -48,9 +48,7 @@ export function TriageDraftHero(props: {
4848
}
4949
>
5050
{isCopied ? "Copied" : "or run"}
51-
{isCopied ? null : (
52-
<code className="ml-1 font-mono text-xs">{TRIAGE_CLI_COMMAND}</code>
53-
)}
51+
{isCopied ? null : <code className="ml-1 font-mono text-xs">{TRIAGE_CLI_COMMAND}</code>}
5452
</TooltipTrigger>
5553
<TooltipPopup side="bottom">Copy command, for a terminal</TooltipPopup>
5654
</Tooltip>

0 commit comments

Comments
 (0)