Skip to content
Open
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
4 changes: 4 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
type GitWorktreeBranchNaming,
ApprovalRequestId,
type ChatAttachment,
type OrchestrationEvent,
Expand Down Expand Up @@ -60,6 +61,7 @@ export const ORCHESTRATION_PROJECTOR_NAMES = {
checkpoints: "projection.checkpoints",
pendingApprovals: "projection.pending-approvals",
} as const;
const DEFAULT_WORKTREE_BRANCH_NAMING: GitWorktreeBranchNaming = { mode: "auto" };

type ProjectorName =
(typeof ORCHESTRATION_PROJECTOR_NAMES)[keyof typeof ORCHESTRATION_PROJECTOR_NAMES];
Expand Down Expand Up @@ -425,6 +427,8 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
interactionMode: event.payload.interactionMode,
branch: event.payload.branch,
worktreePath: event.payload.worktreePath,
worktreeBranchNaming:
event.payload.worktreeBranchNaming ?? DEFAULT_WORKTREE_BRANCH_NAMING,
latestTurnId: null,
createdAt: event.payload.createdAt,
updatedAt: event.payload.updatedAt,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
ChatAttachment,
GitWorktreeBranchNaming,
IsoDateTime,
MessageId,
NonNegativeInt,
Expand Down Expand Up @@ -53,7 +54,11 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields(
}),
);
const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan;
const ProjectionThreadDbRowSchema = ProjectionThread;
const ProjectionThreadDbRowSchema = ProjectionThread.mapFields(
Struct.assign({
worktreeBranchNaming: Schema.fromJsonString(GitWorktreeBranchNaming),
}),
);
const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(
Struct.assign({
payload: Schema.fromJsonString(Schema.Unknown),
Expand Down Expand Up @@ -161,6 +166,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
interaction_mode AS "interactionMode",
branch,
worktree_path AS "worktreePath",
worktree_branch_naming_json AS "worktreeBranchNaming",
latest_turn_id AS "latestTurnId",
created_at AS "createdAt",
updated_at AS "updatedAt",
Expand Down Expand Up @@ -533,6 +539,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
interactionMode: row.interactionMode,
branch: row.branch,
worktreePath: row.worktreePath,
worktreeBranchNaming: row.worktreeBranchNaming,
latestTurn: latestTurnByThread.get(row.threadId) ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
Expand Down
105 changes: 101 additions & 4 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
ProjectId,
ThreadId,
TurnId,
type GitWorktreeBranchNaming,
} from "@t3tools/contracts";
import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect";
import { afterEach, describe, expect, it, vi } from "vitest";
Expand Down Expand Up @@ -83,7 +84,14 @@ describe("ProviderCommandReactor", () => {
createdStateDirs.clear();
});

async function createHarness(input?: { readonly stateDir?: string }) {
async function createHarness(input?: {
readonly stateDir?: string;
readonly threadCreate?: {
readonly branch?: string | null;
readonly worktreePath?: string | null;
readonly worktreeBranchNaming?: GitWorktreeBranchNaming;
};
}) {
const now = new Date().toISOString();
const stateDir = input?.stateDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-"));
createdStateDirs.add(stateDir);
Expand Down Expand Up @@ -171,7 +179,7 @@ describe("ProviderCommandReactor", () => {
: "renamed-branch",
}),
);
const generateBranchName = vi.fn(() =>
const generateBranchName = vi.fn<TextGenerationShape["generateBranchName"]>(() =>
Effect.fail(
new TextGenerationError({
operation: "generateBranchName",
Expand Down Expand Up @@ -242,8 +250,11 @@ describe("ProviderCommandReactor", () => {
model: "gpt-5-codex",
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
branch: null,
worktreePath: null,
branch: input?.threadCreate?.branch ?? null,
worktreePath: input?.threadCreate?.worktreePath ?? null,
...(input?.threadCreate?.worktreeBranchNaming
? { worktreeBranchNaming: input.threadCreate.worktreeBranchNaming }
: {}),
createdAt: now,
}),
);
Expand Down Expand Up @@ -389,6 +400,92 @@ describe("ProviderCommandReactor", () => {
});
});

it("renames temporary worktree branches using a custom prefix", async () => {
const harness = await createHarness({
threadCreate: {
branch: "team-branches/deadbeef",
worktreePath: "/tmp/provider-project/.t3/worktrees/team-branches",
worktreeBranchNaming: {
mode: "prefix",
prefix: "Team Branches",
},
},
});
const now = new Date().toISOString();

harness.generateBranchName.mockImplementation(
() =>
Effect.succeed({
branch: "feat/session",
}) as ReturnType<TextGenerationShape["generateBranchName"]>,
);

await Effect.runPromise(
harness.engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.makeUnsafe("cmd-turn-start-custom-prefix"),
threadId: ThreadId.makeUnsafe("thread-1"),
message: {
messageId: asMessageId("user-message-custom-prefix"),
role: "user",
text: "rename this worktree branch",
attachments: [],
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: now,
}),
);

await waitFor(() => harness.renameBranch.mock.calls.length === 1);
expect(harness.renameBranch.mock.calls[0]?.[0]).toMatchObject({
oldBranch: "team-branches/deadbeef",
newBranch: "team-branches/feat/session",
});
});

it("skips worktree branch rename when the full branch name is explicit", async () => {
const harness = await createHarness({
threadCreate: {
branch: "feature/my-custom-branch",
worktreePath: "/tmp/provider-project/.t3/worktrees/full-branch",
worktreeBranchNaming: {
mode: "full",
branchName: "feature/my-custom-branch",
},
},
});
const now = new Date().toISOString();

harness.generateBranchName.mockImplementation(
() =>
Effect.succeed({
branch: "ignored",
}) as ReturnType<TextGenerationShape["generateBranchName"]>,
);

await Effect.runPromise(
harness.engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.makeUnsafe("cmd-turn-start-full-name"),
threadId: ThreadId.makeUnsafe("thread-1"),
message: {
messageId: asMessageId("user-message-full-name"),
role: "user",
text: "use the explicit branch name",
attachments: [],
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: now,
}),
);

await waitFor(() => harness.sendTurn.mock.calls.length === 1);
expect(harness.generateBranchName).not.toHaveBeenCalled();
expect(harness.renameBranch).not.toHaveBeenCalled();
});

it("reuses the same provider session when runtime mode is unchanged", async () => {
const harness = await createHarness();
const now = new Date().toISOString();
Expand Down
41 changes: 8 additions & 33 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { buildFinalWorktreeBranchName, isTemporaryWorktreeBranchName } from "@t3tools/shared/git";
import {
type ChatAttachment,
CommandId,
Expand Down Expand Up @@ -71,8 +72,6 @@ const serverCommandId = (tag: string): CommandId =>
const HANDLED_TURN_START_KEY_MAX = 10_000;
const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30);
const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
const WORKTREE_BRANCH_PREFIX = "t3code";
const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`);

function isUnknownPendingApprovalRequestError(cause: Cause.Cause<ProviderServiceError>): boolean {
const error = Cause.squash(cause);
Expand All @@ -90,33 +89,6 @@ function isUnknownPendingApprovalRequestError(cause: Cause.Cause<ProviderService
);
}

function isTemporaryWorktreeBranch(branch: string): boolean {
return TEMP_WORKTREE_BRANCH_PATTERN.test(branch.trim().toLowerCase());
}

function buildGeneratedWorktreeBranchName(raw: string): string {
const normalized = raw
.trim()
.toLowerCase()
.replace(/^refs\/heads\//, "")
.replace(/['"`]/g, "");

const withoutPrefix = normalized.startsWith(`${WORKTREE_BRANCH_PREFIX}/`)
? normalized.slice(`${WORKTREE_BRANCH_PREFIX}/`.length)
: normalized;

const branchFragment = withoutPrefix
.replace(/[^a-z0-9/_-]+/g, "-")
.replace(/\/+/g, "/")
.replace(/-+/g, "-")
.replace(/^[./_-]+|[./_-]+$/g, "")
.slice(0, 64)
.replace(/[./_-]+$/g, "");

const safeFragment = branchFragment.length > 0 ? branchFragment : "update";
return `${WORKTREE_BRANCH_PREFIX}/${safeFragment}`;
}

const make = Effect.gen(function* () {
const orchestrationEngine = yield* OrchestrationEngineService;
const providerService = yield* ProviderService;
Expand Down Expand Up @@ -369,14 +341,14 @@ const make = Effect.gen(function* () {
if (!input.branch || !input.worktreePath) {
return;
}
if (!isTemporaryWorktreeBranch(input.branch)) {
return;
}

const thread = yield* resolveThread(input.threadId);
if (!thread) {
return;
}
if (!isTemporaryWorktreeBranchName(input.branch, thread.worktreeBranchNaming)) {
return;
}

const userMessages = thread.messages.filter((message) => message.role === "user");
if (userMessages.length !== 1 || userMessages[0]?.id !== input.messageId) {
Expand All @@ -402,7 +374,10 @@ const make = Effect.gen(function* () {
Effect.flatMap((generated) => {
if (!generated) return Effect.void;

const targetBranch = buildGeneratedWorktreeBranchName(generated.branch);
const targetBranch = buildFinalWorktreeBranchName(
generated.branch,
thread.worktreeBranchNaming,
);
if (targetBranch === oldBranch) return Effect.void;

return Effect.flatMap(
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
interactionMode: command.interactionMode,
branch: command.branch,
worktreePath: command.worktreePath,
...(command.worktreeBranchNaming !== undefined
? { worktreeBranchNaming: command.worktreeBranchNaming }
: {}),
createdAt: command.createdAt,
updatedAt: command.createdAt,
},
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/orchestration/projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,9 @@ export function projectEvent(
interactionMode: payload.interactionMode,
branch: payload.branch,
worktreePath: payload.worktreePath,
...(payload.worktreeBranchNaming !== undefined
? { worktreeBranchNaming: payload.worktreeBranchNaming }
: {}),
latestTurn: null,
createdAt: payload.createdAt,
updatedAt: payload.updatedAt,
Expand Down
Loading