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
3 changes: 3 additions & 0 deletions apps/server/src/application/bootstrap/server-bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ import { RepositoryGitMutationLock } from "../../features/projects/git/repositor
import { SNAPSHOT_STORE_ID, type StoreId } from "../../features/projects/diffs/snapshots/snapshot-store-identity.js";
import { AgentRuntimeCommandPort } from "../../features/agents/orchestration/agent-turn-command-port.js";
import { DelegatedTaskService } from "../../features/thread-control/delegation/delegated-task-service.js";
import { ThreadLaunchService } from "../../features/thread-control/launch/thread-launch-service.js";
import { ProviderTurnEventApplication } from "../../features/agents/turns/provider-turn-event-application.js";
import { TURN_FINALIZER, TurnFinalizer } from "../../features/agents/turns/turn-finalizer.js";
import {
Expand Down Expand Up @@ -337,6 +338,7 @@ const turnRecoveryService = container.resolve(TurnRecoveryService);
const turnResumeScheduler = container.resolve(TurnResumeScheduler);
const threadControlService = container.resolve(ThreadControlService);
const delegatedTaskService = container.resolve(DelegatedTaskService);
const threadLaunchService = container.resolve(ThreadLaunchService);
const threadStartupService = container.resolve(ThreadStartupService);
const stopObservingDelegatedRosters = subagentRosterService.bindDelegatedTasks(delegatedTaskService);
const stopObservingDelegatedStartups = threadStartupService.subscribe((startup) => delegatedTaskService.taskChanged(startup.startupId));
Expand All @@ -361,6 +363,7 @@ const mcodeMcpServer = new McodeMcpServer({
threadStop: threadControlService.threadStop.bind(threadControlService),
threadWait: threadControlService.threadWait.bind(threadControlService),
delegate: delegatedTaskService.delegate.bind(delegatedTaskService),
launch: threadLaunchService.launch.bind(threadLaunchService),
status: delegatedTaskService.status.bind(delegatedTaskService),
cancel: delegatedTaskService.cancel.bind(delegatedTaskService),
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ export type { WorkspaceEnvironmentQueuedTurnSubmission };
/** Injection token for the complete first-turn admission coordinator. */
export const TURN_ADMISSION_DISPATCH_COORDINATOR = "TurnAdmissionDispatchCoordinator";

/** An existing-worktree choice rejected before a thread is persisted. */
export class ExistingWorktreePlacementError extends Error {
constructor(message: string) {
super(message);
this.name = "ExistingWorktreePlacementError";
}
}

/** Why a turn's runtime slot was released; absent for a turn that ran to its terminal state. */
export type RuntimeReleaseCause = "admission-failed";

Expand Down Expand Up @@ -406,7 +414,7 @@ export class TurnAdmissionDispatchCoordinator {
if (!workspace) throw new Error(`Workspace not found: ${params.workspaceId}`);
const known = await this.worktrees.listWorktrees(params.workspaceId);
const matched = known.find((worktree) => this.sameWorktreePath(worktree.path, params.existingWorktreePath));
if (!matched) throw new Error("Path is not a recognized worktree");
if (!matched) throw new ExistingWorktreePlacementError("Path is not a recognized worktree");
const detached = matched.branch === "(detached)";
const branch = detached ? params.baseBranch : matched.branch;
const baseBranch = this.requireAttachedBranch(branch, detached);
Expand All @@ -423,8 +431,8 @@ export class TurnAdmissionDispatchCoordinator {
}

private requireAttachedBranch(branch: string | undefined, detached: boolean): string {
if (!branch) throw new Error("Base branch is required when attaching a detached worktree");
if (detached && branch === "HEAD") throw new Error("Base branch cannot be HEAD when attaching a detached worktree");
if (!branch) throw new ExistingWorktreePlacementError("Base branch is required when attaching a detached worktree");
if (detached && branch === "HEAD") throw new ExistingWorktreePlacementError("Base branch cannot be HEAD when attaching a detached worktree");
validateBranchName(branch);
return branch;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,10 @@ function createThreadService() {
return {
workspaceSearch: vi.fn<ThreadControlToolService["workspaceSearch"]>(),
worktreeList: vi.fn<ThreadControlToolService["worktreeList"]>(),
threadCreateBatch: vi.fn<ThreadControlToolService["threadCreateBatch"]>(),
delegate: vi.fn<ThreadControlToolService["delegate"]>(),
launch: vi.fn<ThreadControlToolService["launch"]>(),
status: vi.fn<ThreadControlToolService["status"]>(),
cancel: vi.fn<ThreadControlToolService["cancel"]>(),
threadTargetList: vi.fn<ThreadControlToolService["threadTargetList"]>(),
threadSearch: vi.fn<ThreadControlToolService["threadSearch"]>(),
threadGet: vi.fn<ThreadControlToolService["threadGet"]>(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ function createService() {
workspaceSearch: vi.fn<ThreadControlToolService["workspaceSearch"]>(),
worktreeList: vi.fn<ThreadControlToolService["worktreeList"]>(),
delegate: vi.fn<ThreadControlToolService["delegate"]>(),
launch: vi.fn<ThreadControlToolService["launch"]>(),
status: vi.fn<ThreadControlToolService["status"]>(),
cancel: vi.fn<ThreadControlToolService["cancel"]>(),
threadTargetList: vi.fn<ThreadControlToolService["threadTargetList"]>(),
Expand Down Expand Up @@ -126,6 +127,7 @@ describe("thread-control toolkit", () => {
"workspace_search",
"worktree_list",
"delegate_task",
"thread_launch",
"task_status",
"task_cancel",
"thread_target_list",
Expand All @@ -143,6 +145,18 @@ describe("thread-control toolkit", () => {
expect(setup({ threadControl: false }).bound).toBeNull();
});

it("lists thread_launch for an internal lease and marks a rejected launch as an error", async () => {
const { bound, call, service } = setup();
expect(bound?.tools.filter((tool) => tool.listed).map((tool) => tool.name)).toContain("thread_launch");
service.launch.mockResolvedValueOnce({ status: "rejected", error: {
code: "invalid_placement", message: "Unknown worktree", retryable: false,
} });
expect(await call("thread_launch", { title: "Independent work", prompt: "Implement it" })).toMatchObject({
isError: true,
structuredContent: { status: "rejected", error: { code: "invalid_placement", retryable: false } },
});
});

it.each(["none", "ineligible"] as const)("refuses every call when the turn lease is %s", async (lease) => {
const { call, service } = setup({ lease });

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
DelegateTaskInputSchema,
DelegateTaskResultSchema,
ThreadLaunchInputSchema,
ThreadLaunchResultSchema,
TaskStatusInputSchema,
TaskStatusResultSchema,
TaskCancelInputSchema,
Expand Down Expand Up @@ -34,6 +36,7 @@ import { toJsonSchemaCompat } from "@modelcontextprotocol/sdk/server/zod-json-sc
import { ErrorCode, McpError, ToolSchema, type CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import type { ThreadControlService } from "../../thread-control/index.js";
import type { DelegatedTaskService } from "../../thread-control/delegation/delegated-task-service.js";
import type { ThreadLaunchService } from "../../thread-control/launch/thread-launch-service.js";
import type { McodeSession, McodeSessionAuthority } from "../mcode-session-authority.js";
import {
defineToolkit,
Expand All @@ -53,7 +56,7 @@ export type ThreadControlToolService = Pick<
| "threadSend"
| "threadStop"
| "threadWait"
> & Pick<DelegatedTaskService, "delegate" | "status" | "cancel">;
> & Pick<DelegatedTaskService, "delegate" | "status" | "cancel"> & Pick<ThreadLaunchService, "launch">;

/** Dependencies for the thread-control toolkit. */
export interface ThreadControlToolkitOptions {
Expand Down Expand Up @@ -191,6 +194,12 @@ function createTaskTools(context: ToolContext): McodeTool<McodeSession>[] {
input: DelegateTaskInputSchema(), result: DelegateTaskResultSchema(),
run: (service, authority, input) => service.delegate(authority, input),
}),
serviceTool(context, {
name: "thread_launch",
description: 'Launch an ordinary top-level Mcode thread for the user, in its own workspace. Use it only when the user asks for a separate thread or conversation; never just because they said sub-agent. The new thread runs on its own and you are not notified when it finishes; use delegate_task when you need the result back. workspace: {type:"new_worktree", baseRef, startFromOrigin?} creates a worktree from a local branch, or from the fetched origin branch; {type:"existing_worktree", worktreePath, baseBranch?} uses one of this project\'s worktrees (baseBranch is required when it is detached); omitted or {type:"root"} uses the project root, NOT your current worktree. Project setup finishes before the first message runs. Provider and model default to yours. Permission and interaction modes default to yours and cannot be broader. A supervised caller cannot use new_worktree. Returns the new threadId; link it for the user.',
input: ThreadLaunchInputSchema(), result: ThreadLaunchResultSchema(),
run: (service, authority, input) => service.launch(authority, input),
}),
serviceTool(context, {
name: "task_status",
description: "Read the current state and bounded result of a task delegated by this Coordinator. Status follows the original task turn and includes its threadId when available; later user turns do not replace its result. Finished/interrupted can become working after recovery. Check later after ending the turn or doing other work; do not poll in a loop.",
Expand Down
15 changes: 15 additions & 0 deletions apps/server/src/features/projects/git/git-repository-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,21 @@ export class GitRepositoryService {
}
}

/** Resolve only a local branch, returning null when its ref does not exist. */
async resolveLocalBranchCommit(repoPath: string, branch: string): Promise<string | null> {
validateBranchName(branch);
try {
const { stdout } = await this.gitExecutor.exec([
"-C", repoPath, "rev-parse", "--verify", "--quiet", `refs/heads/${branch}^{commit}`,
]);
return stdout.trim();
} catch (error) {
const failure = gitCommandFailureSchema.safeParse(error);
if (failure.success && failure.data.code === 1) return null;
throw error;
}
}

/** Fetch an origin branch at an arbitrary repository path. */
async fetchBranchAt(repoPath: string, branch: string, prNumber?: number): Promise<void> {
validateBranchName(branch);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@ import { ThreadControlApprovalRepo } from "../authority/persistence/thread-contr
import { ThreadControlAuditRepo } from "../authority/persistence/thread-control-audit-repo.js";
import { DelegatedTaskRepo } from "../delegation/delegated-task-repo.js";
import { DelegatedTaskService } from "../delegation/delegated-task-service.js";
import { ThreadLaunchService } from "../launch/thread-launch-service.js";
import { ThreadRepo } from "../persistence/thread-repo.js";
import { ThreadStartupService } from "../../thread-startup/thread-startup-service.js";
import { ThreadStartupFetchDecisions } from "../../thread-startup/thread-startup-fetch-decisions.js";
import { WorkspaceEnvironmentService } from "../../projects/environment/workspace-environment-service.js";
import { GitWorktreeService } from "../../projects/git/git-worktree-service.js";
import { GitRepositoryService } from "../../projects/git/git-repository-service.js";
import { WorkspaceRepo } from "../../projects/persistence/workspace-repo.js";
import { AgentService } from "../../agents/orchestration/agent-service.js";
import { AgentTurnCommandPort } from "../../agents/orchestration/agent-turn-command-port.js";
import { CanonicalAgentBoundary } from "../../agents/canonical/canonical-agent-boundary.js";
Expand All @@ -28,6 +31,12 @@ import type { CancelThreadStartupDeps } from "../../thread-startup/cancel-thread

/** Register thread-control mutation, approval, authority, and lifecycle services. */
export function registerThreadControlServices(container: DependencyContainer): void {
container.register(ThreadLaunchService, { useFactory: instanceCachingFactory((c) => new ThreadLaunchService({
threads: c.resolve(ThreadRepo), workspaces: c.resolve(WorkspaceRepo),
targets: c.resolve(delay(() => DelegationTargetResolver)),
repository: c.resolve(delay(() => GitRepositoryService)),
commands: c.resolve(delay(() => AgentTurnCommandPort)),
})) });
container.register(DelegatedTaskRepo, { useClass: DelegatedTaskRepo }, { lifecycle: Lifecycle.Singleton });
container.register<CancelThreadStartupDeps>("CancelThreadStartupDeps", { useFactory: instanceCachingFactory((c) => ({
threadStartupService: c.resolve(ThreadStartupService),
Expand Down
Loading
Loading