From f8081a45bbea36e3c2b929e8edcf65506494071b Mon Sep 17 00:00:00 2001 From: tuanvumaihuynh Date: Sat, 31 Oct 2026 10:52:14 +0700 Subject: [PATCH 1/3] fix(server): scheduled tasks start from the default branch, not always main Unbound scheduled tasks hardcoded `baseRef: "main"`, so on a repository whose default branch is anything else every run failed during worktree provisioning with `ref_not_found`. Resolve the primary remote's default branch when a task is created or unbound from its thread, and name the remote unless it is `origin`, since launch only fetches a bare name from `origin`. When no remote reports a default, as in local-only repositories or clones without a recorded HEAD, use `main` and then `master` if either exists on the remote or locally, and otherwise keep `main`. Fixes #27488 --- .../src/mcp/OrchestratorMcpService.test.ts | 191 ++++++++++++++++++ apps/server/src/mcp/OrchestratorMcpService.ts | 56 ++++- 2 files changed, 237 insertions(+), 10 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 9f27dc6936339..1a04a32e15054 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -3,6 +3,7 @@ import { assert, describe, it } from "@effect/vitest"; import { CommandId, EnvironmentId, + GitCommandError, MessageId, NodeId, ORCHESTRATOR_MCP_RUNNING_TASK_NEXT, @@ -2426,6 +2427,196 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); }); + + describe("the base branch of an unbound scheduled task", () => { + const projectId = ProjectId.make("project:scheduled-base"); + const workspaceRoot = "/repo/scheduled-base"; + const existing: ScheduledTask = { + id: ScheduledTaskId.make("scheduled-task:base"), + title: "Nightly", + prompt: "Check the build", + enabled: true, + schedule: { type: "interval", everyMs: 3_600_000 }, + projectId, + threadId: null, + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "approval-required", + interactionMode: "default", + createdBy: "agent", + creationSource: "mcp", + createdAt: "2026-10-05T00:00:00.000Z", + updatedAt: "2026-10-05T00:00:00.000Z", + nextRunAt: null, + lastRunAt: null, + lastRunStatus: "never", + lastRunError: null, + runCount: 0, + }; + const client: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:scheduled-base"), + requestNamespace: "client:scheduled-base", + thread: undefined, + client: { + sessionId: "scheduled-base", + label: "Claude Code", + access: "approval-required", + }, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + const service = ( + git: Partial, + saved: Ref.Ref>, + ) => + OrchestratorMcpService.layer.pipe( + Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)(git)), + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({}), + ServerSettings.layerTest(), + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([]), + }), + Layer.mock(ProjectService.ProjectService)({ + getById: () => + Effect.succeedSome({ + id: projectId, + workspaceRoot, + defaultModelSelection: existing.modelSelection, + } as never), + }), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({ + list: () => Effect.succeed({ tasks: [existing] }), + upsert: (input) => + Ref.update(saved, (strategies) => [...strategies, input.workspaceStrategy]).pipe( + Effect.as({ task: existing }), + ), + }), + ), + ), + ); + const masterRepository: Partial = { + resolvePrimaryRemoteName: () => Effect.succeed("origin"), + resolveDefaultBranchName: (cwd, remoteName) => + Effect.succeed(cwd === workspaceRoot && remoteName === "origin" ? "master" : null), + }; + const schedule = { type: "interval", everyMs: 3_600_000 } as const; + + it.effect("starts from the project's default branch", () => + Effect.gen(function* () { + const saved = yield* Ref.make>([]); + const mcp = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( + Effect.provide(service(masterRepository, saved)), + ); + yield* mcp.scheduleTask(client, { projectId, prompt: "Check the build", schedule }); + assert.deepEqual(yield* Ref.get(saved), [ + { type: "worktree", baseRef: "master", startFromOrigin: true }, + ]); + }), + ); + + it.effect("follows the default branch when a task is unbound from its thread", () => + Effect.gen(function* () { + const saved = yield* Ref.make>([]); + const mcp = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( + Effect.provide(service(masterRepository, saved)), + ); + yield* mcp.updateScheduledTask(client, { + scheduledTaskId: existing.id, + bindToCurrentThread: false, + }); + assert.deepEqual(yield* Ref.get(saved), [ + { type: "worktree", baseRef: "master", startFromOrigin: true }, + ]); + }), + ); + + it.effect.each([ + { + name: "no remote, only master", + remote: null, + remoteDefault: null, + refs: ["refs/heads/master"], + baseRef: "master", + }, + { + name: "no remote, main and master", + remote: null, + remoteDefault: null, + refs: ["refs/heads/master", "refs/heads/main"], + baseRef: "main", + }, + { + name: "no remote, neither main nor master", + remote: null, + remoteDefault: null, + refs: ["refs/heads/develop"], + baseRef: "main", + }, + { + name: "no recorded HEAD, remote main and a stale local master", + remote: "origin", + remoteDefault: null, + refs: ["refs/heads/master", "refs/remotes/origin/main"], + baseRef: "main", + }, + { + name: "no recorded HEAD, master only on the remote", + remote: "origin", + remoteDefault: null, + refs: ["refs/remotes/origin/master"], + baseRef: "master", + }, + { + name: "a remote not called origin", + remote: "upstream", + remoteDefault: "develop", + refs: ["refs/remotes/upstream/develop"], + baseRef: "upstream/develop", + }, + { + name: "a remote not called origin, no recorded HEAD", + remote: "upstream", + remoteDefault: null, + refs: ["refs/remotes/upstream/main"], + baseRef: "upstream/main", + }, + ])("$name starts from $baseRef", ({ remote, remoteDefault, refs, baseRef }) => + Effect.gen(function* () { + const saved = yield* Ref.make>([]); + const gitFailure = (cwd: string, detail: string) => + new GitCommandError({ operation: "GitVcsDriver.test", command: "git", cwd, detail }); + const mcp = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( + Effect.provide( + service( + { + resolvePrimaryRemoteName: (cwd) => + remote === null + ? Effect.fail(gitFailure(cwd, "No git remote is configured.")) + : Effect.succeed(remote), + resolveDefaultBranchName: () => Effect.succeed(remoteDefault), + resolveCommit: ({ cwd, revision }) => + refs.includes(revision) + ? Effect.succeed({ commitSha: "abc123" }) + : Effect.fail(gitFailure(cwd, "Needed a single revision")), + }, + saved, + ), + ), + ); + yield* mcp.scheduleTask(client, { projectId, prompt: "Check the build", schedule }); + assert.deepEqual(yield* Ref.get(saved), [ + { type: "worktree", baseRef, startFromOrigin: true }, + ]); + }), + ); + }); it.effect( "reports the child run's selection after a restart and the thread selection before launch", () => diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 403d625d7e9b1..7836efb7ff222 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -252,14 +252,6 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function scheduledTaskWorkspaceStrategy( - boundToThread: boolean, -): ScheduledTask["workspaceStrategy"] { - return boundToThread - ? { type: "root" } - : { type: "worktree", baseRef: "main", startFromOrigin: true }; -} - function scheduledTaskSummary(task: ScheduledTask, mayRun: boolean): OrchestratorMcpScheduledTask { return { scheduledTaskId: task.id, @@ -1793,6 +1785,50 @@ const make = Effect.gen(function* () { effect.pipe(Effect.timeoutOption(Duration.millis(timeoutMs)), Effect.map(Option.flatten)), ); + const refExists = (cwd: string, revision: string) => + gitDriver.resolveCommit({ cwd, revision }).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + + const resolveScheduledTaskBaseRef = Effect.fn( + "OrchestratorMcpService.resolveScheduledTaskBaseRef", + )(function* (cwd: string) { + const remoteName = yield* gitDriver + .resolvePrimaryRemoteName(cwd) + .pipe(Effect.orElseSucceed(() => null)); + // Launch fetches a bare name from `origin` only, so any other remote must be named. + const onRemote = (branch: string) => + remoteName === "origin" ? branch : `${remoteName}/${branch}`; + const remoteDefault = + remoteName === null + ? null + : yield* gitDriver + .resolveDefaultBranchName(cwd, remoteName) + .pipe(Effect.orElseSucceed(() => null)); + if (remoteDefault !== null) return onRemote(remoteDefault); + // Local-only repositories and remotes without a recorded HEAD report no default. + for (const branch of ["main", "master"]) { + if (remoteName !== null && (yield* refExists(cwd, `refs/remotes/${remoteName}/${branch}`))) { + return onRemote(branch); + } + if (yield* refExists(cwd, `refs/heads/${branch}`)) return branch; + } + return "main"; + }); + + const scheduledTaskWorkspaceStrategy = Effect.fn( + "OrchestratorMcpService.scheduledTaskWorkspaceStrategy", + )(function* (boundToThread: boolean, projectId: ProjectId) { + if (boundToThread) return { type: "root" as const }; + const { workspaceRoot } = yield* requireProject(projectId); + return { + type: "worktree" as const, + baseRef: yield* resolveScheduledTaskBaseRef(workspaceRoot), + startFromOrigin: true, + }; + }); + const scheduledTaskRunModes = Effect.fn("OrchestratorMcpService.scheduledTaskRunModes")( function* (task: ScheduledTask) { const modes = [{ runtimeMode: task.runtimeMode, interactionMode: task.interactionMode }]; @@ -1904,7 +1940,7 @@ const make = Effect.gen(function* () { schedule: input.schedule, projectId, threadId: bindToCurrentThread && parent !== undefined ? parent.thread.id : null, - workspaceStrategy: scheduledTaskWorkspaceStrategy(bindToCurrentThread), + workspaceStrategy: yield* scheduledTaskWorkspaceStrategy(bindToCurrentThread, projectId), modelSelection, runtimeMode: limits.runtimeMode, interactionMode: limits.interactionMode, @@ -1976,7 +2012,7 @@ const make = Effect.gen(function* () { const workspaceStrategy = input.bindToCurrentThread === undefined ? existing.workspaceStrategy - : scheduledTaskWorkspaceStrategy(input.bindToCurrentThread); + : yield* scheduledTaskWorkspaceStrategy(input.bindToCurrentThread, existing.projectId); const upsertInput: ScheduledTaskUpsertInput = { id: existing.id, title: input.title ?? existing.title, From 7ca575fd67d7639f90b7d38f83d160066ca3c888 Mon Sep 17 00:00:00 2001 From: tuanvumaihuynh Date: Sat, 31 Oct 2026 11:25:38 +0700 Subject: [PATCH 2/3] fix(server): scheduled tasks name the remote for origin too A bare branch name resolves through a local branch's configured upstream, so with origin/HEAD at master and local master tracking upstream/release, a task silently ran from upstream/release instead of origin/master. Always store the selected remote in the base ref. --- apps/server/src/mcp/OrchestratorMcpService.test.ts | 8 ++++---- apps/server/src/mcp/OrchestratorMcpService.ts | 5 ++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 1a04a32e15054..77f5e7ca61b1e 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -2516,7 +2516,7 @@ describe("OrchestratorMcpService provider resolution", () => { ); yield* mcp.scheduleTask(client, { projectId, prompt: "Check the build", schedule }); assert.deepEqual(yield* Ref.get(saved), [ - { type: "worktree", baseRef: "master", startFromOrigin: true }, + { type: "worktree", baseRef: "origin/master", startFromOrigin: true }, ]); }), ); @@ -2532,7 +2532,7 @@ describe("OrchestratorMcpService provider resolution", () => { bindToCurrentThread: false, }); assert.deepEqual(yield* Ref.get(saved), [ - { type: "worktree", baseRef: "master", startFromOrigin: true }, + { type: "worktree", baseRef: "origin/master", startFromOrigin: true }, ]); }), ); @@ -2564,14 +2564,14 @@ describe("OrchestratorMcpService provider resolution", () => { remote: "origin", remoteDefault: null, refs: ["refs/heads/master", "refs/remotes/origin/main"], - baseRef: "main", + baseRef: "origin/main", }, { name: "no recorded HEAD, master only on the remote", remote: "origin", remoteDefault: null, refs: ["refs/remotes/origin/master"], - baseRef: "master", + baseRef: "origin/master", }, { name: "a remote not called origin", diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 7836efb7ff222..979433d603e0d 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1797,9 +1797,8 @@ const make = Effect.gen(function* () { const remoteName = yield* gitDriver .resolvePrimaryRemoteName(cwd) .pipe(Effect.orElseSucceed(() => null)); - // Launch fetches a bare name from `origin` only, so any other remote must be named. - const onRemote = (branch: string) => - remoteName === "origin" ? branch : `${remoteName}/${branch}`; + // Launch resolves a bare name through a local branch's upstream, or else assumes `origin`. + const onRemote = (branch: string) => `${remoteName}/${branch}`; const remoteDefault = remoteName === null ? null From 130b62a1a23e61416208981ec7ee4e802c0f49d9 Mon Sep 17 00:00:00 2001 From: tuanvumaihuynh Date: Sat, 31 Oct 2026 11:32:56 +0700 Subject: [PATCH 3/3] fix(server): scheduled tasks check a remote ref exists before using it Fetch does not update a recorded remote HEAD, so after a default branch rename and prune, origin/HEAD named a branch that was gone and every run failed with ref_not_found. A local main also won over origin/master when no HEAD was recorded, so tasks started from the wrong branch. Only use the remote default if its tracking ref exists, and check both conventional branches on the remote before any local branch. --- .../src/mcp/OrchestratorMcpService.test.ts | 22 ++++++++++++++ apps/server/src/mcp/OrchestratorMcpService.ts | 29 ++++++++++--------- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 77f5e7ca61b1e..97dd45d340edf 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -2505,6 +2505,7 @@ describe("OrchestratorMcpService provider resolution", () => { resolvePrimaryRemoteName: () => Effect.succeed("origin"), resolveDefaultBranchName: (cwd, remoteName) => Effect.succeed(cwd === workspaceRoot && remoteName === "origin" ? "master" : null), + resolveCommit: () => Effect.succeed({ commitSha: "abc123" }), }; const schedule = { type: "interval", everyMs: 3_600_000 } as const; @@ -2573,6 +2574,27 @@ describe("OrchestratorMcpService provider resolution", () => { refs: ["refs/remotes/origin/master"], baseRef: "origin/master", }, + { + name: "no recorded HEAD, local main and master only on the remote", + remote: "origin", + remoteDefault: null, + refs: ["refs/heads/main", "refs/remotes/origin/master"], + baseRef: "origin/master", + }, + { + name: "no recorded HEAD, nothing conventional on the remote, local main", + remote: "origin", + remoteDefault: null, + refs: ["refs/heads/main", "refs/remotes/origin/develop"], + baseRef: "main", + }, + { + name: "a recorded HEAD whose branch is gone", + remote: "origin", + remoteDefault: "main", + refs: ["refs/heads/main", "refs/remotes/origin/master"], + baseRef: "origin/master", + }, { name: "a remote not called origin", remote: "upstream", diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 979433d603e0d..f7910dd9900fb 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1797,20 +1797,23 @@ const make = Effect.gen(function* () { const remoteName = yield* gitDriver .resolvePrimaryRemoteName(cwd) .pipe(Effect.orElseSucceed(() => null)); - // Launch resolves a bare name through a local branch's upstream, or else assumes `origin`. - const onRemote = (branch: string) => `${remoteName}/${branch}`; - const remoteDefault = - remoteName === null - ? null - : yield* gitDriver - .resolveDefaultBranchName(cwd, remoteName) - .pipe(Effect.orElseSucceed(() => null)); - if (remoteDefault !== null) return onRemote(remoteDefault); - // Local-only repositories and remotes without a recorded HEAD report no default. - for (const branch of ["main", "master"]) { - if (remoteName !== null && (yield* refExists(cwd, `refs/remotes/${remoteName}/${branch}`))) { - return onRemote(branch); + const conventional = ["main", "master"]; + if (remoteName !== null) { + const remoteDefault = yield* gitDriver + .resolveDefaultBranchName(cwd, remoteName) + .pipe(Effect.orElseSucceed(() => null)); + // Fetch does not update a recorded HEAD, so it can outlive the branch it names. + for (const branch of remoteDefault === null + ? conventional + : [remoteDefault, ...conventional]) { + // Launch resolves a bare name through a local branch's upstream, or else assumes `origin`. + if (yield* refExists(cwd, `refs/remotes/${remoteName}/${branch}`)) { + return `${remoteName}/${branch}`; + } } + } + // Local-only repositories, or a remote with neither a usable HEAD nor a conventional branch. + for (const branch of conventional) { if (yield* refExists(cwd, `refs/heads/${branch}`)) return branch; } return "main";