blob: e5ef906414ae9f588cb359e1148dc2d3665eed02 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { copyFile, mkdir, mkdtemp, readdir, realpath, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { isAbsolute, join, normalize, resolve } from 'node:path';
import {
SUBAGENT_WORKSPACE_BINDING_SCHEMA_VERSION,
isSubagentWorkspaceBinding,
type ProvisionSubagentWorktreeInput,
type SubagentWorkspaceBinding,
type SubagentWorktreeExecutor,
} from '@maka/core/subagent-workspace';
import { execGitBytes, execGitText, type GitExecOptions } from './git-exec.js';
import { resolveProjectLocation } from './project-catalog.js';
const LEASE_PATTERN = /^subagent_worktree_([a-f0-9]{32})$/;
const WORKTREE_DIRECTORY_PATTERN = /^[a-f0-9]{32}$/;
export interface CreateGitWorktreeChildExecutorInput {
storageRoot: string;
}
/**
* Host-owned Git worktree allocator for linked child Sessions.
*
* Lease identity, lease branch, and path are deterministic. A retry therefore
* adopts the same worktree instead of creating a second filesystem side
* effect. The child may check out its own task branch without changing the
* host-owned lease identity. Worktrees intentionally survive terminal child
* runs so Session resume/follow-up keeps the exact workspace.
*/
export function createGitWorktreeChildExecutor(
input: CreateGitWorktreeChildExecutorInput,
): SubagentWorktreeExecutor {
return new GitWorktreeChildExecutor(join(input.storageRoot, 'subagent-worktrees'));
}
class GitWorktreeChildExecutor implements SubagentWorktreeExecutor {
private readonly inFlight = new Map<string, Promise<SubagentWorkspaceBinding>>();
private readonly repositoryTails = new Map<string, Promise<void>>();
constructor(private readonly worktreeRoot: string) {}
async isAvailable(
input: Pick<ProvisionSubagentWorktreeInput, 'sourceCwd' | 'sourceProjectId'>,
): Promise<boolean> {
try {
const source = await resolveProjectLocation({ path: input.sourceCwd });
return source.kind === 'git' && source.git !== undefined;
} catch {
return false;
}
}
async provision(input: ProvisionSubagentWorktreeInput): Promise<SubagentWorkspaceBinding> {
const suffix = leaseSuffix(input.leaseId);
const existing = this.inFlight.get(input.leaseId);
if (existing) return existing;
const task = this.provisionOnce(input, suffix).finally(() => {
if (this.inFlight.get(input.leaseId) === task) this.inFlight.delete(input.leaseId);
});
this.inFlight.set(input.leaseId, task);
return task;
}
async ensure(binding: SubagentWorkspaceBinding): Promise<void> {
if (!isSubagentWorkspaceBinding(binding)) {
throw new Error('Invalid subagent worktree binding');
}
await this.assertOwnedBindingLocation(binding);
const inspected = await this.inspectOwnedWorktree(binding.worktreePath);
if (!inspected) {
throw new Error(`Subagent worktree is unavailable: ${binding.worktreePath}`);
}
if (inspected.gitCommonDir !== normalize(binding.gitCommonDir)) {
throw new Error(`Subagent worktree binding changed: ${binding.worktreePath}`);
}
const [lease, baseCommit] = await Promise.all([
gitConfigGet(inspected.worktreePath, branchLeaseConfigKey(binding.branch)),
gitConfigGet(inspected.worktreePath, branchBaseConfigKey(binding.branch)),
]);
if (lease !== binding.leaseId || baseCommit !== binding.baseCommit) {
throw new Error(`Subagent worktree lease changed: ${binding.worktreePath}`);
}
}
async capturePatch(binding: SubagentWorkspaceBinding): Promise<Uint8Array> {
await this.ensure(binding);
return this.withRepositoryAllocation(binding.gitCommonDir, async () => {
const temporary = await mkdtemp(join(tmpdir(), 'maka-subagent-patch-'));
const indexPath = join(temporary, 'index');
try {
const currentIndex = (
await runGit(binding.worktreePath, ['rev-parse', '--git-path', 'index'])
).trim();
// Preserve staged and committed ignored paths, then overlay all working-tree changes.
await copyFile(
isAbsolute(currentIndex) ? currentIndex : resolve(binding.worktreePath, currentIndex),
indexPath,
);
const gitOptions = { gitIndexFile: indexPath };
await runGit(binding.worktreePath, ['add', '--all', '--'], gitOptions);
return await runGitBytes(
binding.worktreePath,
[
'diff',
'--cached',
'--binary',
'--full-index',
'--no-ext-diff',
'--no-textconv',
'--no-color',
binding.baseCommit,
'--',
],
gitOptions,
);
} finally {
await rm(temporary, { recursive: true, force: true });
}
});
}
async recover(liveBindings: readonly SubagentWorkspaceBinding[]): Promise<void> {
const liveByPath = new Map<string, SubagentWorkspaceBinding>();
for (const binding of liveBindings) {
if (!isSubagentWorkspaceBinding(binding)) {
throw new Error('Invalid live subagent worktree binding');
}
await this.assertOwnedBindingLocation(binding);
const key = normalize(binding.worktreePath);
if (liveByPath.has(key)) {
throw new Error(`Duplicate live subagent worktree binding: ${binding.worktreePath}`);
}
liveByPath.set(key, binding);
}
if (!(await isDirectory(this.worktreeRoot))) {
if (liveBindings.length > 0) {
throw new Error('Live subagent worktree bindings exist without a worktree root');
}
return;
}
const root = normalize(await realpath(this.worktreeRoot));
const entries = await readdir(root, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory() || !WORKTREE_DIRECTORY_PATTERN.test(entry.name)) {
throw new Error(`Unexpected entry in subagent worktree root: ${entry.name}`);
}
const path = join(root, entry.name);
const live = liveByPath.get(path);
if (live) {
await this.ensure(live);
liveByPath.delete(path);
continue;
}
await this.retireOrphan(path, entry.name);
}
if (liveByPath.size > 0) {
throw new Error(`Live subagent worktree is unavailable: ${liveByPath.keys().next().value}`);
}
}
async retire(binding: SubagentWorkspaceBinding): Promise<void> {
if (!isSubagentWorkspaceBinding(binding)) {
throw new Error('Invalid subagent worktree binding');
}
await this.assertOwnedBindingLocation(binding);
if (!(await isDirectory(binding.worktreePath))) return;
await this.ensure(binding);
await this.withRepositoryAllocation(binding.gitCommonDir, () =>
this.removeOwnedWorktree(binding.worktreePath, binding.branch, binding.gitCommonDir),
);
}
private async provisionOnce(
input: ProvisionSubagentWorktreeInput,
suffix: string,
): Promise<SubagentWorkspaceBinding> {
if (!input.sourceSessionId) throw new Error('Subagent worktree source Session is required');
const source = await resolveProjectLocation({ path: input.sourceCwd });
if (source.kind !== 'git' || !source.git) {
throw new Error('Worktree child execution requires a Git project');
}
const root = await ensureDirectory(this.worktreeRoot);
const worktreePath = join(root, suffix);
const branch = `maka/subagent/${suffix}`;
const gitCommonDir = normalize(source.git.commonDir);
return this.withRepositoryAllocation(gitCommonDir, () =>
this.provisionResolved(input.leaseId, source.git!.worktreeRoot, {
worktreePath,
branch,
gitCommonDir,
}),
);
}
private async provisionResolved(
leaseId: string,
sourceWorktreeRoot: string,
target: {
worktreePath: string;
branch: string;
gitCommonDir: string;
},
): Promise<SubagentWorkspaceBinding> {
const { worktreePath, branch, gitCommonDir } = target;
const adopted = await this.inspectOwnedWorktree(worktreePath);
if (adopted) {
if (adopted.gitCommonDir !== gitCommonDir) {
throw new Error(`Subagent worktree belongs to another Git repository: ${worktreePath}`);
}
return this.finalizeBinding(leaseId, branch, adopted);
}
const branchCommit = await gitRevParseOptional(sourceWorktreeRoot, branch);
const storedLease = await gitConfigGet(sourceWorktreeRoot, branchLeaseConfigKey(branch));
if (branchCommit && storedLease !== leaseId) {
throw new Error(`Subagent worktree branch is already owned: ${branch}`);
}
let baseCommit: string;
if (branchCommit) {
baseCommit =
(await gitConfigGet(sourceWorktreeRoot, branchBaseConfigKey(branch))) ?? branchCommit;
await runGit(sourceWorktreeRoot, ['worktree', 'add', '--quiet', worktreePath, branch]);
} else {
await assertCleanGitWorktree(sourceWorktreeRoot);
baseCommit = await gitRevParse(sourceWorktreeRoot, 'HEAD');
await runGit(sourceWorktreeRoot, [
'worktree',
'add',
'--quiet',
'--detach',
worktreePath,
baseCommit,
]);
await runGit(worktreePath, ['switch', '--quiet', '-c', branch]);
}
const inspected = await this.inspectOwnedWorktree(worktreePath);
if (!inspected || inspected.gitCommonDir !== gitCommonDir) {
throw new Error(`Git did not create the expected subagent worktree: ${worktreePath}`);
}
const checkedOutBranch = await gitCurrentBranch(inspected.worktreePath);
if (checkedOutBranch !== branch) {
throw new Error(`Git did not check out the expected subagent branch: ${worktreePath}`);
}
await setBranchLease(worktreePath, branch, leaseId, baseCommit);
return {
schemaVersion: SUBAGENT_WORKSPACE_BINDING_SCHEMA_VERSION,
kind: 'git_worktree',
leaseId,
gitCommonDir,
worktreePath: inspected.worktreePath,
branch,
baseCommit,
};
}
private async withRepositoryAllocation<T>(
gitCommonDir: string,
operation: () => Promise<T>,
): Promise<T> {
const previous = this.repositoryTails.get(gitCommonDir) ?? Promise.resolve();
let release!: () => void;
const current = new Promise<void>((resolve) => {
release = resolve;
});
const tail = previous.then(() => current);
this.repositoryTails.set(gitCommonDir, tail);
await previous;
try {
return await operation();
} finally {
release();
if (this.repositoryTails.get(gitCommonDir) === tail) {
this.repositoryTails.delete(gitCommonDir);
}
}
}
private async finalizeBinding(
leaseId: string,
leaseBranch: string,
inspected: InspectedWorktree,
): Promise<SubagentWorkspaceBinding> {
const lease = await gitConfigGet(inspected.worktreePath, branchLeaseConfigKey(leaseBranch));
if (lease && lease !== leaseId) {
throw new Error(`Subagent worktree lease changed: ${inspected.worktreePath}`);
}
if (!lease && (await gitCurrentBranch(inspected.worktreePath)) !== leaseBranch) {
throw new Error(`Subagent worktree lease is unavailable: ${inspected.worktreePath}`);
}
const baseCommit =
(await gitConfigGet(inspected.worktreePath, branchBaseConfigKey(leaseBranch))) ??
(await gitRevParse(inspected.worktreePath, leaseBranch));
await setBranchLease(inspected.worktreePath, leaseBranch, leaseId, baseCommit);
return {
schemaVersion: SUBAGENT_WORKSPACE_BINDING_SCHEMA_VERSION,
kind: 'git_worktree',
leaseId,
gitCommonDir: inspected.gitCommonDir,
worktreePath: inspected.worktreePath,
branch: leaseBranch,
baseCommit,
};
}
private async inspectOwnedWorktree(path: string): Promise<InspectedWorktree | undefined> {
if (!(await isDirectory(path))) return undefined;
const location = await resolveProjectLocation({ path });
if (location.kind !== 'git' || !location.git?.isWorktree) {
throw new UnlinkedWorktreeError(path);
}
const worktreePath = normalize(await realpath(location.git.worktreeRoot));
if (worktreePath !== normalize(path)) {
throw new Error(`Subagent workspace resolves outside its Host-owned path: ${path}`);
}
return {
worktreePath,
gitCommonDir: normalize(location.git.commonDir),
};
}
private async assertOwnedBindingLocation(binding: SubagentWorkspaceBinding): Promise<void> {
const suffix = leaseSuffix(binding.leaseId);
const root = normalize(await realpath(this.worktreeRoot));
if (
normalize(binding.worktreePath) !== join(root, suffix) ||
binding.branch !== `maka/subagent/${suffix}`
) {
throw new Error(
`Subagent worktree binding is outside the Host-owned root: ${binding.worktreePath}`,
);
}
}
private async retireOrphan(path: string, suffix: string): Promise<void> {
let inspected: InspectedWorktree | undefined;
try {
inspected = await this.inspectOwnedWorktree(path);
} catch (error) {
if (!(error instanceof UnlinkedWorktreeError)) throw error;
await rm(path, { recursive: true, force: true });
return;
}
if (!inspected) return;
const branch = `maka/subagent/${suffix}`;
const leaseId = `subagent_worktree_${suffix}`;
const branchCommit = await gitRevParseOptional(path, branch);
const storedLease = await gitConfigGet(path, branchLeaseConfigKey(branch));
if (storedLease !== undefined && storedLease !== leaseId) {
throw new Error(`Orphan subagent worktree lease changed: ${path}`);
}
const currentBranch = await gitCurrentBranch(path);
if (!branchCommit && currentBranch !== undefined) {
throw new Error(`Orphan subagent worktree is attached to an unmanaged branch: ${path}`);
}
if (branchCommit && storedLease === undefined && currentBranch !== branch) {
throw new Error(`Orphan subagent worktree ownership is unavailable: ${path}`);
}
await this.withRepositoryAllocation(inspected.gitCommonDir, () =>
this.removeOwnedWorktree(path, branch, inspected.gitCommonDir),
);
}
private async removeOwnedWorktree(
path: string,
leaseBranch: string,
gitCommonDir: string,
): Promise<void> {
await runGit(path, ['clean', '-ffdx']);
await runGit(path, ['checkout', '--detach', '--force', 'HEAD']);
await runGit(path, ['clean', '-ffdx']);
if (await gitRevParseOptional(path, leaseBranch)) {
await runGit(path, ['branch', '-D', leaseBranch]);
}
// Windows cannot remove a process's current directory, so run the final removal elsewhere.
await runGit(gitCommonDir, ['worktree', 'remove', '--force', path]);
}
}
class UnlinkedWorktreeError extends Error {
readonly name = 'UnlinkedWorktreeError';
constructor(path: string) {
super(`Subagent workspace is not a linked Git worktree: ${path}`);
}
}
interface InspectedWorktree {
worktreePath: string;
gitCommonDir: string;
}
async function assertCleanGitWorktree(path: string): Promise<void> {
const status = await runGit(path, [
'status',
'--porcelain=v1',
'--untracked-files=normal',
'--ignore-submodules=none',
]);
if (status.trim()) {
throw new Error(
'Worktree child execution requires the source Git worktree to have no uncommitted changes',
);
}
}
async function setBranchLease(
cwd: string,
branch: string,
leaseId: string,
baseCommit: string,
): Promise<void> {
await runGit(cwd, ['config', '--local', branchLeaseConfigKey(branch), leaseId]);
await runGit(cwd, ['config', '--local', branchBaseConfigKey(branch), baseCommit]);
}
function branchLeaseConfigKey(branch: string): string {
return `branch.${branch}.maka-worktree-lease`;
}
function branchBaseConfigKey(branch: string): string {
return `branch.${branch}.maka-worktree-base`;
}
async function gitConfigGet(cwd: string, key: string): Promise<string | undefined> {
try {
const output = await runGit(cwd, ['config', '--local', '--get', key]);
return output.trim() || undefined;
} catch (error) {
if (gitExitCode(error) === 1) return undefined;
throw error;
}
}
async function gitRevParse(cwd: string, ref: string): Promise<string> {
return (await runGit(cwd, ['rev-parse', '--verify', ref])).trim();
}
async function gitRevParseOptional(cwd: string, ref: string): Promise<string | undefined> {
try {
return await gitRevParse(cwd, ref);
} catch (error) {
if (gitExitCode(error) === 128) return undefined;
throw error;
}
}
async function gitCurrentBranch(cwd: string): Promise<string | undefined> {
try {
const branch = await runGit(cwd, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
return branch.trim() || undefined;
} catch (error) {
if (gitExitCode(error) === 1) return undefined;
throw error;
}
}
async function runGit(
cwd: string,
args: readonly string[],
options: GitExecOptions = {},
): Promise<string> {
return execGitText(cwd, args, options);
}
async function runGitBytes(
cwd: string,
args: readonly string[],
options: GitExecOptions = {},
): Promise<Uint8Array> {
return execGitBytes(cwd, args, options);
}
function gitExitCode(error: unknown): number | undefined {
if (!error || typeof error !== 'object' || !('code' in error)) return undefined;
return typeof error.code === 'number' ? error.code : undefined;
}
function leaseSuffix(leaseId: string): string {
const match = LEASE_PATTERN.exec(leaseId);
if (!match?.[1]) throw new Error(`Invalid subagent worktree lease id: ${leaseId}`);
return match[1];
}
async function ensureDirectory(path: string): Promise<string> {
await mkdir(path, { recursive: true });
const canonical = normalize(await realpath(path));
if (!isAbsolute(canonical) || !(await stat(canonical)).isDirectory()) {
throw new Error(`Invalid subagent worktree root: ${path}`);
}
return canonical;
}
async function isDirectory(path: string): Promise<boolean> {
try {
return (await stat(path)).isDirectory();
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
throw error;
}
}