History

Move worktree branch auto-rename into provider turn reactor

- Generate and rename temporary `t3code/*` worktree branches on first user turn in `ProviderCommandReactor` - Remove websocket/client branch-generation RPCs and related contract/API surface - Update Codex text-generation prompt to include attachment metadata only when attachments exist, with expanded tests

Julius Marminge committed 8 months ago

Commit 5e55aa31 parent a21095b12 files · +405 −264Browse files

Showing the changes to apps/server/src/orchestration (2 of 12 files). Show all

Unified · Split

2 files
  1. apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +189 −0
  2. apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +117 −0
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+189−0
@@ -20,6 +20,8 @@ import { · 19 unmodified lines
2020 ProviderService,
2121 type ProviderServiceShape,
2222 } from "../../provider/Services/ProviderService.ts";
23+import { GitCore, type GitCoreShape } from "../../git/Services/GitCore.ts";
24+import { TextGeneration, type TextGenerationShape } from "../../git/Services/TextGeneration.ts";
2325 import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
2426 import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
2527 import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts";
@@ -92,6 +94,16 @@ describe("ProviderCommandReactor", () => { · 66 unmodified lines
9294 const interruptTurn = vi.fn((_: unknown) => Effect.void);
9395 const respondToRequest = vi.fn((_: unknown) => Effect.void);
9496 const stopSession = vi.fn((_: unknown) => Effect.void);
97+ const renameBranch = vi.fn((_: unknown) =>
98+ Effect.succeed({
99+ branch: "t3code/generated-name",
100+ }),
101+ );
102+ const generateBranchName = vi.fn((_: unknown) =>
103+ Effect.succeed({
104+ branch: "generated-name" as string | null,
105+ }),
106+ );
95107
96108 const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;
97109 const service: ProviderServiceShape = {
@@ -115,6 +127,13 @@ describe("ProviderCommandReactor", () => { · 17 unmodified lines
115127 const layer = ProviderCommandReactorLive.pipe(
116128 Layer.provideMerge(orchestrationLayer),
117129 Layer.provideMerge(Layer.succeed(ProviderService, service)),
130+ Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)),
131+ Layer.provideMerge(
132+ Layer.succeed(
133+ TextGeneration,
134+ { generateBranchName } as unknown as TextGenerationShape,
135+ ),
136+ ),
118137 );
119138 runtime = ManagedRuntime.make(layer);
120139 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
@@ -155,6 +174,8 @@ describe("ProviderCommandReactor", () => { · 34 unmodified lines
155174 interruptTurn,
156175 respondToRequest,
157176 stopSession,
177+ renameBranch,
178+ generateBranchName,
158179 };
159180 }
160181
@@ -196,6 +217,174 @@ describe("ProviderCommandReactor", () => { · 35 unmodified lines
196217 expect(thread?.session?.sandboxMode).toBe("workspace-write");
197218 });
198219
220+ it("generates and renames temporary worktree branch on first turn", async () => {
221+ const harness = await createHarness();
222+ const now = new Date().toISOString();
223+
224+ await Effect.runPromise(
225+ harness.engine.dispatch({
226+ type: "thread.meta.update",
227+ commandId: CommandId.makeUnsafe("cmd-thread-meta-set-temp-branch"),
228+ threadId: ThreadId.makeUnsafe("thread-1"),
229+ branch: "t3code/89abc123",
230+ worktreePath: "/tmp/provider-project/.t3/worktrees/t3code-89abc123",
231+ }),
232+ );
233+
234+ await Effect.runPromise(
235+ harness.engine.dispatch({
236+ type: "thread.turn.start",
237+ commandId: CommandId.makeUnsafe("cmd-turn-start-worktree-rename"),
238+ threadId: ThreadId.makeUnsafe("thread-1"),
239+ message: {
240+ messageId: asMessageId("user-message-worktree-rename"),
241+ role: "user",
242+ text: "Fix visual bug from screenshot",
243+ attachments: [
244+ {
245+ type: "image",
246+ name: "bug.png",
247+ mimeType: "image/png",
248+ sizeBytes: 5,
249+ dataUrl: "data:image/png;base64,SGVsbG8=",
250+ },
251+ ],
252+ },
253+ approvalPolicy: "on-request",
254+ sandboxMode: "workspace-write",
255+ createdAt: now,
256+ }),
257+ );
258+
259+ await waitFor(() => harness.generateBranchName.mock.calls.length === 1);
260+ await waitFor(() => harness.renameBranch.mock.calls.length === 1);
261+
262+ expect(harness.generateBranchName.mock.calls[0]?.[0]).toEqual({
263+ cwd: "/tmp/provider-project/.t3/worktrees/t3code-89abc123",
264+ message: "Fix visual bug from screenshot",
265+ attachments: [
266+ {
267+ type: "image",
268+ name: "bug.png",
269+ mimeType: "image/png",
270+ sizeBytes: 5,
271+ dataUrl: "data:image/png;base64,SGVsbG8=",
272+ },
273+ ],
274+ });
275+ expect(harness.renameBranch.mock.calls[0]?.[0]).toEqual({
276+ cwd: "/tmp/provider-project/.t3/worktrees/t3code-89abc123",
277+ oldBranch: "t3code/89abc123",
278+ newBranch: "t3code/generated-name",
279+ });
280+
281+ await waitFor(() => {
282+ const readModel = Effect.runSync(harness.engine.getReadModel());
283+ const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
284+ return thread?.branch === "t3code/generated-name";
285+ });
286+ });
287+
288+ it("skips worktree branch generation after the first user turn", async () => {
289+ const harness = await createHarness();
290+ const now = new Date().toISOString();
291+
292+ await Effect.runPromise(
293+ harness.engine.dispatch({
294+ type: "thread.meta.update",
295+ commandId: CommandId.makeUnsafe("cmd-thread-meta-set-temp-branch-2"),
296+ threadId: ThreadId.makeUnsafe("thread-1"),
297+ branch: "t3code/1234abcd",
298+ worktreePath: "/tmp/provider-project/.t3/worktrees/t3code-1234abcd",
299+ }),
300+ );
301+
302+ await Effect.runPromise(
303+ harness.engine.dispatch({
304+ type: "thread.turn.start",
305+ commandId: CommandId.makeUnsafe("cmd-turn-start-first"),
306+ threadId: ThreadId.makeUnsafe("thread-1"),
307+ message: {
308+ messageId: asMessageId("user-message-first"),
309+ role: "user",
310+ text: "first",
311+ attachments: [],
312+ },
313+ approvalPolicy: "on-request",
314+ sandboxMode: "workspace-write",
315+ createdAt: now,
316+ }),
317+ );
318+
319+ await waitFor(() => harness.generateBranchName.mock.calls.length === 1);
320+
321+ await Effect.runPromise(
322+ harness.engine.dispatch({
323+ type: "thread.turn.start",
324+ commandId: CommandId.makeUnsafe("cmd-turn-start-second"),
325+ threadId: ThreadId.makeUnsafe("thread-1"),
326+ message: {
327+ messageId: asMessageId("user-message-second"),
328+ role: "user",
329+ text: "second",
330+ attachments: [],
331+ },
332+ approvalPolicy: "on-request",
333+ sandboxMode: "workspace-write",
334+ createdAt: now,
335+ }),
336+ );
337+
338+ await waitFor(() => harness.sendTurn.mock.calls.length === 2);
339+ expect(harness.generateBranchName.mock.calls.length).toBe(1);
340+ expect(harness.renameBranch.mock.calls.length).toBe(1);
341+ });
342+
343+ it("skips worktree rename when generated branch is null", async () => {
344+ const harness = await createHarness();
345+ const now = new Date().toISOString();
346+ harness.generateBranchName.mockImplementationOnce((_: unknown) =>
347+ Effect.succeed({
348+ branch: null,
349+ }),
350+ );
351+
352+ await Effect.runPromise(
353+ harness.engine.dispatch({
354+ type: "thread.meta.update",
355+ commandId: CommandId.makeUnsafe("cmd-thread-meta-set-temp-branch-null"),
356+ threadId: ThreadId.makeUnsafe("thread-1"),
357+ branch: "t3code/0000abcd",
358+ worktreePath: "/tmp/provider-project/.t3/worktrees/t3code-0000abcd",
359+ }),
360+ );
361+
362+ await Effect.runPromise(
363+ harness.engine.dispatch({
364+ type: "thread.turn.start",
365+ commandId: CommandId.makeUnsafe("cmd-turn-start-null-branch"),
366+ threadId: ThreadId.makeUnsafe("thread-1"),
367+ message: {
368+ messageId: asMessageId("user-message-null-branch"),
369+ role: "user",
370+ text: "Fix visual regression",
371+ attachments: [],
372+ },
373+ approvalPolicy: "on-request",
374+ sandboxMode: "workspace-write",
375+ createdAt: now,
376+ }),
377+ );
378+
379+ await waitFor(() => harness.generateBranchName.mock.calls.length === 1);
380+ await Effect.runPromise(Effect.sleep("20 millis"));
381+ expect(harness.renameBranch.mock.calls.length).toBe(0);
382+
383+ const readModel = await Effect.runPromise(harness.engine.getReadModel());
384+ const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
385+ expect(thread?.branch).toBe("t3code/0000abcd");
386+ });
387+
199388 it("reuses the same provider session when runtime mode is unchanged", async () => {
200389 const harness = await createHarness();
201390 const now = new Date().toISOString();
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+117−0
@@ -15,6 +15,8 @@ import { · 14 unmodified lines
1515 import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect";
1616
1717 import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
18+import { GitCore } from "../../git/Services/GitCore.ts";
19+import { TextGeneration } from "../../git/Services/TextGeneration.ts";
1820 import { ProviderService } from "../../provider/Services/ProviderService.ts";
1921 import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
2022 import {
@@ -66,10 +68,41 @@ const HANDLED_TURN_START_KEY_MAX = 10_000; · 45 unmodified lines
6668 const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30);
6769 const DEFAULT_APPROVAL_POLICY: ProviderApprovalPolicy = "never";
6870 const DEFAULT_SANDBOX_MODE: ProviderSandboxMode = "workspace-write";
71+const WORKTREE_BRANCH_PREFIX = "t3code";
72+const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`);
73+
74+function isTemporaryWorktreeBranch(branch: string): boolean {
75+ return TEMP_WORKTREE_BRANCH_PATTERN.test(branch.trim().toLowerCase());
76+}
77+
78+function buildGeneratedWorktreeBranchName(raw: string): string {
79+ const normalized = raw
80+ .trim()
81+ .toLowerCase()
82+ .replace(/^refs\/heads\//, "")
83+ .replace(/['"`]/g, "");
84+
85+ const withoutPrefix = normalized.startsWith(`${WORKTREE_BRANCH_PREFIX}/`)
86+ ? normalized.slice(`${WORKTREE_BRANCH_PREFIX}/`.length)
87+ : normalized;
88+
89+ const branchFragment = withoutPrefix
90+ .replace(/[^a-z0-9/_-]+/g, "-")
91+ .replace(/\/+/g, "/")
92+ .replace(/-+/g, "-")
93+ .replace(/^[./_-]+|[./_-]+$/g, "")
94+ .slice(0, 64)
95+ .replace(/[./_-]+$/g, "");
96+
97+ const safeFragment = branchFragment.length > 0 ? branchFragment : "update";
98+ return `${WORKTREE_BRANCH_PREFIX}/${safeFragment}`;
99+}
69100
70101 const make = Effect.gen(function* () {
71102 const orchestrationEngine = yield* OrchestrationEngineService;
72103 const providerService = yield* ProviderService;
104+ const git = yield* GitCore;
105+ const textGeneration = yield* TextGeneration;
73106 const handledTurnStartKeys = yield* Cache.make<string, true>({
74107 capacity: HANDLED_TURN_START_KEY_MAX,
75108 timeToLive: HANDLED_TURN_START_KEY_TTL,
@@ -247,6 +280,81 @@ const make = Effect.gen(function* () { · 171 unmodified lines
247280 });
248281 });
249282
283+ const maybeGenerateAndRenameWorktreeBranchForFirstTurn = Effect.fnUntraced(function* (input: {
284+ readonly threadId: ThreadId;
285+ readonly branch: string | null;
286+ readonly worktreePath: string | null;
287+ readonly messageId: string;
288+ readonly messageText: string;
289+ readonly attachments?: ReadonlyArray<ChatAttachment>;
290+ }) {
291+ if (!input.branch || !input.worktreePath) {
292+ return;
293+ }
294+ if (!isTemporaryWorktreeBranch(input.branch)) {
295+ return;
296+ }
297+
298+ const thread = yield* resolveThread(input.threadId);
299+ if (!thread) {
300+ return;
301+ }
302+
303+ const userMessages = thread.messages.filter((message) => message.role === "user");
304+ if (userMessages.length !== 1 || userMessages[0]?.id !== input.messageId) {
305+ return;
306+ }
307+
308+ const oldBranch = input.branch;
309+ const cwd = input.worktreePath;
310+ const attachments = input.attachments ?? [];
311+ const renameEffect = textGeneration
312+ .generateBranchName({
313+ cwd,
314+ message: input.messageText,
315+ ...(attachments.length > 0 ? { attachments } : {}),
316+ })
317+ .pipe(
318+ Effect.flatMap((generated) => {
319+ if (!generated.branch) {
320+ return Effect.void;
321+ }
322+ const targetBranch = buildGeneratedWorktreeBranchName(generated.branch);
323+ if (targetBranch === oldBranch) {
324+ return Effect.void;
325+ }
326+ return git
327+ .renameBranch({
328+ cwd,
329+ oldBranch,
330+ newBranch: targetBranch,
331+ })
332+ .pipe(
333+ Effect.flatMap((renamed) =>
334+ orchestrationEngine.dispatch({
335+ type: "thread.meta.update",
336+ commandId: serverCommandId("worktree-branch-rename"),
337+ threadId: input.threadId,
338+ branch: renamed.branch,
339+ worktreePath: cwd,
340+ }),
341+ ),
342+ Effect.asVoid,
343+ );
344+ }),
345+ Effect.catchCause((cause) =>
346+ Effect.logWarning("provider command reactor failed to generate or rename worktree branch", {
347+ threadId: input.threadId,
348+ cwd,
349+ oldBranch,
350+ cause: Cause.pretty(cause),
351+ }),
352+ ),
353+ );
354+
355+ yield* Effect.forkScoped(renameEffect);
356+ });
357+
250358 const processTurnStartRequested = Effect.fnUntraced(function* (
251359 event: Extract<ProviderIntentEvent, { type: "thread.turn-start-requested" }>,
252360 ) {
@@ -273,6 +381,15 @@ const make = Effect.gen(function* () { · 20 unmodified lines
273381 return;
274382 }
275383
384+ yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({
385+ threadId: event.payload.threadId,
386+ branch: thread.branch,
387+ worktreePath: thread.worktreePath,
388+ messageId: message.id,
389+ messageText: message.text,
390+ ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
391+ });
392+
276393 yield* sendTurnForThread({
277394 threadId: event.payload.threadId,
278395 messageText: message.text,