From 6cdfc7d3c8b026bbc32cc153bdf547ec2e8e466d Mon Sep 17 00:00:00 2001 From: Umputun Date: Sat, 21 Feb 2026 21:04:14 -0600 Subject: [PATCH 1/7] add plan: fix-dashboard-task-numbering --- ...2026-02-21-fix-dashboard-task-numbering.md | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 docs/plans/2026-02-21-fix-dashboard-task-numbering.md diff --git a/docs/plans/2026-02-21-fix-dashboard-task-numbering.md b/docs/plans/2026-02-21-fix-dashboard-task-numbering.md new file mode 100644 index 00000000..f82d198c --- /dev/null +++ b/docs/plans/2026-02-21-fix-dashboard-task-numbering.md @@ -0,0 +1,159 @@ +# fix web dashboard task numbering on mid-run plan edits + +## Overview + +Web dashboard shows wrong task numbers when plan is edited mid-run or tasks retry (issue #127). The runner uses its loop counter as TaskNum instead of the actual plan task position. The plan parser silently drops non-integer task headers (e.g., "Task 2.5"), and the single-session plan cache is never invalidated. + +**Approach:** use position-based matching (array index) instead of heading number. Everything stays `int` — no new string fields needed on Section, Event, or BroadcastLogger. The existing int pipeline carries task position instead of loop counter. + +## Context + +**root cause:** in `runTaskPhase()`, the loop passes `i` to `NewTaskIterationSection(i)`. BroadcastLogger sets `currentTask = section.Iteration` (loop counter). Dashboard matches `event.task_num` against `plan.tasks[].number` (from markdown heading). When tasks are inserted or retried, loop counter != plan task position. + +**also affected:** task retries — when a task fails and retries, `i` increments but the plan task doesn't change, causing the same off-by-one in dashboard highlighting. + +**assumption:** tasks are completed in order (runner uses single prompt that reads plan each time, Claude picks next uncompleted task sequentially). + +**files involved:** +- `pkg/web/plan.go` - plan parsing types + regex, needs to move to `pkg/plan/` +- `pkg/processor/runner.go` - loop counter, `hasUncompletedTasks()` (already reads plan file) +- `pkg/web/server.go` - `planCache *Plan`, never invalidated +- `pkg/web/static/app.js` - `getTaskTitle(taskNum)`, `updatePlanTaskStatus`, `handleTaskStart` + +**existing patterns:** +- `hasUncompletedTasks()` in runner already reads plan file each iteration (raw string scan) +- `ParsePlan()` in web does proper parsing with regex + checkbox status detection +- `determineTaskStatus(checkboxes)` returns pending/active/done/failed + +## Development Approach + +- **testing approach**: regular (code first, then tests) +- complete each task fully before moving to the next +- direct imports (no type aliases after move) +- **CRITICAL: every task MUST include new/updated tests** +- **CRITICAL: all tests must pass before starting next task** +- **CRITICAL: update this plan file when scope changes during implementation** + +## Progress Tracking + +- mark completed items with `[x]` immediately when done +- add newly discovered tasks with ➕ prefix +- document issues/blockers with ⚠️ prefix + +## Implementation Steps + +### Task 1: Move plan parsing to `pkg/plan/` + +**Files:** +- Create: `pkg/plan/parse.go` +- Create: `pkg/plan/parse_test.go` +- Modify: `pkg/web/plan.go` (remove types/funcs, keep only web-specific helpers if any) +- Modify: all `pkg/web/` files that reference `Plan`, `Task`, `TaskStatus`, `Checkbox` types + +- [ ] move `ParsePlan`, `ParsePlanFile`, `Plan`, `Task`, `Checkbox`, `TaskStatus`, constants, `determineTaskStatus`, `parseTaskNum` from `pkg/web/plan.go` to `pkg/plan/parse.go` +- [ ] move `JSON()` method to `pkg/plan/parse.go` +- [ ] update all `pkg/web/` imports to use `plan.Plan`, `plan.Task`, etc. (direct imports, no aliases) +- [ ] check if `pkg/web/plan.go` still needed; if only `loadPlanWithFallback` remains, keep it as web-specific helper +- [ ] move relevant tests from `pkg/web/plan_test.go` to `pkg/plan/parse_test.go` +- [ ] run `go test ./pkg/plan/ ./pkg/web/...` - must pass + +### Task 2: Widen regex to support non-integer task headers + +**Files:** +- Modify: `pkg/plan/parse.go` +- Modify: `pkg/plan/parse_test.go` + +- [ ] widen `taskHeaderPattern` regex from `(\d+)` to `([^:]+?)` with `strings.TrimSpace` +- [ ] update `parseTaskNum` to: try `strconv.Atoi`, on success set `Number = parsed int`; on failure set `Number = 0` +- [ ] add test cases for "Task 2.5:", "Task 2a:", "Task 3:" (backward compat) +- [ ] verify non-integer tasks are parsed (not silently dropped) and appear in `Plan.Tasks` array +- [ ] run `go test ./pkg/plan/` - must pass + +### Task 3: Runner passes plan task position instead of loop counter + +**Files:** +- Modify: `pkg/processor/runner.go` +- Modify: `pkg/processor/runner_test.go` +- Modify: `pkg/processor/export_test.go` (expose new method if needed) + +- [ ] add `nextPlanTaskPosition() int` method to Runner: reads plan file via `plan.ParsePlanFile(r.resolvePlanFilePath())`, finds first task with status != `TaskStatusDone`, returns its 1-indexed position in the tasks array (0 on error = fallback to loop counter) +- [ ] in `runTaskPhase`, before `PrintSection`: call `nextPlanTaskPosition()`; if > 0, use it instead of `i` for `NewTaskIterationSection(pos)` +- [ ] keep `hasUncompletedTasks()` as-is (raw string scan works, refactoring changes semantics - deferred to separate task) +- [ ] write tests for `nextPlanTaskPosition` with mock plan files: normal integer plan, plan with inserted "Task 2.5", missing file, no uncompleted tasks, plan with retried (same task still uncompleted) +- [ ] run `go test ./pkg/processor/` - must pass + +### Task 4: Remove planCache and update frontend matching + +**Files:** +- Modify: `pkg/web/server.go` +- Modify: `pkg/web/server_test.go` (if cache is tested) +- Modify: `pkg/web/static/app.js` + +- [ ] remove `planMu sync.Mutex` and `planCache *Plan` fields from `Server` +- [ ] simplify `loadPlan()` to call `loadPlanWithFallback()` directly (no caching) +- [ ] update/remove any tests that assert cache behavior +- [ ] in `renderPlan`: set `data-task-num` from array index (position `i+1`) instead of `task.number` +- [ ] in `handleTaskStart`: if target task element not found by `data-task-num`, re-fetch plan via `fetch('/api/plan')`, rebuild plan panel, then retry highlight. this handles mid-run plan edits where the frontend has a stale task list +- [ ] in `getTaskTitle`: match by position (iterate `planData.tasks` array, return task at `taskNum - 1` index) +- [ ] run `go test ./pkg/web/...` - must pass + +### Task 5: Verify acceptance criteria + +- [ ] verify: integer-only plans work identically to before (backward compat) +- [ ] verify: old progress files with `"task iteration 3"` replay correctly +- [ ] verify: task retries show correct task highlighting (same task stays highlighted) +- [ ] verify: non-integer task headers ("Task 2.5") are parsed and appear in plan panel +- [ ] run full test suite: `go test ./...` +- [ ] run linter: `make lint` +- [ ] run e2e tests: `go test -tags=e2e -timeout=10m -count=1 -v ./e2e/...` + +### Task 6: Update documentation and complete + +- [ ] update CLAUDE.md if new patterns changed +- [ ] move this plan to `docs/plans/completed/` + +## Technical Details + +**position-based data flow after fix:** +``` +plan file: "### Task 1: ...", "### Task 2: ...", "### Task 2.5: ...", "### Task 3: ..." + → plan.ParsePlanFile() → [Task{pos:0}, Task{pos:1}, Task{pos:2}, Task{pos:3}] + → runner.nextPlanTaskPosition() → 3 (1-indexed, first uncompleted) + → NewTaskIterationSection(3) → Section{Iteration:3, Label:"task iteration 3"} + → broadcast_logger → Event{TaskNum:3} + → frontend: data-task-num="3" on 3rd task element → highlights Task 2.5 ✓ +``` + +**backward compat for integer-only plans:** +``` +plan file: "### Task 1: ...", "### Task 2: ...", "### Task 3: ..." + → [Task{pos:0, Number:1}, Task{pos:1, Number:2}, Task{pos:2, Number:3}] + → position == number for sequential integer plans → identical behavior +``` + +**retry scenario:** +``` +iteration 1: Task 1 runs, completes → position=1 ✓ +iteration 2: Task 2 runs, FAILS → position=2 +iteration 3: Task 2 retries → nextPlanTaskPosition() returns 2 (still uncompleted) → position=2 ✓ + (without fix: loop counter i=3, dashboard would show Task 3) +``` + +**mid-run edit (frontend stale plan):** +``` +frontend has old plan (3 tasks), runner sends task_num=4 (new plan has 4 tasks) + → handleTaskStart: querySelector('[data-task-num="4"]') returns null + → triggers fetch('/api/plan'), rebuilds plan panel with 4 tasks + → retries highlight on position 4 → correct ✓ +``` + +**progress file replay:** progress file section labels (`"task iteration 3"`) are always loop counters. the tailer/session_manager regex stays unchanged. replay of old progress files uses the loop counter as position, which is correct for the plan state at recording time. replay of edited-plan sessions from old progress files may show wrong positions — this is acceptable, replay accuracy for old files is not a goal. + +**what does NOT change:** Section struct, Event struct, BroadcastLogger fields, NewTaskStartEvent/NewTaskEndEvent signatures, tailer regex, session_manager regex. The entire int pipeline stays as-is — only the value fed into it changes. + +## Post-Completion + +**manual verification:** +- toy project e2e: run `scripts/prep-toy-test.sh`, edit plan mid-run (insert Task 1.5), verify dashboard shows correct task highlighting +- verify old progress file replay works with `ralphex --serve` +- verify task retry shows correct highlighting (kill a task, observe retry stays on same plan task) From 22536d81f894f7ad7a702644201204768a3c7f5e Mon Sep 17 00:00:00 2001 From: Umputun Date: Sat, 21 Feb 2026 21:10:13 -0600 Subject: [PATCH 2/7] feat: move plan parsing types and functions to pkg/plan/ package Move ParsePlan, ParsePlanFile, Plan, Task, Checkbox, TaskStatus, determineTaskStatus, parseTaskNum and JSON() from pkg/web/plan.go to pkg/plan/parse.go. Update pkg/web imports to use the new package. Keep loadPlanWithFallback as web-specific helper in pkg/web/plan.go. --- ...2026-02-21-fix-dashboard-task-numbering.md | 12 +- pkg/plan/export_test.go | 4 + pkg/plan/parse.go | 163 ++++++++++++ pkg/plan/parse_test.go | 245 ++++++++++++++++++ pkg/web/plan.go | 165 +----------- pkg/web/plan_test.go | 243 ----------------- pkg/web/server.go | 31 +-- 7 files changed, 442 insertions(+), 421 deletions(-) create mode 100644 pkg/plan/export_test.go create mode 100644 pkg/plan/parse.go create mode 100644 pkg/plan/parse_test.go delete mode 100644 pkg/web/plan_test.go diff --git a/docs/plans/2026-02-21-fix-dashboard-task-numbering.md b/docs/plans/2026-02-21-fix-dashboard-task-numbering.md index f82d198c..28d1255e 100644 --- a/docs/plans/2026-02-21-fix-dashboard-task-numbering.md +++ b/docs/plans/2026-02-21-fix-dashboard-task-numbering.md @@ -50,12 +50,12 @@ Web dashboard shows wrong task numbers when plan is edited mid-run or tasks retr - Modify: `pkg/web/plan.go` (remove types/funcs, keep only web-specific helpers if any) - Modify: all `pkg/web/` files that reference `Plan`, `Task`, `TaskStatus`, `Checkbox` types -- [ ] move `ParsePlan`, `ParsePlanFile`, `Plan`, `Task`, `Checkbox`, `TaskStatus`, constants, `determineTaskStatus`, `parseTaskNum` from `pkg/web/plan.go` to `pkg/plan/parse.go` -- [ ] move `JSON()` method to `pkg/plan/parse.go` -- [ ] update all `pkg/web/` imports to use `plan.Plan`, `plan.Task`, etc. (direct imports, no aliases) -- [ ] check if `pkg/web/plan.go` still needed; if only `loadPlanWithFallback` remains, keep it as web-specific helper -- [ ] move relevant tests from `pkg/web/plan_test.go` to `pkg/plan/parse_test.go` -- [ ] run `go test ./pkg/plan/ ./pkg/web/...` - must pass +- [x] move `ParsePlan`, `ParsePlanFile`, `Plan`, `Task`, `Checkbox`, `TaskStatus`, constants, `determineTaskStatus`, `parseTaskNum` from `pkg/web/plan.go` to `pkg/plan/parse.go` +- [x] move `JSON()` method to `pkg/plan/parse.go` +- [x] update all `pkg/web/` imports to use `plan.Plan`, `plan.Task`, etc. (direct imports, no aliases) +- [x] check if `pkg/web/plan.go` still needed; if only `loadPlanWithFallback` remains, keep it as web-specific helper +- [x] move relevant tests from `pkg/web/plan_test.go` to `pkg/plan/parse_test.go` +- [x] run `go test ./pkg/plan/ ./pkg/web/...` - must pass ### Task 2: Widen regex to support non-integer task headers diff --git a/pkg/plan/export_test.go b/pkg/plan/export_test.go new file mode 100644 index 00000000..6a745550 --- /dev/null +++ b/pkg/plan/export_test.go @@ -0,0 +1,4 @@ +package plan + +// DetermineTaskStatus is exported for testing. +var DetermineTaskStatus = determineTaskStatus diff --git a/pkg/plan/parse.go b/pkg/plan/parse.go new file mode 100644 index 00000000..80a7f0f7 --- /dev/null +++ b/pkg/plan/parse.go @@ -0,0 +1,163 @@ +package plan + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "regexp" + "strconv" + "strings" +) + +// TaskStatus represents the execution status of a task. +type TaskStatus string + +// task status constants. +const ( + TaskStatusPending TaskStatus = "pending" + TaskStatusActive TaskStatus = "active" + TaskStatusDone TaskStatus = "done" + TaskStatusFailed TaskStatus = "failed" +) + +// Checkbox represents a single checkbox item in a task. +type Checkbox struct { + Text string `json:"text"` + Checked bool `json:"checked"` +} + +// Task represents a task section in a plan. +type Task struct { + Number int `json:"number"` + Title string `json:"title"` + Status TaskStatus `json:"status"` + Checkboxes []Checkbox `json:"checkboxes"` +} + +// Plan represents a parsed plan file. +type Plan struct { + Title string `json:"title"` + Tasks []Task `json:"tasks"` +} + +// patterns for parsing plan markdown. +var ( + taskHeaderPattern = regexp.MustCompile(`^###\s+(?:Task|Iteration)\s+(\d+):\s*(.*)$`) + checkboxPattern = regexp.MustCompile(`^-\s+\[([ xX])\]\s*(.*)$`) + titlePattern = regexp.MustCompile(`^#\s+(.*)$`) +) + +// ParsePlan parses a plan markdown file into a structured Plan. +func ParsePlan(content string) (*Plan, error) { + p := &Plan{ + Tasks: make([]Task, 0), + } + + scanner := bufio.NewScanner(strings.NewReader(content)) + var currentTask *Task + + for scanner.Scan() { + line := scanner.Text() + + // check for plan title (first h1) + if p.Title == "" { + if matches := titlePattern.FindStringSubmatch(line); matches != nil { + p.Title = strings.TrimSpace(matches[1]) + continue + } + } + + // check for task header + if matches := taskHeaderPattern.FindStringSubmatch(line); matches != nil { + // save previous task if exists + if currentTask != nil { + currentTask.Status = determineTaskStatus(currentTask.Checkboxes) + p.Tasks = append(p.Tasks, *currentTask) + } + + taskNum, _ := parseTaskNum(matches[1]) + + currentTask = &Task{ + Number: taskNum, + Title: strings.TrimSpace(matches[2]), + Status: TaskStatusPending, + Checkboxes: make([]Checkbox, 0), + } + continue + } + + // check for checkbox (only if inside a task) + if currentTask != nil { + if matches := checkboxPattern.FindStringSubmatch(line); matches != nil { + checked := matches[1] == "x" || matches[1] == "X" + currentTask.Checkboxes = append(currentTask.Checkboxes, Checkbox{ + Text: strings.TrimSpace(matches[2]), + Checked: checked, + }) + } + } + } + + // save last task + if currentTask != nil { + currentTask.Status = determineTaskStatus(currentTask.Checkboxes) + p.Tasks = append(p.Tasks, *currentTask) + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("scan plan: %w", err) + } + + return p, nil +} + +// ParsePlanFile reads and parses a plan file from disk. +func ParsePlanFile(path string) (*Plan, error) { + content, err := os.ReadFile(path) //nolint:gosec // path comes from server config + if err != nil { + return nil, fmt.Errorf("read plan file: %w", err) + } + return ParsePlan(string(content)) +} + +// JSON returns the plan as JSON bytes. +func (p *Plan) JSON() ([]byte, error) { + data, err := json.Marshal(p) + if err != nil { + return nil, fmt.Errorf("marshal plan: %w", err) + } + return data, nil +} + +// parseTaskNum extracts task number from string. +func parseTaskNum(s string) (int, error) { + n, err := strconv.Atoi(s) + if err != nil { + return 0, fmt.Errorf("parse task number: %w", err) + } + return n, nil +} + +// determineTaskStatus calculates task status based on checkbox states. +func determineTaskStatus(checkboxes []Checkbox) TaskStatus { + if len(checkboxes) == 0 { + return TaskStatusPending + } + + checkedCount := 0 + for _, cb := range checkboxes { + if cb.Checked { + checkedCount++ + } + } + + switch { + case checkedCount == len(checkboxes): + return TaskStatusDone + case checkedCount > 0: + return TaskStatusActive + default: + return TaskStatusPending + } +} diff --git a/pkg/plan/parse_test.go b/pkg/plan/parse_test.go new file mode 100644 index 00000000..ab64f017 --- /dev/null +++ b/pkg/plan/parse_test.go @@ -0,0 +1,245 @@ +package plan_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/umputun/ralphex/pkg/plan" +) + +func TestParsePlan(t *testing.T) { + t.Run("parses plan with title and tasks", func(t *testing.T) { + content := `# My Test Plan + +Some description here. + +### Task 1: First Task + +- [ ] Do something +- [x] Already done +- [ ] Another item + +### Task 2: Second Task + +- [ ] Task 2 item 1 +- [ ] Task 2 item 2 +` + p, err := plan.ParsePlan(content) + require.NoError(t, err) + + assert.Equal(t, "My Test Plan", p.Title) + require.Len(t, p.Tasks, 2) + + // task 1 + assert.Equal(t, 1, p.Tasks[0].Number) + assert.Equal(t, "First Task", p.Tasks[0].Title) + assert.Equal(t, plan.TaskStatusActive, p.Tasks[0].Status) // has mix of checked/unchecked + require.Len(t, p.Tasks[0].Checkboxes, 3) + assert.False(t, p.Tasks[0].Checkboxes[0].Checked) + assert.True(t, p.Tasks[0].Checkboxes[1].Checked) + assert.False(t, p.Tasks[0].Checkboxes[2].Checked) + + // task 2 + assert.Equal(t, 2, p.Tasks[1].Number) + assert.Equal(t, "Second Task", p.Tasks[1].Title) + assert.Equal(t, plan.TaskStatusPending, p.Tasks[1].Status) // all unchecked + }) + + t.Run("parses iteration headers as tasks", func(t *testing.T) { + content := `# Plan + +### Iteration 1: First Iteration + +- [ ] Item 1 + +### Iteration 2: Second Iteration + +- [x] Item 2 +` + p, err := plan.ParsePlan(content) + require.NoError(t, err) + + require.Len(t, p.Tasks, 2) + assert.Equal(t, 1, p.Tasks[0].Number) + assert.Equal(t, "First Iteration", p.Tasks[0].Title) + assert.Equal(t, plan.TaskStatusPending, p.Tasks[0].Status) + + assert.Equal(t, 2, p.Tasks[1].Number) + assert.Equal(t, "Second Iteration", p.Tasks[1].Title) + assert.Equal(t, plan.TaskStatusDone, p.Tasks[1].Status) + }) + + t.Run("parses completed tasks", func(t *testing.T) { + content := `# Plan + +### Task 1: Complete Task + +- [x] Item 1 +- [x] Item 2 +` + p, err := plan.ParsePlan(content) + require.NoError(t, err) + + require.Len(t, p.Tasks, 1) + assert.Equal(t, plan.TaskStatusDone, p.Tasks[0].Status) + }) + + t.Run("parses task with no checkboxes", func(t *testing.T) { + content := `# Plan + +### Task 1: Empty Task + +Just some text, no checkboxes. + +### Task 2: Has Items + +- [ ] One item +` + p, err := plan.ParsePlan(content) + require.NoError(t, err) + + require.Len(t, p.Tasks, 2) + assert.Equal(t, plan.TaskStatusPending, p.Tasks[0].Status) + assert.Empty(t, p.Tasks[0].Checkboxes) + }) + + t.Run("handles uppercase X in checkbox", func(t *testing.T) { + content := `# Plan + +### Task 1: Test + +- [X] Uppercase checked +- [x] Lowercase checked +` + p, err := plan.ParsePlan(content) + require.NoError(t, err) + + require.Len(t, p.Tasks[0].Checkboxes, 2) + assert.True(t, p.Tasks[0].Checkboxes[0].Checked) + assert.True(t, p.Tasks[0].Checkboxes[1].Checked) + }) + + t.Run("handles plan without title", func(t *testing.T) { + content := `### Task 1: No Title Plan + +- [ ] Item +` + p, err := plan.ParsePlan(content) + require.NoError(t, err) + + assert.Empty(t, p.Title) + require.Len(t, p.Tasks, 1) + }) + + t.Run("handles empty content", func(t *testing.T) { + p, err := plan.ParsePlan("") + require.NoError(t, err) + + assert.Empty(t, p.Title) + assert.Empty(t, p.Tasks) + }) + + t.Run("ignores checkboxes outside tasks", func(t *testing.T) { + content := `# Plan + +- [ ] This is outside any task + +### Task 1: First + +- [ ] Inside task +` + p, err := plan.ParsePlan(content) + require.NoError(t, err) + + require.Len(t, p.Tasks, 1) + require.Len(t, p.Tasks[0].Checkboxes, 1) + assert.Equal(t, "Inside task", p.Tasks[0].Checkboxes[0].Text) + }) +} + +func TestParsePlanFile(t *testing.T) { + t.Run("reads and parses file", func(t *testing.T) { + content := `# File Plan + +### Task 1: File Task + +- [ ] File item +` + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "test-plan.md") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + p, err := plan.ParsePlanFile(path) + require.NoError(t, err) + + assert.Equal(t, "File Plan", p.Title) + require.Len(t, p.Tasks, 1) + }) + + t.Run("returns error for missing file", func(t *testing.T) { + _, err := plan.ParsePlanFile("/nonexistent/file.md") + assert.Error(t, err) + }) +} + +func TestPlan_JSON(t *testing.T) { + p := &plan.Plan{ + Title: "Test Plan", + Tasks: []plan.Task{ + { + Number: 1, + Title: "First Task", + Status: plan.TaskStatusPending, + Checkboxes: []plan.Checkbox{ + {Text: "Item 1", Checked: false}, + {Text: "Item 2", Checked: true}, + }, + }, + }, + } + + data, err := p.JSON() + require.NoError(t, err) + + var decoded map[string]any + require.NoError(t, json.Unmarshal(data, &decoded)) + + assert.Equal(t, "Test Plan", decoded["title"]) + tasks := decoded["tasks"].([]any) + require.Len(t, tasks, 1) +} + +func TestDetermineTaskStatus(t *testing.T) { + tests := []struct { + name string + checkboxes []plan.Checkbox + want plan.TaskStatus + }{ + {"empty", nil, plan.TaskStatusPending}, + {"all unchecked", []plan.Checkbox{{Checked: false}, {Checked: false}}, plan.TaskStatusPending}, + {"all checked", []plan.Checkbox{{Checked: true}, {Checked: true}}, plan.TaskStatusDone}, + {"mixed", []plan.Checkbox{{Checked: true}, {Checked: false}}, plan.TaskStatusActive}, + {"single checked", []plan.Checkbox{{Checked: true}}, plan.TaskStatusDone}, + {"single unchecked", []plan.Checkbox{{Checked: false}}, plan.TaskStatusPending}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := plan.DetermineTaskStatus(tt.checkboxes) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestTaskStatus_Constants(t *testing.T) { + // verify status values for API stability + assert.Equal(t, plan.TaskStatusPending, plan.TaskStatus("pending")) + assert.Equal(t, plan.TaskStatusActive, plan.TaskStatus("active")) + assert.Equal(t, plan.TaskStatusDone, plan.TaskStatus("done")) + assert.Equal(t, plan.TaskStatusFailed, plan.TaskStatus("failed")) +} diff --git a/pkg/web/plan.go b/pkg/web/plan.go index 2ab5b467..adeb3d8a 100644 --- a/pkg/web/plan.go +++ b/pkg/web/plan.go @@ -1,163 +1,24 @@ package web import ( - "bufio" - "encoding/json" + "errors" "fmt" - "os" - "regexp" - "strconv" - "strings" -) - -// TaskStatus represents the execution status of a task. -type TaskStatus string - -// task status constants. -const ( - TaskStatusPending TaskStatus = "pending" - TaskStatusActive TaskStatus = "active" - TaskStatusDone TaskStatus = "done" - TaskStatusFailed TaskStatus = "failed" -) - -// Checkbox represents a single checkbox item in a task. -type Checkbox struct { - Text string `json:"text"` - Checked bool `json:"checked"` -} - -// Task represents a task section in a plan. -type Task struct { - Number int `json:"number"` - Title string `json:"title"` - Status TaskStatus `json:"status"` - Checkboxes []Checkbox `json:"checkboxes"` -} - -// Plan represents a parsed plan file. -type Plan struct { - Title string `json:"title"` - Tasks []Task `json:"tasks"` -} + "io/fs" + "path/filepath" -// patterns for parsing plan markdown. -var ( - taskHeaderPattern = regexp.MustCompile(`^###\s+(?:Task|Iteration)\s+(\d+):\s*(.*)$`) - checkboxPattern = regexp.MustCompile(`^-\s+\[([ xX])\]\s*(.*)$`) - titlePattern = regexp.MustCompile(`^#\s+(.*)$`) + "github.com/umputun/ralphex/pkg/plan" ) -// ParsePlan parses a plan markdown file into a structured Plan. -func ParsePlan(content string) (*Plan, error) { - plan := &Plan{ - Tasks: make([]Task, 0), - } - - scanner := bufio.NewScanner(strings.NewReader(content)) - var currentTask *Task - - for scanner.Scan() { - line := scanner.Text() - - // check for plan title (first h1) - if plan.Title == "" { - if matches := titlePattern.FindStringSubmatch(line); matches != nil { - plan.Title = strings.TrimSpace(matches[1]) - continue - } - } - - // check for task header - if matches := taskHeaderPattern.FindStringSubmatch(line); matches != nil { - // save previous task if exists - if currentTask != nil { - currentTask.Status = determineTaskStatus(currentTask.Checkboxes) - plan.Tasks = append(plan.Tasks, *currentTask) - } - - taskNum, _ := parseTaskNum(matches[1]) - - currentTask = &Task{ - Number: taskNum, - Title: strings.TrimSpace(matches[2]), - Status: TaskStatusPending, - Checkboxes: make([]Checkbox, 0), - } - continue - } - - // check for checkbox (only if inside a task) - if currentTask != nil { - if matches := checkboxPattern.FindStringSubmatch(line); matches != nil { - checked := matches[1] == "x" || matches[1] == "X" - currentTask.Checkboxes = append(currentTask.Checkboxes, Checkbox{ - Text: strings.TrimSpace(matches[2]), - Checked: checked, - }) - } - } - } - - // save last task - if currentTask != nil { - currentTask.Status = determineTaskStatus(currentTask.Checkboxes) - plan.Tasks = append(plan.Tasks, *currentTask) +// loadPlanWithFallback loads a plan from disk with completed/ directory fallback. +// does not cache - each call reads from disk. +func loadPlanWithFallback(path string) (*plan.Plan, error) { + p, err := plan.ParsePlanFile(path) + if err != nil && errors.Is(err, fs.ErrNotExist) { + completedPath := filepath.Join(filepath.Dir(path), "completed", filepath.Base(path)) + p, err = plan.ParsePlanFile(completedPath) } - - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("scan plan: %w", err) - } - - return plan, nil -} - -// ParsePlanFile reads and parses a plan file from disk. -func ParsePlanFile(path string) (*Plan, error) { - content, err := os.ReadFile(path) //nolint:gosec // path comes from server config - if err != nil { - return nil, fmt.Errorf("read plan file: %w", err) - } - return ParsePlan(string(content)) -} - -// JSON returns the plan as JSON bytes. -func (p *Plan) JSON() ([]byte, error) { - data, err := json.Marshal(p) - if err != nil { - return nil, fmt.Errorf("marshal plan: %w", err) - } - return data, nil -} - -// parseTaskNum extracts task number from string. -func parseTaskNum(s string) (int, error) { - n, err := strconv.Atoi(s) if err != nil { - return 0, fmt.Errorf("parse task number: %w", err) - } - return n, nil -} - -// determineTaskStatus calculates task status based on checkbox states. -func determineTaskStatus(checkboxes []Checkbox) TaskStatus { - if len(checkboxes) == 0 { - return TaskStatusPending - } - - checkedCount := 0 - for _, cb := range checkboxes { - if cb.Checked { - checkedCount++ - } - } - - switch { - case checkedCount == len(checkboxes): - return TaskStatusDone - case checkedCount > 0: - return TaskStatusActive - default: - return TaskStatusPending + return nil, fmt.Errorf("load plan with fallback: %w", err) } + return p, nil } diff --git a/pkg/web/plan_test.go b/pkg/web/plan_test.go deleted file mode 100644 index 1e9450b1..00000000 --- a/pkg/web/plan_test.go +++ /dev/null @@ -1,243 +0,0 @@ -package web - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestParsePlan(t *testing.T) { - t.Run("parses plan with title and tasks", func(t *testing.T) { - content := `# My Test Plan - -Some description here. - -### Task 1: First Task - -- [ ] Do something -- [x] Already done -- [ ] Another item - -### Task 2: Second Task - -- [ ] Task 2 item 1 -- [ ] Task 2 item 2 -` - plan, err := ParsePlan(content) - require.NoError(t, err) - - assert.Equal(t, "My Test Plan", plan.Title) - require.Len(t, plan.Tasks, 2) - - // task 1 - assert.Equal(t, 1, plan.Tasks[0].Number) - assert.Equal(t, "First Task", plan.Tasks[0].Title) - assert.Equal(t, TaskStatusActive, plan.Tasks[0].Status) // has mix of checked/unchecked - require.Len(t, plan.Tasks[0].Checkboxes, 3) - assert.False(t, plan.Tasks[0].Checkboxes[0].Checked) - assert.True(t, plan.Tasks[0].Checkboxes[1].Checked) - assert.False(t, plan.Tasks[0].Checkboxes[2].Checked) - - // task 2 - assert.Equal(t, 2, plan.Tasks[1].Number) - assert.Equal(t, "Second Task", plan.Tasks[1].Title) - assert.Equal(t, TaskStatusPending, plan.Tasks[1].Status) // all unchecked - }) - - t.Run("parses iteration headers as tasks", func(t *testing.T) { - content := `# Plan - -### Iteration 1: First Iteration - -- [ ] Item 1 - -### Iteration 2: Second Iteration - -- [x] Item 2 -` - plan, err := ParsePlan(content) - require.NoError(t, err) - - require.Len(t, plan.Tasks, 2) - assert.Equal(t, 1, plan.Tasks[0].Number) - assert.Equal(t, "First Iteration", plan.Tasks[0].Title) - assert.Equal(t, TaskStatusPending, plan.Tasks[0].Status) - - assert.Equal(t, 2, plan.Tasks[1].Number) - assert.Equal(t, "Second Iteration", plan.Tasks[1].Title) - assert.Equal(t, TaskStatusDone, plan.Tasks[1].Status) - }) - - t.Run("parses completed tasks", func(t *testing.T) { - content := `# Plan - -### Task 1: Complete Task - -- [x] Item 1 -- [x] Item 2 -` - plan, err := ParsePlan(content) - require.NoError(t, err) - - require.Len(t, plan.Tasks, 1) - assert.Equal(t, TaskStatusDone, plan.Tasks[0].Status) - }) - - t.Run("parses task with no checkboxes", func(t *testing.T) { - content := `# Plan - -### Task 1: Empty Task - -Just some text, no checkboxes. - -### Task 2: Has Items - -- [ ] One item -` - plan, err := ParsePlan(content) - require.NoError(t, err) - - require.Len(t, plan.Tasks, 2) - assert.Equal(t, TaskStatusPending, plan.Tasks[0].Status) - assert.Empty(t, plan.Tasks[0].Checkboxes) - }) - - t.Run("handles uppercase X in checkbox", func(t *testing.T) { - content := `# Plan - -### Task 1: Test - -- [X] Uppercase checked -- [x] Lowercase checked -` - plan, err := ParsePlan(content) - require.NoError(t, err) - - require.Len(t, plan.Tasks[0].Checkboxes, 2) - assert.True(t, plan.Tasks[0].Checkboxes[0].Checked) - assert.True(t, plan.Tasks[0].Checkboxes[1].Checked) - }) - - t.Run("handles plan without title", func(t *testing.T) { - content := `### Task 1: No Title Plan - -- [ ] Item -` - plan, err := ParsePlan(content) - require.NoError(t, err) - - assert.Empty(t, plan.Title) - require.Len(t, plan.Tasks, 1) - }) - - t.Run("handles empty content", func(t *testing.T) { - plan, err := ParsePlan("") - require.NoError(t, err) - - assert.Empty(t, plan.Title) - assert.Empty(t, plan.Tasks) - }) - - t.Run("ignores checkboxes outside tasks", func(t *testing.T) { - content := `# Plan - -- [ ] This is outside any task - -### Task 1: First - -- [ ] Inside task -` - plan, err := ParsePlan(content) - require.NoError(t, err) - - require.Len(t, plan.Tasks, 1) - require.Len(t, plan.Tasks[0].Checkboxes, 1) - assert.Equal(t, "Inside task", plan.Tasks[0].Checkboxes[0].Text) - }) -} - -func TestParsePlanFile(t *testing.T) { - t.Run("reads and parses file", func(t *testing.T) { - content := `# File Plan - -### Task 1: File Task - -- [ ] File item -` - tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "test-plan.md") - require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) - - plan, err := ParsePlanFile(path) - require.NoError(t, err) - - assert.Equal(t, "File Plan", plan.Title) - require.Len(t, plan.Tasks, 1) - }) - - t.Run("returns error for missing file", func(t *testing.T) { - _, err := ParsePlanFile("/nonexistent/file.md") - assert.Error(t, err) - }) -} - -func TestPlan_JSON(t *testing.T) { - plan := &Plan{ - Title: "Test Plan", - Tasks: []Task{ - { - Number: 1, - Title: "First Task", - Status: TaskStatusPending, - Checkboxes: []Checkbox{ - {Text: "Item 1", Checked: false}, - {Text: "Item 2", Checked: true}, - }, - }, - }, - } - - data, err := plan.JSON() - require.NoError(t, err) - - var decoded map[string]any - require.NoError(t, json.Unmarshal(data, &decoded)) - - assert.Equal(t, "Test Plan", decoded["title"]) - tasks := decoded["tasks"].([]any) - require.Len(t, tasks, 1) -} - -func TestDetermineTaskStatus(t *testing.T) { - tests := []struct { - name string - checkboxes []Checkbox - want TaskStatus - }{ - {"empty", nil, TaskStatusPending}, - {"all unchecked", []Checkbox{{Checked: false}, {Checked: false}}, TaskStatusPending}, - {"all checked", []Checkbox{{Checked: true}, {Checked: true}}, TaskStatusDone}, - {"mixed", []Checkbox{{Checked: true}, {Checked: false}}, TaskStatusActive}, - {"single checked", []Checkbox{{Checked: true}}, TaskStatusDone}, - {"single unchecked", []Checkbox{{Checked: false}}, TaskStatusPending}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := determineTaskStatus(tt.checkboxes) - assert.Equal(t, tt.want, got) - }) - } -} - -func TestTaskStatus_Constants(t *testing.T) { - // verify status values for API stability - assert.Equal(t, TaskStatusPending, TaskStatus("pending")) - assert.Equal(t, TaskStatusActive, TaskStatus("active")) - assert.Equal(t, TaskStatusDone, TaskStatus("done")) - assert.Equal(t, TaskStatusFailed, TaskStatus("failed")) -} diff --git a/pkg/web/server.go b/pkg/web/server.go index 22566d15..082589fb 100644 --- a/pkg/web/server.go +++ b/pkg/web/server.go @@ -14,6 +14,8 @@ import ( "sort" "sync" "time" + + "github.com/umputun/ralphex/pkg/plan" ) //go:embed templates static @@ -37,7 +39,7 @@ type Server struct { // plan caching - set after first successful load (single-session mode) planMu sync.Mutex - planCache *Plan + planCache *plan.Plan } // NewServer creates a new web server for single-session mode (direct execution). @@ -178,14 +180,14 @@ func (s *Server) handlePlan(w http.ResponseWriter, r *http.Request) { return } - plan, err := s.loadPlan() + p, err := s.loadPlan() if err != nil { log.Printf("[WARN] failed to load plan file %s: %v", s.cfg.PlanFile, err) http.Error(w, "unable to load plan", http.StatusInternalServerError) return } - data, err := plan.JSON() + data, err := p.JSON() if err != nil { log.Printf("[WARN] failed to encode plan: %v", err) http.Error(w, "unable to encode plan", http.StatusInternalServerError) @@ -219,14 +221,14 @@ func (s *Server) handleSessionPlan(w http.ResponseWriter, sessionID string) { planPath = filepath.Join(sessionDir, meta.PlanPath) } - plan, err := loadPlanWithFallback(planPath) + p, err := loadPlanWithFallback(planPath) if err != nil { log.Printf("[WARN] failed to load plan file %s: %v", meta.PlanPath, err) http.Error(w, "unable to load plan", http.StatusInternalServerError) return } - data, err := plan.JSON() + data, err := p.JSON() if err != nil { log.Printf("[WARN] failed to encode plan: %v", err) http.Error(w, "unable to encode plan", http.StatusInternalServerError) @@ -238,7 +240,7 @@ func (s *Server) handleSessionPlan(w http.ResponseWriter, sessionID string) { } // loadPlan returns a cached plan or loads it from disk (with completed/ fallback). -func (s *Server) loadPlan() (*Plan, error) { +func (s *Server) loadPlan() (*plan.Plan, error) { s.planMu.Lock() defer s.planMu.Unlock() @@ -246,24 +248,13 @@ func (s *Server) loadPlan() (*Plan, error) { return s.planCache, nil } - plan, err := loadPlanWithFallback(s.cfg.PlanFile) + p, err := loadPlanWithFallback(s.cfg.PlanFile) if err != nil { return nil, err } - s.planCache = plan - return plan, nil -} - -// loadPlanWithFallback loads a plan from disk with completed/ directory fallback. -// does not cache - each call reads from disk. -func loadPlanWithFallback(path string) (*Plan, error) { - plan, err := ParsePlanFile(path) - if err != nil && errors.Is(err, fs.ErrNotExist) { - completedPath := filepath.Join(filepath.Dir(path), "completed", filepath.Base(path)) - plan, err = ParsePlanFile(completedPath) - } - return plan, err + s.planCache = p + return p, nil } // handleEvents serves the SSE stream. From 0927a21a411e8705a61a143604f231dda8a6c95f Mon Sep 17 00:00:00 2001 From: Umputun Date: Sat, 21 Feb 2026 21:12:16 -0600 Subject: [PATCH 3/7] feat: widen plan parser regex to support non-integer task headers Related to #127 --- ...2026-02-21-fix-dashboard-task-numbering.md | 10 +-- pkg/plan/parse.go | 12 +-- pkg/plan/parse_test.go | 74 +++++++++++++++++++ 3 files changed, 86 insertions(+), 10 deletions(-) diff --git a/docs/plans/2026-02-21-fix-dashboard-task-numbering.md b/docs/plans/2026-02-21-fix-dashboard-task-numbering.md index 28d1255e..f878ed57 100644 --- a/docs/plans/2026-02-21-fix-dashboard-task-numbering.md +++ b/docs/plans/2026-02-21-fix-dashboard-task-numbering.md @@ -63,11 +63,11 @@ Web dashboard shows wrong task numbers when plan is edited mid-run or tasks retr - Modify: `pkg/plan/parse.go` - Modify: `pkg/plan/parse_test.go` -- [ ] widen `taskHeaderPattern` regex from `(\d+)` to `([^:]+?)` with `strings.TrimSpace` -- [ ] update `parseTaskNum` to: try `strconv.Atoi`, on success set `Number = parsed int`; on failure set `Number = 0` -- [ ] add test cases for "Task 2.5:", "Task 2a:", "Task 3:" (backward compat) -- [ ] verify non-integer tasks are parsed (not silently dropped) and appear in `Plan.Tasks` array -- [ ] run `go test ./pkg/plan/` - must pass +- [x] widen `taskHeaderPattern` regex from `(\d+)` to `([^:]+?)` with `strings.TrimSpace` +- [x] update `parseTaskNum` to: try `strconv.Atoi`, on success set `Number = parsed int`; on failure set `Number = 0` +- [x] add test cases for "Task 2.5:", "Task 2a:", "Task 3:" (backward compat) +- [x] verify non-integer tasks are parsed (not silently dropped) and appear in `Plan.Tasks` array +- [x] run `go test ./pkg/plan/` - must pass ### Task 3: Runner passes plan task position instead of loop counter diff --git a/pkg/plan/parse.go b/pkg/plan/parse.go index 80a7f0f7..61b8f0f8 100644 --- a/pkg/plan/parse.go +++ b/pkg/plan/parse.go @@ -43,7 +43,7 @@ type Plan struct { // patterns for parsing plan markdown. var ( - taskHeaderPattern = regexp.MustCompile(`^###\s+(?:Task|Iteration)\s+(\d+):\s*(.*)$`) + taskHeaderPattern = regexp.MustCompile(`^###\s+(?:Task|Iteration)\s+([^:]+?):\s*(.*)$`) checkboxPattern = regexp.MustCompile(`^-\s+\[([ xX])\]\s*(.*)$`) titlePattern = regexp.MustCompile(`^#\s+(.*)$`) ) @@ -76,7 +76,7 @@ func ParsePlan(content string) (*Plan, error) { p.Tasks = append(p.Tasks, *currentTask) } - taskNum, _ := parseTaskNum(matches[1]) + taskNum := parseTaskNum(matches[1]) currentTask = &Task{ Number: taskNum, @@ -131,12 +131,14 @@ func (p *Plan) JSON() ([]byte, error) { } // parseTaskNum extracts task number from string. -func parseTaskNum(s string) (int, error) { +// returns 0 for non-integer values (e.g. "2.5", "2a"). +func parseTaskNum(s string) int { + s = strings.TrimSpace(s) n, err := strconv.Atoi(s) if err != nil { - return 0, fmt.Errorf("parse task number: %w", err) + return 0 } - return n, nil + return n } // determineTaskStatus calculates task status based on checkbox states. diff --git a/pkg/plan/parse_test.go b/pkg/plan/parse_test.go index ab64f017..a91ed4c9 100644 --- a/pkg/plan/parse_test.go +++ b/pkg/plan/parse_test.go @@ -160,6 +160,80 @@ Just some text, no checkboxes. require.Len(t, p.Tasks[0].Checkboxes, 1) assert.Equal(t, "Inside task", p.Tasks[0].Checkboxes[0].Text) }) + + t.Run("parses non-integer task headers", func(t *testing.T) { + content := `# Plan with inserted tasks + +### Task 1: First Task + +- [x] Done + +### Task 2: Second Task + +- [x] Done + +### Task 2.5: Inserted Task + +- [ ] New item + +### Task 3: Third Task + +- [ ] Item +` + p, err := plan.ParsePlan(content) + require.NoError(t, err) + + require.Len(t, p.Tasks, 4) + + assert.Equal(t, 1, p.Tasks[0].Number) + assert.Equal(t, "First Task", p.Tasks[0].Title) + + assert.Equal(t, 2, p.Tasks[1].Number) + assert.Equal(t, "Second Task", p.Tasks[1].Title) + + assert.Equal(t, 0, p.Tasks[2].Number) // non-integer gets Number=0 + assert.Equal(t, "Inserted Task", p.Tasks[2].Title) + assert.Equal(t, plan.TaskStatusPending, p.Tasks[2].Status) + + assert.Equal(t, 3, p.Tasks[3].Number) + assert.Equal(t, "Third Task", p.Tasks[3].Title) + }) + + t.Run("parses alphanumeric task headers", func(t *testing.T) { + content := `# Plan + +### Task 2a: Alpha Task + +- [ ] Item +` + p, err := plan.ParsePlan(content) + require.NoError(t, err) + + require.Len(t, p.Tasks, 1) + assert.Equal(t, 0, p.Tasks[0].Number) // non-integer gets Number=0 + assert.Equal(t, "Alpha Task", p.Tasks[0].Title) + }) + + t.Run("backward compat with integer task headers", func(t *testing.T) { + content := `# Plan + +### Task 1: First +- [ ] A + +### Task 2: Second +- [x] B + +### Task 3: Third +- [ ] C +` + p, err := plan.ParsePlan(content) + require.NoError(t, err) + + require.Len(t, p.Tasks, 3) + assert.Equal(t, 1, p.Tasks[0].Number) + assert.Equal(t, 2, p.Tasks[1].Number) + assert.Equal(t, 3, p.Tasks[2].Number) + }) } func TestParsePlanFile(t *testing.T) { From 7dc222414d7b9aa9b23c313b866be38bb9e5f79b Mon Sep 17 00:00:00 2001 From: Umputun Date: Sat, 21 Feb 2026 21:16:00 -0600 Subject: [PATCH 4/7] feat: runner passes plan task position instead of loop counter Add nextPlanTaskPosition() method that reads the plan file and finds the first uncompleted task's 1-indexed position. In runTaskPhase, use this position for NewTaskIterationSection instead of the loop counter, so the dashboard highlights the correct task during retries and mid-run plan edits. --- ...2026-02-21-fix-dashboard-task-numbering.md | 24 ++-- pkg/processor/export_test.go | 5 + pkg/processor/runner.go | 23 +++- pkg/processor/runner_test.go | 105 ++++++++++++++++++ pkg/web/server.go | 26 +---- pkg/web/static/app.js | 42 +++++-- 6 files changed, 181 insertions(+), 44 deletions(-) diff --git a/docs/plans/2026-02-21-fix-dashboard-task-numbering.md b/docs/plans/2026-02-21-fix-dashboard-task-numbering.md index f878ed57..7a99dd6b 100644 --- a/docs/plans/2026-02-21-fix-dashboard-task-numbering.md +++ b/docs/plans/2026-02-21-fix-dashboard-task-numbering.md @@ -76,11 +76,11 @@ Web dashboard shows wrong task numbers when plan is edited mid-run or tasks retr - Modify: `pkg/processor/runner_test.go` - Modify: `pkg/processor/export_test.go` (expose new method if needed) -- [ ] add `nextPlanTaskPosition() int` method to Runner: reads plan file via `plan.ParsePlanFile(r.resolvePlanFilePath())`, finds first task with status != `TaskStatusDone`, returns its 1-indexed position in the tasks array (0 on error = fallback to loop counter) -- [ ] in `runTaskPhase`, before `PrintSection`: call `nextPlanTaskPosition()`; if > 0, use it instead of `i` for `NewTaskIterationSection(pos)` -- [ ] keep `hasUncompletedTasks()` as-is (raw string scan works, refactoring changes semantics - deferred to separate task) -- [ ] write tests for `nextPlanTaskPosition` with mock plan files: normal integer plan, plan with inserted "Task 2.5", missing file, no uncompleted tasks, plan with retried (same task still uncompleted) -- [ ] run `go test ./pkg/processor/` - must pass +- [x] add `nextPlanTaskPosition() int` method to Runner: reads plan file via `plan.ParsePlanFile(r.resolvePlanFilePath())`, finds first task with status != `TaskStatusDone`, returns its 1-indexed position in the tasks array (0 on error = fallback to loop counter) +- [x] in `runTaskPhase`, before `PrintSection`: call `nextPlanTaskPosition()`; if > 0, use it instead of `i` for `NewTaskIterationSection(pos)` +- [x] keep `hasUncompletedTasks()` as-is (raw string scan works, refactoring changes semantics - deferred to separate task) +- [x] write tests for `nextPlanTaskPosition` with mock plan files: normal integer plan, plan with inserted "Task 2.5", missing file, no uncompleted tasks, plan with retried (same task still uncompleted) +- [x] run `go test ./pkg/processor/` - must pass ### Task 4: Remove planCache and update frontend matching @@ -89,13 +89,13 @@ Web dashboard shows wrong task numbers when plan is edited mid-run or tasks retr - Modify: `pkg/web/server_test.go` (if cache is tested) - Modify: `pkg/web/static/app.js` -- [ ] remove `planMu sync.Mutex` and `planCache *Plan` fields from `Server` -- [ ] simplify `loadPlan()` to call `loadPlanWithFallback()` directly (no caching) -- [ ] update/remove any tests that assert cache behavior -- [ ] in `renderPlan`: set `data-task-num` from array index (position `i+1`) instead of `task.number` -- [ ] in `handleTaskStart`: if target task element not found by `data-task-num`, re-fetch plan via `fetch('/api/plan')`, rebuild plan panel, then retry highlight. this handles mid-run plan edits where the frontend has a stale task list -- [ ] in `getTaskTitle`: match by position (iterate `planData.tasks` array, return task at `taskNum - 1` index) -- [ ] run `go test ./pkg/web/...` - must pass +- [x] remove `planMu sync.Mutex` and `planCache *Plan` fields from `Server` +- [x] simplify `loadPlan()` to call `loadPlanWithFallback()` directly (no caching) +- [x] update/remove any tests that assert cache behavior +- [x] in `renderPlan`: set `data-task-num` from array index (position `i+1`) instead of `task.number` +- [x] in `handleTaskStart`: if target task element not found by `data-task-num`, re-fetch plan via `fetch('/api/plan')`, rebuild plan panel, then retry highlight. this handles mid-run plan edits where the frontend has a stale task list +- [x] in `getTaskTitle`: match by position (iterate `planData.tasks` array, return task at `taskNum - 1` index) +- [x] run `go test ./pkg/web/...` - must pass ### Task 5: Verify acceptance criteria diff --git a/pkg/processor/export_test.go b/pkg/processor/export_test.go index 37898624..e966bc94 100644 --- a/pkg/processor/export_test.go +++ b/pkg/processor/export_test.go @@ -26,3 +26,8 @@ func (r *Runner) TestHasUncompletedTasks() bool { func (r *Runner) TestBuildCodexPrompt(isFirst bool, claudeResponse string) string { return r.buildCodexPrompt(isFirst, claudeResponse) } + +// TestNextPlanTaskPosition exposes nextPlanTaskPosition for testing. +func (r *Runner) TestNextPlanTaskPosition() int { + return r.nextPlanTaskPosition() +} diff --git a/pkg/processor/runner.go b/pkg/processor/runner.go index 291cd500..e68f4a5f 100644 --- a/pkg/processor/runner.go +++ b/pkg/processor/runner.go @@ -12,6 +12,7 @@ import ( "github.com/umputun/ralphex/pkg/config" "github.com/umputun/ralphex/pkg/executor" + "github.com/umputun/ralphex/pkg/plan" "github.com/umputun/ralphex/pkg/status" ) @@ -342,7 +343,12 @@ func (r *Runner) runTaskPhase(ctx context.Context) error { default: } - r.log.PrintSection(status.NewTaskIterationSection(i)) + // use plan task position instead of loop counter for correct dashboard highlighting + taskNum := i + if pos := r.nextPlanTaskPosition(); pos > 0 { + taskNum = pos + } + r.log.PrintSection(status.NewTaskIterationSection(taskNum)) result := r.claude.Run(ctx, prompt) if result.Error != nil { @@ -669,6 +675,21 @@ func (r *Runner) hasUncompletedTasks() bool { return false } +// nextPlanTaskPosition returns the 1-indexed position of the first uncompleted task in the plan. +// returns 0 if the plan file can't be read/parsed or no uncompleted tasks exist (caller falls back to loop counter). +func (r *Runner) nextPlanTaskPosition() int { + p, err := plan.ParsePlanFile(r.resolvePlanFilePath()) + if err != nil { + return 0 + } + for i, t := range p.Tasks { + if t.Status != plan.TaskStatusDone { + return i + 1 // 1-indexed + } + } + return 0 +} + // showCodexSummary displays a condensed summary of codex output before Claude evaluation. // extracts text until first code block or maxCodexSummaryLen chars, whichever is shorter. func (r *Runner) showCodexSummary(output string) { diff --git a/pkg/processor/runner_test.go b/pkg/processor/runner_test.go index 8ac58ec7..6e0c3071 100644 --- a/pkg/processor/runner_test.go +++ b/pkg/processor/runner_test.go @@ -1912,3 +1912,108 @@ func TestRunner_SleepWithContext_CancelDuringDelay(t *testing.T) { assert.Less(t, elapsed, time.Duration(longDelay)*time.Millisecond, "should exit promptly on cancellation, not wait for full iteration delay") } + +func TestRunner_NextPlanTaskPosition(t *testing.T) { + tests := []struct { + name string + content string + expected int + }{ + {name: "first task uncompleted", content: "# Plan\n### Task 1: setup\n- [ ] do thing\n### Task 2: build\n- [ ] build it", expected: 1}, + {name: "second task uncompleted", content: "# Plan\n### Task 1: setup\n- [x] done\n### Task 2: build\n- [ ] build it", expected: 2}, + {name: "all done", content: "# Plan\n### Task 1: setup\n- [x] done\n### Task 2: build\n- [x] built", expected: 0}, + {name: "inserted task 2.5", content: "# Plan\n### Task 1: setup\n- [x] done\n### Task 2: api\n- [x] done\n### Task 2.5: middleware\n- [ ] add it\n### Task 3: tests\n- [ ] test", expected: 3}, + {name: "retry same task", content: "# Plan\n### Task 1: setup\n- [x] done\n### Task 2: build\n- [x] first\n- [ ] second\n### Task 3: test\n- [ ] test", expected: 2}, + {name: "no tasks", content: "# Plan\nJust some text", expected: 0}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tmpDir := t.TempDir() + planFile := filepath.Join(tmpDir, "plan.md") + require.NoError(t, os.WriteFile(planFile, []byte(tc.content), 0o600)) + + log := newMockLogger("") + claude := newMockExecutor(nil) + codex := newMockExecutor(nil) + + cfg := processor.Config{PlanFile: planFile} + r := processor.NewWithExecutors(cfg, log, claude, codex, nil, &status.PhaseHolder{}) + + assert.Equal(t, tc.expected, r.TestNextPlanTaskPosition()) + }) + } +} + +func TestRunner_NextPlanTaskPosition_MissingFile(t *testing.T) { + log := newMockLogger("") + claude := newMockExecutor(nil) + codex := newMockExecutor(nil) + + cfg := processor.Config{PlanFile: "/nonexistent/plan.md"} + r := processor.NewWithExecutors(cfg, log, claude, codex, nil, &status.PhaseHolder{}) + + assert.Equal(t, 0, r.TestNextPlanTaskPosition(), "missing file should return 0") +} + +func TestRunner_NextPlanTaskPosition_EmptyPlanFile(t *testing.T) { + log := newMockLogger("") + claude := newMockExecutor(nil) + codex := newMockExecutor(nil) + + cfg := processor.Config{PlanFile: ""} + r := processor.NewWithExecutors(cfg, log, claude, codex, nil, &status.PhaseHolder{}) + + assert.Equal(t, 0, r.TestNextPlanTaskPosition(), "empty plan file path should return 0") +} + +func TestRunner_TaskPhase_UsesPlanTaskPosition(t *testing.T) { + tmpDir := t.TempDir() + planFile := filepath.Join(tmpDir, "plan.md") + // task 1 done, task 2 uncompleted - position should be 2 + planContent := "# Plan\n### Task 1: setup\n- [x] done\n### Task 2: build\n- [ ] build it" + require.NoError(t, os.WriteFile(planFile, []byte(planContent), 0o600)) + + log := newMockLogger("progress.txt") + // first call: task runs, signals completed; but plan still has [ ] items + // so runner continues, and on second iteration, plan is updated (simulate by updating file) + callCount := 0 + claude := &mocks.ExecutorMock{ + RunFunc: func(_ context.Context, _ string) executor.Result { + callCount++ + if callCount == 1 { + // simulate task 2 completion: update plan file and signal completed + updated := strings.ReplaceAll(planContent, "- [ ] build it", "- [x] build it") + _ = os.WriteFile(planFile, []byte(updated), 0o600) + return executor.Result{Output: "task done", Signal: status.Completed} + } + return executor.Result{Error: errors.New("no more mock results")} + }, + } + codex := newMockExecutor(nil) + + cfg := processor.Config{Mode: processor.ModeTasksOnly, PlanFile: planFile, MaxIterations: 50, AppConfig: testAppConfig(t)} + r := processor.NewWithExecutors(cfg, log, claude, codex, nil, &status.PhaseHolder{}) + err := r.Run(context.Background()) + + require.NoError(t, err) + + // verify section was printed with task position 2 (not loop counter 1) + require.NotEmpty(t, log.PrintSectionCalls()) + var foundTaskSection bool + for _, call := range log.PrintSectionCalls() { + if call.Section.Iteration == 2 && strings.Contains(call.Section.Label, "task iteration 2") { + foundTaskSection = true + break + } + } + assert.True(t, foundTaskSection, "should print section with task position 2, got sections: %v", + func() []string { + calls := log.PrintSectionCalls() + labels := make([]string, 0, len(calls)) + for _, c := range calls { + labels = append(labels, c.Section.Label) + } + return labels + }()) +} diff --git a/pkg/web/server.go b/pkg/web/server.go index 082589fb..fe831038 100644 --- a/pkg/web/server.go +++ b/pkg/web/server.go @@ -12,7 +12,6 @@ import ( "net/http" "path/filepath" "sort" - "sync" "time" "github.com/umputun/ralphex/pkg/plan" @@ -36,10 +35,6 @@ type Server struct { sm *SessionManager // used for multi-session mode (dashboard) srv *http.Server tmpl *template.Template - - // plan caching - set after first successful load (single-session mode) - planMu sync.Mutex - planCache *plan.Plan } // NewServer creates a new web server for single-session mode (direct execution). @@ -157,7 +152,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { } // handlePlan serves the parsed plan as JSON. -// in single-session mode, uses the server's configured plan file with caching. +// in single-session mode, uses the server's configured plan file. // in multi-session mode, accepts ?session= to load plan from session metadata. func (s *Server) handlePlan(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { @@ -174,7 +169,7 @@ func (s *Server) handlePlan(w http.ResponseWriter, r *http.Request) { return } - // single-session mode - use cached server plan + // single-session mode - load plan from disk if s.cfg.PlanFile == "" { http.Error(w, "no plan file configured", http.StatusNotFound) return @@ -239,22 +234,9 @@ func (s *Server) handleSessionPlan(w http.ResponseWriter, sessionID string) { _, _ = w.Write(data) } -// loadPlan returns a cached plan or loads it from disk (with completed/ fallback). +// loadPlan loads a plan from disk (with completed/ fallback). func (s *Server) loadPlan() (*plan.Plan, error) { - s.planMu.Lock() - defer s.planMu.Unlock() - - if s.planCache != nil { - return s.planCache, nil - } - - p, err := loadPlanWithFallback(s.cfg.PlanFile) - if err != nil { - return nil, err - } - - s.planCache = p - return p, nil + return loadPlanWithFallback(s.cfg.PlanFile) } // handleEvents serves the SSE stream. diff --git a/pkg/web/static/app.js b/pkg/web/static/app.js index 926fdb69..a61d9419 100644 --- a/pkg/web/static/app.js +++ b/pkg/web/static/app.js @@ -337,13 +337,12 @@ }; } - // look up task title by number from plan data + // look up task title by position (1-indexed) from plan data function getTaskTitle(taskNum) { if (!state.planData || !state.planData.tasks) return null; - for (var i = 0; i < state.planData.tasks.length; i++) { - if (state.planData.tasks[i].number === taskNum) { - return state.planData.tasks[i].title; - } + var index = taskNum - 1; + if (index >= 0 && index < state.planData.tasks.length) { + return state.planData.tasks[index].title; } return null; } @@ -705,11 +704,36 @@ state.elapsedTimerInterval = setInterval(updateTimers, 1000); } - // handle task boundary events + // handle task boundary events. + // if target task element not found (plan was edited mid-run), re-fetch plan and retry. function handleTaskStart(event) { state.currentTaskNum = event.task_num; clearActiveTasksExcept(event.task_num); - updatePlanTaskStatus(event.task_num, 'active'); + + var taskEl = planContent.querySelector('.plan-task[data-task-num="' + event.task_num + '"]'); + if (taskEl) { + updatePlanTaskStatus(event.task_num, 'active'); + return; + } + + // task element not found - plan may have been edited mid-run, re-fetch + var url = '/api/plan'; + if (state.currentSessionId) { + url += '?session=' + encodeURIComponent(state.currentSessionId); + } + fetch(url) + .then(function(response) { + if (!response.ok) throw new Error('plan not available'); + return response.json(); + }) + .then(function(plan) { + state.planData = plan; + renderPlan(plan); + updatePlanTaskStatus(event.task_num, 'active'); + }) + .catch(function(err) { + console.log('plan re-fetch failed:', err.message); + }); } function handleTaskEnd(event) { @@ -1639,10 +1663,10 @@ return; } - plan.tasks.forEach(function(task) { + plan.tasks.forEach(function(task, index) { const taskEl = document.createElement('div'); taskEl.className = 'plan-task'; - taskEl.dataset.taskNum = task.number; + taskEl.dataset.taskNum = index + 1; var displayStatus = task.status; if (displayStatus === 'active') { From 6aa05058bc80cdfa4e7b5bb207511de80dbc7a22 Mon Sep 17 00:00:00 2001 From: Umputun Date: Sat, 21 Feb 2026 21:26:45 -0600 Subject: [PATCH 5/7] feat: verify acceptance criteria and fix non-integer task display in plan panel --- CLAUDE.md | 2 +- README.md | 2 +- .../2026-02-21-fix-dashboard-task-numbering.md | 18 +++++++++--------- pkg/plan/export_test.go | 4 ---- pkg/plan/parse.go | 12 ++++++------ pkg/plan/plan.go | 2 +- pkg/processor/runner.go | 1 + pkg/web/event.go | 2 +- pkg/web/static/app.js | 9 +++++++-- 9 files changed, 27 insertions(+), 25 deletions(-) rename docs/plans/{ => completed}/2026-02-21-fix-dashboard-task-numbering.md (94%) delete mode 100644 pkg/plan/export_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 6c9af7f8..e103fc9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ pkg/executor/ # claude and codex CLI execution pkg/git/ # git operations (external git CLI) pkg/input/ # terminal input collector (fzf/fallback, draft review) pkg/notify/ # notification delivery (telegram, email, slack, webhook, custom) -pkg/plan/ # plan file selection and manipulation +pkg/plan/ # plan file selection, parsing, and manipulation pkg/processor/ # orchestration loop, prompts, signal helpers pkg/progress/ # timestamped logging with color pkg/status/ # shared execution model types: signals, phases, sections diff --git a/README.md b/README.md index 0592ec07..1f5a1f5d 100644 --- a/README.md +++ b/README.md @@ -483,7 +483,7 @@ Add JWT-based authentication to the API. ``` **Requirements:** -- Task headers must use `### Task N:` or `### Iteration N:` format +- Task headers must use `### Task N:` or `### Iteration N:` format (N can be integer or non-integer like `2.5`, `2a`) - Checkboxes: `- [ ]` (incomplete) or `- [x]` (completed) - Include `## Validation Commands` section with test/lint commands - Place plans in `docs/plans/` directory (configurable via `plans_dir`) diff --git a/docs/plans/2026-02-21-fix-dashboard-task-numbering.md b/docs/plans/completed/2026-02-21-fix-dashboard-task-numbering.md similarity index 94% rename from docs/plans/2026-02-21-fix-dashboard-task-numbering.md rename to docs/plans/completed/2026-02-21-fix-dashboard-task-numbering.md index 7a99dd6b..f6dbb142 100644 --- a/docs/plans/2026-02-21-fix-dashboard-task-numbering.md +++ b/docs/plans/completed/2026-02-21-fix-dashboard-task-numbering.md @@ -99,18 +99,18 @@ Web dashboard shows wrong task numbers when plan is edited mid-run or tasks retr ### Task 5: Verify acceptance criteria -- [ ] verify: integer-only plans work identically to before (backward compat) -- [ ] verify: old progress files with `"task iteration 3"` replay correctly -- [ ] verify: task retries show correct task highlighting (same task stays highlighted) -- [ ] verify: non-integer task headers ("Task 2.5") are parsed and appear in plan panel -- [ ] run full test suite: `go test ./...` -- [ ] run linter: `make lint` -- [ ] run e2e tests: `go test -tags=e2e -timeout=10m -count=1 -v ./e2e/...` +- [x] verify: integer-only plans work identically to before (backward compat) +- [x] verify: old progress files with `"task iteration 3"` replay correctly +- [x] verify: task retries show correct task highlighting (same task stays highlighted) +- [x] verify: non-integer task headers ("Task 2.5") are parsed and appear in plan panel +- [x] run full test suite: `go test ./...` +- [x] run linter: `make lint` +- [x] run e2e tests: `go test -tags=e2e -timeout=10m -count=1 -v ./e2e/...` ### Task 6: Update documentation and complete -- [ ] update CLAUDE.md if new patterns changed -- [ ] move this plan to `docs/plans/completed/` +- [x] update CLAUDE.md if new patterns changed +- [x] move this plan to `docs/plans/completed/` ## Technical Details diff --git a/pkg/plan/export_test.go b/pkg/plan/export_test.go deleted file mode 100644 index 6a745550..00000000 --- a/pkg/plan/export_test.go +++ /dev/null @@ -1,4 +0,0 @@ -package plan - -// DetermineTaskStatus is exported for testing. -var DetermineTaskStatus = determineTaskStatus diff --git a/pkg/plan/parse.go b/pkg/plan/parse.go index 61b8f0f8..003b3f18 100644 --- a/pkg/plan/parse.go +++ b/pkg/plan/parse.go @@ -48,7 +48,7 @@ var ( titlePattern = regexp.MustCompile(`^#\s+(.*)$`) ) -// ParsePlan parses a plan markdown file into a structured Plan. +// ParsePlan parses plan markdown content into a structured Plan. func ParsePlan(content string) (*Plan, error) { p := &Plan{ Tasks: make([]Task, 0), @@ -72,7 +72,7 @@ func ParsePlan(content string) (*Plan, error) { if matches := taskHeaderPattern.FindStringSubmatch(line); matches != nil { // save previous task if exists if currentTask != nil { - currentTask.Status = determineTaskStatus(currentTask.Checkboxes) + currentTask.Status = DetermineTaskStatus(currentTask.Checkboxes) p.Tasks = append(p.Tasks, *currentTask) } @@ -101,7 +101,7 @@ func ParsePlan(content string) (*Plan, error) { // save last task if currentTask != nil { - currentTask.Status = determineTaskStatus(currentTask.Checkboxes) + currentTask.Status = DetermineTaskStatus(currentTask.Checkboxes) p.Tasks = append(p.Tasks, *currentTask) } @@ -114,7 +114,7 @@ func ParsePlan(content string) (*Plan, error) { // ParsePlanFile reads and parses a plan file from disk. func ParsePlanFile(path string) (*Plan, error) { - content, err := os.ReadFile(path) //nolint:gosec // path comes from server config + content, err := os.ReadFile(path) //nolint:gosec // path is internally resolved, not from user input if err != nil { return nil, fmt.Errorf("read plan file: %w", err) } @@ -141,8 +141,8 @@ func parseTaskNum(s string) int { return n } -// determineTaskStatus calculates task status based on checkbox states. -func determineTaskStatus(checkboxes []Checkbox) TaskStatus { +// DetermineTaskStatus calculates task status based on checkbox states. +func DetermineTaskStatus(checkboxes []Checkbox) TaskStatus { if len(checkboxes) == 0 { return TaskStatusPending } diff --git a/pkg/plan/plan.go b/pkg/plan/plan.go index 8756dd94..4a55372d 100644 --- a/pkg/plan/plan.go +++ b/pkg/plan/plan.go @@ -1,4 +1,4 @@ -// Package plan provides plan file selection and manipulation. +// Package plan provides plan file selection, parsing, and manipulation. package plan import ( diff --git a/pkg/processor/runner.go b/pkg/processor/runner.go index e68f4a5f..1cf73e82 100644 --- a/pkg/processor/runner.go +++ b/pkg/processor/runner.go @@ -680,6 +680,7 @@ func (r *Runner) hasUncompletedTasks() bool { func (r *Runner) nextPlanTaskPosition() int { p, err := plan.ParsePlanFile(r.resolvePlanFilePath()) if err != nil { + r.log.Print("[WARN] failed to parse plan file for task position: %v", err) return 0 } for i, t := range p.Tasks { diff --git a/pkg/web/event.go b/pkg/web/event.go index b7b8ba16..08b6ba36 100644 --- a/pkg/web/event.go +++ b/pkg/web/event.go @@ -33,7 +33,7 @@ type Event struct { Text string `json:"text"` Timestamp time.Time `json:"timestamp"` Signal string `json:"signal,omitempty"` - TaskNum int `json:"task_num,omitempty"` // 1-based task index from plan (matches plan.tasks[].number) + TaskNum int `json:"task_num,omitempty"` // 1-based task position in plan (array index + 1) IterationNum int `json:"iteration_num,omitempty"` // 1-based iteration index for review/codex phases } diff --git a/pkg/web/static/app.js b/pkg/web/static/app.js index a61d9419..8081d1c0 100644 --- a/pkg/web/static/app.js +++ b/pkg/web/static/app.js @@ -721,6 +721,7 @@ if (state.currentSessionId) { url += '?session=' + encodeURIComponent(state.currentSessionId); } + var taskNum = event.task_num; fetch(url) .then(function(response) { if (!response.ok) throw new Error('plan not available'); @@ -729,7 +730,10 @@ .then(function(plan) { state.planData = plan; renderPlan(plan); - updatePlanTaskStatus(event.task_num, 'active'); + // only set active if task_end hasn't arrived during fetch + if (state.currentTaskNum === taskNum) { + updatePlanTaskStatus(taskNum, 'active'); + } }) .catch(function(err) { console.log('plan re-fetch failed:', err.message); @@ -1690,7 +1694,8 @@ const title = document.createElement('span'); title.className = 'plan-task-title'; - title.textContent = 'Task ' + task.number + ': ' + task.title; + var displayNum = task.number > 0 ? task.number : (index + 1); + title.textContent = 'Task ' + displayNum + ': ' + task.title; header.appendChild(statusIcon); header.appendChild(title); From 108887d6c06f726ad617fe315f50dd1a5115a06f Mon Sep 17 00:00:00 2001 From: Umputun Date: Sat, 21 Feb 2026 21:58:00 -0600 Subject: [PATCH 6/7] fix: use duration tolerance in e2e elapsed timer assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit replace exact string comparison with ±1s tolerance to handle JS interval boundary timing where timer can land 1 second short. --- e2e/e2e_test.go | 14 ++++++++++++++ e2e/sse_test.go | 4 ++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index a47d4492..b276baf2 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -549,6 +549,20 @@ func clickSessionByName(t *testing.T, page playwright.Page, name string) bool { return clicked } +// assertDurationsClose asserts two duration strings (e.g. "7m 0s", "6m 59s") are within tolerance. +func assertDurationsClose(t *testing.T, s1, s2 string, tolerance time.Duration, msgAndArgs ...any) { + t.Helper() + d1, err1 := time.ParseDuration(strings.ReplaceAll(s1, " ", "")) + d2, err2 := time.ParseDuration(strings.ReplaceAll(s2, " ", "")) + require.NoError(t, err1, "failed to parse duration %q", s1) + require.NoError(t, err2, "failed to parse duration %q", s2) + diff := d1 - d2 + if diff < 0 { + diff = -diff + } + require.LessOrEqual(t, diff, tolerance, msgAndArgs...) +} + // TestDashboardSmoke verifies the server is running and page loads. func TestDashboardSmoke(t *testing.T) { page := newPage(t) diff --git a/e2e/sse_test.go b/e2e/sse_test.go index 00927752..f09d3dc4 100644 --- a/e2e/sse_test.go +++ b/e2e/sse_test.go @@ -393,8 +393,8 @@ func TestSignalEventRendering(t *testing.T) { time2, err := elapsedEl.TextContent() require.NoError(t, err) - // timer should be stopped after terminal signal, so times should match - assert.Equal(t, time1, time2, "elapsed time should not change after COMPLETED signal") + // timer should be stopped after terminal signal, so times should match (±1s for interval boundary) + assertDurationsClose(t, time1, time2, time.Second, "elapsed time should not change after COMPLETED signal") }) t.Run("completion message is rendered", func(t *testing.T) { From 5d75f6d349995e730910b04c4d36c1a461cf0d88 Mon Sep 17 00:00:00 2001 From: Umputun Date: Sat, 21 Feb 2026 22:11:21 -0600 Subject: [PATCH 7/7] fix: skip header-only tasks in nextPlanTaskPosition tasks with no checkboxes are permanently pending, which would make position detection stick on them. skip checkbox-less tasks to match hasUncompletedTasks behavior. --- pkg/processor/runner.go | 2 +- pkg/processor/runner_test.go | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/processor/runner.go b/pkg/processor/runner.go index 1cf73e82..172a8f63 100644 --- a/pkg/processor/runner.go +++ b/pkg/processor/runner.go @@ -684,7 +684,7 @@ func (r *Runner) nextPlanTaskPosition() int { return 0 } for i, t := range p.Tasks { - if t.Status != plan.TaskStatusDone { + if len(t.Checkboxes) > 0 && t.Status != plan.TaskStatusDone { return i + 1 // 1-indexed } } diff --git a/pkg/processor/runner_test.go b/pkg/processor/runner_test.go index 6e0c3071..a98c0783 100644 --- a/pkg/processor/runner_test.go +++ b/pkg/processor/runner_test.go @@ -1924,6 +1924,7 @@ func TestRunner_NextPlanTaskPosition(t *testing.T) { {name: "all done", content: "# Plan\n### Task 1: setup\n- [x] done\n### Task 2: build\n- [x] built", expected: 0}, {name: "inserted task 2.5", content: "# Plan\n### Task 1: setup\n- [x] done\n### Task 2: api\n- [x] done\n### Task 2.5: middleware\n- [ ] add it\n### Task 3: tests\n- [ ] test", expected: 3}, {name: "retry same task", content: "# Plan\n### Task 1: setup\n- [x] done\n### Task 2: build\n- [x] first\n- [ ] second\n### Task 3: test\n- [ ] test", expected: 2}, + {name: "header-only task skipped", content: "# Plan\n### Task 1: setup\n- [x] done\n### Task 2: notes\n### Task 3: build\n- [ ] build it", expected: 3}, {name: "no tasks", content: "# Plan\nJust some text", expected: 0}, }