Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,19 @@ Tier 4: Pause (human intervention required)

`NewConflictResolver` signature: `(senior llm.Client, seniorModel string, techLead llm.Client, techLeadModel string, maxTokens int, projStore state.ProjectionStore, es state.EventStore)`. Pass `nil, ""` for `techLead/techLeadModel` to disable escalation (senior-only mode, used in tests).

#### Deterministic resolution tiers (no LLM) + commentary fallback (2026-06-25)

Before any LLM call, conflicts are resolved deterministically where a correct rule exists — and crucially, the LLM is given a **non-abortive fallback** so a single un-mergeable file can never thrash a story through every escalation tier forever (the root cause of clipforge/pulsereview hanging for hours on `package.json`/`vitest.config.ts` with *"tech lead returned commentary, not file content"*).

Resolution order per conflicted file (`RebaseWithResolution` loop):
1. **Generated lock files** (`isGeneratedLockFile`: package-lock.json, go.sum, Cargo.lock, …) → `git checkout --ours` (regenerated by the build).
2. **Line-oriented configs** (`isUnionMergeableConfig`: .gitignore, .dockerignore, …) → `unionResolveConflict` (union of both sides' lines).
3. **JSON configs** (`isStructuredJSONMergeable`: package.json, tsconfig*.json, jsconfig.json, composer.json, .eslintrc.json, …; lock files excluded) → `structuralJSONMerge`: pulls the two sides from the index via `git.ConflictSides` (`:2:`=ours/base, `:3:`=theirs/story) and **deep-unions the objects** (`deepMergeJSON`) so BOTH sides' dependencies/scripts/compilerOptions survive; scalar/array/type-mismatch positions take theirs (the story side). Invalid-JSON on either side falls through to the LLM. This is the deterministic fix for the file class that deadlocked the LLM.
4. **Senior → Tech-Lead LLM** for everything else.
5. **Deterministic `--theirs` fallback** (`git.CheckoutTheirs`): when the LLM returns commentary or leaves conflict markers — surfaced as the sentinel `errUnmergeable` (wrapped by `resolveFile`/`resolveFileTechLead`) — the resolver keeps the story-branch version and continues instead of aborting. The pre-merge QA gate + post-merge integration build validate the result. **API/transport errors (`IsFatalAPIError`, `IsCapacityError`, exhausted/transient client) are NOT `errUnmergeable`** → they still abort/pause so a retry or `vxd resume` can produce a correct merge (never silently take a side under a transient outage). Emits `deterministic_fallback_theirs` / `structural_json_merge_deterministic` escalation events.

Rebase ours/theirs semantics (pinned by `TestConflictSides_OursIsBaseTheirsIsStory`): `git rebase <upstream>` runs from the story worktree, so during a conflict **ours = the base/upstream, theirs = the story commit being replayed**. Keeping the story's work = `--theirs`. Tests: `conflict_json_merge_test.go`, `conflict_sides_test.go` (git pkg), `conflict_fallback_test.go` (commentary→fallback succeeds; generic LLM error still aborts).

### Config (vxd.yaml)
```yaml
workspace:
Expand Down
86 changes: 86 additions & 0 deletions internal/cli/resume_orphan_recovery_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package cli

import (
"os"
"path/filepath"
"testing"

"github.com/tzone85/vortex-dispatch/internal/state"
)

func TestIsRecoverableStalledStatus(t *testing.T) {
recoverable := []string{"in_progress", "review", "qa"}
for _, s := range recoverable {
if !isRecoverableStalledStatus(s) {
t.Errorf("status %q should be recoverable", s)
}
}
notRecoverable := []string{"draft", "merged", "split", "awaiting_approval", "pr_submitted", ""}
for _, s := range notRecoverable {
if isRecoverableStalledStatus(s) {
t.Errorf("status %q should NOT be recoverable", s)
}
}
}

// A story stranded in "review" (agent emitted STORY_COMPLETED, then the monitor
// was killed before review→QA→merge) with a worktree of committed work must be
// recovered so an interrupted build resumes instead of stalling forever. This
// guards the transient-failure recovery fix (#103), which shipped without a test.
func TestRecoverOrphanedStories_RecoversReviewAndQAStalls(t *testing.T) {
dir := t.TempDir()
ps, err := state.NewSQLiteStore(filepath.Join(dir, "vxd.db"))
if err != nil {
t.Fatalf("create store: %v", err)
}
defer ps.Close()

cfg := defaultConfig()
cfg.Workspace.StateDir = dir

for _, id := range []string{"s-review", "s-qa", "s-draft"} {
if err := os.MkdirAll(filepath.Join(dir, "worktrees", id), 0o755); err != nil {
t.Fatal(err)
}
}

stories := []state.Story{
{ID: "s-review", Status: "review"},
{ID: "s-qa", Status: "qa"},
{ID: "s-draft", Status: "draft"}, // dispatchable, not an orphan
}

orphans := recoverOrphanedStories(stories, ps, cfg)
got := map[string]bool{}
for _, o := range orphans {
got[o.Assignment.StoryID] = true
}
if !got["s-review"] || !got["s-qa"] {
t.Fatalf("expected review+qa stalls recovered, got %v", got)
}
if got["s-draft"] {
t.Fatal("a draft (dispatchable) story must not be recovered as an orphan")
}
if len(orphans) != 2 {
t.Fatalf("expected exactly 2 orphans, got %d", len(orphans))
}
}

// A post-agent-state story with NO worktree (work lost) cannot be recovered —
// there is nothing to re-route through post-execution.
func TestRecoverOrphanedStories_ReviewWithoutWorktreeSkipped(t *testing.T) {
dir := t.TempDir()
ps, err := state.NewSQLiteStore(filepath.Join(dir, "vxd.db"))
if err != nil {
t.Fatalf("create store: %v", err)
}
defer ps.Close()

cfg := defaultConfig()
cfg.Workspace.StateDir = dir

stories := []state.Story{{ID: "s-review", Status: "review"}}
if orphans := recoverOrphanedStories(stories, ps, cfg); len(orphans) != 0 {
t.Fatalf("expected no orphans without a worktree, got %d", len(orphans))
}
}
53 changes: 53 additions & 0 deletions internal/engine/conflict_fallback_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package engine

import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/tzone85/vortex-dispatch/internal/llm"
)

// When the LLM returns conversational COMMENTARY instead of merged file content,
// the resolver must NOT abort and thrash the story through every escalation tier
// forever (the clipforge/pulsereview failure). Instead it deterministically
// keeps the story-branch version (--theirs) and the rebase completes. The
// pre-merge QA gate validates the result downstream.
func TestRebaseWithResolution_CommentaryFallsBackToStoryVersion(t *testing.T) {
_, worktreeDir := setupDivergentRepos(t, true)

// Every resolver call returns commentary (a known chatter marker), which
// classifies as errUnmergeable → deterministic fallback, not abort.
commentary := llm.CompletionResponse{Content: "Conflict resolved. Kept both sides merged."}
client := llm.NewReplayClient(commentary, commentary, commentary, commentary)

cr := NewConflictResolver(client, "test-model", nil, "", 4096, nil, nil)

err := cr.RebaseWithResolution(context.Background(), "s-commentary", worktreeDir, "origin/main")
if err != nil {
t.Fatalf("expected rebase to succeed via deterministic fallback, got: %v", err)
}

// Rebase must be finished, not left in progress.
status := exec.Command("git", "status")
status.Dir = worktreeDir
out, _ := status.CombinedOutput()
if strings.Contains(string(out), "rebase in progress") {
t.Errorf("rebase still in progress after fallback:\n%s", out)
}

// The story-branch (theirs) version must have won.
got, err := os.ReadFile(filepath.Join(worktreeDir, "feature.go"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(got), "feature version") {
t.Errorf("expected story-branch content after fallback, got:\n%s", got)
}
if strings.Contains(string(got), "<<<<<<<") {
t.Errorf("conflict markers left in file:\n%s", got)
}
}
117 changes: 117 additions & 0 deletions internal/engine/conflict_json_merge_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package engine

import (
"encoding/json"
"reflect"
"testing"
)

func TestIsStructuredJSONMergeable(t *testing.T) {
yes := []string{
"package.json", "tsconfig.json", "jsconfig.json",
"app/tsconfig.json", "tsconfig.build.json", "tsconfig.spec.json",
"composer.json", ".eslintrc.json", "nest-cli.json",
}
for _, f := range yes {
if !isStructuredJSONMergeable(f) {
t.Errorf("%q should be structurally JSON-mergeable", f)
}
}
no := []string{
"package-lock.json", // lock file — handled separately, never merged
"composer.lock",
"main.ts", "README.md", ".gitignore", "Cargo.toml", "data.json.txt",
}
for _, f := range no {
if isStructuredJSONMergeable(f) {
t.Errorf("%q should NOT be structurally JSON-mergeable", f)
}
}
}

// The headline case: both sides add different dependencies and scripts to
// package.json. The correct resolution keeps BOTH — this is exactly what the
// LLM resolver kept failing to do (returning commentary), thrashing the story.
func TestStructuralJSONMerge_UnionsDependencies(t *testing.T) {
ours := []byte(`{
"name": "app",
"version": "1.0.0",
"dependencies": { "react": "^18.0.0", "left-pad": "^1.0.0" },
"scripts": { "build": "tsc" }
}`)
theirs := []byte(`{
"name": "app",
"version": "1.1.0",
"dependencies": { "react": "^18.0.0", "zod": "^3.0.0" },
"scripts": { "test": "vitest" }
}`)

out, err := structuralJSONMerge(ours, theirs)
if err != nil {
t.Fatalf("merge: %v", err)
}
var got map[string]any
if err := json.Unmarshal(out, &got); err != nil {
t.Fatalf("merged output is not valid JSON: %v\n%s", err, out)
}

deps := got["dependencies"].(map[string]any)
for _, want := range []string{"react", "left-pad", "zod"} {
if _, ok := deps[want]; !ok {
t.Errorf("merged dependencies missing %q (deps=%v)", want, deps)
}
}
scripts := got["scripts"].(map[string]any)
if _, ok := scripts["build"]; !ok {
t.Error("merged scripts lost ours.build")
}
if _, ok := scripts["test"]; !ok {
t.Error("merged scripts lost theirs.test")
}
// Scalar conflict (version): theirs (story side) wins.
if got["version"] != "1.1.0" {
t.Errorf("version = %v, want theirs 1.1.0", got["version"])
}
}

func TestStructuralJSONMerge_TsconfigCompilerOptions(t *testing.T) {
ours := []byte(`{"compilerOptions":{"strict":true,"outDir":"./dist"}}`)
theirs := []byte(`{"compilerOptions":{"strict":true,"target":"ES2022"}}`)
out, err := structuralJSONMerge(ours, theirs)
if err != nil {
t.Fatalf("merge: %v", err)
}
var got map[string]any
_ = json.Unmarshal(out, &got)
co := got["compilerOptions"].(map[string]any)
if co["outDir"] != "./dist" || co["target"] != "ES2022" || co["strict"] != true {
t.Errorf("compilerOptions not unioned: %v", co)
}
}

func TestStructuralJSONMerge_EmptySideKeepsOther(t *testing.T) {
body := []byte(`{"name":"app"}`)
if out, err := structuralJSONMerge(nil, body); err != nil || string(out) != string(body) {
t.Errorf("empty ours: got %q err %v", out, err)
}
if out, err := structuralJSONMerge(body, []byte(" ")); err != nil || string(out) != string(body) {
t.Errorf("empty theirs: got %q err %v", out, err)
}
}

func TestStructuralJSONMerge_InvalidJSONErrors(t *testing.T) {
if _, err := structuralJSONMerge([]byte(`{"a":1}`), []byte(`not json`)); err == nil {
t.Fatal("expected error when a side is not valid JSON (so caller falls back to LLM)")
}
}

func TestDeepMergeJSON_TheirsWinsOnTypeMismatch(t *testing.T) {
// ours value is an object, theirs is a scalar at the same key → theirs wins.
ours := map[string]any{"x": map[string]any{"deep": true}}
theirs := map[string]any{"x": "scalar"}
got := deepMergeJSON(ours, theirs)
want := map[string]any{"x": "scalar"}
if !reflect.DeepEqual(got, want) {
t.Errorf("deepMergeJSON = %v, want %v", got, want)
}
}
Loading
Loading