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

@@ -12,6 +12,7 @@ test("loads defaults and freezes config", () => {
assert.equal(config.agentManager.maxConcurrentAgents, 4);
assert.equal(config.orchestration.maxDepth, 4);
assert.equal(config.orchestration.mergeConflictMaxAttempts, 2);
assert.equal(config.provisioning.portRange.basePort, 36000);
assert.equal(config.discovery.fileRelativePath, ".agent-context/resources.json");
assert.equal(config.security.violationHandling, "hard_abort");
@@ -127,3 +128,10 @@ test("validates AGENT_WORKTREE_TARGET_PATH against parent traversal", () => {
/must not contain "\.\." path segments/,
);
});
test("validates AGENT_MERGE_CONFLICT_MAX_ATTEMPTS bounds", () => {
assert.throws(
() => loadConfig({ AGENT_MERGE_CONFLICT_MAX_ATTEMPTS: "0" }),
/AGENT_MERGE_CONFLICT_MAX_ATTEMPTS must be an integer >= 1/,
);
});

View File

@@ -62,6 +62,35 @@ test("project context store reads defaults and applies domain patches", async ()
assert.equal(updated.schemaVersion, 1);
});
test("project context accepts conflict-aware task statuses", async () => {
const root = await mkdtemp(resolve(tmpdir(), "ai-ops-project-context-conflict-"));
const store = new FileSystemProjectContextStore({
filePath: resolve(root, "project-context.json"),
});
const updated = await store.patchState({
upsertTasks: [
{
taskId: "task-conflict",
id: "task-conflict",
title: "Resolve merge conflict",
status: "conflict",
},
{
taskId: "task-resolving",
id: "task-resolving",
title: "Retry merge",
status: "resolving_conflict",
},
],
});
assert.deepEqual(
updated.taskQueue.map((task) => `${task.taskId}:${task.status}`),
["task-conflict:conflict", "task-resolving:resolving_conflict"],
);
});
test("project context parser merges missing root keys with defaults", async () => {
const root = await mkdtemp(resolve(tmpdir(), "ai-ops-project-context-"));
const filePath = resolve(root, "project-context.json");

View File

@@ -184,3 +184,54 @@ test("run service creates, runs, and closes explicit sessions", async () => {
code: "ENOENT",
});
});
test("run service marks session closed_with_conflicts when close merge conflicts", async () => {
const workspaceRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-run-service-close-conflict-"));
const stateRoot = resolve(workspaceRoot, "state");
const envPath = resolve(workspaceRoot, ".env");
const projectPath = resolve(workspaceRoot, "project");
await mkdir(projectPath, { recursive: true });
await execFileAsync("git", ["init", projectPath], { encoding: "utf8" });
await execFileAsync("git", ["-C", projectPath, "config", "user.name", "AI Ops"], { encoding: "utf8" });
await execFileAsync("git", ["-C", projectPath, "config", "user.email", "ai-ops@example.local"], { encoding: "utf8" });
await writeFile(resolve(projectPath, "README.md"), "base\n", "utf8");
await execFileAsync("git", ["-C", projectPath, "add", "README.md"], { encoding: "utf8" });
await execFileAsync("git", ["-C", projectPath, "commit", "-m", "initial"], { encoding: "utf8" });
await writeFile(
envPath,
[
`AGENT_STATE_ROOT=${stateRoot}`,
"AGENT_WORKTREE_ROOT=.ai_ops/worktrees",
"AGENT_WORKTREE_BASE_REF=HEAD",
].join("\n"),
"utf8",
);
const runService = new UiRunService({
workspaceRoot,
envFilePath: ".env",
});
const createdSession = await runService.createSession({
projectPath,
});
await writeFile(resolve(createdSession.baseWorkspacePath, "README.md"), "base branch update\n", "utf8");
await execFileAsync("git", ["-C", createdSession.baseWorkspacePath, "add", "README.md"], { encoding: "utf8" });
await execFileAsync("git", ["-C", createdSession.baseWorkspacePath, "commit", "-m", "base update"], { encoding: "utf8" });
await writeFile(resolve(projectPath, "README.md"), "project branch update\n", "utf8");
await execFileAsync("git", ["-C", projectPath, "add", "README.md"], { encoding: "utf8" });
await execFileAsync("git", ["-C", projectPath, "commit", "-m", "project update"], { encoding: "utf8" });
const closed = await runService.closeSession({
sessionId: createdSession.sessionId,
mergeToProject: true,
});
assert.equal(closed.sessionStatus, "closed_with_conflicts");
const baseWorkspaceStats = await stat(createdSession.baseWorkspacePath);
assert.equal(baseWorkspaceStats.isDirectory(), true);
});

