Handle merge conflicts as orchestration events
This commit is contained in:
@@ -28,6 +28,7 @@ AGENT_PROJECT_CONTEXT_PATH=.ai_ops/project-context.json
|
|||||||
AGENT_TOPOLOGY_MAX_DEPTH=4
|
AGENT_TOPOLOGY_MAX_DEPTH=4
|
||||||
AGENT_TOPOLOGY_MAX_RETRIES=2
|
AGENT_TOPOLOGY_MAX_RETRIES=2
|
||||||
AGENT_RELATIONSHIP_MAX_CHILDREN=4
|
AGENT_RELATIONSHIP_MAX_CHILDREN=4
|
||||||
|
AGENT_MERGE_CONFLICT_MAX_ATTEMPTS=2
|
||||||
|
|
||||||
# Resource provisioning (hard + soft constraints)
|
# Resource provisioning (hard + soft constraints)
|
||||||
AGENT_WORKTREE_ROOT=.ai_ops/worktrees
|
AGENT_WORKTREE_ROOT=.ai_ops/worktrees
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
- `AGENT_TOPOLOGY_MAX_DEPTH`
|
- `AGENT_TOPOLOGY_MAX_DEPTH`
|
||||||
- `AGENT_TOPOLOGY_MAX_RETRIES`
|
- `AGENT_TOPOLOGY_MAX_RETRIES`
|
||||||
- `AGENT_RELATIONSHIP_MAX_CHILDREN`
|
- `AGENT_RELATIONSHIP_MAX_CHILDREN`
|
||||||
|
- `AGENT_MERGE_CONFLICT_MAX_ATTEMPTS`
|
||||||
- Provisioning/resource controls:
|
- Provisioning/resource controls:
|
||||||
- `AGENT_WORKTREE_ROOT`
|
- `AGENT_WORKTREE_ROOT`
|
||||||
- `AGENT_WORKTREE_BASE_REF`
|
- `AGENT_WORKTREE_BASE_REF`
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ TypeScript runtime for deterministic multi-agent execution with:
|
|||||||
- `artifactPointers`
|
- `artifactPointers`
|
||||||
- `taskQueue`
|
- `taskQueue`
|
||||||
- each task record stores `taskId`, status, and optional `worktreePath` for task-scoped workspace ownership
|
- each task record stores `taskId`, status, and optional `worktreePath` for task-scoped workspace ownership
|
||||||
|
- conflict-aware statuses are supported (`conflict`, `resolving_conflict`)
|
||||||
|
|
||||||
## Deep Dives
|
## Deep Dives
|
||||||
|
|
||||||
@@ -132,7 +133,7 @@ Domain events are typed and can trigger edges directly:
|
|||||||
- planning: `requirements_defined`, `tasks_planned`
|
- planning: `requirements_defined`, `tasks_planned`
|
||||||
- execution: `code_committed`, `task_ready_for_review`, `task_blocked`
|
- execution: `code_committed`, `task_ready_for_review`, `task_blocked`
|
||||||
- validation: `validation_passed`, `validation_failed`
|
- validation: `validation_passed`, `validation_failed`
|
||||||
- integration: `branch_merged`
|
- integration: `branch_merged`, `merge_conflict_detected`, `merge_conflict_resolved`, `merge_conflict_unresolved`, `merge_retry_started`
|
||||||
|
|
||||||
Actors can emit events in `ActorExecutionResult.events`. Pipeline status also emits default validation/execution events.
|
Actors can emit events in `ActorExecutionResult.events`. Pipeline status also emits default validation/execution events.
|
||||||
|
|
||||||
@@ -272,6 +273,7 @@ jq -c 'select(.severity=="critical")' .ai_ops/events/runtime-events.ndjson
|
|||||||
- `AGENT_TOPOLOGY_MAX_DEPTH`
|
- `AGENT_TOPOLOGY_MAX_DEPTH`
|
||||||
- `AGENT_TOPOLOGY_MAX_RETRIES`
|
- `AGENT_TOPOLOGY_MAX_RETRIES`
|
||||||
- `AGENT_RELATIONSHIP_MAX_CHILDREN`
|
- `AGENT_RELATIONSHIP_MAX_CHILDREN`
|
||||||
|
- `AGENT_MERGE_CONFLICT_MAX_ATTEMPTS`
|
||||||
|
|
||||||
### Provisioning / Resource Controls
|
### Provisioning / Resource Controls
|
||||||
|
|
||||||
|
|||||||
@@ -52,10 +52,16 @@ This keeps orchestration policy resolution separate from executor enforcement. E
|
|||||||
- planning: `requirements_defined`, `tasks_planned`
|
- planning: `requirements_defined`, `tasks_planned`
|
||||||
- execution: `code_committed`, `task_blocked`
|
- execution: `code_committed`, `task_blocked`
|
||||||
- validation: `validation_passed`, `validation_failed`
|
- validation: `validation_passed`, `validation_failed`
|
||||||
- integration: `branch_merged`
|
- integration: `branch_merged`, `merge_conflict_detected`, `merge_conflict_resolved`, `merge_conflict_unresolved`, `merge_retry_started`
|
||||||
- Pipeline edges can trigger on domain events (`edge.event`) in addition to legacy status triggers (`edge.on`).
|
- Pipeline edges can trigger on domain events (`edge.event`) in addition to legacy status triggers (`edge.on`).
|
||||||
- `history_has_event` route conditions evaluate persisted domain event history entries (`validation_failed`, `task_blocked`, etc.).
|
- `history_has_event` route conditions evaluate persisted domain event history entries (`validation_failed`, `task_blocked`, etc.).
|
||||||
|
|
||||||
|
## Merge conflict orchestration
|
||||||
|
|
||||||
|
- Task merge/close merge operations return structured outcomes (`success`, `conflict`, `fatal_error`) instead of throwing for conflicts.
|
||||||
|
- Task state supports conflict workflows (`conflict`, `resolving_conflict`) and conflict metadata is persisted under `task.metadata.mergeConflict`.
|
||||||
|
- Conflict retries are bounded by `AGENT_MERGE_CONFLICT_MAX_ATTEMPTS`; exhaustion emits `merge_conflict_unresolved` and the session continues without crashing.
|
||||||
|
|
||||||
## Security note
|
## Security note
|
||||||
|
|
||||||
Security enforcement now lives in `src/security`:
|
Security enforcement now lives in `src/security`:
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import type { JsonObject } from "./types.js";
|
|||||||
export type PlanningDomainEventType = "requirements_defined" | "tasks_planned";
|
export type PlanningDomainEventType = "requirements_defined" | "tasks_planned";
|
||||||
export type ExecutionDomainEventType = "code_committed" | "task_blocked" | "task_ready_for_review";
|
export type ExecutionDomainEventType = "code_committed" | "task_blocked" | "task_ready_for_review";
|
||||||
export type ValidationDomainEventType = "validation_passed" | "validation_failed";
|
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 =
|
export type DomainEventType =
|
||||||
| PlanningDomainEventType
|
| PlanningDomainEventType
|
||||||
@@ -50,6 +55,10 @@ const DOMAIN_EVENT_TYPES = new Set<DomainEventType>([
|
|||||||
"validation_passed",
|
"validation_passed",
|
||||||
"validation_failed",
|
"validation_failed",
|
||||||
"branch_merged",
|
"branch_merged",
|
||||||
|
"merge_conflict_detected",
|
||||||
|
"merge_conflict_resolved",
|
||||||
|
"merge_conflict_unresolved",
|
||||||
|
"merge_retry_started",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function isDomainEventType(value: string): value is DomainEventType {
|
export function isDomainEventType(value: string): value is DomainEventType {
|
||||||
|
|||||||
@@ -50,10 +50,14 @@ function toNodeAttemptSeverity(status: ActorResultStatus): RuntimeEventSeverity
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toDomainEventSeverity(type: DomainEventType): RuntimeEventSeverity {
|
function toDomainEventSeverity(type: DomainEventType): RuntimeEventSeverity {
|
||||||
if (type === "task_blocked") {
|
if (type === "task_blocked" || type === "merge_conflict_unresolved") {
|
||||||
return "critical";
|
return "critical";
|
||||||
}
|
}
|
||||||
if (type === "validation_failed") {
|
if (
|
||||||
|
type === "validation_failed" ||
|
||||||
|
type === "merge_conflict_detected" ||
|
||||||
|
type === "merge_retry_started"
|
||||||
|
) {
|
||||||
return "warning";
|
return "warning";
|
||||||
}
|
}
|
||||||
return "info";
|
return "info";
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { resolve } from "node:path";
|
|||||||
import { getConfig, loadConfig, type AppConfig } from "../config.js";
|
import { getConfig, loadConfig, type AppConfig } from "../config.js";
|
||||||
import { createDefaultMcpRegistry, loadMcpConfigFromEnv, McpRegistry } from "../mcp.js";
|
import { createDefaultMcpRegistry, loadMcpConfigFromEnv, McpRegistry } from "../mcp.js";
|
||||||
import { parseAgentManifest, type AgentManifest } from "./manifest.js";
|
import { parseAgentManifest, type AgentManifest } from "./manifest.js";
|
||||||
|
import type { DomainEventEmission } from "./domain-events.js";
|
||||||
import { AgentManager } from "./manager.js";
|
import { AgentManager } from "./manager.js";
|
||||||
import {
|
import {
|
||||||
PersonaRegistry,
|
PersonaRegistry,
|
||||||
@@ -44,6 +45,7 @@ export type OrchestrationSettings = {
|
|||||||
maxDepth: number;
|
maxDepth: number;
|
||||||
maxRetries: number;
|
maxRetries: number;
|
||||||
maxChildren: number;
|
maxChildren: number;
|
||||||
|
mergeConflictMaxAttempts: number;
|
||||||
securityViolationHandling: "hard_abort" | "validation_fail";
|
securityViolationHandling: "hard_abort" | "validation_fail";
|
||||||
runtimeContext: Record<string, string | number | boolean>;
|
runtimeContext: Record<string, string | number | boolean>;
|
||||||
};
|
};
|
||||||
@@ -62,6 +64,7 @@ export function loadOrchestrationSettingsFromEnv(
|
|||||||
maxDepth: config.orchestration.maxDepth,
|
maxDepth: config.orchestration.maxDepth,
|
||||||
maxRetries: config.orchestration.maxRetries,
|
maxRetries: config.orchestration.maxRetries,
|
||||||
maxChildren: config.orchestration.maxChildren,
|
maxChildren: config.orchestration.maxChildren,
|
||||||
|
mergeConflictMaxAttempts: config.orchestration.mergeConflictMaxAttempts,
|
||||||
securityViolationHandling: config.security.violationHandling,
|
securityViolationHandling: config.security.violationHandling,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -241,21 +244,43 @@ function readTaskIdFromPayload(payload: JsonObject, fallback: string): string {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toTaskStatusForFailure(resultStatus: "validation_fail" | "failure"): ProjectTaskStatus {
|
function toTaskStatusForFailure(
|
||||||
|
resultStatus: "validation_fail" | "failure",
|
||||||
|
statusAtStart: string,
|
||||||
|
): ProjectTaskStatus {
|
||||||
if (resultStatus === "failure") {
|
if (resultStatus === "failure") {
|
||||||
return "failed";
|
return "failed";
|
||||||
}
|
}
|
||||||
|
if (statusAtStart === "conflict" || statusAtStart === "resolving_conflict") {
|
||||||
|
return "conflict";
|
||||||
|
}
|
||||||
return "in_progress";
|
return "in_progress";
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldMergeFromStatus(statusAtStart: string): boolean {
|
function shouldMergeFromStatus(statusAtStart: string): boolean {
|
||||||
return statusAtStart === "review";
|
return statusAtStart === "review" || statusAtStart === "resolving_conflict";
|
||||||
}
|
}
|
||||||
|
|
||||||
function toTaskIdLabel(task: ProjectTask): string {
|
function toTaskIdLabel(task: ProjectTask): string {
|
||||||
return task.taskId || task.id || "task";
|
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 {
|
export class SchemaDrivenExecutionEngine {
|
||||||
private readonly manifest: AgentManifest;
|
private readonly manifest: AgentManifest;
|
||||||
private readonly personaRegistry = new PersonaRegistry();
|
private readonly personaRegistry = new PersonaRegistry();
|
||||||
@@ -296,6 +321,8 @@ export class SchemaDrivenExecutionEngine {
|
|||||||
maxDepth: input.settings?.maxDepth ?? config.orchestration.maxDepth,
|
maxDepth: input.settings?.maxDepth ?? config.orchestration.maxDepth,
|
||||||
maxRetries: input.settings?.maxRetries ?? config.orchestration.maxRetries,
|
maxRetries: input.settings?.maxRetries ?? config.orchestration.maxRetries,
|
||||||
maxChildren: input.settings?.maxChildren ?? config.orchestration.maxChildren,
|
maxChildren: input.settings?.maxChildren ?? config.orchestration.maxChildren,
|
||||||
|
mergeConflictMaxAttempts:
|
||||||
|
input.settings?.mergeConflictMaxAttempts ?? config.orchestration.mergeConflictMaxAttempts,
|
||||||
securityViolationHandling:
|
securityViolationHandling:
|
||||||
input.settings?.securityViolationHandling ?? config.security.violationHandling,
|
input.settings?.securityViolationHandling ?? config.security.violationHandling,
|
||||||
runtimeContext: {
|
runtimeContext: {
|
||||||
@@ -480,7 +507,11 @@ export class SchemaDrivenExecutionEngine {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const statusAtStart: ProjectTaskStatus =
|
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({
|
await input.projectContextStore.patchState({
|
||||||
upsertTasks: [
|
upsertTasks: [
|
||||||
@@ -490,6 +521,7 @@ export class SchemaDrivenExecutionEngine {
|
|||||||
status: statusAtStart,
|
status: statusAtStart,
|
||||||
worktreePath: ensured.taskWorktreePath,
|
worktreePath: ensured.taskWorktreePath,
|
||||||
...(existing?.title ? { title: existing.title } : { title: taskId }),
|
...(existing?.title ? { title: existing.title } : { title: taskId }),
|
||||||
|
...(existing?.metadata ? { metadata: existing.metadata } : {}),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -498,44 +530,267 @@ export class SchemaDrivenExecutionEngine {
|
|||||||
taskId,
|
taskId,
|
||||||
worktreePath: ensured.taskWorktreePath,
|
worktreePath: ensured.taskWorktreePath,
|
||||||
statusAtStart,
|
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") {
|
if (result.status === "failure" || result.status === "validation_fail") {
|
||||||
await input.projectContextStore.patchState({
|
await input.projectContextStore.patchState({
|
||||||
upsertTasks: [
|
upsertTasks: [
|
||||||
{
|
{
|
||||||
taskId: task.taskId,
|
taskId: task.taskId,
|
||||||
id: task.taskId,
|
id: task.taskId,
|
||||||
status: toTaskStatusForFailure(result.status),
|
status: toTaskStatusForFailure(result.status, task.statusAtStart),
|
||||||
worktreePath: task.worktreePath,
|
worktreePath: task.worktreePath,
|
||||||
title: task.taskId,
|
title: task.taskId,
|
||||||
|
...(task.metadata ? { metadata: task.metadata } : {}),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
return;
|
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)) {
|
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,
|
taskId: task.taskId,
|
||||||
baseWorkspacePath: input.session.baseWorkspacePath,
|
baseWorkspacePath: input.session.baseWorkspacePath,
|
||||||
taskWorktreePath: task.worktreePath,
|
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({
|
await input.projectContextStore.patchState({
|
||||||
upsertTasks: [
|
upsertTasks: [
|
||||||
{
|
{
|
||||||
taskId: task.taskId,
|
taskId: task.taskId,
|
||||||
id: task.taskId,
|
id: task.taskId,
|
||||||
status: "merged",
|
status: "failed",
|
||||||
|
worktreePath: task.worktreePath,
|
||||||
title: task.taskId,
|
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({
|
await input.projectContextStore.patchState({
|
||||||
upsertTasks: [
|
upsertTasks: [
|
||||||
{
|
{
|
||||||
@@ -544,9 +799,18 @@ export class SchemaDrivenExecutionEngine {
|
|||||||
status: "review",
|
status: "review",
|
||||||
worktreePath: task.worktreePath,
|
worktreePath: task.worktreePath,
|
||||||
title: task.taskId,
|
title: task.taskId,
|
||||||
|
...(nextMetadata ? { metadata: nextMetadata } : {}),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (additionalEvents.length > 0) {
|
||||||
|
return {
|
||||||
|
additionalEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ export type TaskExecutionResolution = {
|
|||||||
taskId: string;
|
taskId: string;
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
statusAtStart: string;
|
statusAtStart: string;
|
||||||
|
metadata?: JsonObject;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TaskExecutionLifecycle = {
|
export type TaskExecutionLifecycle = {
|
||||||
@@ -185,7 +186,13 @@ export type TaskExecutionLifecycle = {
|
|||||||
task: TaskExecutionResolution;
|
task: TaskExecutionResolution;
|
||||||
result: ActorExecutionResult;
|
result: ActorExecutionResult;
|
||||||
domainEvents: DomainEvent[];
|
domainEvents: DomainEvent[];
|
||||||
}) => Promise<void>;
|
}) => Promise<
|
||||||
|
| void
|
||||||
|
| {
|
||||||
|
additionalEvents?: DomainEventEmission[];
|
||||||
|
handoffPayloadPatch?: JsonObject;
|
||||||
|
}
|
||||||
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type QueueItem = {
|
type QueueItem = {
|
||||||
@@ -921,7 +928,7 @@ export class PipelineExecutor {
|
|||||||
customEvents: result.events,
|
customEvents: result.events,
|
||||||
});
|
});
|
||||||
const topologyKind: NodeTopologyKind = node.topology?.kind ?? "sequential";
|
const topologyKind: NodeTopologyKind = node.topology?.kind ?? "sequential";
|
||||||
const payloadForNext = {
|
let payloadForNext: JsonObject = {
|
||||||
...context.handoff.payload,
|
...context.handoff.payload,
|
||||||
...(result.payload ?? {}),
|
...(result.payload ?? {}),
|
||||||
...(taskResolution
|
...(taskResolution
|
||||||
@@ -936,6 +943,34 @@ export class PipelineExecutor {
|
|||||||
this.shouldRetryValidation(node) &&
|
this.shouldRetryValidation(node) &&
|
||||||
attempt <= maxRetriesForNode;
|
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({
|
await this.lifecycleObserver.onNodeAttempt({
|
||||||
sessionId,
|
sessionId,
|
||||||
node,
|
node,
|
||||||
@@ -948,16 +983,6 @@ export class PipelineExecutor {
|
|||||||
topologyKind,
|
topologyKind,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (taskResolution && this.options.taskLifecycle) {
|
|
||||||
await this.options.taskLifecycle.finalizeTaskExecution({
|
|
||||||
sessionId,
|
|
||||||
node,
|
|
||||||
task: taskResolution,
|
|
||||||
result,
|
|
||||||
domainEvents,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const emittedEventTypes = domainEvents.map((event) => event.type);
|
const emittedEventTypes = domainEvents.map((event) => event.type);
|
||||||
nodeRecords.push({
|
nodeRecords.push({
|
||||||
nodeId: node.id,
|
nodeId: node.id,
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export type ProjectTaskStatus =
|
|||||||
| "pending"
|
| "pending"
|
||||||
| "in_progress"
|
| "in_progress"
|
||||||
| "review"
|
| "review"
|
||||||
|
| "conflict"
|
||||||
|
| "resolving_conflict"
|
||||||
| "merged"
|
| "merged"
|
||||||
| "failed"
|
| "failed"
|
||||||
| "blocked"
|
| "blocked"
|
||||||
@@ -65,6 +67,8 @@ function toTaskStatus(value: unknown, label: string): ProjectTaskStatus {
|
|||||||
value === "pending" ||
|
value === "pending" ||
|
||||||
value === "in_progress" ||
|
value === "in_progress" ||
|
||||||
value === "review" ||
|
value === "review" ||
|
||||||
|
value === "conflict" ||
|
||||||
|
value === "resolving_conflict" ||
|
||||||
value === "merged" ||
|
value === "merged" ||
|
||||||
value === "failed" ||
|
value === "failed" ||
|
||||||
value === "blocked" ||
|
value === "blocked" ||
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const execFileAsync = promisify(execFile);
|
|||||||
|
|
||||||
const SESSION_METADATA_FILE_NAME = "session-metadata.json";
|
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 = {
|
export type SessionMetadata = {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -24,6 +24,58 @@ export type CreateSessionRequest = {
|
|||||||
projectPath: string;
|
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 {
|
function toErrorMessage(error: unknown): string {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
return error.message;
|
return error.message;
|
||||||
@@ -46,7 +98,12 @@ function assertNonEmptyString(value: unknown, label: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toSessionStatus(value: unknown): SessionStatus {
|
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;
|
return value;
|
||||||
}
|
}
|
||||||
throw new Error(`Session status "${String(value)}" is not supported.`);
|
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> {
|
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 {
|
try {
|
||||||
const { stdout } = await execFileAsync("git", args, {
|
const { stdout, stderr } = await execFileAsync("git", args, {
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
});
|
});
|
||||||
return stdout.trim();
|
return {
|
||||||
|
exitCode: 0,
|
||||||
|
stdout: stdout.trim(),
|
||||||
|
stderr: stderr.trim(),
|
||||||
|
};
|
||||||
} catch (error) {
|
} 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)}`);
|
throw new Error(`git ${args.join(" ")} failed: ${toErrorMessage(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,6 +186,18 @@ function sanitizeSegment(value: string, fallback: string): string {
|
|||||||
return normalized || fallback;
|
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 {
|
export class FileSystemSessionMetadataStore {
|
||||||
private readonly stateRoot: string;
|
private readonly stateRoot: string;
|
||||||
|
|
||||||
@@ -306,58 +399,227 @@ export class SessionWorktreeManager {
|
|||||||
taskId: string;
|
taskId: string;
|
||||||
baseWorkspacePath: string;
|
baseWorkspacePath: string;
|
||||||
taskWorktreePath: string;
|
taskWorktreePath: string;
|
||||||
}): Promise<void> {
|
}): Promise<MergeTaskIntoBaseOutcome> {
|
||||||
const baseWorkspacePath = assertAbsolutePath(input.baseWorkspacePath, "baseWorkspacePath");
|
const baseWorkspacePath = assertAbsolutePath(input.baseWorkspacePath, "baseWorkspacePath");
|
||||||
const taskWorktreePath = assertAbsolutePath(input.taskWorktreePath, "taskWorktreePath");
|
const taskWorktreePath = assertAbsolutePath(input.taskWorktreePath, "taskWorktreePath");
|
||||||
|
const taskId = input.taskId;
|
||||||
|
|
||||||
await runGit(["-C", taskWorktreePath, "add", "-A"]);
|
if (!(await pathExists(baseWorkspacePath))) {
|
||||||
const hasPending = await this.hasStagedChanges(taskWorktreePath);
|
throw new Error(`Base workspace "${baseWorkspacePath}" does not exist.`);
|
||||||
if (hasPending) {
|
}
|
||||||
await runGit([
|
if (!(await pathExists(taskWorktreePath))) {
|
||||||
"-C",
|
throw new Error(`Task worktree "${taskWorktreePath}" does not exist.`);
|
||||||
taskWorktreePath,
|
|
||||||
"commit",
|
|
||||||
"-m",
|
|
||||||
`ai_ops: finalize task ${input.taskId}`,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const branchName = await runGit(["-C", taskWorktreePath, "rev-parse", "--abbrev-ref", "HEAD"]);
|
let mergeBase: string | undefined;
|
||||||
await runGit(["-C", baseWorkspacePath, "merge", "--no-ff", "--no-edit", branchName]);
|
try {
|
||||||
await this.removeWorktree({
|
await runGit(["-C", taskWorktreePath, "add", "-A"]);
|
||||||
repoPath: baseWorkspacePath,
|
const hasPending = await this.hasStagedChanges(taskWorktreePath);
|
||||||
worktreePath: taskWorktreePath,
|
if (hasPending) {
|
||||||
});
|
await runGit(["-C", taskWorktreePath, "commit", "-m", `ai_ops: finalize task ${taskId}`]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async closeSession(input: {
|
const branchName = await runGit(["-C", taskWorktreePath, "rev-parse", "--abbrev-ref", "HEAD"]);
|
||||||
session: SessionMetadata;
|
const baseBranch = await runGit(["-C", baseWorkspacePath, "rev-parse", "--abbrev-ref", "HEAD"]);
|
||||||
taskWorktreePaths: string[];
|
mergeBase = await this.tryReadMergeBase(baseWorkspacePath, baseBranch, branchName);
|
||||||
mergeBaseIntoProject?: boolean;
|
|
||||||
}): Promise<void> {
|
|
||||||
const projectPath = assertAbsolutePath(input.session.projectPath, "projectPath");
|
|
||||||
const baseWorkspacePath = assertAbsolutePath(input.session.baseWorkspacePath, "baseWorkspacePath");
|
|
||||||
|
|
||||||
for (const taskWorktreePath of input.taskWorktreePaths) {
|
if (await this.hasOngoingMerge(taskWorktreePath)) {
|
||||||
if (!taskWorktreePath.trim()) {
|
return {
|
||||||
continue;
|
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({
|
await this.removeWorktree({
|
||||||
repoPath: baseWorkspacePath,
|
repoPath: baseWorkspacePath,
|
||||||
worktreePath: taskWorktreePath,
|
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) {
|
let baseBranch: string | undefined;
|
||||||
const baseBranch = await runGit(["-C", baseWorkspacePath, "rev-parse", "--abbrev-ref", "HEAD"]);
|
let mergeBase: string | undefined;
|
||||||
await runGit(["-C", projectPath, "merge", "--no-ff", "--no-edit", baseBranch]);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.removeWorktree({
|
try {
|
||||||
repoPath: projectPath,
|
for (const taskWorktreePath of input.taskWorktreePaths) {
|
||||||
worktreePath: baseWorkspacePath,
|
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: {
|
private async removeWorktree(input: {
|
||||||
@@ -386,4 +648,43 @@ export class SessionWorktreeManager {
|
|||||||
throw new Error(`Unable to inspect staged changes: ${toErrorMessage(error)}`);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export type OrchestrationRuntimeConfig = {
|
|||||||
maxDepth: number;
|
maxDepth: number;
|
||||||
maxRetries: number;
|
maxRetries: number;
|
||||||
maxChildren: number;
|
maxChildren: number;
|
||||||
|
mergeConflictMaxAttempts: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DiscoveryRuntimeConfig = {
|
export type DiscoveryRuntimeConfig = {
|
||||||
@@ -77,6 +78,7 @@ const DEFAULT_ORCHESTRATION: OrchestrationRuntimeConfig = {
|
|||||||
maxDepth: 4,
|
maxDepth: 4,
|
||||||
maxRetries: 2,
|
maxRetries: 2,
|
||||||
maxChildren: 4,
|
maxChildren: 4,
|
||||||
|
mergeConflictMaxAttempts: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_PROVISIONING: BuiltInProvisioningConfig = {
|
const DEFAULT_PROVISIONING: BuiltInProvisioningConfig = {
|
||||||
@@ -411,6 +413,12 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Readonly<AppCo
|
|||||||
DEFAULT_ORCHESTRATION.maxChildren,
|
DEFAULT_ORCHESTRATION.maxChildren,
|
||||||
{ min: 1 },
|
{ min: 1 },
|
||||||
),
|
),
|
||||||
|
mergeConflictMaxAttempts: readIntegerWithBounds(
|
||||||
|
env,
|
||||||
|
"AGENT_MERGE_CONFLICT_MAX_ATTEMPTS",
|
||||||
|
DEFAULT_ORCHESTRATION.mergeConflictMaxAttempts,
|
||||||
|
{ min: 1 },
|
||||||
|
),
|
||||||
},
|
},
|
||||||
provisioning: {
|
provisioning: {
|
||||||
gitWorktree: {
|
gitWorktree: {
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ function buildActorPrompt(input: ActorExecutionInput): string {
|
|||||||
},
|
},
|
||||||
events: [
|
events: [
|
||||||
{
|
{
|
||||||
type: "requirements_defined | tasks_planned | code_committed | task_ready_for_review | task_blocked | validation_passed | validation_failed | branch_merged",
|
type: "requirements_defined | tasks_planned | code_committed | task_ready_for_review | task_blocked | validation_passed | validation_failed | branch_merged | merge_conflict_detected | merge_conflict_resolved | merge_conflict_unresolved | merge_retry_started",
|
||||||
payload: {
|
payload: {
|
||||||
summary: "optional",
|
summary: "optional",
|
||||||
details: {},
|
details: {},
|
||||||
|
|||||||
@@ -117,6 +117,10 @@ const MANIFEST_EVENT_TRIGGERS = [
|
|||||||
"validation_passed",
|
"validation_passed",
|
||||||
"validation_failed",
|
"validation_failed",
|
||||||
"branch_merged",
|
"branch_merged",
|
||||||
|
"merge_conflict_detected",
|
||||||
|
"merge_conflict_resolved",
|
||||||
|
"merge_conflict_unresolved",
|
||||||
|
"merge_retry_started",
|
||||||
];
|
];
|
||||||
|
|
||||||
const RUN_MANIFEST_EDITOR_VALUE = "__editor__";
|
const RUN_MANIFEST_EDITOR_VALUE = "__editor__";
|
||||||
|
|||||||
@@ -413,12 +413,22 @@ export class UiRunService {
|
|||||||
.map((task) => task.worktreePath)
|
.map((task) => task.worktreePath)
|
||||||
.filter((path): path is string => typeof path === "string" && path.trim().length > 0);
|
.filter((path): path is string => typeof path === "string" && path.trim().length > 0);
|
||||||
|
|
||||||
await runtime.worktreeManager.closeSession({
|
const outcome = await runtime.worktreeManager.closeSession({
|
||||||
session,
|
session,
|
||||||
taskWorktreePaths,
|
taskWorktreePaths,
|
||||||
mergeBaseIntoProject: input.mergeToProject === true,
|
mergeBaseIntoProject: input.mergeToProject === true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (outcome.kind === "fatal_error") {
|
||||||
|
throw new Error(`Session close failed: ${outcome.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outcome.kind === "conflict") {
|
||||||
|
return runtime.sessionStore.updateSession(session.sessionId, {
|
||||||
|
sessionStatus: "closed_with_conflicts",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return runtime.sessionStore.updateSession(session.sessionId, {
|
return runtime.sessionStore.updateSession(session.sessionId, {
|
||||||
sessionStatus: "closed",
|
sessionStatus: "closed",
|
||||||
});
|
});
|
||||||
@@ -448,7 +458,10 @@ export class UiRunService {
|
|||||||
if (input.sessionId?.trim() && !session) {
|
if (input.sessionId?.trim() && !session) {
|
||||||
throw new Error(`Session \"${sessionId}\" does not exist.`);
|
throw new Error(`Session \"${sessionId}\" does not exist.`);
|
||||||
}
|
}
|
||||||
if (session && session.sessionStatus === "closed") {
|
if (
|
||||||
|
session &&
|
||||||
|
(session.sessionStatus === "closed" || session.sessionStatus === "closed_with_conflicts")
|
||||||
|
) {
|
||||||
throw new Error(`Session \"${sessionId}\" is closed and cannot run new tasks.`);
|
throw new Error(`Session \"${sessionId}\" is closed and cannot run new tasks.`);
|
||||||
}
|
}
|
||||||
const runId = randomUUID();
|
const runId = randomUUID();
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ test("loads defaults and freezes config", () => {
|
|||||||
|
|
||||||
assert.equal(config.agentManager.maxConcurrentAgents, 4);
|
assert.equal(config.agentManager.maxConcurrentAgents, 4);
|
||||||
assert.equal(config.orchestration.maxDepth, 4);
|
assert.equal(config.orchestration.maxDepth, 4);
|
||||||
|
assert.equal(config.orchestration.mergeConflictMaxAttempts, 2);
|
||||||
assert.equal(config.provisioning.portRange.basePort, 36000);
|
assert.equal(config.provisioning.portRange.basePort, 36000);
|
||||||
assert.equal(config.discovery.fileRelativePath, ".agent-context/resources.json");
|
assert.equal(config.discovery.fileRelativePath, ".agent-context/resources.json");
|
||||||
assert.equal(config.security.violationHandling, "hard_abort");
|
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/,
|
/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);
|
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 () => {
|
test("project context parser merges missing root keys with defaults", async () => {
|
||||||
const root = await mkdtemp(resolve(tmpdir(), "ai-ops-project-context-"));
|
const root = await mkdtemp(resolve(tmpdir(), "ai-ops-project-context-"));
|
||||||
const filePath = resolve(root, "project-context.json");
|
const filePath = resolve(root, "project-context.json");
|
||||||
|
|||||||
@@ -184,3 +184,54 @@ test("run service creates, runs, and closes explicit sessions", async () => {
|
|||||||
code: "ENOENT",
|
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");
|
const readBack = await store.readSession("session-abc");
|
||||||
assert.equal(readBack?.sessionStatus, "closed");
|
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 () => {
|
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 writeFile(resolve(taskWorktreePath, "feature.txt"), "task output\n", "utf8");
|
||||||
|
|
||||||
await manager.mergeTaskIntoBase({
|
const mergeOutcome = await manager.mergeTaskIntoBase({
|
||||||
taskId: "task-1",
|
taskId: "task-1",
|
||||||
baseWorkspacePath,
|
baseWorkspacePath,
|
||||||
taskWorktreePath,
|
taskWorktreePath,
|
||||||
});
|
});
|
||||||
|
assert.equal(mergeOutcome.kind, "success");
|
||||||
|
|
||||||
const mergedFile = await readFile(resolve(baseWorkspacePath, "feature.txt"), "utf8");
|
const mergedFile = await readFile(resolve(baseWorkspacePath, "feature.txt"), "utf8");
|
||||||
assert.equal(mergedFile, "task output\n");
|
assert.equal(mergedFile, "task output\n");
|
||||||
@@ -104,13 +110,70 @@ test("session worktree manager provisions and merges task worktrees", async () =
|
|||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
await manager.closeSession({
|
const closeOutcome = await manager.closeSession({
|
||||||
session,
|
session,
|
||||||
taskWorktreePaths: [],
|
taskWorktreePaths: [],
|
||||||
mergeBaseIntoProject: false,
|
mergeBaseIntoProject: false,
|
||||||
});
|
});
|
||||||
|
assert.equal(closeOutcome.kind, "success");
|
||||||
|
|
||||||
await assert.rejects(() => stat(baseWorkspacePath), {
|
await assert.rejects(() => stat(baseWorkspacePath), {
|
||||||
code: "ENOENT",
|
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