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 @@ -280,6 +280,7 @@ async function autoDispatchSchedulableTasks(
vmSize: resolvedVmSize,
vmLocation: resolvedVmLocation,
branch: projectRow.default_branch,
defaultBranch: projectRow.default_branch,
userName: userRow?.name ?? null,
userEmail: userRow?.email ?? null,
githubId: userRow?.github_id ?? null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ export async function dispatchTask(
vmSize: resolvedVmSize,
vmLocation: resolvedVmLocation,
branch: checkoutBranch,
defaultBranch: project.defaultBranch,
userName: userRow?.name ?? null,
userEmail: userRow?.email ?? null,
githubId: userRow?.githubId ?? null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ export async function retrySubtask(
vmSize: resolvedVmSize,
vmLocation: resolvedVmLocation,
branch: original.projectDefaultBranch,
defaultBranch: original.projectDefaultBranch,
userName: userRow?.name ?? null,
userEmail: userRow?.email ?? null,
githubId: userRow?.githubId ?? null,
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/durable-objects/task-runner/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export interface TaskRunConfig {
repository: string;
installationId: string;
outputBranch: string | null;
/** Project's default branch (e.g. 'main'). Used to skip branch-exists check when cloning the default branch. */
defaultBranch: string;
projectDefaultVmSize: VMSize | null;
/** Chat session ID created at task submit time (TDF-6: single session per task) */
chatSessionId: string | null;
Expand Down
65 changes: 65 additions & 0 deletions apps/api/src/durable-objects/task-runner/workspace-steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ async function createAndProvisionWorkspace(
await startComputeTrackingBestEffort(state, rc, db, workspaceId, nodeId);
await ensureSessionLinked(state, workspaceId, rc);
await setOutputBranch(state, rc, now);
await ensureBranchExistsOnRemote(state, rc);
await createWorkspaceOnVmAgent(state, rc, workspaceId, nodeId);
await rc.env.DATABASE.prepare(
`UPDATE workspaces SET dispatched_at = ? WHERE id = ?`
Expand Down Expand Up @@ -215,6 +216,70 @@ async function setOutputBranch(
).bind(outputBranch, now, state.taskId).run();
}

/**
* Ensure the checkout branch exists on the remote before cloning.
* If the branch differs from the project's default branch and doesn't exist,
* create it from the default branch via the GitHub API.
*
* Best-effort: failures are logged but do not block workspace creation.
* The clone will fail with a clear error from the VM agent if the branch
* truly doesn't exist.
*/
export async function ensureBranchExistsOnRemote(
state: TaskRunnerState,
rc: TaskRunnerContext,
): Promise<void> {
const defaultBranch = state.config.defaultBranch || 'main';

// If cloning the default branch, no need to check — it always exists
if (state.config.branch === defaultBranch) {
return;
}

// Parse owner/repo from repository string (format: "owner/repo")
const repoParts = state.config.repository.split('/');
if (repoParts.length !== 2 || !repoParts[0] || !repoParts[1]) {
log.warn('task_runner_do.ensure_branch.invalid_repository', {
taskId: state.taskId,
repository: state.config.repository,
});
return;
}

const [owner, repo] = repoParts;

try {
const { ensureBranchExists } = await import('../../services/github-app');
const created = await ensureBranchExists(
state.config.installationId,
owner,
repo,
state.config.branch,
defaultBranch,
rc.env,
);

if (created) {
log.info('task_runner_do.ensure_branch.ok', {
taskId: state.taskId,
branch: state.config.branch,
});
} else {
log.warn('task_runner_do.ensure_branch.failed', {
taskId: state.taskId,
branch: state.config.branch,
defaultBranch,
});
}
} catch (err) {
log.warn('task_runner_do.ensure_branch.error', {
taskId: state.taskId,
branch: state.config.branch,
error: err instanceof Error ? err.message : String(err),
});
}
}

async function createWorkspaceOnVmAgent(
state: TaskRunnerState,
rc: TaskRunnerContext,
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/routes/mcp/dispatch-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,7 @@ export async function handleDispatchTask(
vmSize: resolvedVmSize,
vmLocation: resolvedVmLocation,
branch: checkoutBranch,
defaultBranch: project.defaultBranch,
userName: userRow?.name ?? null,
userEmail: userRow?.email ?? null,
githubId: userRow?.githubId ?? null,
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/routes/mcp/orchestration-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,7 @@ export async function handleRetrySubtask(
vmSize: resolvedVmSize,
vmLocation: resolvedVmLocation,
branch: checkoutBranch,
defaultBranch: project.defaultBranch,
userName: userRow?.name ?? null,
userEmail: userRow?.email ?? null,
githubId: userRow?.githubId ?? null,
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/routes/tasks/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ runRoutes.post('/:taskId/run', requireAuth(), requireApproved(), async (c) => {
vmSize,
vmLocation,
branch,
defaultBranch: project.defaultBranch,
preferredNodeId: body.nodeId,
userName: auth.user.name,
userEmail: auth.user.email,
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/routes/tasks/submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,7 @@ submitRoutes.post('/submit', requireAuth(), requireApproved(), jsonValidator(Sub
vmSize,
vmLocation,
branch,
defaultBranch: project.defaultBranch,
preferredNodeId: body.nodeId,
userName: auth.user.name,
userEmail: auth.user.email,
Expand Down
100 changes: 100 additions & 0 deletions apps/api/src/services/github-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,106 @@ export async function getRepositoryBranches(
return allBranches;
}

/**
* Ensure a branch exists in a repository. If the branch does not exist,
* create it from the default branch.
*
* This is called before workspace provisioning to prevent git clone failures
* when a task specifies a branch that hasn't been created yet.
*
* @returns true if the branch exists (or was created), false if creation failed
*/
export async function ensureBranchExists(
installationId: string,
owner: string,
repo: string,
branchName: string,
defaultBranch: string,
env: Env,
): Promise<boolean> {
const { token } = await getInstallationToken(installationId, env);
const headers: HeadersInit = {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'Simple-Agent-Manager',
};

// Check if the branch already exists
const checkResp = await fetch(
`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches/${encodeURIComponent(branchName)}`,
{ headers },
);

if (checkResp.ok) {
return true; // Branch already exists
}

if (checkResp.status !== 404) {
// Unexpected error — log and return false
log.warn('github.ensure_branch.check_failed', {
owner, repo, branchName,
status: checkResp.status,
});
return false;
}

// Branch doesn't exist — get the SHA of the default branch
const refResp = await fetch(
`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/ref/heads/${encodeURIComponent(defaultBranch)}`,
{ headers },
);

if (!refResp.ok) {
log.warn('github.ensure_branch.default_branch_ref_failed', {
owner, repo, defaultBranch,
status: refResp.status,
});
return false;
}

const refData = await refResp.json() as { object?: { sha?: string } };
const sha = refData.object?.sha;
if (!sha) {
log.warn('github.ensure_branch.no_sha', { owner, repo, defaultBranch });
return false;
}

// Create the new branch
const createResp = await fetch(
`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/refs`,
{
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
ref: `refs/heads/${branchName}`,
sha,
}),
},
);

if (createResp.ok) {
log.info('github.ensure_branch.created', {
owner, repo, branchName, fromBranch: defaultBranch, sha,
});
return true;
}

if (createResp.status === 422) {
// Race condition — another caller created the branch between our check and create
log.info('github.ensure_branch.race_already_exists', { owner, repo, branchName });
return true;
}

const errorText = await createResp.text().catch(() => '');
log.warn('github.ensure_branch.create_failed', {
owner, repo, branchName,
status: createResp.status,
message: errorText.slice(0, 200),
});
return false;
}

/**
* Verify a webhook signature from GitHub.
*/
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/services/task-runner-do.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export async function startTaskRunnerDO(
vmSize: VMSize;
vmLocation: VMLocation;
branch: string;
defaultBranch?: string;
preferredNodeId?: string | null;
userName?: string | null;
userEmail?: string | null;
Expand Down Expand Up @@ -103,6 +104,7 @@ export async function startTaskRunnerDO(
vmSize: input.vmSize,
vmLocation: input.vmLocation,
branch: input.branch,
defaultBranch: input.defaultBranch ?? input.branch,
preferredNodeId: input.preferredNodeId ?? null,
userName: input.userName ?? null,
userEmail: input.userEmail ?? null,
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/services/trigger-submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ export async function submitTriggeredTask(
vmSize,
vmLocation,
branch,
defaultBranch: project.defaultBranch,
userName: userRow?.name ?? null,
userEmail: userRow?.email ?? null,
githubId: userRow?.githubId ?? null,
Expand Down
Loading
Loading