Skip to content
Open
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
2 changes: 1 addition & 1 deletion apps/agor-daemon/src/mcp/tools/repos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export function registerRepoTools(server: McpServer, ctx: McpContext): void {
'Get detailed information about a specific repository, including async clone state. ' +
'For repos created via agor_repos_create_remote, check `clone_status` ' +
'(`cloning` | `ready` | `failed`). On `failed`, `clone_error.category` ' +
'(`auth_failed` | `not_found` | `network` | `unknown`) tells you what went wrong; ' +
'(`auth_failed` | `not_found` | `network` | `timeout` | `unknown`) tells you what went wrong; ' +
'`auth_failed` usually means the calling user has not configured a `GITHUB_TOKEN` ' +
'in Settings → API Keys (or it has expired/lost access).',
annotations: { readOnlyHint: true },
Expand Down
83 changes: 81 additions & 2 deletions apps/agor-daemon/src/services/branches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ import { emitServiceEvent } from '../utils/emit-service-event.js';
import { resolveExecutorReadAsUser } from '../utils/executor-read-impersonation.js';
import { resolveGitImpersonationForBranch } from '../utils/git-impersonation.js';
import { parseLastMessageTruncationLength } from '../utils/query-params.js';
import {
cancelTrackedCloneOperation,
REPO_CLONE_CLEANUP_TIMEOUT_MS,
REPO_CLONE_TIMEOUT_MS,
REPO_CLONE_TOKEN_TTL_SECONDS,
superviseRepoClone,
trackCloneOperation,
} from '../utils/repo-clone-supervisor.js';
import {
generateScopedServiceToken,
getDaemonUrl,
Expand Down Expand Up @@ -1287,6 +1295,11 @@ export class BranchesService extends DrizzleService<Branch, Partial<Branch>, Bra
// Get branch details before deletion
const branch = await this.withTenantDatabase(params, () => this.get(id, params));

// A delete while a self-standing clone is being materialized is explicit
// cancellation. Wait for its complete process tree before deleting the DB
// row or asking a cleanup executor to touch the same directory.
await cancelTrackedCloneOperation('branch', branch.branch_id);

// Remove from database FIRST for instant UI feedback
// CASCADE will clean up related comments automatically
const result = await super.remove(id, params);
Expand Down Expand Up @@ -1372,6 +1385,8 @@ export class BranchesService extends DrizzleService<Branch, Partial<Branch>, Bra
// Hook chain enforces auth before we get here.
const currentUserId = (params as AuthenticatedParams).user!.user_id as UUID;

await cancelTrackedCloneOperation('branch', branch.branch_id);

// Stop environment if running
if (branch.environment_instance?.status === 'running') {
console.log(`⚠️ Stopping environment for branch ${branch.name} before ${metadataAction}`);
Expand Down Expand Up @@ -1668,9 +1683,11 @@ export class BranchesService extends DrizzleService<Branch, Partial<Branch>, Bra
// templates without tripping requireAdminForEnvConfig when unarchive
// is performed by a non-admin user.
const sessionToken = generateScopedServiceToken(
this.app as unknown as { settings: { authentication?: { secret?: string } } }
this.app as unknown as { settings: { authentication?: { secret?: string } } },
{},
storageMode === 'clone' ? REPO_CLONE_TOKEN_TTL_SECONDS : undefined
);
spawnExecutor(
const branchProcess = spawnExecutor(
{
command: 'git.branch.add',
sessionToken,
Expand Down Expand Up @@ -1712,6 +1729,68 @@ export class BranchesService extends DrizzleService<Branch, Partial<Branch>, Bra
logPrefix: `[BranchesService.unarchive ${branch.name}]`,
}
);

if (storageMode === 'clone') {
const tenantId = params?.tenant?.tenant_id ?? getCurrentTenantId();
const branchCloneSupervisor = superviseRepoClone({
process: branchProcess,
timeoutMs: REPO_CLONE_TIMEOUT_MS,
cleanupPartialClone: async () => {
const cleanupToken = generateScopedServiceToken(
this.app as unknown as {
settings: { authentication?: { secret?: string } };
}
);
const cleanupResult = await runExecutorCommand(
{
command: 'git.branch.remove',
sessionToken: cleanupToken,
daemonUrl: getDaemonUrl(),
params: {
branchId: branch.branch_id,
branchPath: branch.path,
branchesRoot: getBranchesDir(tenantId),
deleteDbRecord: false,
storageMode: 'clone',
},
},
{
logPrefix: `[BranchesService.unarchive ${branch.name} cleanup]`,
timeoutMs: REPO_CLONE_CLEANUP_TIMEOUT_MS,
}
);
if (!cleanupResult.success) {
throw new Error(
cleanupResult.error?.message ?? 'branch clone cleanup executor failed'
);
}
},
onExit: async ({ code, signal }) => {
if (code === 0) return;
const current = await this.get(branch.branch_id);
if (current.filesystem_status !== 'creating') return;
const exitDescription = signal ? `signal ${signal}` : `exit code ${code ?? 1}`;
await this.patch(branch.branch_id, {
filesystem_status: 'failed',
error_message: `Branch clone exited with ${exitDescription} before reporting an error.`,
});
},
onTimeout: async ({ error }) => {
await this.patch(branch.branch_id, {
filesystem_status: 'failed',
error_message: error.message,
});
},
});

trackCloneOperation('branch', branch.branch_id, branchCloneSupervisor);
void branchCloneSupervisor.completion.catch((error) => {
console.error(
`[BranchesService.unarchive ${branch.name}] Clone supervisor failed:`,
error instanceof Error ? error.message : String(error)
);
});
}
} catch (error) {
console.error(
`⚠️ Failed to spawn executor for branch recreation:`,
Expand Down
52 changes: 52 additions & 0 deletions apps/agor-daemon/src/services/repos.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,58 @@ vi.mock('@agor/core/git/exec', async (importOriginal) => {
});

describe('ReposService.createBranch clone preflight', () => {
it.each([
{
status: 'cloning',
cloneError: undefined,
expected: /clone is still in progress.*clone_status='ready'/i,
},
{
status: 'failed',
cloneError: {
exit_code: 124,
category: 'timeout',
message: 'Clone timed out after 30 minutes.',
},
expected: /clone failed \(timeout\).*Clone timed out after 30 minutes.*Retry/i,
},
])('rejects a $status repository before branch persistence or git preflight', async ({
status,
cloneError,
expected,
}) => {
const app = {
service(path: string) {
throw new Error(`Unexpected service: ${path}`);
},
} as unknown as Application;
const service = new ReposService({} as never, app);
vi.spyOn(service, 'get').mockResolvedValue({
repo_id: 'repo-1',
slug: 'org/repo',
repo_type: 'remote',
remote_url: 'https://github.com/org/repo.git',
local_path: '/tmp/incomplete-repo',
clone_status: status,
clone_error: cloneError,
} as never);

await expect(
service.createBranch(
'repo-1',
{
name: 'new-branch',
ref: 'new-branch',
sourceBranch: 'main',
boardId: 'board-1',
},
{ user: { user_id: 'user-1' } } as never
)
).rejects.toThrow(expected);

expect(assertRemoteRefVisibleForClone).not.toHaveBeenCalled();
});

it('rejects clone-mode local-only source refs before persisting branch state', async () => {
const branchesCreate = vi.fn();
const getGitEnvironment = vi.fn(async () => ({ GITHUB_TOKEN: 'gh_test_token_for_preflight' }));
Expand Down
Loading