Handle merge conflicts as orchestration events
This commit is contained in:
@@ -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/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user