From 7f91d8e953f60414ff00c3279736587e59dba36a Mon Sep 17 00:00:00 2001 From: Agent Runtime Fixture Date: Sun, 27 Sep 2026 19:55:12 +0100 Subject: [PATCH 1/2] fix(server): persist selected provider at managed thread insert Managed worktree threads were inserted with the repository default provider and only corrected after provisioning via updateProvider, so clients materializing the row during checkout saw provider "claude" and rendered the Claude icon on e.g. Devin SWE-2 threads. ThreadService.create now accepts a provider option forwarded to ThreadRepo.create, and both managed creation paths pass the selected provider at insert, making the post-provisioning updateProvider patches redundant. --- .../__tests__/thread-creation-startup.test.ts | 70 +++++++++++++++++-- .../turns/thread-creation-coordinator.ts | 2 +- .../worktrees/thread-branching-service.ts | 4 +- .../__tests__/thread-service.test.ts | 24 +++++++ .../lifecycle/thread-service.ts | 6 +- 5 files changed, 96 insertions(+), 10 deletions(-) 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, From 916c631facbe11fc3623588c7e71254b56c5185b Mon Sep 17 00:00:00 2001 From: Agent Runtime Fixture Date: Mon, 28 Sep 2026 00:19:44 +0100 Subject: [PATCH 2/2] test(server): expect provider in managed worktree create calls --- .../__tests__/agent-service-existing-worktree.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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",