View File

@@ -44,6 +44,11 @@ test("session metadata store persists and updates session metadata", async () =>
const readBack = await store.readSession("session-abc");
assert.equal(readBack?.sessionStatus, "closed");
const closedWithConflicts = await store.updateSession("session-abc", {
sessionStatus: "closed_with_conflicts",
});
assert.equal(closedWithConflicts.sessionStatus, "closed_with_conflicts");
});
test("session worktree manager provisions and merges task worktrees", async () => {
@@ -86,11 +91,12 @@ test("session worktree manager provisions and merges task worktrees", async () =
await writeFile(resolve(taskWorktreePath, "feature.txt"), "task output\n", "utf8");
await manager.mergeTaskIntoBase({
const mergeOutcome = await manager.mergeTaskIntoBase({
taskId: "task-1",
baseWorkspacePath,
taskWorktreePath,
});
assert.equal(mergeOutcome.kind, "success");
const mergedFile = await readFile(resolve(baseWorkspacePath, "feature.txt"), "utf8");
assert.equal(mergedFile, "task output\n");
@@ -104,13 +110,70 @@ test("session worktree manager provisions and merges task worktrees", async () =
updatedAt: new Date().toISOString(),
};
await manager.closeSession({
const closeOutcome = await manager.closeSession({
session,
taskWorktreePaths: [],
mergeBaseIntoProject: false,
});
assert.equal(closeOutcome.kind, "success");
await assert.rejects(() => stat(baseWorkspacePath), {
code: "ENOENT",
});
});
test("session worktree manager returns conflict outcome instead of throwing", async () => {
const root = await mkdtemp(resolve(tmpdir(), "ai-ops-session-worktree-conflict-"));
const projectPath = resolve(root, "project");
const worktreeRoot = resolve(root, "worktrees");
await mkdir(projectPath, { recursive: true });
await git(["init", projectPath]);
await git(["-C", projectPath, "config", "user.name", "AI Ops"]);
await git(["-C", projectPath, "config", "user.email", "ai-ops@example.local"]);
await writeFile(resolve(projectPath, "README.md"), "base\n", "utf8");
await git(["-C", projectPath, "add", "README.md"]);
await git(["-C", projectPath, "commit", "-m", "initial commit"]);
const manager = new SessionWorktreeManager({
worktreeRoot,
baseRef: "HEAD",
});
const sessionId = "session-conflict-1";
const baseWorkspacePath = manager.resolveBaseWorkspacePath(sessionId);
await manager.initializeSessionBaseWorkspace({
sessionId,
projectPath,
baseWorkspacePath,
});
const taskWorktreePath = (
await manager.ensureTaskWorktree({
sessionId,
taskId: "task-conflict",
baseWorkspacePath,
})
).taskWorktreePath;
await writeFile(resolve(baseWorkspacePath, "README.md"), "base branch change\n", "utf8");
await git(["-C", baseWorkspacePath, "add", "README.md"]);
await git(["-C", baseWorkspacePath, "commit", "-m", "base update"]);
await writeFile(resolve(taskWorktreePath, "README.md"), "task branch change\n", "utf8");
const mergeOutcome = await manager.mergeTaskIntoBase({
taskId: "task-conflict",
baseWorkspacePath,
taskWorktreePath,
});
assert.equal(mergeOutcome.kind, "conflict");
if (mergeOutcome.kind !== "conflict") {
throw new Error("Expected merge conflict outcome.");
}
assert.equal(mergeOutcome.taskId, "task-conflict");
assert.equal(mergeOutcome.worktreePath, taskWorktreePath);
assert.ok(mergeOutcome.conflictFiles.includes("README.md"));
});