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
20 ProviderService,20 ProviderService,
21 type ProviderServiceShape,21 type ProviderServiceShape,
22} from "../../provider/Services/ProviderService.ts";22} from "../../provider/Services/ProviderService.ts";
23import { GitCore, type GitCoreShape } from "../../git/Services/GitCore.ts";
24import { TextGeneration, type TextGenerationShape } from "../../git/Services/TextGeneration.ts";
23import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";25import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
24import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";26import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
25import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts";27import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts";
@@ -92,6 +94,16 @@ describe("ProviderCommandReactor", () => { · 66 unmodified lines
92 const interruptTurn = vi.fn((_: unknown) => Effect.void);94 const interruptTurn = vi.fn((_: unknown) => Effect.void);
93 const respondToRequest = vi.fn((_: unknown) => Effect.void);95 const respondToRequest = vi.fn((_: unknown) => Effect.void);
94 const stopSession = vi.fn((_: unknown) => Effect.void);96 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 );
95 107
96 const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;108 const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;
97 const service: ProviderServiceShape = {109 const service: ProviderServiceShape = {
@@ -115,6 +127,13 @@ describe("ProviderCommandReactor", () => { · 17 unmodified lines
115 const layer = ProviderCommandReactorLive.pipe(127 const layer = ProviderCommandReactorLive.pipe(
116 Layer.provideMerge(orchestrationLayer),128 Layer.provideMerge(orchestrationLayer),
117 Layer.provideMerge(Layer.succeed(ProviderService, service)),129 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 ),
118 );137 );
119 runtime = ManagedRuntime.make(layer);138 runtime = ManagedRuntime.make(layer);
120 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));139 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
@@ -155,6 +174,8 @@ describe("ProviderCommandReactor", () => { · 34 unmodified lines
155 interruptTurn,174 interruptTurn,
156 respondToRequest,175 respondToRequest,
157 stopSession,176 stopSession,
177 renameBranch,
178 generateBranchName,
158 };179 };
159 }180 }
160 181
@@ -196,6 +217,174 @@ describe("ProviderCommandReactor", () => { · 35 unmodified lines
196 expect(thread?.session?.sandboxMode).toBe("workspace-write");217 expect(thread?.session?.sandboxMode).toBe("workspace-write");
197 });218 });
198 219
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
199 it("reuses the same provider session when runtime mode is unchanged", async () => {388 it("reuses the same provider session when runtime mode is unchanged", async () => {
200 const harness = await createHarness();389 const harness = await createHarness();
201 const now = new Date().toISOString();390 const now = new Date().toISOString();
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+117−0
@@ -15,6 +15,8 @@ import { · 14 unmodified lines
15import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect";15import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect";
16 16
17import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";17import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
18import { GitCore } from "../../git/Services/GitCore.ts";
19import { TextGeneration } from "../../git/Services/TextGeneration.ts";
18import { ProviderService } from "../../provider/Services/ProviderService.ts";20import { ProviderService } from "../../provider/Services/ProviderService.ts";
19import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";21import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
20import {22import {
@@ -66,10 +68,41 @@ const HANDLED_TURN_START_KEY_MAX = 10_000; · 45 unmodified lines
66const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30);68const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30);
67const DEFAULT_APPROVAL_POLICY: ProviderApprovalPolicy = "never";69const DEFAULT_APPROVAL_POLICY: ProviderApprovalPolicy = "never";
68const DEFAULT_SANDBOX_MODE: ProviderSandboxMode = "workspace-write";70const DEFAULT_SANDBOX_MODE: ProviderSandboxMode = "workspace-write";
71const WORKTREE_BRANCH_PREFIX = "t3code";
72const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`);
73
74function isTemporaryWorktreeBranch(branch: string): boolean {
75 return TEMP_WORKTREE_BRANCH_PATTERN.test(branch.trim().toLowerCase());
76}
77
78function 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}
69 100
70const make = Effect.gen(function* () {101const make = Effect.gen(function* () {
71 const orchestrationEngine = yield* OrchestrationEngineService;102 const orchestrationEngine = yield* OrchestrationEngineService;
72 const providerService = yield* ProviderService;103 const providerService = yield* ProviderService;
104 const git = yield* GitCore;
105 const textGeneration = yield* TextGeneration;
73 const handledTurnStartKeys = yield* Cache.make<string, true>({106 const handledTurnStartKeys = yield* Cache.make<string, true>({
74 capacity: HANDLED_TURN_START_KEY_MAX,107 capacity: HANDLED_TURN_START_KEY_MAX,
75 timeToLive: HANDLED_TURN_START_KEY_TTL,108 timeToLive: HANDLED_TURN_START_KEY_TTL,
@@ -247,6 +280,81 @@ const make = Effect.gen(function* () { · 171 unmodified lines
247 });280 });
248 });281 });
249 282
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
250 const processTurnStartRequested = Effect.fnUntraced(function* (358 const processTurnStartRequested = Effect.fnUntraced(function* (
251 event: Extract<ProviderIntentEvent, { type: "thread.turn-start-requested" }>,359 event: Extract<ProviderIntentEvent, { type: "thread.turn-start-requested" }>,
252 ) {360 ) {
@@ -273,6 +381,15 @@ const make = Effect.gen(function* () { · 20 unmodified lines
273 return;381 return;
274 }382 }
275 383
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
276 yield* sendTurnForThread({393 yield* sendTurnForThread({
277 threadId: event.payload.threadId,394 threadId: event.payload.threadId,
278 messageText: message.text,395 messageText: message.text,