Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((_resolve, reject) => { failProvision = reject; });
return child;
});
Expand Down Expand Up @@ -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");
});
Expand All @@ -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"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,7 @@ export class ThreadCreationCoordinator {
): Promise<Thread & { warnings?: string[] }> {
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)) {
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<Thread & { warnings?: string[] }> {
validateBranchName(branch);

Expand All @@ -58,7 +58,7 @@ export class ThreadService {
threadMode,
branch,
true,
"claude",
options.provider,
undefined,
options.branchless ? "branchless" : "named",
options.branchless ? branch : null,
Expand Down
Loading