diff --git a/apps/server/src/features/agents/orchestration/__tests__/agent-service-existing-worktree.test.ts b/apps/server/src/features/agents/orchestration/__tests__/agent-service-existing-worktree.test.ts index ec2048b00..f8325ca77 100644 --- a/apps/server/src/features/agents/orchestration/__tests__/agent-service-existing-worktree.test.ts +++ b/apps/server/src/features/agents/orchestration/__tests__/agent-service-existing-worktree.test.ts @@ -971,7 +971,7 @@ describe("AgentService.createAndSend existing worktree attach", () => { "Work from feature base", "worktree", "feature/base", - { branchless: true }, + { branchless: true, provider: "claude" }, ); expect(thread).toMatchObject({ mode: "worktree", @@ -1015,7 +1015,7 @@ describe("AgentService.createAndSend existing worktree attach", () => { "Review PR", "worktree", "contributor/pr-branch", - { branchless: false }, + { branchless: false, provider: "claude" }, ); expect(thread).toMatchObject({ mode: "worktree", diff --git a/apps/server/src/features/agents/turns/__tests__/thread-creation-startup.test.ts b/apps/server/src/features/agents/turns/__tests__/thread-creation-startup.test.ts index 0b9b4e419..5cebcf7a2 100644 --- a/apps/server/src/features/agents/turns/__tests__/thread-creation-startup.test.ts +++ b/apps/server/src/features/agents/turns/__tests__/thread-creation-startup.test.ts @@ -66,8 +66,8 @@ describe("ThreadCreationCoordinator startup lifecycle", () => { const { db, workspace, threads, threadService, startups, parent, fork, makeCoordinator } = branchHarness(); let failProvision: ((error: Error) => void) | undefined; vi.mocked(threadService.create).mockImplementation(async (workspaceId, title, _mode, branch, options) => { - const child = threads.create(workspaceId, title, "worktree", branch, true, "claude"); - options.lifecycle?.onThreadPersisted(child); + const child = threads.create(workspaceId, title, "worktree", branch, true, options?.provider); + options?.lifecycle?.onThreadPersisted(child); await new Promise((_resolve, reject) => { failProvision = reject; }); return child; }); @@ -108,8 +108,8 @@ describe("ThreadCreationCoordinator startup lifecycle", () => { it("clears a managed branch binding when checkout failure deletes its child", async () => { const { db, workspace, threads, threadService, startups, parent, fork, makeCoordinator } = branchHarness(); vi.mocked(threadService.create).mockImplementation(async (workspaceId, title, _mode, branch, options) => { - const child = threads.create(workspaceId, title, "worktree", branch, true, "claude"); - options.lifecycle?.onThreadPersisted(child); + const child = threads.create(workspaceId, title, "worktree", branch, true, options?.provider); + options?.lifecycle?.onThreadPersisted(child); threads.hardDelete(child.id); throw new Error("Checkout failed"); }); @@ -124,6 +124,68 @@ describe("ThreadCreationCoordinator startup lifecycle", () => { db.close(); }); + it("inserts a managed thread row with the selected provider before checkout runs", async () => { + const { db, workspace, threads, threadService, admissions, coordinator } = harness(); + let providerAtBind: string | undefined; + vi.mocked(threadService.create).mockImplementation(async (workspaceId, title, _mode, branch, options) => { + const thread = threads.create(workspaceId, title, "worktree", branch, true, options?.provider); + providerAtBind = threads.findById(thread.id)?.provider; + options?.lifecycle?.onThreadPersisted(thread); + return thread; + }); + admissions.admitInitialAutomaticTurn.mockResolvedValue({ kind: "queued" }); + + const created = await coordinator.createInitialTurn({ + workspaceId: workspace.id, + content: "Managed Devin turn", + mode: "worktree", + branch: "feature/devin", + provider: "devin", + startupId: managedStartupId, + }); + + expect(threadService.create).toHaveBeenCalledWith( + workspace.id, expect.any(String), "worktree", "feature/devin", + expect.objectContaining({ provider: "devin" }), + ); + expect(providerAtBind).toBe("devin"); + expect(created.thread.provider).toBe("devin"); + expect(threads.findById(created.thread.id)?.provider).toBe("devin"); + db.close(); + }); + + it("inserts a managed branch row with the selected provider before checkout runs", async () => { + const { db, workspace, threads, threadService, parent, fork, makeCoordinator } = branchHarness(); + let providerAtBind: string | undefined; + vi.mocked(threadService.create).mockImplementation(async (workspaceId, title, _mode, branch, options) => { + const thread = threads.create(workspaceId, title, "worktree", branch, true, options?.provider); + providerAtBind = threads.findById(thread.id)?.provider; + options?.lifecycle?.onThreadPersisted(thread); + return thread; + }); + + const created = await makeCoordinator().createInitialTurn({ + workspaceId: workspace.id, + content: "Branch to Devin", + mode: "worktree", + branch: "feature/devin-branch", + parentThreadId: parent.id, + forkedFromMessageId: fork.id, + provider: "devin", + startupId: managedStartupId, + }); + + expect(threadService.create).toHaveBeenCalledWith( + workspace.id, expect.any(String), "worktree", "feature/devin-branch", + expect.objectContaining({ provider: "devin" }), + ); + expect(providerAtBind).toBe("devin"); + expect(created).toMatchObject({ kind: "dispatch", thread: { provider: "devin" } }); + if (created.kind !== "dispatch") throw new Error("Expected dispatch result"); + expect(threads.findById(created.thread.id)?.provider).toBe("devin"); + db.close(); + }); + it("keeps a failed branch bound when its child still exists", async () => { const { db, workspace, threads, startups, parent, fork, handoffs, makeCoordinator } = branchHarness(); handoffs.deliverHandoff.mockRejectedValueOnce(new Error("Handoff failed")); diff --git a/apps/server/src/features/agents/turns/thread-creation-coordinator.ts b/apps/server/src/features/agents/turns/thread-creation-coordinator.ts index ce8f423f7..b962715ee 100644 --- a/apps/server/src/features/agents/turns/thread-creation-coordinator.ts +++ b/apps/server/src/features/agents/turns/thread-creation-coordinator.ts @@ -401,6 +401,7 @@ export class ThreadCreationCoordinator { ): Promise { const created = await this.threadService().create(input.workspaceId, input.title, "worktree", input.branch, { branchless: input.worktreeBranchMode !== "named", + provider: input.provider, ...this.managedLifecycle(startupId), }); if (startupId && this.startups()?.isCancellationRequested(startupId)) { @@ -492,7 +493,6 @@ export class ThreadCreationCoordinator { created: Thread & { warnings?: string[] }, input: CreateThreadForTurnInput, ): Thread & { warnings?: string[] } { - this.threads.updateProvider(created.id, input.provider); this.threads.updateModel(created.id, input.model); this.threads.updateSettings(created.id, this.settings(input)); return this.configuredThread(created, input); diff --git a/apps/server/src/features/projects/worktrees/thread-branching-service.ts b/apps/server/src/features/projects/worktrees/thread-branching-service.ts index 691e77f8f..68f624eb8 100644 --- a/apps/server/src/features/projects/worktrees/thread-branching-service.ts +++ b/apps/server/src/features/projects/worktrees/thread-branching-service.ts @@ -167,16 +167,16 @@ export class ThreadBranchingService { private async createManaged(input: CreateBranchedThreadInput, messageId: string, lifecycle?: BranchedThreadLifecycle) { const created = await this.threadService.create(input.workspaceId, input.title, "worktree", input.branch, { branchless: input.worktreeBranchMode !== "named", + provider: input.provider, lifecycle: { onThreadPersisted: (thread) => { this.threads.updateLineage(thread.id, input.parentThreadId, messageId); - this.threads.updateProvider(thread.id, input.provider); lifecycle?.onManagedThreadPersisted(thread); }, }, }); return { - thread: { ...created, provider: input.provider, parent_thread_id: input.parentThreadId, forked_from_message_id: messageId }, + thread: { ...created, parent_thread_id: input.parentThreadId, forked_from_message_id: messageId }, warnings: created.warnings, }; } diff --git a/apps/server/src/features/thread-control/lifecycle/__tests__/thread-service.test.ts b/apps/server/src/features/thread-control/lifecycle/__tests__/thread-service.test.ts index e8d91cede..79f68b63a 100644 --- a/apps/server/src/features/thread-control/lifecycle/__tests__/thread-service.test.ts +++ b/apps/server/src/features/thread-control/lifecycle/__tests__/thread-service.test.ts @@ -134,3 +134,27 @@ describe("ThreadService.delete", () => { expect(teardownThread).toHaveBeenCalledExactlyOnceWith(parent.id); }); }); + +describe("ThreadService.create", () => { + it("persists the selected provider on the inserted row", async () => { + const database = openMemoryDatabase(); + const threads = new ThreadRepo(database); + const workspaces = new WorkspaceRepo(database); + const service = new ThreadService( + threads, + {} as ProjectWorktreeService, + {} as AttachmentService, + {} as HandoffStorage, + {} as ThreadDeletionTeardownService, + ); + const workspace = workspaces.create("Project", "/repo"); + + const thread = await service.create(workspace.id, "Devin work", "direct", "main", { provider: "devin" }); + expect(thread.provider).toBe("devin"); + expect(threads.findById(thread.id)?.provider).toBe("devin"); + + const fallback = await service.create(workspace.id, "Other", "direct", "main"); + expect(fallback.provider).toBe("claude"); + database.close(); + }); +}); diff --git a/apps/server/src/features/thread-control/lifecycle/thread-service.ts b/apps/server/src/features/thread-control/lifecycle/thread-service.ts index 93ac82569..4ae9155fb 100644 --- a/apps/server/src/features/thread-control/lifecycle/thread-service.ts +++ b/apps/server/src/features/thread-control/lifecycle/thread-service.ts @@ -6,7 +6,7 @@ import { delay, injectable, inject } from "tsyringe"; import { validateBranchName, logger } from "@mcode/shared"; -import type { Thread, RecentThread, ThreadMode, ContextWindowMode } from "@mcode/contracts"; +import type { Thread, RecentThread, ThreadMode, ContextWindowMode, ProviderId } from "@mcode/contracts"; import { ThreadRepo } from "../persistence/thread-repo.js"; import { ProjectWorktreeService } from "../../projects/index.js"; import { AttachmentService } from "../../attachments/storage/attachment-service.js"; @@ -41,7 +41,7 @@ export class ThreadService { title: string, mode: string, branch: string, - options: { branchless?: boolean; lifecycle?: ThreadCreateLifecycle } = {}, + options: { branchless?: boolean; provider?: ProviderId; lifecycle?: ThreadCreateLifecycle } = {}, ): Promise { validateBranchName(branch); @@ -58,7 +58,7 @@ export class ThreadService { threadMode, branch, true, - "claude", + options.provider, undefined, options.branchless ? "branchless" : "named", options.branchless ? branch : null,