Handle merge conflicts as orchestration events

This commit is contained in:
2026-02-24 10:29:06 -05:00
parent ca5fd3f096
commit 9f032d9b14
18 changed files with 863 additions and 70 deletions

View File

@@ -4,7 +4,12 @@ import type { JsonObject } from "./types.js";
export type PlanningDomainEventType = "requirements_defined" | "tasks_planned";
export type ExecutionDomainEventType = "code_committed" | "task_blocked" | "task_ready_for_review";
export type ValidationDomainEventType = "validation_passed" | "validation_failed";
export type IntegrationDomainEventType = "branch_merged";
export type IntegrationDomainEventType =
| "branch_merged"
| "merge_conflict_detected"
| "merge_conflict_resolved"
| "merge_conflict_unresolved"
| "merge_retry_started";
export type DomainEventType =
| PlanningDomainEventType
@@ -50,6 +55,10 @@ const DOMAIN_EVENT_TYPES = new Set<DomainEventType>([
"validation_passed",
"validation_failed",
"branch_merged",
"merge_conflict_detected",
"merge_conflict_resolved",
"merge_conflict_unresolved",
"merge_retry_started",
]);
export function isDomainEventType(value: string): value is DomainEventType {

View File

@@ -50,10 +50,14 @@ function toNodeAttemptSeverity(status: ActorResultStatus): RuntimeEventSeverity
}
function toDomainEventSeverity(type: DomainEventType): RuntimeEventSeverity {
if (type === "task_blocked") {
if (type === "task_blocked" || type === "merge_conflict_unresolved") {
return "critical";
}
if (type === "validation_failed") {
if (
type === "validation_failed" ||
type === "merge_conflict_detected" ||
type === "merge_retry_started"
) {
return "warning";
}
return "info";

View File

@@ -2,6 +2,7 @@ import { resolve } from "node:path";
import { getConfig, loadConfig, type AppConfig } from "../config.js";
import { createDefaultMcpRegistry, loadMcpConfigFromEnv, McpRegistry } from "../mcp.js";
import { parseAgentManifest, type AgentManifest } from "./manifest.js";
import type { DomainEventEmission } from "./domain-events.js";
import { AgentManager } from "./manager.js";
import {
PersonaRegistry,
@@ -44,6 +45,7 @@ export type OrchestrationSettings = {
maxDepth: number;
maxRetries: number;
maxChildren: number;
mergeConflictMaxAttempts: number;
securityViolationHandling: "hard_abort" | "validation_fail";
runtimeContext: Record<string, string | number | boolean>;
};
@@ -62,6 +64,7 @@ export function loadOrchestrationSettingsFromEnv(
maxDepth: config.orchestration.maxDepth,
maxRetries: config.orchestration.maxRetries,
maxChildren: config.orchestration.maxChildren,
mergeConflictMaxAttempts: config.orchestration.mergeConflictMaxAttempts,
securityViolationHandling: config.security.violationHandling,
};
}
@@ -241,21 +244,43 @@ function readTaskIdFromPayload(payload: JsonObject, fallback: string): string {
return fallback;
}
function toTaskStatusForFailure(resultStatus: "validation_fail" | "failure"): ProjectTaskStatus {
function toTaskStatusForFailure(
resultStatus: "validation_fail" | "failure",
statusAtStart: string,
): ProjectTaskStatus {
if (resultStatus === "failure") {
return "failed";
}
if (statusAtStart === "conflict" || statusAtStart === "resolving_conflict") {
return "conflict";
}
return "in_progress";
}
function shouldMergeFromStatus(statusAtStart: string): boolean {
return statusAtStart === "review";
return statusAtStart === "review" || statusAtStart === "resolving_conflict";
}
function toTaskIdLabel(task: ProjectTask): string {
return task.taskId || task.id || "task";
}
function toJsonObject(value: unknown): JsonObject | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
return value as JsonObject;
}
function readMergeConflictAttempts(metadata: JsonObject | undefined): number {
const record = toJsonObject(metadata?.mergeConflict);
const attempts = record?.attempts;
if (typeof attempts === "number" && Number.isInteger(attempts) && attempts >= 0) {
return attempts;
}
return 0;
}
export class SchemaDrivenExecutionEngine {
private readonly manifest: AgentManifest;
private readonly personaRegistry = new PersonaRegistry();
@@ -296,6 +321,8 @@ export class SchemaDrivenExecutionEngine {
maxDepth: input.settings?.maxDepth ?? config.orchestration.maxDepth,
maxRetries: input.settings?.maxRetries ?? config.orchestration.maxRetries,
maxChildren: input.settings?.maxChildren ?? config.orchestration.maxChildren,
mergeConflictMaxAttempts:
input.settings?.mergeConflictMaxAttempts ?? config.orchestration.mergeConflictMaxAttempts,
securityViolationHandling:
input.settings?.securityViolationHandling ?? config.security.violationHandling,
runtimeContext: {
@@ -480,7 +507,11 @@ export class SchemaDrivenExecutionEngine {
});
const statusAtStart: ProjectTaskStatus =
existing?.status === "review" ? "review" : "in_progress";
existing?.status === "review" ||
existing?.status === "conflict" ||
existing?.status === "resolving_conflict"
? existing.status
: "in_progress";
await input.projectContextStore.patchState({
upsertTasks: [
@@ -490,6 +521,7 @@ export class SchemaDrivenExecutionEngine {
status: statusAtStart,
worktreePath: ensured.taskWorktreePath,
...(existing?.title ? { title: existing.title } : { title: taskId }),
...(existing?.metadata ? { metadata: existing.metadata } : {}),
},
],
});
@@ -498,44 +530,267 @@ export class SchemaDrivenExecutionEngine {
taskId,
worktreePath: ensured.taskWorktreePath,
statusAtStart,
...(existing?.metadata ? { metadata: existing.metadata } : {}),
};
},
finalizeTaskExecution: async ({ task, result }) => {
finalizeTaskExecution: async ({ task, result, domainEvents }) => {
const emittedTypes = new Set(domainEvents.map((event) => event.type));
const additionalEvents: DomainEventEmission[] = [];
const emitEvent = (
type: DomainEventEmission["type"],
payload?: DomainEventEmission["payload"],
): void => {
if (emittedTypes.has(type)) {
return;
}
emittedTypes.add(type);
additionalEvents.push(payload ? { type, payload } : { type });
};
if (result.status === "failure" || result.status === "validation_fail") {
await input.projectContextStore.patchState({
upsertTasks: [
{
taskId: task.taskId,
id: task.taskId,
status: toTaskStatusForFailure(result.status),
status: toTaskStatusForFailure(result.status, task.statusAtStart),
worktreePath: task.worktreePath,
title: task.taskId,
...(task.metadata ? { metadata: task.metadata } : {}),
},
],
});
return;
}
if (task.statusAtStart === "conflict") {
const attempts = readMergeConflictAttempts(task.metadata);
const metadata: JsonObject = {
...(task.metadata ?? {}),
mergeConflict: {
attempts,
maxAttempts: this.settings.mergeConflictMaxAttempts,
status: "resolved",
resolvedAt: new Date().toISOString(),
},
};
await input.projectContextStore.patchState({
upsertTasks: [
{
taskId: task.taskId,
id: task.taskId,
status: "resolving_conflict",
worktreePath: task.worktreePath,
title: task.taskId,
metadata,
},
],
});
emitEvent("merge_conflict_resolved", {
summary: `Merge conflicts resolved for task "${task.taskId}".`,
details: {
taskId: task.taskId,
worktreePath: task.worktreePath,
attempts,
},
});
return {
additionalEvents,
handoffPayloadPatch: {
taskId: task.taskId,
worktreePath: task.worktreePath,
mergeConflictStatus: "resolved",
mergeConflictAttempts: attempts,
} as JsonObject,
};
}
if (shouldMergeFromStatus(task.statusAtStart)) {
await this.sessionWorktreeManager.mergeTaskIntoBase({
const attemptsBeforeMerge = readMergeConflictAttempts(task.metadata);
if (task.statusAtStart === "resolving_conflict") {
emitEvent("merge_retry_started", {
summary: `Retrying merge for task "${task.taskId}".`,
details: {
taskId: task.taskId,
worktreePath: task.worktreePath,
nextAttempt: attemptsBeforeMerge + 1,
maxAttempts: this.settings.mergeConflictMaxAttempts,
},
});
}
const mergeOutcome = await this.sessionWorktreeManager.mergeTaskIntoBase({
taskId: task.taskId,
baseWorkspacePath: input.session.baseWorkspacePath,
taskWorktreePath: task.worktreePath,
});
if (mergeOutcome.kind === "success") {
await input.projectContextStore.patchState({
upsertTasks: [
{
taskId: task.taskId,
id: task.taskId,
status: "merged",
title: task.taskId,
metadata: {
...(task.metadata ?? {}),
mergeConflict: {
attempts: attemptsBeforeMerge,
maxAttempts: this.settings.mergeConflictMaxAttempts,
status: "merged",
mergedAt: new Date().toISOString(),
},
},
},
],
});
emitEvent("branch_merged", {
summary: `Task "${task.taskId}" merged into session base branch.`,
details: {
taskId: task.taskId,
worktreePath: task.worktreePath,
},
});
return {
additionalEvents,
handoffPayloadPatch: {
taskId: task.taskId,
mergeStatus: "merged",
} as JsonObject,
};
}
if (mergeOutcome.kind === "conflict") {
const attempts = attemptsBeforeMerge + 1;
const exhausted = attempts >= this.settings.mergeConflictMaxAttempts;
const metadata: JsonObject = {
...(task.metadata ?? {}),
mergeConflict: {
attempts,
maxAttempts: this.settings.mergeConflictMaxAttempts,
status: exhausted ? "unresolved" : "conflict",
conflictFiles: mergeOutcome.conflictFiles,
worktreePath: mergeOutcome.worktreePath,
detectedAt: new Date().toISOString(),
...(mergeOutcome.mergeBase ? { mergeBase: mergeOutcome.mergeBase } : {}),
},
};
await input.projectContextStore.patchState({
upsertTasks: [
{
taskId: task.taskId,
id: task.taskId,
status: "conflict",
worktreePath: task.worktreePath,
title: task.taskId,
metadata,
},
],
});
emitEvent("merge_conflict_detected", {
summary: `Merge conflict detected for task "${task.taskId}".`,
details: {
taskId: task.taskId,
worktreePath: mergeOutcome.worktreePath,
conflictFiles: mergeOutcome.conflictFiles,
attempts,
maxAttempts: this.settings.mergeConflictMaxAttempts,
...(mergeOutcome.mergeBase ? { mergeBase: mergeOutcome.mergeBase } : {}),
},
});
if (exhausted) {
emitEvent("merge_conflict_unresolved", {
summary:
`Merge conflict attempts exhausted for task "${task.taskId}" ` +
`(${String(attempts)}/${String(this.settings.mergeConflictMaxAttempts)}).`,
details: {
taskId: task.taskId,
worktreePath: mergeOutcome.worktreePath,
conflictFiles: mergeOutcome.conflictFiles,
attempts,
maxAttempts: this.settings.mergeConflictMaxAttempts,
},
});
}
return {
additionalEvents,
handoffPayloadPatch: {
taskId: task.taskId,
worktreePath: task.worktreePath,
mergeConflictStatus: exhausted ? "unresolved" : "conflict",
mergeConflictAttempts: attempts,
mergeConflictMaxAttempts: this.settings.mergeConflictMaxAttempts,
mergeConflictFiles: mergeOutcome.conflictFiles,
...(mergeOutcome.mergeBase ? { mergeBase: mergeOutcome.mergeBase } : {}),
} as JsonObject,
};
}
await input.projectContextStore.patchState({
upsertTasks: [
{
taskId: task.taskId,
id: task.taskId,
status: "merged",
status: "failed",
worktreePath: task.worktreePath,
title: task.taskId,
metadata: {
...(task.metadata ?? {}),
mergeConflict: {
attempts: attemptsBeforeMerge,
maxAttempts: this.settings.mergeConflictMaxAttempts,
status: "fatal_error",
error: mergeOutcome.error,
...(mergeOutcome.mergeBase ? { mergeBase: mergeOutcome.mergeBase } : {}),
},
},
},
],
});
return;
emitEvent("merge_conflict_unresolved", {
summary: `Fatal merge error for task "${task.taskId}".`,
details: {
taskId: task.taskId,
worktreePath: mergeOutcome.worktreePath,
error: mergeOutcome.error,
...(mergeOutcome.mergeBase ? { mergeBase: mergeOutcome.mergeBase } : {}),
},
});
emitEvent("task_blocked", {
summary: `Task "${task.taskId}" blocked due to fatal merge error.`,
details: {
taskId: task.taskId,
error: mergeOutcome.error,
},
});
return {
additionalEvents,
handoffPayloadPatch: {
taskId: task.taskId,
worktreePath: task.worktreePath,
mergeStatus: "fatal_error",
mergeError: mergeOutcome.error,
} as JsonObject,
};
}
const nextMetadata = task.metadata
? {
...task.metadata,
}
: undefined;
await input.projectContextStore.patchState({
upsertTasks: [
{
@@ -544,9 +799,18 @@ export class SchemaDrivenExecutionEngine {
status: "review",
worktreePath: task.worktreePath,
title: task.taskId,
...(nextMetadata ? { metadata: nextMetadata } : {}),
},
],
});
if (additionalEvents.length > 0) {
return {
additionalEvents,
};
}
return;
},
};
}

View File

@@ -171,6 +171,7 @@ export type TaskExecutionResolution = {
taskId: string;
worktreePath: string;
statusAtStart: string;
metadata?: JsonObject;
};
export type TaskExecutionLifecycle = {
@@ -185,7 +186,13 @@ export type TaskExecutionLifecycle = {
task: TaskExecutionResolution;
result: ActorExecutionResult;
domainEvents: DomainEvent[];
}) => Promise<void>;
}) => Promise<
| void
| {
additionalEvents?: DomainEventEmission[];
handoffPayloadPatch?: JsonObject;
}
>;
};
type QueueItem = {
@@ -921,7 +928,7 @@ export class PipelineExecutor {
customEvents: result.events,
});
const topologyKind: NodeTopologyKind = node.topology?.kind ?? "sequential";
const payloadForNext = {
let payloadForNext: JsonObject = {
...context.handoff.payload,
...(result.payload ?? {}),
...(taskResolution
@@ -936,6 +943,34 @@ export class PipelineExecutor {
this.shouldRetryValidation(node) &&
attempt <= maxRetriesForNode;
if (taskResolution && this.options.taskLifecycle) {
const finalization = await this.options.taskLifecycle.finalizeTaskExecution({
sessionId,
node,
task: taskResolution,
result,
domainEvents,
});
for (const eventEmission of finalization?.additionalEvents ?? []) {
domainEvents.push(
createDomainEvent({
type: eventEmission.type,
source: "pipeline",
sessionId,
nodeId: node.id,
attempt,
payload: eventEmission.payload,
}),
);
}
if (finalization?.handoffPayloadPatch) {
payloadForNext = {
...payloadForNext,
...finalization.handoffPayloadPatch,
};
}
}
await this.lifecycleObserver.onNodeAttempt({
sessionId,
node,
@@ -948,16 +983,6 @@ export class PipelineExecutor {
topologyKind,
});
if (taskResolution && this.options.taskLifecycle) {
await this.options.taskLifecycle.finalizeTaskExecution({
sessionId,
node,
task: taskResolution,
result,
domainEvents,
});
}
const emittedEventTypes = domainEvents.map((event) => event.type);
nodeRecords.push({
nodeId: node.id,

View File

@@ -9,6 +9,8 @@ export type ProjectTaskStatus =
| "pending"
| "in_progress"
| "review"
| "conflict"
| "resolving_conflict"
| "merged"
| "failed"
| "blocked"
@@ -65,6 +67,8 @@ function toTaskStatus(value: unknown, label: string): ProjectTaskStatus {
value === "pending" ||
value === "in_progress" ||
value === "review" ||
value === "conflict" ||
value === "resolving_conflict" ||
value === "merged" ||
value === "failed" ||
value === "blocked" ||

View File

@@ -9,7 +9,7 @@ const execFileAsync = promisify(execFile);
const SESSION_METADATA_FILE_NAME = "session-metadata.json";
export type SessionStatus = "active" | "suspended" | "closed";
export type SessionStatus = "active" | "suspended" | "closed" | "closed_with_conflicts";
export type SessionMetadata = {
sessionId: string;
@@ -24,6 +24,58 @@ export type CreateSessionRequest = {
projectPath: string;
};
export type MergeTaskIntoBaseOutcome =
| {
kind: "success";
taskId: string;
worktreePath: string;
baseWorkspacePath: string;
}
| {
kind: "conflict";
taskId: string;
worktreePath: string;
baseWorkspacePath: string;
conflictFiles: string[];
mergeBase?: string;
}
| {
kind: "fatal_error";
taskId: string;
worktreePath: string;
baseWorkspacePath: string;
error: string;
mergeBase?: string;
};
export type CloseSessionOutcome =
| {
kind: "success";
sessionId: string;
mergedToProject: boolean;
}
| {
kind: "conflict";
sessionId: string;
worktreePath: string;
conflictFiles: string[];
mergeBase?: string;
baseBranch?: string;
}
| {
kind: "fatal_error";
sessionId: string;
error: string;
baseBranch?: string;
mergeBase?: string;
};
type GitExecutionResult = {
exitCode: number;
stdout: string;
stderr: string;
};
function toErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
@@ -46,7 +98,12 @@ function assertNonEmptyString(value: unknown, label: string): string {
}
function toSessionStatus(value: unknown): SessionStatus {
if (value === "active" || value === "suspended" || value === "closed") {
if (
value === "active" ||
value === "suspended" ||
value === "closed" ||
value === "closed_with_conflicts"
) {
return value;
}
throw new Error(`Session status "${String(value)}" is not supported.`);
@@ -73,12 +130,36 @@ function toSessionMetadata(value: unknown): SessionMetadata {
}
async function runGit(args: string[]): Promise<string> {
const result = await runGitWithResult(args);
if (result.exitCode !== 0) {
throw new Error(`git ${args.join(" ")} failed: ${result.stderr || result.stdout || "unknown git error"}`);
}
return result.stdout.trim();
}
async function runGitWithResult(args: string[]): Promise<GitExecutionResult> {
try {
const { stdout } = await execFileAsync("git", args, {
const { stdout, stderr } = await execFileAsync("git", args, {
encoding: "utf8",
});
return stdout.trim();
return {
exitCode: 0,
stdout: stdout.trim(),
stderr: stderr.trim(),
};
} catch (error) {
const failure = error as {
code?: number | string;
stdout?: string;
stderr?: string;
};
if (typeof failure.code === "number") {
return {
exitCode: failure.code,
stdout: String(failure.stdout ?? "").trim(),
stderr: String(failure.stderr ?? "").trim(),
};
}
throw new Error(`git ${args.join(" ")} failed: ${toErrorMessage(error)}`);
}
}
@@ -105,6 +186,18 @@ function sanitizeSegment(value: string, fallback: string): string {
return normalized || fallback;
}
function toGitFailureMessage(result: GitExecutionResult): string {
const details = result.stderr || result.stdout || "unknown git error";
return `git command failed with exit code ${String(result.exitCode)}: ${details}`;
}
function toStringLines(value: string): string[] {
return value
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
}
export class FileSystemSessionMetadataStore {
private readonly stateRoot: string;
@@ -306,58 +399,227 @@ export class SessionWorktreeManager {
taskId: string;
baseWorkspacePath: string;
taskWorktreePath: string;
}): Promise<void> {
}): Promise<MergeTaskIntoBaseOutcome> {
const baseWorkspacePath = assertAbsolutePath(input.baseWorkspacePath, "baseWorkspacePath");
const taskWorktreePath = assertAbsolutePath(input.taskWorktreePath, "taskWorktreePath");
const taskId = input.taskId;
await runGit(["-C", taskWorktreePath, "add", "-A"]);
const hasPending = await this.hasStagedChanges(taskWorktreePath);
if (hasPending) {
await runGit([
"-C",
taskWorktreePath,
"commit",
"-m",
`ai_ops: finalize task ${input.taskId}`,
]);
if (!(await pathExists(baseWorkspacePath))) {
throw new Error(`Base workspace "${baseWorkspacePath}" does not exist.`);
}
if (!(await pathExists(taskWorktreePath))) {
throw new Error(`Task worktree "${taskWorktreePath}" does not exist.`);
}
const branchName = await runGit(["-C", taskWorktreePath, "rev-parse", "--abbrev-ref", "HEAD"]);
await runGit(["-C", baseWorkspacePath, "merge", "--no-ff", "--no-edit", branchName]);
await this.removeWorktree({
repoPath: baseWorkspacePath,
worktreePath: taskWorktreePath,
});
}
let mergeBase: string | undefined;
try {
await runGit(["-C", taskWorktreePath, "add", "-A"]);
const hasPending = await this.hasStagedChanges(taskWorktreePath);
if (hasPending) {
await runGit(["-C", taskWorktreePath, "commit", "-m", `ai_ops: finalize task ${taskId}`]);
}
async closeSession(input: {
session: SessionMetadata;
taskWorktreePaths: string[];
mergeBaseIntoProject?: boolean;
}): Promise<void> {
const projectPath = assertAbsolutePath(input.session.projectPath, "projectPath");
const baseWorkspacePath = assertAbsolutePath(input.session.baseWorkspacePath, "baseWorkspacePath");
const branchName = await runGit(["-C", taskWorktreePath, "rev-parse", "--abbrev-ref", "HEAD"]);
const baseBranch = await runGit(["-C", baseWorkspacePath, "rev-parse", "--abbrev-ref", "HEAD"]);
mergeBase = await this.tryReadMergeBase(baseWorkspacePath, baseBranch, branchName);
for (const taskWorktreePath of input.taskWorktreePaths) {
if (!taskWorktreePath.trim()) {
continue;
if (await this.hasOngoingMerge(taskWorktreePath)) {
return {
kind: "conflict",
taskId,
worktreePath: taskWorktreePath,
baseWorkspacePath,
conflictFiles: await this.readConflictFiles(taskWorktreePath),
...(mergeBase ? { mergeBase } : {}),
};
}
const syncTaskBranch = await runGitWithResult([
"-C",
taskWorktreePath,
"merge",
"--no-ff",
"--no-edit",
baseBranch,
]);
if (syncTaskBranch.exitCode === 1) {
return {
kind: "conflict",
taskId,
worktreePath: taskWorktreePath,
baseWorkspacePath,
conflictFiles: await this.readConflictFiles(taskWorktreePath),
...(mergeBase ? { mergeBase } : {}),
};
}
if (syncTaskBranch.exitCode !== 0) {
return {
kind: "fatal_error",
taskId,
worktreePath: taskWorktreePath,
baseWorkspacePath,
error: toGitFailureMessage(syncTaskBranch),
...(mergeBase ? { mergeBase } : {}),
};
}
if (await this.hasOngoingMerge(baseWorkspacePath)) {
return {
kind: "conflict",
taskId,
worktreePath: baseWorkspacePath,
baseWorkspacePath,
conflictFiles: await this.readConflictFiles(baseWorkspacePath),
...(mergeBase ? { mergeBase } : {}),
};
}
const mergeIntoBase = await runGitWithResult([
"-C",
baseWorkspacePath,
"merge",
"--no-ff",
"--no-edit",
branchName,
]);
if (mergeIntoBase.exitCode === 1) {
return {
kind: "conflict",
taskId,
worktreePath: baseWorkspacePath,
baseWorkspacePath,
conflictFiles: await this.readConflictFiles(baseWorkspacePath),
...(mergeBase ? { mergeBase } : {}),
};
}
if (mergeIntoBase.exitCode !== 0) {
return {
kind: "fatal_error",
taskId,
worktreePath: taskWorktreePath,
baseWorkspacePath,
error: toGitFailureMessage(mergeIntoBase),
...(mergeBase ? { mergeBase } : {}),
};
}
await this.removeWorktree({
repoPath: baseWorkspacePath,
worktreePath: taskWorktreePath,
});
return {
kind: "success",
taskId,
worktreePath: taskWorktreePath,
baseWorkspacePath,
};
} catch (error) {
return {
kind: "fatal_error",
taskId,
worktreePath: taskWorktreePath,
baseWorkspacePath,
error: toErrorMessage(error),
...(mergeBase ? { mergeBase } : {}),
};
}
}
async closeSession(input: {
session: SessionMetadata;
taskWorktreePaths: string[];
mergeBaseIntoProject?: boolean;
}): Promise<CloseSessionOutcome> {
const projectPath = assertAbsolutePath(input.session.projectPath, "projectPath");
const baseWorkspacePath = assertAbsolutePath(input.session.baseWorkspacePath, "baseWorkspacePath");
if (!(await pathExists(projectPath))) {
throw new Error(`Project path "${projectPath}" does not exist.`);
}
if (!(await pathExists(baseWorkspacePath))) {
throw new Error(`Base workspace "${baseWorkspacePath}" does not exist.`);
}
if (input.mergeBaseIntoProject) {
const baseBranch = await runGit(["-C", baseWorkspacePath, "rev-parse", "--abbrev-ref", "HEAD"]);
await runGit(["-C", projectPath, "merge", "--no-ff", "--no-edit", baseBranch]);
}
let baseBranch: string | undefined;
let mergeBase: string | undefined;
await this.removeWorktree({
repoPath: projectPath,
worktreePath: baseWorkspacePath,
});
try {
for (const taskWorktreePath of input.taskWorktreePaths) {
if (!taskWorktreePath.trim()) {
continue;
}
await this.removeWorktree({
repoPath: baseWorkspacePath,
worktreePath: taskWorktreePath,
});
}
if (input.mergeBaseIntoProject) {
baseBranch = await runGit(["-C", baseWorkspacePath, "rev-parse", "--abbrev-ref", "HEAD"]);
mergeBase = await this.tryReadMergeBase(projectPath, "HEAD", baseBranch);
if (await this.hasOngoingMerge(projectPath)) {
return {
kind: "conflict",
sessionId: input.session.sessionId,
worktreePath: projectPath,
conflictFiles: await this.readConflictFiles(projectPath),
...(baseBranch ? { baseBranch } : {}),
...(mergeBase ? { mergeBase } : {}),
};
}
const mergeResult = await runGitWithResult([
"-C",
projectPath,
"merge",
"--no-ff",
"--no-edit",
baseBranch,
]);
if (mergeResult.exitCode === 1) {
return {
kind: "conflict",
sessionId: input.session.sessionId,
worktreePath: projectPath,
conflictFiles: await this.readConflictFiles(projectPath),
...(baseBranch ? { baseBranch } : {}),
...(mergeBase ? { mergeBase } : {}),
};
}
if (mergeResult.exitCode !== 0) {
return {
kind: "fatal_error",
sessionId: input.session.sessionId,
error: toGitFailureMessage(mergeResult),
...(baseBranch ? { baseBranch } : {}),
...(mergeBase ? { mergeBase } : {}),
};
}
}
await this.removeWorktree({
repoPath: projectPath,
worktreePath: baseWorkspacePath,
});
return {
kind: "success",
sessionId: input.session.sessionId,
mergedToProject: input.mergeBaseIntoProject === true,
};
} catch (error) {
return {
kind: "fatal_error",
sessionId: input.session.sessionId,
error: toErrorMessage(error),
...(baseBranch ? { baseBranch } : {}),
...(mergeBase ? { mergeBase } : {}),
};
}
}
private async removeWorktree(input: {
@@ -386,4 +648,43 @@ export class SessionWorktreeManager {
throw new Error(`Unable to inspect staged changes: ${toErrorMessage(error)}`);
}
}
private async hasOngoingMerge(worktreePath: string): Promise<boolean> {
const result = await runGitWithResult([
"-C",
worktreePath,
"rev-parse",
"-q",
"--verify",
"MERGE_HEAD",
]);
return result.exitCode === 0;
}
private async readConflictFiles(worktreePath: string): Promise<string[]> {
const result = await runGitWithResult([
"-C",
worktreePath,
"diff",
"--name-only",
"--diff-filter=U",
]);
if (result.exitCode !== 0) {
return [];
}
return toStringLines(result.stdout);
}
private async tryReadMergeBase(
repoPath: string,
leftRef: string,
rightRef: string,
): Promise<string | undefined> {
const result = await runGitWithResult(["-C", repoPath, "merge-base", leftRef, rightRef]);
if (result.exitCode !== 0) {
return undefined;
}
const mergeBase = result.stdout.trim();
return mergeBase || undefined;
}
}